The Descriptor Protocol & Attribute Resolution
Master the Python descriptor protocol: Data vs Non-Data descriptors, the 6-step attribute resolution pipeline, method binding, and custom validation descriptors.
The descriptor protocol is the foundational mechanism powering Python's object model. Properties (@property), bound methods, class methods (@classmethod), static methods (@staticmethod), and __slots__ are all direct applications of descriptors.
A descriptor is any object that customizes attribute access, assignment, and deletion by implementing one or more protocol methods: __get__, __set__, and __delete__.
CPython executes a strict 6-stage precedence pipeline when evaluating obj.attr.
1. Data vs Non-Data Descriptors#
Python classifies descriptors into two distinct categories based on which protocol methods they implement:
| Descriptor Category | Required Methods | Key Characteristic | Examples |
|---|---|---|---|
| Data Descriptor | Implements __get__ AND (__set__ OR __delete__) | Overrides instance dictionary attributes with the same name! | @property, __slots__ members, ORM database fields |
| Non-Data Descriptor | Implements ONLY __get__ | Yields precedence to instance dictionary attributes if present | Standard function objects (methods), @staticmethod, @classmethod |
2. The 6-Stage Attribute Resolution Pipeline#
When an expression like obj.attr is evaluated on an instance obj of class Cls, CPython's C-API function PyObject_GenericGetAttr() executes the following resolution pipeline:
obj.__dict__['x'] = 10 will not shadow or override a property or data descriptor named x! CPython always queries data descriptors first.3. How Functions Become Bound Methods#
Ever wonder why regular Python functions require self as their first parameter when called on an instance?
In Python, all standard function objects implement __get__, making every function a Non-Data Descriptor!
1234567891011121314def calculate_area(self): return self.width * self.height class Rectangle: area = calculate_area # Assign function directly to class attribute rect = Rectangle() rect.width = 10 rect.height = 5 # Function.__get__ binds instance rect to self: bound_method = Rectangle.area.__get__(rect, Rectangle) print(f"Bound Method Result: {bound_method()}") # Output: 50 print(f"Calling rect.area(): {rect.area()}") # Output: 50
When you evaluate rect.area():
rect.__dict__has noareakey.- CPython finds
calculate_areainRectangle.__dict__. - CPython detects that the function implements
__get__. - It calls
calculate_area.__get__(rect, Rectangle), which returns atypes.MethodTypeproxy wrapping both the function and the instancerect. - Calling the proxy invokes
calculate_area(rect).
4. Building a Reusable Validation Descriptor#
Descriptors shine when abstracting cross-cutting attribute validation logic across multiple domain classes:
1234567891011121314151617181920212223242526272829303132333435class PositiveInteger: """Reusable Data Descriptor enforcing positive integer constraints.""" def __set_name__(self, owner, name): # Automatically captures attribute name (Python 3.6+) self.storage_name = f"_{name}" def __get__(self, instance, owner=None): if instance is None: # Accessed from class: return descriptor instance itself return self return getattr(instance, self.storage_name, 0) def __set__(self, instance, value): if not isinstance(value, int) or value <= 0: raise ValueError(f"{self.storage_name[1:]} must be a positive integer, got {value}") setattr(instance, self.storage_name, value) def __delete__(self, instance): raise AttributeError("Cannot delete this validated attribute") class BankAccount: balance = PositiveInteger() account_number = PositiveInteger() def __init__(self, acc_num: int, opening_balance: int): self.account_number = acc_num self.balance = opening_balance account = BankAccount(1001, 500) print(f"Account balance: {account.balance}") # 500 try: account.balance = -50 except ValueError as e: print(f"Caught validation error: {e}")
5. Concept Verification#
Test your understanding of descriptors and attribute resolution:
What defines the operational difference between a Data Descriptor and a Non-Data Descriptor in Python?
How does Python transform a standalone function into a bound method when accessed on an instance?