get set delete¶
The Three Methods¶
1. Method Signatures¶
class Descriptor:
def __get__(self, instance, owner):
"""Called on attribute read"""
pass
def __set__(self, instance, value):
"""Called on attribute write"""
pass
def __delete__(self, instance):
"""Called on attribute deletion"""
pass
2. Parameters¶
__get__(self, instance, owner):
- self - the descriptor object itself
- instance - the instance being accessed (None if accessed from class)
- owner - the class that owns the descriptor
__set__(self, instance, value):
- self - the descriptor object itself
- instance - the instance being modified
- value - the value being assigned
__delete__(self, instance):
- self - the descriptor object itself
- instance - the instance where attribute is being deleted
3. When They're Called¶
obj.attr # → __get__(descriptor, obj, type(obj))
obj.attr = val # → __set__(descriptor, obj, val)
del obj.attr # → __delete__(descriptor, obj)
Class.attr # → __get__(descriptor, None, Class)
get Method¶
1. Basic Implementation¶
class GetDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
print(f"__get__ called")
print(f" self = {self}")
print(f" instance = {instance}")
print(f" owner = {owner}")
if instance is None:
return self # Accessed from class
return instance.__dict__.get(self.name, "default")
class MyClass:
attr = GetDescriptor('attr')
# Access from class
print(MyClass.attr)
# __get__ called
# self = <GetDescriptor object>
# instance = None
# owner = <class 'MyClass'>
# Access from instance
obj = MyClass()
obj.attr = 42
print(obj.attr)
# __get__ called
# self = <GetDescriptor object>
# instance = <MyClass object>
# owner = <class 'MyClass'>
# 42
2. Handling Class vs Instance¶
class SmartGetter:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
if instance is None:
# Accessed from class - return descriptor
return self
# Accessed from instance - return value
return self.value
class Example:
x = SmartGetter(42)
print(Example.x) # <SmartGetter object>
print(Example().x) # 42
3. Computed Values¶
class ComputedProperty:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
if instance is None:
return self
return self.func(instance)
class Circle:
def __init__(self, radius):
self.radius = radius
@ComputedProperty
def area(self):
from math import pi
return pi * self.radius ** 2
c = Circle(5)
print(c.area) # 78.54... (computed each time)
set Method¶
1. Basic Implementation¶
class SetDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
print(f"__set__ called")
print(f" self = {self}")
print(f" instance = {instance}")
print(f" value = {value}")
instance.__dict__[self.name] = value
class MyClass:
attr = SetDescriptor('attr')
obj = MyClass()
obj.attr = 42
# __set__ called
# self = <SetDescriptor object>
# instance = <MyClass object>
# value = 42
2. Validation¶
class ValidatedDescriptor:
def __init__(self, name, validator):
self.name = name
self.validator = validator
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not self.validator(value):
raise ValueError(f"Invalid value for {self.name}: {value}")
instance.__dict__[self.name] = value
class Person:
age = ValidatedDescriptor('age', lambda x: 0 <= x <= 150)
name = ValidatedDescriptor('name', lambda x: len(x) > 0)
p = Person()
p.age = 30 # ✅ OK
# p.age = 200 # ❌ ValueError
p.name = "Alice" # ✅ OK
# p.name = "" # ❌ ValueError
3. Type Enforcement¶
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
instance.__dict__[self.name] = value
class Product:
name = TypedDescriptor('name', str)
price = TypedDescriptor('price', (int, float))
quantity = TypedDescriptor('quantity', int)
prod = Product()
prod.name = "Widget" # ✅ OK
prod.price = 19.99 # ✅ OK
# prod.name = 123 # ❌ TypeError
delete Method¶
1. Basic Implementation¶
class DeleteDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value
def __delete__(self, instance):
print(f"__delete__ called")
print(f" self = {self}")
print(f" instance = {instance}")
del instance.__dict__[self.name]
class MyClass:
attr = DeleteDescriptor('attr')
obj = MyClass()
obj.attr = 42
del obj.attr
# __delete__ called
# self = <DeleteDescriptor object>
# instance = <MyClass object>
2. Protected Deletion¶
class ProtectedDescriptor:
def __init__(self, name, deletable=True):
self.name = name
self.deletable = deletable
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value
def __delete__(self, instance):
if not self.deletable:
raise AttributeError(f"Cannot delete {self.name}")
del instance.__dict__[self.name]
class Person:
id = ProtectedDescriptor('id', deletable=False)
name = ProtectedDescriptor('name', deletable=True)
p = Person()
p.id = 123
p.name = "Alice"
del p.name # ✅ OK
# del p.id # ❌ AttributeError
3. Cleanup on Delete¶
class ResourceDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value
def __delete__(self, instance):
resource = instance.__dict__.get(self.name)
if resource and hasattr(resource, 'close'):
print(f"Closing resource: {self.name}")
resource.close()
del instance.__dict__[self.name]
class FileHandler:
file = ResourceDescriptor('file')
handler = FileHandler()
handler.file = open('test.txt', 'w')
del handler.file # Closes file before deleting
Complete Example¶
1. Full Descriptor¶
class ManagedAttribute:
def __init__(self, name, validator=None, default=None):
self.name = name
self.validator = validator
self.default = default
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, self.default)
def __set__(self, instance, value):
if self.validator and not self.validator(value):
raise ValueError(f"Invalid value for {self.name}")
instance.__dict__[self.name] = value
def __delete__(self, instance):
if self.name in instance.__dict__:
del instance.__dict__[self.name]
class Person:
name = ManagedAttribute(
'name',
validator=lambda x: isinstance(x, str) and len(x) > 0
)
age = ManagedAttribute(
'age',
validator=lambda x: isinstance(x, int) and 0 <= x <= 150,
default=0
)
p = Person()
p.name = "Alice"
p.age = 30
print(p.name, p.age) # Alice 30
del p.age
print(p.age) # 0 (default)
2. With Logging¶
class LoggedDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
value = instance.__dict__.get(self.name)
print(f"[GET] {self.name} = {value}")
return value
def __set__(self, instance, value):
print(f"[SET] {self.name} = {value}")
instance.__dict__[self.name] = value
def __delete__(self, instance):
print(f"[DEL] {self.name}")
if self.name in instance.__dict__:
del instance.__dict__[self.name]
class Example:
x = LoggedDescriptor('x')
y = LoggedDescriptor('y')
obj = Example()
obj.x = 10 # [SET] x = 10
print(obj.x) # [GET] x = 10
del obj.x # [DEL] x
3. With Caching¶
class CachedDescriptor:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
# Check cache
cache_name = f'_cached_{self.name}'
if cache_name not in instance.__dict__:
# Compute and cache
value = self.func(instance)
instance.__dict__[cache_name] = value
return instance.__dict__[cache_name]
def __set__(self, instance, value):
raise AttributeError("Cannot set computed property")
def __delete__(self, instance):
# Clear cache
cache_name = f'_cached_{self.name}'
if cache_name in instance.__dict__:
del instance.__dict__[cache_name]
class ExpensiveCalculation:
def __init__(self, data):
self.data = data
@CachedDescriptor
def result(self):
print("Computing...")
return sum(x**2 for x in self.data)
obj = ExpensiveCalculation([1, 2, 3, 4, 5])
print(obj.result) # Computing... 55
print(obj.result) # 55 (from cache)
del obj.result # Clear cache
print(obj.result) # Computing... 55
Method Interaction¶
1. All Three Together¶
When all three methods are defined, they work together:
obj.attr # → __get__
obj.attr = val # → __set__
del obj.attr # → __delete__
2. Only Some Defined¶
You don't need all three:
# Read-only descriptor (no __set__)
class ReadOnly:
def __get__(self, instance, owner):
return "constant"
# Write-only descriptor (unusual, but possible)
class WriteOnly:
def __set__(self, instance, value):
instance.__dict__['_value'] = value
3. Call Order¶
class TrackedDescriptor:
def __get__(self, instance, owner):
print("1. __get__")
return instance.__dict__.get('value', 0)
def __set__(self, instance, value):
print("2. __set__")
instance.__dict__['value'] = value
def __delete__(self, instance):
print("3. __delete__")
del instance.__dict__['value']
class Example:
attr = TrackedDescriptor()
obj = Example()
obj.attr = 10 # 2. __set__
x = obj.attr # 1. __get__
del obj.attr # 3. __delete__
Runnable Example: method_as_descriptor.py¶
"""
TUTORIAL: Methods as Descriptors - Understanding the Descriptor Protocol
This tutorial reveals the magic behind Python methods. Every time you access
a method on an object, Python's descriptor protocol is at work. Functions
are actually non-data descriptors that use __get__ to bind 'self'.
Understanding this explains fundamental Python behavior: why methods work,
why 'self' is implicit, and how you can manipulate methods if needed.
Key Learning Goals:
- See methods as descriptors (functions with __get__)
- Understand how 'self' gets bound automatically
- Learn the difference between bound and unbound methods
- Understand access via instance vs class
- See practical uses of this knowledge
"""
import collections
if __name__ == "__main__":
print("=" * 70)
print("TUTORIAL: Methods as Descriptors - The Function Protocol")
print("=" * 70)
# ============ EXAMPLE 1: Functions Are Descriptors ============
print("\n# Example 1: Functions Implement the Descriptor Protocol")
print("=" * 70)
class MyClass:
"""Simple class with a method."""
def method(self):
"""A simple method."""
return "method result"
print("A function object is a descriptor:")
print(f"type(MyClass.method) = {type(MyClass.method)}")
print(f"hasattr(function, '__get__') = {hasattr(MyClass.method, '__get__')}")
print()
print("When you access via instance, __get__ is called:")
obj = MyClass()
print(f"obj.method = {obj.method}")
print(f"type(obj.method) = {type(obj.method)}")
print()
print("When you access via class:")
print(f"MyClass.method = {MyClass.method}")
print(f"type(MyClass.method) = {type(MyClass.method)}")
print()
print("""
WHY: Functions are non-data descriptors:
- They have __get__ but not __set__
- When accessed via instance, __get__ creates a bound method
- When accessed via class, __get__ returns the function itself
- Instance __dict__ can shadow methods (methods aren't data descriptors)
This is the descriptor protocol in action!
""")
# ============ EXAMPLE 2: The Bound Method ============
print("\n# Example 2: Accessing Methods Creates Bound Methods")
print("=" * 70)
class Text(collections.UserString):
"""Text class with a reverse method."""
def __repr__(self):
return 'Text({!r})'.format(self.data)
def reverse(self):
"""Return text reversed."""
return self[::-1]
word = Text('forward')
print(f"Original: {word}")
print()
# Access method via instance
print("Access method via instance:")
method = word.reverse
print(f" word.reverse = {method}")
print(f" type = {type(method)}")
print()
# Call it
result = method()
print(f" word.reverse() = {result}")
print()
# Access method via class
print("Access method via class:")
func = Text.reverse
print(f" Text.reverse = {func}")
print(f" type = {type(func)}")
print()
# Call it with instance as argument
result = func(word)
print(f" Text.reverse(word) = {result}")
print("""
WHY: The bound method:
- Returned by obj.method (instance access)
- Carries a reference to both the function and the instance
- When called, automatically passes the instance as 'self'
The unbound function:
- Returned by Class.method (class access)
- Just the function, no 'self' binding
- You must pass an instance if you call it
This is how Python makes 'self' implicit and convenient.
""")
# ============ EXAMPLE 3: How __get__ Works ============
print("\n# Example 3: Calling __get__ Directly")
print("=" * 70)
class Demo:
def method(self):
return f"method called on {self}"
obj = Demo()
print("The __get__ method creates the bound method:")
print(f"Demo.method.__get__(obj, Demo) = {Demo.method.__get__(obj, Demo)}")
print()
print("Calling __get__ with None instance returns unbound:")
print(f"Demo.method.__get__(None, Demo) = {Demo.method.__get__(None, Demo)}")
print()
print("The bound method carries references to function and instance:")
bound = obj.method
print(f"bound.__func__ = {bound.__func__}")
print(f"bound.__self__ = {bound.__self__}")
print(f"bound.__func__ is Demo.method = {bound.__func__ is Demo.method}")
print(f"bound.__self__ is obj = {bound.__self__ is obj}")
print()
print("You can call the bound method:")
print(f"bound() = {bound()}")
print("""
WHY: Understanding __get__ explains:
- __func__: The actual function object
- __self__: The instance it's bound to
- When you call the bound method, __self__ is passed as 'self'
This is all transparent normally, but now you see the mechanism!
""")
# ============ EXAMPLE 4: Shadowing Methods ============
print("\n# Example 4: Instance Attributes Shadow Methods")
print("=" * 70)
class Shadowing:
def method(self):
return "class method"
obj = Shadowing()
print("Initially, call the class method:")
print(f" obj.method() = {obj.method()}")
print()
print("Shadow it with an instance attribute:")
obj.method = lambda: "instance lambda"
print(f" obj.method = {obj.method}")
print()
print("Now it calls the instance attribute:")
print(f" obj.method() = {obj.method()}")
print()
print("Class still has the original:")
print(f" Shadowing.method = {Shadowing.method}")
print(f" Shadowing.method(obj) = {Shadowing.method(obj)}")
print()
print("Delete the instance attribute to restore:")
del obj.method
print(f" obj.method() = {obj.method()}")
print("""
WHY: Methods are non-data descriptors:
- Non-data means instance __dict__ can shadow them
- Set obj.method = something, and that takes priority
- This is unlike @property (data descriptor), which always intercepts
- This flexibility is useful but requires care
This shows why non-data descriptors matter - methods are practical!
""")
# ============ EXAMPLE 5: Bound Method Equality ============
print("\n# Example 5: Bound Methods and Equality")
print("=" * 70)
class Data:
def check(self):
return True
obj1 = Data()
obj2 = Data()
print("Getting the same bound method twice:")
m1 = obj1.check
m2 = obj1.check
print(f" m1 = obj1.check")
print(f" m2 = obj1.check")
print(f" m1 == m2 = {m1 == m2}")
print(f" m1 is m2 = {m1 is m2}") # Different objects!
print()
print("Bound methods from different instances:")
m3 = obj1.check
m4 = obj2.check
print(f" m3 = obj1.check")
print(f" m4 = obj2.check")
print(f" m3 == m4 = {m3 == m4}")
print(f" m3.__self__ is m4.__self__ = {m3.__self__ is m4.__self__}")
print("""
WHY: Bound methods are created fresh on each access:
- obj.method doesn't cache the bound method
- Each access calls __get__ and creates a new bound method object
- They compare equal if they wrap the same function and instance
- But they're not identical (different objects)
This is usually transparent, but matters if you use bound methods as dict keys.
""")
# ============ EXAMPLE 6: Using map With Methods ============
print("\n# Example 6: Practical Use - Applying Methods Across Objects")
print("=" * 70)
class Number:
def __init__(self, value):
self.value = value
def squared(self):
return self.value ** 2
def __repr__(self):
return f"Number({self.value})"
numbers = [Number(1), Number(2), Number(3)]
print("Using map with a method:")
print(f"numbers = {numbers}")
print()
print("Apply squared() to all:")
# This works because the unbound method can take any instance
results = list(map(Number.squared, numbers))
print(f"map(Number.squared, numbers) = {results}")
print()
print("""
WHY: This works because:
- Number.squared is the unbound function
- map passes each number as the first argument
- The function is called as Number.squared(number)
You could also do:
results = [num.squared() for num in numbers]
But using the unbound method is more elegant in some cases.
""")
# ============ EXAMPLE 7: Staticmethod and Classmethod ============
print("\n# Example 7: Other Descriptor Types - staticmethod and classmethod")
print("=" * 70)
class Demo:
@staticmethod
def static():
"""Static methods don't get 'self' or 'cls' binding."""
return "static result"
@classmethod
def cls_method(cls):
"""Class methods get 'cls' as first argument."""
return f"class method from {cls.__name__}"
def instance_method(self):
"""Normal methods get 'self'."""
return "instance method"
print("Instance method (normal):")
print(f" Demo.instance_method = {Demo.instance_method}")
print()
print("Class method (bound to class):")
print(f" Demo.cls_method = {Demo.cls_method}")
print(f" Demo.cls_method() = {Demo.cls_method()}")
print()
print("Static method (no binding):")
print(f" Demo.static = {Demo.static}")
print(f" Demo.static() = {Demo.static()}")
print()
print("All are descriptors but work differently:")
obj = Demo()
print(f"obj.instance_method() = {obj.instance_method()}")
print(f"obj.cls_method() = {obj.cls_method()}")
print(f"obj.static() = {obj.static()}")
print("""
WHY: Python provides three method types:
1. INSTANCE METHOD (normal function)
- Descriptor: __get__ binds self
- Access: obj.method() or Class.method(obj)
- Use: Most methods
2. CLASS METHOD (@classmethod)
- Descriptor: __get__ binds cls
- Access: obj.method() or Class.method()
- Use: Factory methods, class-specific operations
3. STATIC METHOD (@staticmethod)
- Not a descriptor, just a plain function wrapper
- Access: obj.method() or Class.method()
- Use: Utility functions in a class namespace
All are descriptors (or descriptor-like) but serve different purposes!
""")
# ============ EXAMPLE 8: Custom Descriptor Mimicking Methods ============
print("\n# Example 8: Building a Method-Like Descriptor")
print("=" * 70)
class MethodLike:
"""Descriptor that mimics method behavior."""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if obj is None:
return self.func # Accessed from class
# Create a bound method-like object
return lambda *args, **kwargs: self.func(obj, *args, **kwargs)
class MyClass:
@MethodLike
def my_method(self):
return f"Called on {self}"
obj = MyClass()
print("Using the descriptor:")
print(f"MyClass.my_method = {MyClass.my_method}")
print(f"obj.my_method = {obj.my_method}")
print()
print("Calling the bound version:")
result = obj.my_method()
print(f"obj.my_method() = {result}")
print()
print("""
WHY: This demonstrates:
- How to build a descriptor from scratch
- How __get__ can return different things based on context
- How to create a "bound method" using a lambda
- That descriptors give you complete control over access
In practice, use @property or regular methods. This is educational!
""")
# ============ EXAMPLE 9: Summary - The Method Mechanism ============
print("\n# Example 9: Complete Picture - How Methods Work")
print("=" * 70)
print("""
THE METHOD MECHANISM - STEP BY STEP:
WHEN YOU WRITE:
class MyClass:
def method(self):
pass
PYTHON CREATES:
- A function object (the code)
- Stores it as MyClass.method
WHEN YOU ACCESS obj.method:
1. Python looks up 'method' in obj.__dict__ (not found)
2. Python looks in MyClass.__dict__ (finds the function)
3. Function is a descriptor, so calls function.__get__(obj, MyClass)
4. __get__ returns a bound method (the function + obj + call wrapper)
5. You get a callable bound method
WHEN YOU CALL obj.method():
1. The bound method is callable
2. It calls the original function with obj as the first argument
3. That first argument is named 'self' in the function definition
4. Everything works transparently
WHY THIS IS BRILLIANT:
- Functions implement __get__ to enable method binding
- No special syntax needed (no need to write obj.method(obj))
- Class and instance access both work (different behaviors)
- Non-data descriptor means instance __dict__ can shadow (flexibility)
- The same mechanism is used for @property, @staticmethod, etc.
THE DESCRIPTOR PROTOCOL MAKES THIS POSSIBLE:
- __get__(self, instance, owner)
- instance: the object being accessed (None if via class)
- owner: the class
- Returns: the computed value (in this case, a bound method)
PERFORMANCE NOTE:
- Each obj.method access calls __get__ (creates new bound method)
- But Python optimizes this so it's very fast
- You shouldn't cache bound methods unless you have a specific reason
WHEN YOU'D USE THIS KNOWLEDGE:
- Understanding how Python works internally
- Implementing custom descriptors
- Debugging method binding issues
- Advanced metaprogramming
- Building frameworks and tools
""")
# ============ EXAMPLE 10: Practical Takeaway ============
print("\n# Example 10: Practical Application - Knowing This Helps")
print("=" * 70)
print("""
NOW THAT YOU KNOW METHODS ARE DESCRIPTORS:
YOU UNDERSTAND WHY:
✓ obj.method needs no argument but method() gets 'self'
✓ You can access unbound methods via Class.method
✓ Instance methods can be shadowed by instance attributes
✓ @property and @staticmethod are also descriptors
✓ Python's attribute access is so flexible and powerful
YOU CAN:
✓ Debug issues with method binding
✓ Write custom descriptors when needed
✓ Understand frameworks that use descriptors
✓ Know why certain patterns work
✓ Appreciate Python's elegance
YOU KNOW TO:
✓ Use methods normally (no need for manual binding)
✓ Not worry about __get__ in daily code
✓ Reach for descriptors when you need custom access control
✓ Read framework code with understanding
REMEMBER:
- This is advanced knowledge
- You probably won't write custom descriptors often
- But understanding them makes you a better Python programmer
- Every Python feature you use depends on this protocol
""")
print("\n" + "=" * 70)
print("KEY TAKEAWAYS")
print("=" * 70)
print("""
1. FUNCTIONS ARE DESCRIPTORS: Every function has __get__, making them
descriptors that create bound methods.
2. METHODS ARE CREATED ON ACCESS: Accessing obj.method calls function.__get__
which creates a bound method carrying both function and instance.
3. BOUND VS UNBOUND: obj.method is bound (self is fixed), Class.method is
unbound (you must pass instance).
4. __GET__ DOES THE MAGIC: The function's __get__ method is what creates
the callable bound method with 'self' implicitly available.
5. THIS IS THE DESCRIPTOR PROTOCOL: Understanding __get__ and __set__ explains
how Python implements methods, properties, and more.
6. NON-DATA DESCRIPTORS: Since functions don't have __set__, instance __dict__
can shadow them (unlike @property).
7. METHODS ARE FLEXIBLE: You can replace them with instance attributes, call
unbound versions, use them with map(), etc.
8. THIS EXTENDS BEYOND METHODS: The same mechanism powers @property,
@staticmethod, @classmethod, and custom descriptors.
9. PYTHON'S ELEGANCE: All of Python's method magic boils down to a simple
protocol (__get__, __set__, __delete__).
10. DEEP UNDERSTANDING: Knowing this puts you in the top tier of Python
programmers who truly understand how the language works.
FINAL THOUGHT:
The beauty of Python's descriptor protocol is that it unifies behavior
that would require special-casing in other languages. One simple protocol
handles methods, properties, static methods, and more.
""")