Descriptors
Control attribute access with descriptors - the power behind @property and more.
Descriptors are a powerful Python feature that control how attributes are accessed, set, and deleted. They’re the mechanism behind @property, @staticmethod, @classmethod, and many other Python features.
Why Use Descriptors?
Section titled “Why Use Descriptors?”Without descriptors (manual validation):
class Person: def __init__(self, age: int): self._age = None self.set_age(age) # Must call setter
def get_age(self) -> int: """Get age""" return self._age
def set_age(self, value: int): """Set age with validation""" if value < 0: raise ValueError("Age cannot be negative") self._age = value
# Usage - verbose and error-proneperson = Person(25)print(person.get_age()) # Must use getterperson.set_age(30) # Must use setterperson.set_age(-5) # Raises ValueErrorWith descriptors (clean and automatic):
class PositiveInteger: """Descriptor for positive integers""" def __init__(self, name: str): self.name = name
def __get__(self, obj, objtype=None): return obj.__dict__.get(self.name)
def __set__(self, obj, value: int): if value < 0: raise ValueError(f"{self.name} cannot be negative") obj.__dict__[self.name] = value
class Person: age = PositiveInteger('age') # Descriptor
def __init__(self, age: int): self.age = age # Automatically uses descriptor!
# Usage - clean and automaticperson = Person(25)print(person.age) # Automatically uses __get__person.age = 30 # Automatically uses __set__person.age = -5 # Raises ValueError automaticallyUnderstanding Descriptors
Section titled “Understanding Descriptors”Descriptor Protocol
Section titled “Descriptor Protocol”A descriptor must implement at least one of these methods:
__get__(self, obj, objtype=None)- Called when attribute is accessed__set__(self, obj, value)- Called when attribute is set__delete__(self, obj)- Called when attribute is deleted
class MyDescriptor: """Basic descriptor example""" def __init__(self, name: str): self.name = name
def __get__(self, obj, objtype=None): """Called when: obj.attr or Class.attr""" if obj is None: # Accessed on class, not instance return self print(f"Getting {self.name}") return obj.__dict__.get(self.name, None)
def __set__(self, obj, value): """Called when: obj.attr = value""" print(f"Setting {self.name} to {value}") obj.__dict__[self.name] = value
def __delete__(self, obj): """Called when: del obj.attr""" print(f"Deleting {self.name}") del obj.__dict__[self.name]
class MyClass: x = MyDescriptor('x') # Descriptor instance
# Usageobj = MyClass()obj.x = 10 # Prints: Setting x to 10print(obj.x) # Prints: Getting x, then 10del obj.x # Prints: Deleting xTypes of Descriptors
Section titled “Types of Descriptors”1. Data Descriptors
Section titled “1. Data Descriptors”Data descriptors implement __set__ or __delete__:
class ValidatedString: """Data descriptor that validates string length""" def __init__(self, name: str, min_length: int = 0, max_length: int = 100): self.name = name self.min_length = min_length self.max_length = max_length
def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name, "")
def __set__(self, obj, value: str): if not isinstance(value, str): raise TypeError(f"{self.name} must be a string") if len(value) < self.min_length: raise ValueError(f"{self.name} must be at least {self.min_length} characters") if len(value) > self.max_length: raise ValueError(f"{self.name} must be at most {self.max_length} characters") obj.__dict__[self.name] = value
class User: username = ValidatedString('username', min_length=3, max_length=20) email = ValidatedString('email', min_length=5, max_length=100)
def __init__(self, username: str, email: str): self.username = username # Validation happens automatically! self.email = email
# Usageuser = User("alice", "alice@example.com") # Worksuser.username = "ab" # Raises ValueError (too short)user.email = "a" # Raises ValueError (too short)2. Non-Data Descriptors
Section titled “2. Non-Data Descriptors”Non-data descriptors only implement __get__:
class CachedProperty: """Non-data descriptor that caches computed values""" def __init__(self, func): self.func = func self.name = func.__name__
def __get__(self, obj, objtype=None): if obj is None: return self
# Check cache cache_attr = f"_{self.name}_cache" if hasattr(obj, cache_attr): return getattr(obj, cache_attr)
# Compute and cache value = self.func(obj) setattr(obj, cache_attr, value) return value
class Circle: def __init__(self, radius: float): self.radius = radius
@CachedProperty def area(self): """Compute area (cached after first access)""" print("Computing area...") # Only prints once return 3.14159 * self.radius ** 2
# Usagecircle = Circle(5.0)print(circle.area) # Prints: Computing area..., then 78.53975print(circle.area) # Just prints: 78.53975 (from cache)How @property Works
Section titled “How @property Works”The @property decorator creates a descriptor:
class Temperature: """Temperature class using @property (which uses descriptors)""" def __init__(self, celsius: float): self._celsius = celsius
@property def celsius(self) -> float: """Get temperature in Celsius""" return self._celsius
@celsius.setter def celsius(self, value: float): """Set temperature in Celsius""" if value < -273.15: raise ValueError("Temperature cannot be below absolute zero") self._celsius = value
@property def fahrenheit(self) -> float: """Get temperature in Fahrenheit (read-only)""" return self._celsius * 9/5 + 32
@fahrenheit.setter def fahrenheit(self, value: float): """Set temperature via Fahrenheit""" self.celsius = (value - 32) * 5/9
# Usagetemp = Temperature(25)print(temp.celsius) # 25print(temp.fahrenheit) # 77.0
temp.fahrenheit = 100 # Sets via Fahrenheitprint(temp.celsius) # 37.777...Real-World Examples
Section titled “Real-World Examples”1. Type Validation Descriptor
Section titled “1. Type Validation Descriptor”class Typed: """Descriptor that enforces type checking""" def __init__(self, name: str, expected_type: 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"{self.name} must be of type {self.expected_type.__name__}, " f"got {type(value).__name__}" ) obj.__dict__[self.name] = value
class Person: name = Typed('name', str) age = Typed('age', int) height = Typed('height', float)
def __init__(self, name: str, age: int, height: float): self.name = name self.age = age self.height = height
# Usageperson = Person("Alice", 25, 165.5) # Worksperson.age = "25" # Raises TypeError2. Lazy Loading Descriptor
Section titled “2. Lazy Loading Descriptor”class LazyProperty: """Descriptor that loads value only when accessed""" 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 loaded if self.name in obj.__dict__: return obj.__dict__[self.name]
# Load value value = self.func(obj) obj.__dict__[self.name] = value return value
class Database: def __init__(self, connection_string: str): self.connection_string = connection_string self._connection = None
@LazyProperty def connection(self): """Lazy-load database connection""" print("Connecting to database...") # Simulate connection return {"status": "connected", "db": "mydb"}
# Usagedb = Database("postgresql://localhost/db")# Connection not created yet
print(db.connection) # Prints: Connecting to database..., then connection dictprint(db.connection) # Just returns connection (already loaded)3. Range Validation Descriptor
Section titled “3. Range Validation Descriptor”class Bounded: """Descriptor that validates value is within range""" def __init__(self, name: str, min_value: float = None, max_value: float = None): self.name = name self.min_value = min_value self.max_value = max_value
def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name)
def __set__(self, obj, value: float): if self.min_value is not None and value < self.min_value: raise ValueError(f"{self.name} must be >= {self.min_value}") if self.max_value is not None and value > self.max_value: raise ValueError(f"{self.name} must be <= {self.max_value}") obj.__dict__[self.name] = value
class Percentage: """Percentage that must be between 0 and 100""" value = Bounded('value', min_value=0.0, max_value=100.0)
def __init__(self, value: float): self.value = value
# Usagepct = Percentage(50.0) # Workspct.value = 75.0 # Workspct.value = 150.0 # Raises ValueErrorpct.value = -10.0 # Raises ValueError4. Unit Conversion Descriptor
Section titled “4. Unit Conversion Descriptor”class UnitConverter: """Descriptor that converts between units""" def __init__(self, name: str, base_unit: str, conversion_factor: float): self.name = name self.base_unit = base_unit self.conversion_factor = conversion_factor
def __get__(self, obj, objtype=None): if obj is None: return self # Get value in base unit and convert base_value = obj.__dict__.get(self.base_unit, 0) return base_value * self.conversion_factor
def __set__(self, obj, value: float): # Convert to base unit and store base_value = value / self.conversion_factor obj.__dict__[self.base_unit] = base_value
class Distance: """Distance with multiple unit support""" def __init__(self, meters: float): self.meters = meters
# Descriptors for different units kilometers = UnitConverter('kilometers', 'meters', 0.001) miles = UnitConverter('miles', 'meters', 0.000621371) feet = UnitConverter('feet', 'meters', 3.28084)
# Usagedistance = Distance(1000) # 1000 metersprint(distance.kilometers) # 1.0 kmprint(distance.miles) # 0.621371 milesprint(distance.feet) # 3280.84 feet
distance.kilometers = 2 # Set via kilometersprint(distance.meters) # 2000.0 metersHow @staticmethod and @classmethod Work
Section titled “How @staticmethod and @classmethod Work”Both @staticmethod and @classmethod are implemented using descriptors:
class StaticMethod: """How @staticmethod works (simplified)""" def __init__(self, func): self.func = func
def __get__(self, obj, objtype=None): # Always return the function unchanged return self.func
class ClassMethod: """How @classmethod works (simplified)""" def __init__(self, func): self.func = func
def __get__(self, obj, objtype=None): # Bind function to class (not instance) if objtype is None: objtype = type(obj)
def bound_func(*args, **kwargs): return self.func(objtype, *args, **kwargs)
return bound_func
class MyClass: @StaticMethod def static_method(x): """Static method - no self or cls""" return x * 2
@ClassMethod def class_method(cls, x): """Class method - receives cls""" return f"{cls.__name__}: {x}"
# Usageprint(MyClass.static_method(5)) # 10print(MyClass.class_method(5)) # MyClass: 5Descriptor Precedence
Section titled “Descriptor Precedence”Descriptors have a specific lookup order:
- Data descriptors (highest priority)
- Instance dictionary
- Non-data descriptors
- Class dictionary
__getattr__(if defined)
class DataDescriptor: """Data descriptor - highest priority""" def __init__(self, name): self.name = name
def __get__(self, obj, objtype=None): return f"DataDescriptor: {self.name}"
def __set__(self, obj, value): obj.__dict__[f"_{self.name}"] = value
class NonDataDescriptor: """Non-data descriptor - lower priority""" def __init__(self, name): self.name = name
def __get__(self, obj, objtype=None): return f"NonDataDescriptor: {self.name}"
class MyClass: data_attr = DataDescriptor('data_attr') non_data_attr = NonDataDescriptor('non_data_attr')
obj = MyClass()obj.non_data_attr = "Instance value" # Can override non-data descriptor
print(obj.data_attr) # DataDescriptor: data_attr (descriptor wins)print(obj.non_data_attr) # Instance value (instance dict wins)Common Mistakes to Avoid
Section titled “Common Mistakes to Avoid”Mistake 1: Storing Value in Descriptor
Section titled “Mistake 1: Storing Value in Descriptor”# ❌ Bad - shared state across instancesclass BadDescriptor: def __init__(self): self.value = None # Shared across all instances!
def __get__(self, obj, objtype=None): return self.value
def __set__(self, obj, value): self.value = value # All instances share same value!
# ✅ Good - store in instance dictionaryclass GoodDescriptor: def __init__(self, name): self.name = name
def __get__(self, obj, objtype=None): return obj.__dict__.get(self.name)
def __set__(self, obj, value): obj.__dict__[self.name] = value # Each instance has own valueMistake 2: Not Handling Class Access
Section titled “Mistake 2: Not Handling Class Access”# ❌ Bad - doesn't handle class accessclass BadDescriptor: def __get__(self, obj, objtype=None): return obj.__dict__[self.name] # Fails when obj is None
# ✅ Good - handles both instance and class accessclass GoodDescriptor: def __get__(self, obj, objtype=None): if obj is None: return self # Return descriptor when accessed on class return obj.__dict__.get(self.name)Mistake 3: Forgetting to Store Name
Section titled “Mistake 3: Forgetting to Store Name”# ❌ Bad - hardcoded nameclass BadDescriptor: def __set__(self, obj, value): obj.__dict__['value'] = value # Hardcoded name
# ✅ Good - store name during initializationclass GoodDescriptor: def __init__(self, name): self.name = name # Store the attribute name
def __set__(self, obj, value): obj.__dict__[self.name] = value # Use stored nameBest Practices
Section titled “Best Practices”- Store values in instance dictionary - Not in descriptor itself
- Handle class access - Check if
obj is Nonein__get__ - Use descriptive names - Make descriptor purpose clear
- Document behavior - Explain what the descriptor does
- Consider data vs non-data - Choose based on your needs
- Test thoroughly - Descriptors can be tricky to debug
Key Takeaways
Section titled “Key Takeaways”- Descriptors control attribute access via
__get__,__set__,__delete__ - Data descriptors implement
__set__or__delete__(higher priority) - Non-data descriptors only implement
__get__(lower priority) - @property is a descriptor - powers property decorator
- @staticmethod/@classmethod use descriptors
- Store values in instance dict - Not in descriptor itself
- Handle class access - Check
obj is Nonein__get__ - Reusable - Write once, use in multiple classes
Remember: Descriptors give you fine-grained control over attribute access - they’re the power behind @property and many other Python features! 🎯