Python Descriptors Explained
Basic Concepts of Descriptors
Descriptors in Python are a powerful mechanism for implementing attribute access control. The descriptor protocol consists of three methods: __get__, __set__, and __delete__. Any object that implements these methods can be used as a descriptor.
Descriptor Protocol
pythonclass Descriptor: def __get__(self, obj, objtype=None): """Get attribute value""" pass def __set__(self, obj, value): """Set attribute value""" pass def __delete__(self, obj): """Delete attribute""" pass
Data Descriptors vs Non-Data Descriptors
Data Descriptors
Descriptors that implement both __get__ and __set__ methods are called data descriptors.
pythonclass DataDescriptor: def __init__(self, initial_value=None): self.value = initial_value def __get__(self, obj, objtype=None): print(f"Getting data descriptor value: {self.value}") return self.value def __set__(self, obj, value): print(f"Setting data descriptor value: {value}") self.value = value class MyClass: attr = DataDescriptor(42) obj = MyClass() print(obj.attr) # Getting data descriptor value: 42 obj.attr = 100 # Setting data descriptor value: 100 print(obj.attr) # Getting data descriptor value: 100
Non-Data Descriptors
Descriptors that only implement the __get__ method are called non-data descriptors.
pythonclass NonDataDescriptor: def __init__(self, initial_value=None): self.value = initial_value def __get__(self, obj, objtype=None): print(f"Getting non-data descriptor value: {self.value}") return self.value class MyClass: attr = NonDataDescriptor(42) obj = MyClass() print(obj.attr) # Getting non-data descriptor value: 42 obj.attr = 100 # Sets instance attribute, doesn't call __set__ print(obj.attr) # 100 (instance attribute takes priority)
Data Descriptor vs Non-Data Descriptor Priority
pythonclass DataDesc: def __get__(self, obj, objtype=None): return "Data descriptor" def __set__(self, obj, value): pass class NonDataDesc: def __get__(self, obj, objtype=None): return "Non-data descriptor" class MyClass: data_desc = DataDesc() non_data_desc = NonDataDesc() obj = MyClass() obj.data_desc = "instance value" obj.non_data_desc = "instance value" print(obj.data_desc) # Data descriptor (data descriptor takes priority) print(obj.non_data_desc) # instance value (instance attribute takes priority)
Practical Applications of Descriptors
1. Type Checking
pythonclass Typed: """Type checking descriptor""" def __init__(self, name, expected_type): self.name = name self.expected_type = expected_type def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): if not isinstance(value, self.expected_type): raise TypeError( f"Attribute {self.name} should be {self.expected_type} type, " f"but got {type(value)}" ) obj.__dict__[self.name] = value class Person: name = Typed('name', str) age = Typed('age', int) person = Person() person.name = "Alice" # Normal person.age = 25 # Normal # person.age = "25" # TypeError: Attribute age should be <class 'int'> type, but got <class 'str'>
2. Value Validation
pythonclass Validated: """Value validation descriptor""" def __init__(self, name, validator): self.name = name self.validator = validator def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): if not self.validator(value): raise ValueError(f"Invalid value {value} for attribute {self.name}") obj.__dict__[self.name] = value class Person: age = Validated('age', lambda x: isinstance(x, int) and 0 <= x <= 150) name = Validated('name', lambda x: isinstance(x, str) and len(x) > 0) person = Person() person.age = 25 # Normal person.name = "Alice" # Normal # person.age = -5 # ValueError: Invalid value -5 for attribute age # person.name = "" # ValueError: Invalid value for attribute name
3. Lazy Evaluation
pythonclass LazyProperty: """Lazy evaluation property descriptor""" def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, obj, objtype=None): if obj is None: return self # Check if already computed if self.name not in obj.__dict__: print(f"Lazy computing {self.name}") obj.__dict__[self.name] = self.func(obj) return obj.__dict__[self.name] class Circle: def __init__(self, radius): self.radius = radius @LazyProperty def area(self): print("Computing area...") return 3.14159 * self.radius ** 2 @LazyProperty def circumference(self): print("Computing circumference...") return 2 * 3.14159 * self.radius circle = Circle(5) print(circle.area) # Lazy computing area, Computing area..., 78.53975 print(circle.area) # 78.53975 (Returns cached value directly) print(circle.circumference) # Lazy computing circumference, Computing circumference..., 31.4159
4. Read-Only Attributes
pythonclass ReadOnly: """Read-only attribute descriptor""" def __init__(self, name, value): self.name = name self.value = value def __get__(self, obj, objtype=None): if obj is None: return self return self.value def __set__(self, obj, value): raise AttributeError(f"Attribute {self.name} is read-only") class Config: VERSION = ReadOnly('VERSION', '1.0.0') AUTHOR = ReadOnly('AUTHOR', 'Alice') config = Config() print(config.VERSION) # 1.0.0 # config.VERSION = '2.0.0' # AttributeError: Attribute VERSION is read-only
5. Attribute Access Logging
pythonclass Logged: """Attribute access logging descriptor""" def __init__(self, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self value = obj.__dict__.get(self.name) print(f"Reading attribute {self.name}: {value}") return value def __set__(self, obj, value): print(f"Setting attribute {self.name}: {value}") obj.__dict__[self.name] = value def __delete__(self, obj): print(f"Deleting attribute {self.name}") del obj.__dict__[self.name] class Person: name = Logged('name') age = Logged('age') person = Person() person.name = "Alice" # Setting attribute name: Alice person.age = 25 # Setting attribute age: 25 print(person.name) # Reading attribute name: Alice del person.age # Deleting attribute age
6. Cached Attributes
pythonclass Cached: """Cached attribute descriptor""" def __init__(self, func): self.func = func self.name = func.__name__ self.cache = {} def __get__(self, obj, objtype=None): if obj is None: return self # Use object ID as cache key obj_id = id(obj) if obj_id not in self.cache: print(f"Computing and caching {self.name}") self.cache[obj_id] = self.func(obj) else: print(f"Using cache {self.name}") return self.cache[obj_id] class ExpensiveCalculator: def __init__(self, base): self.base = base @Cached def expensive_operation(self): print("Executing expensive operation...") import time time.sleep(1) return self.base ** 2 calc = ExpensiveCalculator(5) print(calc.expensive_operation) # Computing and caching expensive_operation, Executing expensive operation..., 25 print(calc.expensive_operation) # Using cache expensive_operation, 25
Relationship Between Descriptors and property
property is Essentially a Descriptor
python# property is actually a descriptor class class MyClass: @property def my_property(self): return "property value" @my_property.setter def my_property(self, value): print(f"Setting property: {value}") obj = MyClass() print(obj.my_property) # property value obj.my_property = "new value" # Setting property: new value
Custom property Class
pythonclass MyProperty: """Custom property class""" def __init__(self, fget=None, fset=None, fdel=None, doc=None): self.fget = fget self.fset = fset self.fdel = fdel self.__doc__ = doc def __get__(self, obj, objtype=None): if obj is None: return self if self.fget is None: raise AttributeError("Not readable") return self.fget(obj) def __set__(self, obj, value): if self.fset is None: raise AttributeError("Not writable") self.fset(obj, value) def __delete__(self, obj): if self.fdel is None: raise AttributeError("Not deletable") self.fdel(obj) def getter(self, fget): self.fget = fget return self def setter(self, fset): self.fset = fset return self def deleter(self, fdel): self.fdel = fdel return self class Person: def __init__(self): self._name = "" @MyProperty def name(self): return self._name @name.setter def name(self, value): self._name = value person = Person() person.name = "Alice" print(person.name) # Alice
Advanced Descriptor Usage
Descriptors with Class Methods
pythonclass ClassMethodDescriptor: """Class method descriptor""" def __init__(self, func): self.func = func def __get__(self, obj, objtype=None): if objtype is None: objtype = type(obj) return self.func.__get__(objtype, objtype) class MyClass: @ClassMethodDescriptor def class_method(cls): return f"Class method: {cls.__name__}" print(MyClass.class_method()) # Class method: MyClass
Descriptors with Static Methods
pythonclass StaticMethodDescriptor: """Static method descriptor""" def __init__(self, func): self.func = func def __get__(self, obj, objtype=None): return self.func class MyClass: @StaticMethodDescriptor def static_method(): return "Static method" print(MyClass.static_method()) # Static method
Descriptor Chains
pythonclass ValidatedTyped: """Combined validation and type checking descriptor""" def __init__(self, name, expected_type, validator=None): self.name = name self.expected_type = expected_type self.validator = validator def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): # Type checking if not isinstance(value, self.expected_type): raise TypeError( f"Attribute {self.name} should be {self.expected_type} type" ) # Value validation if self.validator and not self.validator(value): raise ValueError(f"Invalid value {value} for attribute {self.name}") obj.__dict__[self.name] = value class Person: age = ValidatedTyped( 'age', int, lambda x: 0 <= x <= 150 ) name = ValidatedTyped( 'name', str, lambda x: len(x) > 0 ) person = Person() person.name = "Alice" person.age = 25 # person.age = "25" # TypeError # person.age = -5 # ValueError
Descriptor Best Practices
1. Use set_name Method (Python 3.6+)
pythonclass Descriptor: """Descriptor using __set_name__""" def __set_name__(self, owner, name): self.name = name self.private_name = f'_{name}' def __get__(self, obj, objtype=None): if obj is None: return self return getattr(obj, self.private_name) def __set__(self, obj, value): setattr(obj, self.private_name, value) class MyClass: attr = Descriptor() obj = MyClass() obj.attr = 42 print(obj.attr) # 42
2. Avoid Circular References in Descriptors
pythonimport weakref class WeakRefDescriptor: """Descriptor using weak references""" def __init__(self): self.instances = weakref.WeakKeyDictionary() def __get__(self, obj, objtype=None): if obj is None: return self return self.instances.get(obj) def __set__(self, obj, value): self.instances[obj] = value class MyClass: attr = WeakRefDescriptor() obj = MyClass() obj.attr = 42 print(obj.attr) # 42
3. Provide Clear Error Messages
pythonclass ValidatedDescriptor: """Descriptor with clear error messages""" def __init__(self, name, expected_type): self.name = name self.expected_type = expected_type def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): if not isinstance(value, self.expected_type): raise TypeError( f"In class {obj.__class__.__name__}, " f"attribute '{self.name}' should be {self.expected_type.__name__} type, " f"but got {type(value).__name__}" ) obj.__dict__[self.name] = value class Person: age = ValidatedDescriptor('age', int) person = Person() # person.age = "25" # TypeError: In class Person, attribute 'age' should be int type, but got str
Real-world Descriptor Application Cases
1. ORM Model Fields
pythonclass Field: """ORM field descriptor""" def __init__(self, field_type, primary_key=False): self.field_type = field_type self.primary_key = primary_key self.name = None def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(f'_{self.name}') def __set__(self, obj, value): if not isinstance(value, self.field_type): raise TypeError(f"Field {self.name} type error") obj.__dict__[f'_{self.name}'] = value class ModelMeta(type): """Model metaclass""" def __new__(cls, name, bases, attrs): fields = {} for key, value in list(attrs.items()): if isinstance(value, Field): fields[key] = value attrs['_fields'] = fields return super().__new__(cls, name, bases, attrs) class User(metaclass=ModelMeta): id = Field(int, primary_key=True) name = Field(str) age = Field(int) user = User() user.name = "Alice" user.age = 25 print(user.name) # Alice
2. Unit Conversion
pythonclass Temperature: """Temperature unit conversion descriptor""" def __init__(self, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(f'_{self.name}') def __set__(self, obj, value): if isinstance(value, (int, float)): obj.__dict__[f'_{self.name}'] = value elif isinstance(value, str): if value.endswith('°C'): obj.__dict__[f'_{self.name}'] = float(value[:-2]) elif value.endswith('°F'): obj.__dict__[f'_{self.name}'] = (float(value[:-2]) - 32) * 5/9 else: raise ValueError("Invalid temperature format") else: raise TypeError("Invalid temperature type") class Weather: celsius = Temperature('celsius') weather = Weather() weather.celsius = "25°C" print(weather.celsius) # 25.0 weather.celsius = "77°F" print(weather.celsius) # 25.0
Summary
Core concepts of Python descriptors:
- Descriptor Protocol: Three methods:
__get__,__set__,__delete__ - Data Descriptors: Implement both
__get__and__set__, higher priority than instance attributes - Non-Data Descriptors: Only implement
__get__, lower priority than instance attributes - Practical Applications:
- Type checking
- Value validation
- Lazy evaluation
- Read-only attributes
- Attribute access logging
- Cached attributes
Advantages of descriptors:
- Powerful attribute access control
- Reusable attribute logic
- Clear code organization
- Integration with Python built-in mechanisms
Descriptor best practices:
- Use
__set_name__method (Python 3.6+) - Avoid circular references, use weak references
- Provide clear error messages
- Understand descriptor priority rules
- Consider simpler alternatives (property)
Descriptors are the core mechanism for implementing advanced attribute control in Python. Understanding descriptors is very important for deeply mastering Python's object-oriented programming. property, classmethod, staticmethod, etc., are all implemented based on descriptors.