Prerequisites
- Basic understanding of programming concepts ๐
- Python installation (3.8+) ๐
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write clean, Pythonic code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on Python descriptors and the property protocol! ๐ In this guide, weโll explore how descriptors work under the hood and how they power Pythonโs property decorators.
Youโll discover how descriptors can transform your Python development experience. Whether youโre building frameworks ๐๏ธ, creating reusable components ๐ฆ, or designing elegant APIs ๐จ, understanding descriptors is essential for writing powerful, Pythonic code.
By the end of this tutorial, youโll feel confident using descriptors and properties in your own projects! Letโs dive in! ๐โโ๏ธ
๐ Understanding Descriptors
๐ค What are Descriptors?
Descriptors are like magical gatekeepers ๐ช for your attributes. Think of them as special objects that control what happens when you access, set, or delete attributes on a class.
In Python terms, a descriptor is any object that defines __get__()
, __set__()
, and/or __delete__()
methods. This means you can:
- โจ Control attribute access transparently
- ๐ Implement computed properties
- ๐ก๏ธ Add validation and type checking
- ๐ Create lazy-loaded attributes
๐ก Why Use Descriptors?
Hereโs why developers love descriptors:
- Clean Syntax ๐: Access looks like normal attributes
- Reusability ๐ป: Create once, use everywhere
- Separation of Concerns ๐: Logic lives in the descriptor
- Performance ๐ง: Implement caching and lazy loading
Real-world example: Imagine building a user profile system ๐ค. With descriptors, you can validate email addresses, ensure ages are positive, and compute full names automatically!
๐ง Basic Syntax and Usage
๐ Simple Descriptor Example
Letโs start with a friendly example:
# ๐ Hello, Descriptors!
class Descriptor:
def __init__(self, name=None):
self.name = name
def __get__(self, obj, objtype=None):
# ๐จ Called when accessing the attribute
if obj is None:
return self
return obj.__dict__.get(self.name, None)
def __set__(self, obj, value):
# ๐พ Called when setting the attribute
obj.__dict__[self.name] = value
print(f"โจ Set {self.name} = {value}")
def __delete__(self, obj):
# ๐๏ธ Called when deleting the attribute
del obj.__dict__[self.name]
print(f"๐๏ธ Deleted {self.name}")
# ๐ฏ Using our descriptor
class Person:
name = Descriptor('name') # ๐ค Person's name
age = Descriptor('age') # ๐ Person's age
๐ก Explanation: Notice how we define special methods to control attribute behavior! The descriptor protocol makes this magic happen.
๐ฏ The Property Decorator
Hereโs how Pythonโs built-in property works:
# ๐๏ธ Using property decorator
class Temperature:
def __init__(self):
self._celsius = 0
@property
def celsius(self):
# ๐ก๏ธ Getter method
return self._celsius
@celsius.setter
def celsius(self, value):
# โ
Setter with validation
if value < -273.15:
raise ValueError("โ Temperature below absolute zero!")
self._celsius = value
@property
def fahrenheit(self):
# ๐ Computed property
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
# ๐ฏ Convert and store
self.celsius = (value - 32) * 5/9
# ๐ฎ Let's use it!
temp = Temperature()
temp.celsius = 25
print(f"๐ก๏ธ {temp.celsius}ยฐC = {temp.fahrenheit}ยฐF")
๐ก Practical Examples
๐ Example 1: Shopping Cart with Validated Products
Letโs build something real:
# ๐๏ธ Custom descriptors for validation
class PositiveNumber:
def __init__(self, name):
self.name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, 0)
def __set__(self, obj, value):
if value < 0:
raise ValueError(f"โ {self.name[1:]} cannot be negative!")
setattr(obj, self.name, value)
class Product:
# ๐ฐ Price must be positive
price = PositiveNumber('price')
quantity = PositiveNumber('quantity')
def __init__(self, name, price, quantity, emoji="๐ฆ"):
self.name = name
self.emoji = emoji
self.price = price # Uses descriptor!
self.quantity = quantity # Uses descriptor!
@property
def total_value(self):
# ๐ต Computed property
return self.price * self.quantity
def __str__(self):
return f"{self.emoji} {self.name}: ${self.price} x {self.quantity}"
# ๐ Shopping cart implementation
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, product):
# โ Add validated product
self.items.append(product)
print(f"โ
Added {product}")
@property
def total(self):
# ๐ฐ Calculate total with property
return sum(item.total_value for item in self.items)
def checkout(self):
# ๐๏ธ Display cart
print("๐ Your cart contains:")
for item in self.items:
print(f" {item}")
print(f"๐ต Total: ${self.total:.2f}")
# ๐ฎ Let's shop!
cart = ShoppingCart()
cart.add_item(Product("Python Book", 29.99, 2, "๐"))
cart.add_item(Product("Coffee", 4.99, 5, "โ"))
cart.checkout()
๐ฏ Try it yourself: Add a discount descriptor that ensures discounts are between 0 and 100%!
๐ฎ Example 2: Game Character with Stats
Letโs make it fun:
# ๐ Bounded stat descriptor
class GameStat:
def __init__(self, min_val=0, max_val=100):
self.min_val = min_val
self.max_val = max_val
def __set_name__(self, owner, name):
# ๐ฏ Auto-set the name
self.name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, self.min_val)
def __set__(self, obj, value):
# ๐ก๏ธ Enforce bounds
value = max(self.min_val, min(value, self.max_val))
setattr(obj, self.name, value)
class CachedProperty:
# ๐ Lazy-loaded, cached property
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = obj.__dict__.get(self.name, None)
if value is None:
value = self.func(obj)
obj.__dict__[self.name] = value
print(f"๐ฏ Computed {self.name}: {value}")
return value
class GameCharacter:
# ๐ฎ Game stats with bounds
health = GameStat(0, 100)
mana = GameStat(0, 100)
strength = GameStat(1, 50)
def __init__(self, name, emoji="๐ง"):
self.name = name
self.emoji = emoji
self.health = 100
self.mana = 50
self.strength = 10
self.level = 1
@CachedProperty
def power_level(self):
# โก Expensive calculation, cached!
import time
time.sleep(0.1) # Simulate complex calculation
return (self.health + self.mana) * self.strength // 10
@property
def status(self):
# ๐ Dynamic status
if self.health == 0:
return "๐ Defeated"
elif self.health < 30:
return "๐ฉน Critical"
elif self.health < 70:
return "๐ฐ Injured"
return "๐ช Healthy"
def take_damage(self, damage):
# ๐ฅ Apply damage
self.health -= damage
print(f"{self.emoji} {self.name} took {damage} damage! Status: {self.status}")
def heal(self, amount):
# ๐ Heal character
old_health = self.health
self.health += amount
healed = self.health - old_health
print(f"โจ {self.name} healed {healed} HP! Health: {self.health}/100")
# ๐ฎ Play the game!
hero = GameCharacter("Pythonista", "๐ฆธ")
print(f"โก Power Level: {hero.power_level}")
print(f"โก Power Level (cached): {hero.power_level}")
hero.take_damage(65)
hero.heal(200) # Will cap at 100!
๐ Advanced Concepts
๐งโโ๏ธ Data vs Non-Data Descriptors
When youโre ready to level up, understand the difference:
# ๐ฏ Data descriptor (has __set__)
class DataDescriptor:
def __get__(self, obj, objtype=None):
return "โจ I'm a data descriptor"
def __set__(self, obj, value):
print(f"๐ฏ Setting value: {value}")
# ๐ Non-data descriptor (no __set__)
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return "๐ซ I'm a non-data descriptor"
class MyClass:
data = DataDescriptor()
non_data = NonDataDescriptor()
# ๐ช Testing descriptor priority
obj = MyClass()
print(obj.data) # Uses descriptor
# Instance attribute vs descriptor
obj.__dict__['data'] = "Instance value"
print(obj.data) # Still uses descriptor! ๐ฏ
obj.__dict__['non_data'] = "Instance wins"
print(obj.non_data) # Instance attribute wins! ๐ซ
๐๏ธ Creating a Type-Checked Descriptor
For the brave developers:
# ๐ Advanced type-checking descriptor
class TypedProperty:
def __init__(self, expected_type, default=None):
self.expected_type = expected_type
self.default = default
def __set_name__(self, owner, name):
self.name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, self.default)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"โ Expected {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.name, value)
def __delete__(self, obj):
delattr(obj, self.name)
# ๐จ Using typed properties
class User:
name = TypedProperty(str, "Anonymous")
age = TypedProperty(int, 0)
email = TypedProperty(str)
scores = TypedProperty(list, default_factory=list)
def __init__(self, name, age, email):
self.name = name # โ
Type checked!
self.age = age # โ
Type checked!
self.email = email # โ
Type checked!
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Forgetting set_name
# โ Wrong way - manual name tracking
class BadDescriptor:
def __init__(self, name):
self.name = name # ๐ฐ Have to pass name manually
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
class MyClass:
attr = BadDescriptor('attr') # ๐คฎ Repeating name!
# โ
Correct way - use __set_name__
class GoodDescriptor:
def __set_name__(self, owner, name):
self.name = name # ๐ฏ Automatic!
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
class MyClass:
attr = GoodDescriptor() # โจ Clean!
๐คฏ Pitfall 2: Descriptor Instance Sharing
# โ Dangerous - shared mutable state!
class BadCachedProperty:
def __init__(self):
self.cache = {} # ๐ฅ Shared between instances!
def __get__(self, obj, objtype=None):
if obj not in self.cache:
self.cache[obj] = expensive_computation()
return self.cache[obj]
# โ
Safe - store in instance
class GoodCachedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# ๐ก๏ธ Store in instance dict
value = obj.__dict__.get(self.name)
if value is None:
value = self.func(obj)
obj.__dict__[self.name] = value
return value
๐ ๏ธ Best Practices
- ๐ฏ Use set_name: Let Python handle name management
- ๐ Store in Instance: Keep instance data in obj.dict
- ๐ก๏ธ Handle obj is None: Support class-level access
- ๐จ Prefer @property: For simple cases, use the decorator
- โจ Document Behavior: Clear docstrings for descriptors
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Model Field System
Create a mini-ORM with field validation:
๐ Requirements:
- โ Field types: StringField, IntField, EmailField
- ๐ท๏ธ Required vs optional fields
- ๐ค Default values support
- ๐ Automatic validation on set
- ๐จ Clean error messages
๐ Bonus Points:
- Add ChoiceField with allowed values
- Implement MinLength/MaxLength validators
- Create a Model base class
๐ก Solution
๐ Click to see solution
# ๐ฏ Our mini-ORM field system!
import re
class Field:
def __init__(self, required=True, default=None):
self.required = required
self.default = default
def __set_name__(self, owner, name):
self.name = f"_{name}"
self.public_name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, self.default)
def __set__(self, obj, value):
if value is None and self.required:
raise ValueError(f"โ {self.public_name} is required!")
if value is not None:
value = self.validate(value)
setattr(obj, self.name, value)
def validate(self, value):
return value # Override in subclasses
class StringField(Field):
def __init__(self, min_length=0, max_length=None, **kwargs):
super().__init__(**kwargs)
self.min_length = min_length
self.max_length = max_length
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"โ {self.public_name} must be a string!")
if len(value) < self.min_length:
raise ValueError(f"โ {self.public_name} too short!")
if self.max_length and len(value) > self.max_length:
raise ValueError(f"โ {self.public_name} too long!")
return value
class IntField(Field):
def __init__(self, min_value=None, max_value=None, **kwargs):
super().__init__(**kwargs)
self.min_value = min_value
self.max_value = max_value
def validate(self, value):
if not isinstance(value, int):
raise TypeError(f"โ {self.public_name} must be an integer!")
if self.min_value is not None and value < self.min_value:
raise ValueError(f"โ {self.public_name} too small!")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"โ {self.public_name} too large!")
return value
class EmailField(StringField):
EMAIL_REGEX = re.compile(r'^[\w\.-]+@[\w\.-]+\.\w+$')
def validate(self, value):
value = super().validate(value)
if not self.EMAIL_REGEX.match(value):
raise ValueError(f"โ Invalid email format!")
return value.lower()
class Model:
def __init__(self, **kwargs):
# ๐ฏ Set field values
for name, value in kwargs.items():
setattr(self, name, value)
def __str__(self):
# ๐ Nice string representation
fields = []
for name in dir(self.__class__):
attr = getattr(self.__class__, name)
if isinstance(attr, Field):
value = getattr(self, name)
fields.append(f"{name}={value!r}")
return f"{self.__class__.__name__}({', '.join(fields)})"
# ๐ฎ Test our ORM!
class User(Model):
username = StringField(min_length=3, max_length=20)
age = IntField(min_value=0, max_value=150)
email = EmailField()
bio = StringField(required=False, default="No bio yet ๐")
# โจ Create users
user1 = User(
username="pythonista",
age=25,
email="[email protected]"
)
print(f"๐ค {user1}")
# ๐ฏ Test validation
try:
user2 = User(username="py", age=200, email="bad-email")
except ValueError as e:
print(f"Caught error: {e}")
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create custom descriptors with confidence ๐ช
- โ Use the property protocol effectively ๐ก๏ธ
- โ Implement validation and type checking ๐ฏ
- โ Build reusable components with descriptors ๐
- โ Understand Pythonโs attribute access magic! ๐
Remember: Descriptors are the secret sauce behind many Python features. Theyโre powerful tools in your Python toolkit! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered descriptors and the property protocol!
Hereโs what to do next:
- ๐ป Practice with the exercises above
- ๐๏ธ Build a validation library using descriptors
- ๐ Explore how Django and SQLAlchemy use descriptors
- ๐ Share your descriptor creations with the community!
Remember: Every Python expert was once a beginner. Keep coding, keep learning, and most importantly, have fun! ๐
Happy coding! ๐๐โจ