8 min read
ā¢Question 29 of 41hardDescriptors in Python
Customizing attribute access.
What You'll Learn
- What descriptors are and why they matter
- The descriptor protocol (get, set, delete)
- Data vs non-data descriptors
- Practical use cases: validation, lazy properties, ORM fields
- How property() works under the hood
What are Descriptors?
A descriptor is any object that defines __get__, __set__, or __delete__. Descriptors let you customize what happens when an attribute is accessed, set, or deleted.
code.pyPython
class Descriptor:
def __get__(self, obj, objtype=None):
print(f"__get__ called: obj={obj}, type={objtype}")
return "value from descriptor"
def __set__(self, obj, value):
print(f"__set__ called: setting {value}")
def __delete__(self, obj):
print("__delete__ called")
class MyClass:
attr = Descriptor() # Descriptor as class attribute
obj = MyClass()
# Accessing triggers __get__
print(obj.attr)
# __get__ called: obj=<MyClass object>, type=<class 'MyClass'>
# value from descriptor
# Setting triggers __set__
obj.attr = 42
# __set__ called: setting 42
# Deleting triggers __delete__
del obj.attr
# __delete__ calledData vs Non-Data Descriptors
| Type | Methods | Priority |
|---|---|---|
| Data descriptor | get + set (or delete) | Highest - overrides instance dict |
| Non-data descriptor | get only | Lower - instance dict takes precedence |
code.pyPython
class DataDescriptor:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass # Just having __set__ makes it a data descriptor
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class MyClass:
data = DataDescriptor()
nondata = NonDataDescriptor()
obj = MyClass()
# Data descriptor always wins
obj.__dict__['data'] = "instance value"
print(obj.data) # "data descriptor" (descriptor wins)
# Non-data descriptor loses to instance dict
obj.__dict__['nondata'] = "instance value"
print(obj.nondata) # "instance value" (instance wins)Practical Use Cases
Type Validation
code.pyPython
class Typed:
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"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class Person:
name = Typed('name', str)
age = Typed('age', int)
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30) # OK
print(p.name, p.age) # Alice 30
# p = Person("Bob", "thirty")
# TypeError: age must be int, got strRange Validation
code.pyPython
class Ranged:
def __init__(self, name, min_val, max_val):
self.name = name
self.min_val = min_val
self.max_val = max_val
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.min_val <= value <= self.max_val:
raise ValueError(
f"{self.name} must be between {self.min_val} and {self.max_val}"
)
obj.__dict__[self.name] = value
class Temperature:
celsius = Ranged('celsius', -273.15, 1000)
temp = Temperature()
temp.celsius = 25 # OK
# temp.celsius = -300 # ValueErrorLazy Property (Cached)
code.pyPython
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Compute and cache in instance dict
value = self.func(obj)
obj.__dict__[self.name] = value # Replace descriptor access
return value
class DataAnalyzer:
def __init__(self, data):
self.data = data
@LazyProperty
def statistics(self):
print("Computing statistics (expensive)...")
return {
'sum': sum(self.data),
'avg': sum(self.data) / len(self.data),
'max': max(self.data),
'min': min(self.data)
}
analyzer = DataAnalyzer([1, 2, 3, 4, 5])
print(analyzer.statistics)
# Computing statistics (expensive)...
# {'sum': 15, 'avg': 3.0, 'max': 5, 'min': 1}
print(analyzer.statistics) # No computation - cached!
# {'sum': 15, 'avg': 3.0, 'max': 5, 'min': 1}ORM-Style Fields
code.pyPython
class Field:
def __init__(self, column_name=None):
self.column_name = column_name
def __set_name__(self, owner, name):
# Called automatically when class is created
self.name = name
if self.column_name is None:
self.column_name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class Model:
id = Field(column_name='_id')
name = Field()
user = Model()
user.id = 1
user.name = "Alice"
print(Model.id.column_name) # '_id'
print(Model.name.column_name) # 'name'How property() Works
The built-in property is a descriptor:
code.pyPython
class Property:
"""Simplified property implementation."""
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("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 setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
# Real property usage
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2Interview Tip
When asked about descriptors:
- Descriptors customize attribute access via get/set/delete
- Data descriptors (with set) take precedence over instance dict
- Non-data descriptors (only get) are overridden by instance dict
- property, classmethod, staticmethod are all descriptors
- Use set_name (Python 3.6+) to get the attribute name automatically