Python Descriptors: Using get, set, and delete
The Python descriptor protocol provides a standardized way to
customize what happens when an object's attribute is accessed, modified,
or deleted. By implementing any combination of the
__get__(), __set__(), and
__delete__() special methods, a class can become a
descriptor and take control over attribute operations on another class.
This protocol serves as the foundational mechanism behind core Python
features such as @property, methods,
classmethod, and staticmethod, as well as
Object-Relational Mapping (ORM) field definitions.
What is a Descriptor?
A descriptor is an instance of a class that implements at least one method of the descriptor protocol:
__get__(self, obj, objtype=None): Manages read access.__set__(self, obj, value): Manages write access.__delete__(self, obj): Manages deletion.
To function, a descriptor must always be assigned as a class attribute on another class, never directly to an instance.
The Descriptor Methods Explained
1.
__get__(self, obj, objtype=None)
Invoked when an attribute is read.
self: The descriptor instance itself.obj: The instance of the owner class through which the attribute was accessed. If accessed directly from the owner class (e.g.,OwnerClass.attr),objisNone.objtype: The owner class itself.
class VerboseGet:
def __get__(self, obj, objtype=None):
if obj is None:
return self # Accessed from the class level
return f"Accessed from instance: {obj}"
class Example:
attribute = VerboseGet()
inst = Example()
print(inst.attribute) # Accessed from instance: <Example object at ...>
print(Example.attribute) # <VerboseGet object at ...>2.
__set__(self, obj, value)
Invoked when a value is assigned to the attribute (e.g.,
obj.attr = value).
self: The descriptor instance.obj: The instance of the owner class.value: The value being assigned.
Defining __set__ allows data validation, transformation,
or restricted access before saving the value into the instance's
internal __dict__.
3. __delete__(self, obj)
Invoked when an attribute is deleted using the del
statement (e.g., del obj.attr).
self: The descriptor instance.obj: The instance of the owner class from which the attribute is being deleted.
Data vs. Non-Data Descriptors
Python handles attribute resolution based on whether a descriptor is classified as a "data" or "non-data" descriptor:
- Data Descriptors: Implement
__set__()or__delete__()(or both). These take precedence over an instance's__dict__. Even if an instance has a key with the same name in__dict__, Python will route the access through the descriptor. - Non-Data Descriptors: Implement only
__get__(). These do not take precedence over an instance's__dict__. If an attribute with the same name exists in the instance dictionary, it overrides the descriptor. Standard Python methods are non-data descriptors.
Practical Implementation: A Validated Field
Here is an example demonstrating all three methods by creating a descriptor that enforces non-negative integer values.
class NonNegativeInteger:
def __set_name__(self, owner, name):
# Automatically stores the attribute name (e.g., '_price')
self.storage_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage_name, 0)
def __set__(self, obj, value):
if not isinstance(value, int) or value < 0:
raise ValueError(f"{self.storage_name[1:]} must be a non-negative integer.")
setattr(obj, self.storage_name, value)
def __delete__(self, obj):
if hasattr(obj, self.storage_name):
delattr(obj, self.storage_name)
else:
raise AttributeError(f"{self.storage_name[1:]} not found.")
class Product:
price = NonNegativeInteger()
quantity = NonNegativeInteger()
def __init__(self, price, quantity):
self.price = price
self.quantity = quantityUsage:
item = Product(price=50, quantity=10)
print(item.price) # Triggers __get__, prints: 50
item.price = 100 # Triggers __set__
# item.price = -5 # Triggers __set__, raises ValueError
del item.price # Triggers __delete__Attribute Lookup Order
When an attribute is accessed via obj.attribute,
Python’s object.__getattribute__() executes the following
sequence:
- Checks if the attribute is a data descriptor on the class.
- If not, checks the instance
__dict__. - If not in the instance
__dict__, checks if it is a non-data descriptor on the class. - If not a non-data descriptor, retrieves standard class attributes.
- If the attribute is nowhere to be found, calls
__getattr__()if implemented; otherwise, raisesAttributeError.