Explain Python descriptors.
Python descriptors are objects that allow you to customize attribute access (getting, setting, and deleting) on other objects. They are a powerful, low-level mechanism that underpins many common Python features like properties, methods, `staticmethod`, and `classmethod`.
What are Descriptors?
At their core, a descriptor is an object that implements at least one of the __get__, __set__, or __delete__ special methods. When an attribute is accessed on an object, Python's attribute lookup machinery checks if the attribute's value is an object that implements the descriptor protocol. If so, the appropriate descriptor method is called instead of simply returning or setting the attribute directly.
The Descriptor Protocol
__get__(self, instance, owner): Called when the attribute is accessed (e.g.,obj.attr).instanceis the object the attribute was accessed through (orNoneif accessed directly on the class), andowneris the class ofinstance.__set__(self, instance, value): Called when the attribute is assigned (e.g.,obj.attr = value).instanceis the object the attribute was accessed through, andvalueis the new value.__delete__(self, instance): Called when the attribute is deleted (e.g.,del obj.attr).instanceis the object the attribute was accessed through.
Why Use Descriptors?
- Reusability: Define attribute behavior once and apply it to multiple attributes or classes.
- Validation: Enforce rules on attribute values (e.g., type checking, range validation) upon assignment.
- Lazy Loading: Defer the computation or loading of an attribute's value until it's first accessed.
- Computed Attributes: Create attributes whose value is dynamically computed each time it's accessed.
- Attribute Management: Implement advanced access patterns, like ORM fields or mocking objects.
Example: A Simple Integer Descriptor
Let's create a descriptor that ensures an attribute is always an integer and allows setting and getting its value. The __set_name__ method (introduced in Python 3.6) is often used to get the name of the attribute in the owning class.
class Integer:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = '_' + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Access the value from the instance's private attribute
return getattr(obj, self.private_name)
def __set__(self, obj, value):
if not isinstance(value, int):
raise ValueError(f"Expected an integer for {self.public_name}, got {type(value).__name__}")
# Store the value in the instance's private attribute
setattr(obj, self.private_name, value)
def __delete__(self, obj):
delattr(obj, self.private_name)
class MyClass:
x = Integer() # x is an instance of Integer, which is a descriptor
y = Integer()
def __init__(self, x_val, y_val):
self.x = x_val # This calls Integer.__set__
self.y = y_val
# Usage
try:
obj = MyClass(10, 20)
print(f"obj.x: {obj.x}") # Calls Integer.__get__
obj.x = 30 # Calls Integer.__set__
print(f"obj.x after change: {obj.x}")
del obj.y # Calls Integer.__delete__
# obj.y = "hello" # This would raise a ValueError
except ValueError as e:
print(e)
except AttributeError as e:
print(e) # Will catch 'obj has no attribute _y' after deletion
Data vs. Non-Data Descriptors
Python distinguishes between two types of descriptors based on the methods they implement, which affects attribute lookup precedence:
- Data Descriptors: Define
__set__or__delete__(and optionally__get__). These descriptors take precedence over an instance's__dict__entries. Examples includeproperty()objects and theIntegerdescriptor shown above. - Non-Data Descriptors: Only define
__get__. These descriptors have lower precedence; if an attribute with the same name exists in an instance's__dict__, that instance attribute will be returned instead. Examples include functions (methods) before they are bound to an instance,staticmethod, andclassmethod.
Understanding this distinction is crucial for predicting how attribute access works. For a data descriptor, obj.attr will always call the descriptor's __get__ method, even if attr exists in obj.__dict__. For a non-data descriptor, obj.attr will first check obj.__dict__; if attr is not found there, then the descriptor's __get__ is called.
Common Built-in Descriptors
property(): A built-in class that implements the data descriptor protocol, allowing attributes to be managed like object-oriented getters, setters, and deleters.staticmethod(): A non-data descriptor that wraps a method so it can be accessed without an instance, acting like a regular function attached to a class.classmethod(): A non-data descriptor that wraps a method so it receives the class as its first argument (instead of the instance).- Functions: Regular Python functions are non-data descriptors when they are defined inside a class, becoming bound methods when accessed via an instance.
Key Takeaways
- Descriptors are Python objects implementing
__get__,__set__, or__delete__. - They provide fine-grained control over attribute access, enabling powerful customization.
- They are central to many core Python features like
property,staticmethod,classmethod, and methods themselves. - The distinction between data (has
__set__or__delete__) and non-data (only__get__) descriptors affects attribute lookup precedence.