How Python Really Finds Your Attributes (And Why Properties Always Win)
Timothy stared at his screen, genuinely confused. He'd written what seemed like straightforward Python code, but the behavior made no sense.
class Person:
def __init__(self, name):
self.name = name
person = Person("Alice")
print(person.name) # Alice
# Now let's add it to the instance dictionary directly
person.__dict__['name'] = "Bob"
print(person.name) # Bob - makes sense!
"That works as expected," he muttered. "The instance dictionary stores the value, and we get it back."
But then he tried something with a property:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return f"Property: {self._name}"
person = Person("Alice")
print(person.name) # Property: Alice
# Try to override it via __dict__
person.__dict__['name'] = "Bob"
print(person.name) # Still "Property: Alice" - WHAT?!
"That... that doesn't make sense!" Timothy exclaimed, catching Margaret's attention. "I just put 'Bob' directly into the instance dictionary, but the property still wins! How is that possible?"
Margaret smiled knowingly. "Ah, you've stumbled upon one of Python's most elegant secrets—the attribute lookup algorithm. What you're seeing isn't magic, Timothy. It's a carefully orchestrated priority system."
"But I thought the instance dictionary was where attributes lived!"
"It is—but it's not the first place Python looks. Come, let me show you the hidden machinery that makes this all work."
The Attribute Lookup Algorithm
"When you write person.name," Margaret began, pulling out a worn reference manual, "Python follows a specific sequence. This isn't arbitrary—it's the foundation of how descriptors, properties, and methods all work together."
She wrote out the algorithm:
# When you access: obj.attr
# Python actually calls: type(obj).__getattribute__(obj, 'attr')
#
# The default object.__getattribute__ implements this algorithm:
def __getattribute__(obj, name):
"""The actual attribute lookup algorithm (simplified)"""
# Step 1: Find the attribute in the class hierarchy
mro = type(obj).__mro__ # Method Resolution Order
descriptor = None
dict_get = dict.get # Optimization
# Walk the MRO looking for the attribute
for base in mro:
base_dict = getattr(base, '__dict__', None)
if base_dict is not None:
descriptor = dict_get(base_dict, name)
if descriptor is not None:
break
# Step 2: If we found a data descriptor, call it and return
if descriptor is not None:
descriptor_get = getattr(type(descriptor), '__get__', None)
if descriptor_get is not None:
# Check if it's a data descriptor (has __set__ or __delete__)
if (hasattr(type(descriptor), '__set__') or
hasattr(type(descriptor), '__delete__')):
# Data descriptor - call it and return
return descriptor_get(descriptor, obj, type(obj))
# Step 3: Check instance __dict__
obj_dict = getattr(obj, '__dict__', None)
if obj_dict is not None:
if name in obj_dict:
return obj_dict[name]
# Step 4: If we found a non-data descriptor, call it now
if descriptor is not None:
descriptor_get = getattr(type(descriptor), '__get__', None)
if descriptor_get is not None:
return descriptor_get(descriptor, obj, type(obj))
# Found in class dict but not a descriptor
return descriptor
# Step 5: Call __getattr__ if it exists
getattr_method = getattr(type(obj), '__getattr__', None)
if getattr_method is not None:
return getattr_method(obj, name)
# Step 6: Not found anywhere - raise AttributeError
raise AttributeError(f"'{type(obj).__name__}' object has no attribute '{name}'")
"The key insight," Margaret emphasized, "is that all of this happens inside __getattribute__. Every single attribute access goes through this method. When you write obj.attr, Python calls type(obj).__getattribute__(obj, 'attr'), which then implements this entire algorithm."
Timothy studied the code carefully. "So the order is:
-
Data descriptors from class (properties, slots with
__set__) -
Instance
__dict__(regular attributes) - Non-data descriptors from class (methods, classmethod, staticmethod)
-
Class
__dict__(class variables that aren't descriptors) -
__getattr__(if defined) - AttributeError"
"Exactly! And notice something crucial—the algorithm walks the MRO (Method Resolution Order) when looking in class dictionaries. This is how inheritance works with descriptors."
class Base:
@property
def name(self):
return "Base"
class Child(Base):
pass
obj = Child()
print(obj.name) # "Base" - found by walking MRO
print(Child.__mro__) # (<class 'Child'>, <class 'Base'>, <class 'object'>)
"So that's why properties override instance attributes," Timothy realized. "They're data descriptors, which are checked before __dict__!"
"Precisely. Now let's see what makes something a descriptor in the first place."
The Descriptor Protocol
Margaret opened a dusty tome titled "The Descriptor Protocol: Python's Hidden Interface."
"A descriptor is any object whose class defines at least one of these three methods:"
class Descriptor:
def __get__(self, obj, objtype=None):
"""Called when attribute is accessed"""
pass
def __set__(self, obj, value):
"""Called when attribute is assigned"""
pass
def __delete__(self, obj):
"""Called when attribute is deleted"""
pass
"The presence of these methods determines the descriptor's behavior:
-
Non-data descriptor: Only
__get__defined -
Data descriptor: Has
__get__plus (__set__OR__delete__)
The distinction matters because data descriptors have priority over instance dictionaries."
"Let me show you a simple descriptor:"
class LoggedAccess:
"""A descriptor that logs all access"""
def __init__(self, name):
self.name = name
self.storage_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None: # Called on class
return self
value = getattr(obj, self.storage_name)
print(f"Accessing {self.name}: {value}")
return value
def __set__(self, obj, value):
print(f"Setting {self.name} to {value}")
setattr(obj, self.storage_name, value)
class Person:
name = LoggedAccess('name')
def __init__(self, name):
self.name = name # Triggers __set__
person = Person("Alice")
# Setting name to Alice
print(person.name)
# Accessing name: Alice
# Alice
# Try to override via __dict__
person.__dict__['name'] = "Bob"
print(person.name)
# Accessing name: Alice (descriptor still wins!)
"Even though we put 'Bob' in __dict__, the descriptor wins because it's a data descriptor—it has both __get__ and __set__."
Timothy's eyes widened. "So that's the mechanism! Data descriptors get priority."
"Indeed. Now let's look at Python's most famous descriptor..."
Properties: The Elegant Descriptor
"Properties," Margaret said warmly, "are simply pre-built descriptors with a beautiful interface."
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
"""Get the temperature in Celsius"""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Set the temperature in Celsius"""
if value < -273.15:
raise ValueError("Temperature below absolute zero!")
self._celsius = value
@property
def fahrenheit(self):
"""Get the temperature in Fahrenheit"""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Set the temperature via Fahrenheit"""
celsius = (value - 32) * 5/9
self.celsius = celsius # Reuse validation
temp = Temperature(20)
print(temp.celsius) # 20
print(temp.fahrenheit) # 68.0
temp.fahrenheit = 86
print(temp.celsius) # 30.0
try:
temp.celsius = -300
except ValueError as e:
print(e) # Temperature below absolute zero!
"The @property decorator creates a data descriptor. Here's approximately how it works (the actual implementation is in C and more optimized, but this captures the essence):"
class Property:
"""Simplified property implementation (actual is in C)"""
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc if doc is not None else fget.__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 __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
# When you write:
# @property
# def name(self):
# return self._name
#
# Python transforms it to:
# name = Property(name)
Note: This is a simplified Python equivalent of the actual C implementation. The real property class is implemented in C for performance and includes additional optimizations and error handling.
"Notice that having both __get__ and __set__ makes it a data descriptor—even if the setter just raises an error for read-only properties!"
Functions Are Descriptors Too
"Here's something that surprises many," Margaret said with a twinkle in her eye. "Methods aren't special. They're just functions that happen to be descriptors."
class Demo:
def method(self):
return f"Called on {self}"
# The function object IS a descriptor
print(hasattr(Demo.method, '__get__')) # True
print(hasattr(Demo.method, '__set__')) # False (non-data descriptor!)
# When accessed on the class, we get the function
print(Demo.method) # <function Demo.method at 0x...>
# When accessed on an instance, __get__ is called
obj = Demo()
bound_method = obj.method
print(bound_method) # <bound method Demo.method of <Demo object>>
print(bound_method.__self__) # <Demo object>
print(bound_method.__func__) # <function Demo.method at 0x...>
"The function's __get__ method creates a bound method—a wrapper that remembers both the function and the instance."
She demonstrated the mechanism:
class Function:
"""Simplified function descriptor (actual is in C)"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if obj is None:
return self # Accessed on class
# Create bound method
return MethodType(self.func, obj)
from types import MethodType
class Person:
def __init__(self, name):
self.name = name
def greet(self):
return f"Hello, I'm {self.name}"
# Accessing on class returns function
print(Person.greet) # <function Person.greet>
# Accessing on instance creates bound method via __get__
person = Person("Alice")
bound = person.greet
print(bound) # <bound method Person.greet of <Person object>>
print(bound()) # Hello, I'm Alice
# The bound method has references to both
print(bound.__self__) # <Person object>
print(bound.__func__) # <function Person.greet>
# You can even call the function directly with explicit self
print(Person.greet(person)) # Hello, I'm Alice
"This is why self exists! When you call obj.method(), Python:
- Looks up
methodin the class - Finds a function (non-data descriptor)
- Calls its
__get__, which returns a bound method - Calls the bound method, which calls the function with
selfautomatically filled in"
Timothy nodded slowly. "So methods are just functions whose __get__ method creates bound methods..."
"Exactly! And because functions are non-data descriptors (only __get__, no __set__), you can override them in the instance dictionary:"
class Demo:
def method(self):
return "original"
obj = Demo()
print(obj.method()) # original
# Override in instance dict (works because function is non-data descriptor!)
obj.__dict__['method'] = lambda: "overridden"
print(obj.method()) # overridden
Practical Descriptor Patterns
Pattern 1: Type Validation
"One of the most common uses of descriptors is enforcing type constraints:"
class TypedProperty:
"""A descriptor that enforces type checking"""
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
self.storage_name = f'_{name}'
def __set_name__(self, owner, name):
# Called when descriptor is assigned to class attribute
# This happens once, during class creation (not per instance)
self.name = name
self.storage_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage_name, None)
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__}"
)
setattr(obj, self.storage_name, value)
class Person:
name = TypedProperty('name', str)
age = TypedProperty('age', int)
def __init__(self, name, age):
self.name = name
self.age = age
# Works fine
person = Person("Alice", 30)
print(f"{person.name} is {person.age}") # Alice is 30
# Type checking in action
try:
person.age = "thirty"
except TypeError as e:
print(e) # age must be int, got str
try:
person.name = 123
except TypeError as e:
print(e) # name must be str, got int
Note: The __set_name__ method is called once during class creation, not when creating instances. When Python creates the Person class, it calls __set_name__ on each descriptor to let it know its attribute name.
Pattern 2: Lazy Computation with Caching
"Descriptors can compute values on first access and cache them:"
class CachedProperty:
"""A property that computes once and caches the result"""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Compute the value
value = self.func(obj)
# Store in instance __dict__ - this makes it override the descriptor!
# Next access will find it in __dict__ (checked before non-data descriptors)
obj.__dict__[self.name] = value
return value
class DataProcessor:
def __init__(self, numbers):
self.numbers = numbers
@CachedProperty
def total(self):
"""Expensive computation - only do it once"""
print("Computing total...")
return sum(self.numbers)
@CachedProperty
def average(self):
"""Another expensive computation"""
print("Computing average...")
return self.total / len(self.numbers)
processor = DataProcessor([1, 2, 3, 4, 5])
print(processor.total) # Computing total... \n 15
print(processor.total) # 15 (no recomputation!)
print(processor.average) # Computing average... \n 3.0
print(processor.average) # 3.0 (cached!)
# The values are now in __dict__
print(processor.__dict__) # {'numbers': [1,2,3,4,5], 'total': 15, 'average': 3.0}
"Notice the clever trick: CachedProperty is a non-data descriptor (only __get__), so after it puts the value in __dict__, the instance dictionary takes precedence on subsequent accesses!"
Python 3.8+ includes this pattern in the standard library:
from functools import cached_property
class DataProcessor:
def __init__(self, numbers):
self.numbers = numbers
@cached_property
def total(self):
print("Computing total...")
return sum(self.numbers)
processor = DataProcessor([1, 2, 3, 4, 5])
print(processor.total) # Computing total... \n 15
print(processor.total) # 15 (cached!)
Note: The custom CachedProperty implementation shown above is not thread-safe. For production code with threading, use functools.cached_property which includes proper locking mechanisms.
Pattern 3: Attribute Aliasing
"Descriptors can create aliases or computed attributes:"
class Alias:
"""Create an alias to another attribute"""
def __init__(self, target_name):
self.target_name = target_name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.target_name)
def __set__(self, obj, value):
setattr(obj, self.target_name, value)
class Person:
def __init__(self, full_name):
self.full_name = full_name
# Create an alias
name = Alias('full_name')
person = Person("Alice Wonderland")
print(person.name) # Alice Wonderland
print(person.full_name) # Alice Wonderland
person.name = "Bob Builder"
print(person.full_name) # Bob Builder
Real-World Example: Django's Model Fields
"Let's look at how Django uses descriptors for database fields," Margaret suggested, pulling out a Django models reference.
class Field:
"""Simplified Django field descriptor"""
def __init__(self, default=None):
self.default = default
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 value from instance dict, or default
return obj.__dict__.get(self.name, self.default)
def __set__(self, obj, value):
# Validate and store
obj.__dict__[self.name] = self.validate(value)
def validate(self, value):
"""Override in subclasses"""
return value
class CharField(Field):
def __init__(self, max_length, **kwargs):
super().__init__(**kwargs)
self.max_length = max_length
def validate(self, value):
if value is None:
return value
if not isinstance(value, str):
raise TypeError(f"Expected str, got {type(value).__name__}")
if len(value) > self.max_length:
raise ValueError(f"String too long (max {self.max_length})")
return value
class IntegerField(Field):
def validate(self, value):
if value is None:
return value
if not isinstance(value, int):
raise TypeError(f"Expected int, got {type(value).__name__}")
return value
# Using the fields (like Django models)
class User:
username = CharField(max_length=50)
age = IntegerField(default=0)
def __init__(self, username, age=0):
self.username = username
self.age = age
user = User("alice", 30)
print(user.username) # alice
print(user.age) # 30
# Validation in action
try:
user.username = "a" * 100
except ValueError as e:
print(e) # String too long (max 50)
try:
user.age = "thirty"
except TypeError as e:
print(e) # Expected int, got str
"This pattern is at the heart of Django's ORM. Each model field is a descriptor that handles validation, type conversion, and database mapping."
slots: The Space-Saving Descriptor
"There's one more important descriptor built into Python," Margaret noted. "Slots."
class Regular:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# Regular class has __dict__
regular = Regular(1, 2)
print(hasattr(regular, '__dict__')) # True
print(regular.__dict__) # {'x': 1, 'y': 2}
# Slotted class doesn't have __dict__
slotted = WithSlots(1, 2)
print(hasattr(slotted, '__dict__')) # False
# Can't add arbitrary attributes
try:
slotted.z = 3
except AttributeError as e:
print(e) # 'WithSlots' object has no attribute 'z'
# But declared slots work fine
print(slotted.x) # 1
slotted.x = 10
print(slotted.x) # 10
"Each slot is actually a data descriptor created by Python:"
class WithSlots:
__slots__ = ('x', 'y')
# Python creates descriptors for each slot
print(type(WithSlots.x)) # <class 'member_descriptor'>
print(hasattr(WithSlots.x, '__get__')) # True
print(hasattr(WithSlots.x, '__set__')) # True - data descriptor!
print(hasattr(WithSlots.x, '__delete__')) # True
# The descriptor stores values in a fixed location
obj = WithSlots()
obj.x = 42
# The descriptor's __set__ stores 42 in a fixed memory slot
# Since slots are data descriptors, they override __dict__
# (if __dict__ were present, which it isn't with slots)
"Slots save memory by eliminating __dict__ and using descriptors to access fixed memory locations instead."
getattribute vs getattr: The Safety Net
"We've seen how __getattribute__ implements the standard lookup algorithm," Margaret said. "But there's also __getattr__—a fallback hook."
class Fallback:
def __init__(self):
self.existing = "I exist"
def __getattr__(self, name):
"""Called only when normal lookup fails"""
print(f"__getattr__ called for: {name}")
return f"Generated: {name}"
obj = Fallback()
# Normal lookup succeeds - __getattr__ not called
print(obj.existing) # I exist
# Normal lookup fails - __getattr__ called
print(obj.missing)
# __getattr__ called for: missing
# Generated: missing
"But __getattribute__ is different—it's called for every attribute access:"
class Monitor:
def __init__(self):
# Use object.__setattr__ to avoid recursion
object.__setattr__(self, 'existing', 'I exist')
def __getattribute__(self, name):
"""Called for EVERY attribute access"""
print(f"__getattribute__ called for: {name}")
# Must call parent to do actual lookup
return object.__getattribute__(self, name)
obj = Monitor()
print(obj.existing)
# __getattribute__ called for: existing
# I exist
try:
print(obj.missing)
# __getattribute__ called for: missing
# AttributeError: 'Monitor' object has no attribute 'missing'
except AttributeError as e:
print(e)
"Be very careful with __getattribute__—it's easy to create infinite recursion:"
class Dangerous:
def __init__(self):
self.value = 42
def __getattribute__(self, name):
print(f"Getting {name}")
# DON'T DO THIS! Causes infinite recursion
# return self.value # This calls __getattribute__ again!
# DO THIS instead:
return object.__getattribute__(self, name)
obj = Dangerous()
print(obj.value)
# Getting value
# 42
"The key difference:
-
__getattribute__: Called for every access (implement carefully!) -
__getattr__: Called only when normal lookup fails (safe fallback)"
Practical Use: Proxy Pattern
class LazyProxy:
"""Proxy that creates wrapped object on first access"""
def __init__(self, factory):
# Store factory without triggering __setattr__
object.__setattr__(self, '_factory', factory)
object.__setattr__(self, '_wrapped', None)
def _ensure_wrapped(self):
"""Create wrapped object if needed"""
if object.__getattribute__(self, '_wrapped') is None:
factory = object.__getattribute__(self, '_factory')
wrapped = factory()
object.__setattr__(self, '_wrapped', wrapped)
def __getattribute__(self, name):
# Handle special attributes
if name in ('_factory', '_wrapped', '_ensure_wrapped'):
return object.__getattribute__(self, name)
# Ensure wrapped object exists
object.__getattribute__(self, '_ensure_wrapped')()
# Delegate to wrapped object
wrapped = object.__getattribute__(self, '_wrapped')
return getattr(wrapped, name)
# Usage
class Expensive:
def __init__(self):
print("Creating expensive object...")
self.data = "expensive data"
def process(self):
return f"Processing: {self.data}"
# Create proxy - doesn't create Expensive yet
proxy = LazyProxy(lambda: Expensive())
print("Proxy created")
# First access creates the wrapped object
print(proxy.process())
# Creating expensive object...
# Processing: expensive data
# Subsequent access reuses wrapped object
print(proxy.process())
# Processing: expensive data
Debugging Attribute Lookup
"When debugging descriptor issues," Margaret advised, "use these tools:"
import inspect
class MyClass:
class_var = "class variable"
def __init__(self):
self.instance_var = "instance variable"
@property
def prop(self):
return "property value"
def method(self):
return "method result"
obj = MyClass()
# Check if something is a descriptor
def is_descriptor(obj):
return (hasattr(type(obj), '__get__') or
hasattr(type(obj), '__set__') or
hasattr(type(obj), '__delete__'))
# Check if it's a data descriptor
def is_data_descriptor(obj):
return (hasattr(type(obj), '__get__') and
(hasattr(type(obj), '__set__') or
hasattr(type(obj), '__delete__')))
# Inspect class attributes
for name, value in inspect.getmembers(MyClass):
if not name.startswith('_'):
is_desc = is_descriptor(value)
is_data = is_data_descriptor(value)
print(f"{name:15} descriptor={is_desc:5} data={is_data:5} type={type(value).__name__}")
# Output:
# class_var descriptor=False data=False type=str
# method descriptor=True data=False type=function
# prop descriptor=True data=True type=property
# Check instance __dict__
print(f"\nInstance __dict__: {obj.__dict__}")
# {'instance_var': 'instance variable'}
# Trace lookup manually
def trace_lookup(obj, attr):
"""Show where an attribute comes from"""
print(f"\nLooking up '{attr}' on {type(obj).__name__}:")
# Check class hierarchy for descriptors
for cls in type(obj).__mro__:
if attr in cls.__dict__:
value = cls.__dict__[attr]
if is_data_descriptor(value):
print(f" ✓ Found data descriptor in {cls.__name__}.__dict__")
return
else:
print(f" - Found in {cls.__name__}.__dict__ (will check instance first)")
break
# Check instance __dict__
if hasattr(obj, '__dict__') and attr in obj.__dict__:
print(f" ✓ Found in instance.__dict__")
return
# Check for non-data descriptors
for cls in type(obj).__mro__:
if attr in cls.__dict__:
value = cls.__dict__[attr]
if is_descriptor(value):
print(f" ✓ Found non-data descriptor in {cls.__name__}.__dict__")
else:
print(f" ✓ Found in {cls.__name__}.__dict__")
return
print(f" ✗ Not found - would raise AttributeError")
trace_lookup(obj, 'prop') # Data descriptor
trace_lookup(obj, 'instance_var') # Instance dict
trace_lookup(obj, 'method') # Non-data descriptor
trace_lookup(obj, 'class_var') # Class dict
trace_lookup(obj, 'missing') # Not found
Conclusion: The Hidden Orchestra
Timothy leaned back, his confusion replaced by understanding. "So when I write person.name, Python:
- Calls
type(person).__getattribute__(person, 'name') - Which walks the MRO looking for 'name' in class dictionaries
- If found and it's a data descriptor, calls its
__get__and returns - Otherwise checks
person.__dict__ - If not there, checks for non-data descriptors in the class
- Falls back to
__getattr__if defined - Finally raises
AttributeErrorif nothing found
And this whole system enables properties, methods, slots, and custom descriptors to all work together!"
"Precisely," Margaret smiled. "The descriptor protocol is Python's way of giving you control over attribute access without sacrificing the simple obj.attr syntax we all love."
"Properties are just data descriptors that happen to look nice with the @property decorator. Methods are functions whose __get__ creates bound methods. Slots are descriptors created by Python to save memory. And you can write your own descriptors for validation, lazy computation, aliasing—anything you need."
"The beauty," she continued, "is that this all happens invisibly. Most Python programmers use properties and methods every day without knowing about descriptors. But now you understand the machinery beneath."
Timothy nodded, closing his notebook. "And that's why properties always win over __dict__—they're checked first in the lookup order."
"Exactly. The simple mystery you discovered led us to one of Python's most elegant designs." Margaret gestured to the shelves around them. "This library is full of such discoveries. Every time you think you understand Python, there's another layer waiting."
"Speaking of layers," Timothy said with a grin, "I'm curious about that super() function. How does it navigate the MRO?"
Margaret's eyes twinkled. "Ah, Timothy. That's a story for another day..."
Key Takeaways
-
Attribute access calls
__getattribute__, which implements the lookup algorithm - The MRO is walked when looking for descriptors in class dictionaries
-
Data descriptors (with
__get__and__set__or__delete__) override instance__dict__ -
Instance
__dict__is checked after data descriptors but before non-data descriptors -
Non-data descriptors (only
__get__) are checked after instance__dict__ - Properties are data descriptors with a nice syntax
- Functions are non-data descriptors that create bound methods
- Slots are data descriptors that save memory
-
__getattr__is only called when normal lookup fails -
__getattribute__is called for every attribute access (use carefully!)
Next in The Secret Life of Python: "super() and the Method Resolution Order"
About This Series
The Secret Life of Python reveals the hidden mechanisms that make Python work. Through the adventures of Timothy (a curious developer) and Margaret (a wise Python expert) in a vast library, we explore the elegant designs beneath Python's simple syntax.
Each article stands alone but builds on previous knowledge. Whether you're debugging mysterious behavior or just curious about how Python really works, this series illuminates the path.
Previous articles include The Descriptor Protocol Basics, Method Resolution Order, Metaclasses Demystified, and more.
Discussion Questions
- When would you choose a descriptor over a property?
- How do Django's model fields use descriptors for database mapping?
- What are the trade-offs of using
__slots__? - When is
__getattribute__worth the complexity? - How do descriptors relate to the decorator pattern?
Share your thoughts and experiences with descriptors in the comments below!
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)