Using the New Python Instance Properties
Last week, I showed you my new implementation for instance-level properties in Python. This week, we’ll take another look at it by implementing a few Delegated Properties and helpers to make using them just a hair nicer.
Recreating Kotlin’s Built-In Delegates
For inspiration of what Delegated Properties to create, we’ll start by recreating the ones built into Kotlin, starting with Lazy
.
Lazy
The Lazy
Delegated Property allows you to initialize it with a function that takes no parameters and returns an object – preferably one that is relatively expensive to create. Then, the first time the property is retrieved, the object is created a stored, which subsequent lookups will use.
To start us off, let’s create an object that we can use as a flag to represent that the value isn’t initialized yet (we could use None
, but it’s possible that the function could return None
, and we’d start having some difficulties).
no_value = object()
Using that, here’s Lazy
:
class Lazy(DelegatedProperty): def __init__(self, initializer, *, readonly=False): self.value = no_value self.initialize = initializer self._readonly = readonly @classmethod def readonly(cls, initializer): return cls(intializer, readonly=True) def get(self, instance, name): if self.value is no_value: self.value = self.initialize() self.initialize = None # release the reference to prevent memory leak return self.value def set(self, value, instance, name): if self._readonly: raise AttributeError() else: self.value = value if self.initializer: # if it's not cleaned up, clean it up self.initializer = None
Note the optional read-only with the convenient readonly()
class method. I’m not sure what else to say about it. If you have any trouble understanding any of these, ask me about it in the comments.
LateInit
Next up is LateInit
. This isn’t quite as handy in Python as it is in Kotlin, since it is largely provided to allow for non-null properties in a class that implements the bean protocol. The problem is that the bean protocol requires a constructor that takes no arguments, where all the fields on the class are initialized to null. In Kotlin, you have null safety, and people like to mark their fields as non-nullable. So, in order to make the bean field non-nullable and yet have them start of null, Kotlin includes lateinit
, which represents an uninitialized non-nullable field. If the field is accessed before it is ever set to something, then it triggers an exception. Technically, this isn’t a Delegated Property in Kotlin; it’s a language modifier, but it could be implemented as a Delegated Property.
What use does it have in Python? Well, it’s possible you might want to create a class where some or all of the attributes aren’t initialized right away, – rather they’re set later – but the class requires some or all of those attributes for certain actions. This way, instead of writing verifiers at the beginning of those actions to be certain that they were set, you simply access the attribute, and if it wasn’t set, it’ll raise an exception on its own.
Here’s what such a Delegated Property looks like:
class LateInit(DelegatedProperty): def __init__(self, value=no_value, *, readonly=False): self.value = value self._readonly = readonly @classmethod def readonly(cls, value=no_value): return cls(value, readonly=True) def get(self, instance, name): if self.value is no_value: raise AttributeError(f"Attribute, {name}, not yet initialized") else: return self.value def set(self, value, instance, name): if self._readonly and self.value is not no_value: # the second part allows you to use set to initialize the value later, even if it's read-only raise AttributeError() else: self.value = value
The constructor and readonly()
class method include the ability to initialize the property immediately, even though that’s not the typical case, but I like the idea of making it a little more flexible that way.
ByMap
Sometimes, you just want to take in a dictionary in the constructor and have attributes refer to that dictionary for their values. That’s what this Delegated Property does for you. So, it would be used like this:
class ByMapUser: a = InstanceProperty(ByMap) b = InstanceProperty(ByMap.readonly) def __init__(self, dict_of_values): # dict_of_values should have entries for 'a' and 'b', or else # using either property could cause a KeyError, although setting # before getting will add the entry to the dict if it wasn't # already there self.a.initialize(dict_of_values) self.b.initialize(dict_of_values)
And here, a
and b
will read from and write to the provided dictionary instead of to the instance’s __dict__
.
class ByMap(DelegatedProperty): def __init__(self, mapping, *, readonly=False): self.mapping = mapping self._readonly = readonly @classmethod def readonly(cls, mapping): cls(mapping, readonly=True) def get(self, instance, name): return self.mapping[name] def set(self, value, instance, name): if self._readonly: raise AttributeError() else: self.mapping[name] = value
Here is where passing in name
to get()
and set()
methods really comes in handy. Without the automatic calculating and passing of names, this would be more tedious to implement and use. The name would have to be taken in through the constructor, which means the user would have to provide it with initialize call. And that means that it would have to be updated by hand if they ever decide to change the name in a refactoring.
Observable
Kotlin also has the Observable
Delegated Property, which I don’t see as being all that useful, so I won’t bother showing you that one.
Custom Delegated Property: Validate
One of the biggest reasons we ever need properties is to ensure that what is provided is a valid value to be given. While using the built-in Python property
isn’t bad for this use case, it does still require the user to have to think about where to actually store the value, and it requires the verbosity of creating a method. But the biggest shortfall is when you need to reuse the logic of that property elsewhere. You could implement a full-blown descriptor for it, but that’s also excessive. With Validate
, all you really need to implement is the validation function and possibly a helper function for creating the specific validator.
def positive_int(num):
return isinstance(num, int) and num > 0
def positive_int_property():
return InstanceProperty(Validate.using(positive_int))
class Foo:
bar = positive_int_property()
def __init__(self, num):
self.bar.initialize(num)
See how you could just write a simple validator function and use that with InstanceProperty(Validate.using(<function>))
? Pretty nice, right? Let’s see what Validate
looks like, shall we?
class InvalidValueError(TypeError): pass class Validate(DelegatedProperty): def __init__(self, validator, value, *, readonly=False): self.validate = validator if self.validate(value): self.value = value else: raise InvalidValueError() self._readonly = readonly @classmethod def using(cls, validator, *, readonly=False): return lambda value: cls(validator, value, readonly=readonly) @classmethod def readonly_using(cls, validator): return cls.using(validator, readonly=True) def get(self, *args): return self.value def set(self, value, instance, name): if self._readonly: raise AttributeError() elif self.validate(value): self.value = value else: Raise InvalidValueError(f"{name}, {value}") # needs a better message, I know
We had to unfortunately use lambda to implement the class methods. It could have been done by returning an inner function as well, but the lack of need for a name makes that excessive.
It also kind of sucks that we can’t do a proper error message in __init__()
because we don’t have access to the name and instance. To get around this, I’m passing those arguments in as named arguments in initialize
in the final version on GitHub (I wrote Validate
for the first time after last week’s article and didn’t want to present any changes to last week’s code here). The Delegated Properties that don’t use them can just add **kwargs
into their __init__()
signature and ignore it.
Helpers
Shortening InstanceProperty
Always instantiating an InstanceProperty
using that full name is tedious, and could potentially be enough to keep people from using it in the first place. So let’s make a shorter-named function that delegates to it:
def by(instantiator): return InstanceProperty(instantiator)
I chose “by” as the name since it reflects the keyword used in Kotlin. You can use whatever name you want.
Why don’t I just rename InstanceProperty
to something shorter? Because anything shorter would likely lose meaning, and we always want our names to have meaning.
Read Only Wrappers
I have a couple ideas for this, both of which aren’t compatible with 100% of Delegated Properties. Both of these make it so we don’t need to have a class method named “readonly”, and otherwise make implementing Delegated Properties easier.
The first is just a convenience function:
def readonly(instantiator): return partial(instantiator, readonly=True)
Which can then be used like this:
class Foo: bar = by(readonly(LateInit)) …
Seeing that Delegated Properties aren’t required to have the readonly
parameter, this isn’t 100% compatible. The respective properties should document whether or not they work with this function or not. This applies to the next one as well.
class ReadOnly(DelegatedProperty): def __init__(self, delegate): self.delegate = delegate @classmethod def of(cls, delegate): return lambda *args, **kwargs: cls(delegate(*args, **kwargs)) def get(self, instance, name): return self.delegate.get(instance, name)
Readonly
is used in the following way:
class Foo: bar = by(ReadOnly.of(LateInit)) ...
It’s annoying that we had to return a function within a function again, but when InstanceProperty
needs a function for instantiating a Delegated Property, there’s not a whole lot of choice.
Outro
So that’s everything. Again, this is all in a GitHub repo, which turns out to be the same repo (descriptor-tools) I made for my book. The new code is added, but not all the tests and documentation are written for it yet. When that’s done, I’ll submit the rest to PyPI so you can easily install it with pip.
Thanks for reading, and I’ll see you next week with an article about the MVP pattern.
Reference: | Using the New Python Instance Properties from our WCG partner Jacob Zimmerman at the Programming Ideas With Jake blog. |