Descriptors & Properties in Python
Difficulty: Hard | Companies: Google, Meta, Amazon, Netflix, Stripe
Descriptor Protocol Deep Dive
from typing import Any, Optional, Type
class BasicDescriptor:
"""Basic descriptor implementing the full protocol."""
def __set_name__(self, owner: type, name: str):
"""Called when descriptor is assigned to a class attribute."""
self.name = name
self.private_name = f"_{name}"
print(f"Descriptor {self.name} assigned to {owner.__name__}")
def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any:
"""Called when attribute is accessed."""
if obj is None:
return self # Return descriptor itself when accessed via class
value = getattr(obj, self.private_name, None)
print(f"Getting {self.name}: {value}")
return value
def __set__(self, obj: Any, value: Any) -> None:
"""Called when attribute is set."""
print(f"Setting {self.name}: {value}")
setattr(obj, self.private_name, value)
def __delete__(self, obj: Any) -> None:
"""Called when attribute is deleted."""
print(f"Deleting {self.name}")
if hasattr(obj, self.private_name):
delattr(obj, self.private_name)
class Person:
"""Example class using descriptors."""
name = BasicDescriptor()
age = BasicDescriptor()
def __init__(self, name: str, age: int):
self.name = name # Triggers __set__
self.age = age # Triggers __set__
# Usage
p = Person("Alice", 30) # Sets both name and age
print(p.name) # Gets name
print(p.age) # Gets age
# Access via class returns descriptor itself
print(Person.name) # <BasicDescriptor object>
βΉοΈ
The
__set_name__ method is called during class creation, allowing descriptors to know their attribute name and class owner.Data vs Non-Data Descriptors
class NonDataDescriptor:
"""Non-data descriptor: only implements __get__."""
def __get__(self, obj, objtype=None):
if obj is None:
return self
return lambda: f"Called from {obj}"
class DataDescriptor:
"""Data descriptor: implements both __get__ and __set__."""
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, None)
def __set__(self, obj, value):
setattr(obj, self.private_name, value)
class Example:
non_data = NonDataDescriptor()
data = DataDescriptor()
def __init__(self):
self.data = "initial" # Uses data descriptor
self.non_data = "instance" # Binds to instance, shadows descriptor
# Data descriptors take precedence over instance attributes
e = Example()
print(e.data) # Uses DataDescriptor.__get__
print(e.non_data) # Returns "instance" (instance attribute)
# Non-data descriptors can be overridden by instance attributes
def method_descriptor(self):
return "From descriptor"
class MyClass:
method = method_descriptor # Non-data descriptor
obj = MyClass()
obj.method = lambda: "From instance" # Shadows descriptor
print(obj.method()) # "From instance"
Validated Fields with Descriptors
from typing import Callable, Any, Optional
import re
class ValidatedField:
"""Descriptor with validation."""
def __init__(
self,
validator: Callable[[Any], bool],
error_message: str = "Invalid value",
default: Any = None
):
self.validator = validator
self.error_message = error_message
self.default = default
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, self.default)
def __set__(self, obj, value):
if not self.validator(value):
raise ValueError(f"{self.name}: {self.error_message}")
setattr(obj, self.private_name, value)
class Email(ValidatedField):
"""Email validation descriptor."""
EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
def __init__(self, **kwargs):
super().__init__(
validator=lambda x: bool(re.match(self.EMAIL_REGEX, x)) if x else False,
error_message="Must be a valid email address",
**kwargs
)
class PositiveNumber(ValidatedField):
"""Positive number validation descriptor."""
def __init__(self, **kwargs):
super().__init__(
validator=lambda x: isinstance(x, (int, float)) and x > 0 if x is not None else False,
error_message="Must be a positive number",
**kwargs
)
class StringLength(ValidatedField):
"""String length validation descriptor."""
def __init__(self, min_length: int = 0, max_length: int = 255, **kwargs):
def validator(x):
if not isinstance(x, str):
return False
return min_length <= len(x) <= max_length
super().__init__(
validator=validator,
error_message=f"Must be between {min_length} and {max_length} characters",
**kwargs
)
class User:
"""Example user class with validated fields."""
email = Email()
age = PositiveNumber()
name = StringLength(min_length=2, max_length=50)
def __init__(self, email: str, age: int, name: str):
self.email = email
self.age = age
self.name = name
# Usage
user = User("john@example.com", 30, "John Doe")
print(user.email) # john@example.com
try:
user.email = "invalid-email" # Raises ValueError
except ValueError as e:
print(e) # email: Must be a valid email address
β οΈ
Data descriptors override instance attributes, while non-data descriptors can be overridden. Use data descriptors for validation and non-data descriptors for methods.
Custom Property Implementation
from typing import Callable, Any, Optional, TypeVar
from functools import wraps
F = TypeVar('F', bound=Callable[..., Any])
class Property:
"""Custom property implementation using descriptors."""
def __init__(
self,
fget: Optional[Callable] = None,
fset: Optional[Callable] = None,
fdel: Optional[Callable] = None,
doc: Optional[str] = None
):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
"""Create new property with getter."""
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
"""Create new property with setter."""
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
"""Create new property with deleter."""
return type(self)(self.fget, self.fset, fdel, self.__doc__)
class Temperature:
"""Example using custom property."""
def __init__(self, celsius: float = 0):
self._celsius = celsius
@Property
def celsius(self) -> float:
"""Temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value: float):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@Property
def fahrenheit(self) -> float:
"""Temperature in Fahrenheit."""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float):
self.celsius = (value - 32) * 5/9
# Usage
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 32
print(temp.celsius) # 0.0
Cached Properties
import time
from typing import Callable, Any
from functools import wraps
class cached_property:
"""Cached property descriptor."""
def __init__(self, func: Callable):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
def __set_name__(self, owner, name):
self.attrname = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Check if value is cached
try:
cache = obj.__dict__
except AttributeError:
cache = {}
if self.attrname in cache:
return cache[self.attrname]
# Compute and cache value
value = self.func(obj)
cache[self.attrname] = value
return value
class TTLCacheProperty:
"""Time-to-live cached property."""
def __init__(self, ttl: float = 60.0):
self.ttl = ttl
self.attrname = None
def __set_name__(self, owner, name):
self.attrname = f"_ttl_{name}"
def __call__(self, func: Callable) -> 'TTLCacheProperty':
self.func = func
self.__doc__ = func.__doc__
return self
def __get__(self, obj, objtype=None):
if obj is None:
return self
cache = obj.__dict__
now = time.time()
# Check if cached value exists and is not expired
if self.attrname in cache:
value, timestamp = cache[self.attrname]
if now - timestamp < self.ttl:
return value
# Compute and cache with timestamp
value = self.func(obj)
cache[self.attrname] = (value, now)
return value
class ExpensiveCalculation:
"""Example with cached and TTL properties."""
@cached_property
def computed_value(self) -> int:
"""Expensive computation - cached after first call."""
print("Computing expensive value...")
time.sleep(1)
return 42
@TTLCacheProperty(ttl=5.0)
def dynamic_value(self) -> float:
"""Value that expires after TTL."""
print("Computing dynamic value...")
return time.time()
# Usage
calc = ExpensiveCalculation()
print(calc.computed_value) # Computes
print(calc.computed_value) # Returns cached
print(calc.dynamic_value) # Computes
print(calc.dynamic_value) # Returns cached
time.sleep(6)
print(calc.dynamic_value) # Recomputes (TTL expired)
Descriptors in Python Standard Library
from typing import ClassVar
import math
class StaticMethod:
"""Manual implementation of staticmethod."""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
return self.func
class ClassMethod:
"""Manual implementation of classmethod."""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
def new_func(*args, **kwargs):
return self.func(objtype, *args, **kwargs)
return new_func
class Circle:
"""Example using descriptor-based methods."""
def __init__(self, radius: float):
self.radius = radius
@StaticMethod
def validate_radius(value: float) -> bool:
"""Static method for validation."""
return isinstance(value, (int, float)) and value >= 0
@ClassMethod
def from_diameter(cls, diameter: float) -> 'Circle':
"""Class method to create from diameter."""
return cls(diameter / 2)
@property
def area(self) -> float:
"""Calculated property."""
return math.pi * self.radius ** 2
# Usage
circle = Circle.from_diameter(10) # Uses classmethod descriptor
print(circle.radius) # 5.0
print(circle.area) # Uses property descriptor
print(Circle.validate_radius(5)) # Uses staticmethod descriptor
βΉοΈ
Python's built-in
property, classmethod, and staticmethod are all implemented using the descriptor protocol. Understanding descriptors gives you deep insight into Python's object model.Follow-Up Questions
-
Explain the difference between data descriptors and non-data descriptors.
-
How does Python's property decorator work internally?
-
When would you use descriptors over regular methods?
-
How do descriptors interact with inheritance?
-
Explain the descriptor lookup chain and precedence rules.