Eve S
Posted on March 8, 2024
In Python I have been making frequent use of the property decorator:
class Foo:
def __init__(self, a):
self.a = a
@property
def a(self):
return self._a
@name.setter
def a(self, a):
self._a = a
A decorator (denoted by '@') is a fast and clean way to wrap a function in another function. The above code written without using a decorators is
class Foo:
def __init__(self, a):
self.a = a
def getter(self):
return self._a
a = property(getter)
def setter(self, a):
self._a = a
a = a.setter(setter)
This reveals a bit more about what is happening. We are assigning our new variable to the property function with our getter function as an argument, which returns a special type of object called a descriptor. This descriptor has a method setter() which we use to assign the setter function. We could just as easily define the getter and setter then say a = property(getter, setter)
to do both in one line, and property can even take a third argument, a deleter. We can check a.fget is getter # True
to see that our a's getter is the same in memory as the getter function we defined.
The __init__
method wasn't needed up to this point, but a
property isn't very useful without it. When we instantiate a new Foo object and set its a attribute, Python will use the getter method stored in the Foo class to assign the new object's 'a'. The same goes for reassigning 'a', since the setter will be called. Most commonly this has been useful to me for checking if a new value is acceptable before changing it, manipulating it (by changing type, for example) to fit if needed, and giving an error otherwise.
But how exactly does property work? The object property returns has these attributes fget and fset that point to getter and setter, but that alone isn't enough for it to work. Like any descriptor, it uses magic methods to hook into its basic functionality as an object. In the Python doc on descriptors, there is an example of a class that uses __get__
and __set__
to reproduce some of the functionality of property
. I've cut it down some:
class LoggedAgeAccess:
def __get__(self, obj, objtype=None):
return obj._age
def __set__(self, obj, value):
obj._age = value
class Person:
age = LoggedAgeAccess() # Descriptor instance
def __init__(self, name, age):
self.name = name # Regular instance attribute
self.age = age # Calls __set__()
def birthday(self):
self.age += 1 # Calls both __get__() and __set__()
__get__
is the run when the descriptor instance is evaluated. Creating Person instantiates a LoggedAgeAccess object. Writing p = Person('name', 4); p.age
, runs the LoggedAgeAccess instance's __get__
method, which passes the descriptor instance (self), then p, then Person. As __get__
is defined, it will return p.age. The "" marks a private instance which accesses the attribute value without using the descriptor, which if done would cause a loop.
__set__
works similarly and is run when the attribute is reassigned. Note the line self.age = age
does not need to call __get__
because it does not need to evaluate the left hand side as an expression.
Defining magic methods in a class is an example of "operator overloading." Every class, even if you don't define it as a subclass, inherits from the "object" class and comes with these magic methods ready-made. They can handle what happens when you instantiate their objects, add them to each other with the plus sign, compare them with ==, and more. When you define one of these magic methods inside a class you are actually overwriting the previous definition of the method, and your new one will be referred to first as the case is with variable scope.
The code example above only works for a making a managed variable named age, but with one more magic function it can be made much more useful.
class LoggedAccess:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.private_name)
def __set__(self, obj, value):
setattr(obj, self.private_name, value)
The above class makes use of __set_name__
, which checks the name of the variable the class instance is assigned to. getattr
and setattr
exist for every object and do what they say in the name - they too can be overwritten for a class with __getattr__
and __setattr__
.
It is easy enough to added some data validation before setattr
to determine if the reassignment should occur, and Python even has an inbuilt Validator class that makes this easier (custom calidatorscan be made as subclasses by being defined with TheClassName(Validator)
).
The property decorator, along with the classmethod decorator and others, can handle all this work of hooking in with descriptors for us, but often we need to start using magic methods ourselves to change how our custom objects behave. __init__
is all over the place! This type of work can get close to the underlying C Python is written in. I'll include here the source code for property, a pure Python sample implementation of the property class from the official descriptor guide and a Stack Overflow post that explained it well. If you're surprised that Property is a class in this implementation, don't be! Functions are first class objects in Python. When you write def foo()
, you are making 'foo' an object of the type "function". This Property class is similar to a function in that it takes arguments to create an instance which will will rely on its magic methods to be referenced, and can even be referenced just by being evaluated on it's own because of __get__
.
class Property:
"Emulate PyProperty_Type() in Objects/descrobject.c"
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
Posted on March 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 3, 2024
November 13, 2024
November 12, 2024