Bài 1: Advanced OOP - Property Decorators & Descriptors (Phần 1)
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- ✅ Master property decorators (@property, @setter, @deleter)
- ✅ Tạo computed properties
- ✅ Implement data validation với properties
- ✅ Hiểu và sử dụng descriptors
- ✅ Tạo lazy properties
- ✅ Áp dụng property patterns trong thực tế
Property Decorators Review
Từ Python Basics, bạn đã biết @property cơ bản. Giờ chúng ta đi sâu hơn!
Basic Property Pattern
class Temperature: """Temperature with validation.""" def __init__(self, celsius): self._celsius = celsius @property def celsius(self): """Get temperature in Celsius.""" return self._celsius @celsius.setter def celsius(self, value): """Set temperature with validation.""" if value < -273.15: raise ValueError("Temperature below absolute zero!") self._celsius = value @celsius.deleter def celsius(self): """Delete temperature.""" print("Deleting temperature") del self._celsius @property def fahrenheit(self): """Computed property - always calculated.""" return self._celsius * 9/5 + 32 @fahrenheit.setter def fahrenheit(self, value): """Set via Fahrenheit.""" self.celsius = (value - 32) * 5/9 @property def kelvin(self): """Another computed property.""" return self._celsius + 273.15 # Usagetemp = Temperature(25)print(temp.celsius) # 25print(temp.fahrenheit) # 77.0print(temp.kelvin) # 298.15 temp.fahrenheit = 86 # Set via Fahrenheitprint(temp.celsius) # 30.0 # Validation works# temp.celsius = -300 # ValueError!
Read-Only Properties
from datetime import datetime class User: """User with read-only properties.""" def __init__(self, username, email): self._username = username self._email = email self._created_at = datetime.now() @property def username(self): """Read-only username.""" return self._username @property def email(self): """Email with setter.""" return self._email @email.setter def email(self, value): if '@' not in value: raise ValueError("Invalid email") self._email = value @property def created_at(self): """Read-only timestamp.""" return self._created_at @property def age_in_days(self): """Computed read-only property.""" return (datetime.now() - self._created_at).days # Usageuser = User("alice", "[email protected]") print(user.username) # aliceprint(user.created_at) # 2025-10-27 ...print(user.age_in_days) # 0 user.email = "[email protected]" # ✅ Works# user.username = "bob" # ❌ AttributeError (no setter)# user.created_at = datetime.now() # ❌ AttributeError
Advanced Property Patterns
1. Lazy Properties (Cached Computation)
class DataProcessor: """Process data with lazy loading.""" def __init__(self, filename): self.filename = filename self._data = None self._processed = None @property def data(self): """Lazy load data - load once when accessed.""" if self._data is None: print(f"Loading data from {self.filename}...") # Simulate expensive operation with open(self.filename, 'r') as f: self._data = f.read() return self._data @property def processed(self): """Lazy process - compute once, cache result.""" if self._processed is None: print("Processing data...") # Simulate expensive computation self._processed = self.data.upper() return self._processed def invalidate_cache(self): """Clear cache to force reload.""" self._data = None self._processed = None # Usageprocessor = DataProcessor('data.txt') # First access - loads dataprint(processor.data) # Loading data from data.txt... # Second access - uses cacheprint(processor.data) # (no loading message) # First process - computesprint(processor.processed) # Processing data... # Clear cacheprocessor.invalidate_cache()
2. Property with Complex Validation
import refrom datetime import datetime class Person: """Person with validated properties.""" def __init__(self, name, email, age, phone): self.name = name self.email = email self.age = age self.phone = phone @property def name(self): return self._name @name.setter def name(self, value): """Name must be 2-50 characters, letters and spaces only.""" if not isinstance(value, str): raise TypeError("Name must be string") value = value.strip() if len(value) < 2: raise ValueError("Name too short (min 2 chars)") if len(value) > 50: raise ValueError("Name too long (max 50 chars)") if not re.match(r'^[A-Za-z\s]+$', value): raise ValueError("Name must contain only letters and spaces") self._name = value @property def email(self): return self._email @email.setter def email(self, value): """Validate email format.""" pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' if not re.match(pattern, value): raise ValueError("Invalid email format") self._email = value.lower() @property def age(self): return self._age @age.setter def age(self, value): """Age must be 0-150.""" if not isinstance(value, int): raise TypeError("Age must be integer") if value < 0 or value > 150: raise ValueError("Age must be 0-150") self._age = value @property def phone(self): return self._phone @phone.setter def phone(self, value): """Phone must be 10 digits.""" # Remove non-digits digits = re.sub(r'\D', '', value) if len(digits) != 10: raise ValueError("Phone must be 10 digits") self._phone = digits @property def is_adult(self): """Computed property.""" return self._age >= 18 # Usageperson = Person("Alice Nguyen", "[email protected]", 25, "0123-456-789") print(person.name) # Alice Nguyenprint(person.phone) # 0123456789print(person.is_adult) # True # Validation works# person.name = "A" # ValueError: Name too short# person.email = "invalid" # ValueError: Invalid email# person.age = 200 # ValueError: Age must be 0-150# person.phone = "123" # ValueError: Phone must be 10 digits
3. Property with Side Effects
class BankAccount: """Bank account with transaction logging.""" def __init__(self, owner, balance=0): self.owner = owner self._balance = balance self._transactions = [] @property def balance(self): """Get current balance.""" return self._balance @balance.setter def balance(self, value): """ Set balance with transaction logging. Note: Direct balance setting is logged as adjustment. """ if value < 0: raise ValueError("Balance cannot be negative") # Calculate change change = value - self._balance # Log transaction self._transactions.append({ 'type': 'adjustment', 'amount': change, 'old_balance': self._balance, 'new_balance': value, 'timestamp': datetime.now() }) self._balance = value @property def transactions(self): """Read-only transaction history.""" return self._transactions.copy() def deposit(self, amount): """Proper way to add money.""" if amount <= 0: raise ValueError("Deposit amount must be positive") self._transactions.append({ 'type': 'deposit', 'amount': amount, 'old_balance': self._balance, 'new_balance': self._balance + amount, 'timestamp': datetime.now() }) self._balance += amount def withdraw(self, amount): """Proper way to remove money.""" if amount <= 0: raise ValueError("Withdrawal amount must be positive") if amount > self._balance: raise ValueError("Insufficient funds") self._transactions.append({ 'type': 'withdrawal', 'amount': amount, 'old_balance': self._balance, 'new_balance': self._balance - amount, 'timestamp': datetime.now() }) self._balance -= amount # Usageaccount = BankAccount("Alice", 1000) account.deposit(500)account.withdraw(200)account.balance = 1500 # Adjustment logged print(f"Balance: ${account.balance}")print(f"\nTransactions: {len(account.transactions)}")for trans in account.transactions: print(f"{trans['type']}: {trans['amount']} -> ${trans['new_balance']}")
Descriptors
Descriptors là protocol cho việc customize attribute access. Properties internally sử dụng descriptors!
Descriptor Protocol
class Descriptor: """ Descriptor protocol có 3 methods: - __get__(self, instance, owner): Called when attribute accessed - __set__(self, instance, value): Called when attribute set - __delete__(self, instance): Called when attribute deleted """ def __get__(self, instance, owner): """Get attribute value.""" print(f"__get__ called: instance={instance}, owner={owner}") def __set__(self, instance, value): """Set attribute value.""" print(f"__set__ called: instance={instance}, value={value}") def __delete__(self, instance): """Delete attribute.""" print(f"__delete__ called: instance={instance}") class MyClass: attr = Descriptor() # Usageobj = MyClass()obj.attr # __get__ calledobj.attr = 10 # __set__ calleddel obj.attr # __delete__ called
Practical Descriptor: Type Validator
class TypedProperty: """Descriptor that validates type.""" def __init__(self, name, expected_type): self.name = name self.expected_type = expected_type self.storage_name = f'_{name}' def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.storage_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__}" ) setattr(instance, self.storage_name, value) class Person: """Person with type-validated attributes.""" name = TypedProperty('name', str) age = TypedProperty('age', int) salary = TypedProperty('salary', float) def __init__(self, name, age, salary): self.name = name self.age = age self.salary = salary # Usageperson = Person("Alice", 25, 50000.0) print(person.name) # Aliceprint(person.age) # 25 # Type validation# person.name = 123 # TypeError: name must be str# person.age = "25" # TypeError: age must be int# person.salary = 50000 # TypeError: salary must be float
Advanced Descriptor: Range Validator
class RangeValidator: """Descriptor with range validation.""" def __init__(self, name, min_value=None, max_value=None): self.name = name self.min_value = min_value self.max_value = max_value self.storage_name = f'_{name}' def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.storage_name, None) def __set__(self, instance, value): # Type check if not isinstance(value, (int, float)): raise TypeError(f"{self.name} must be number") # Range check if self.min_value is not None and value < self.min_value: raise ValueError( f"{self.name} must be >= {self.min_value}, got {value}" ) if self.max_value is not None and value > self.max_value: raise ValueError( f"{self.name} must be <= {self.max_value}, got {value}" ) setattr(instance, self.storage_name, value) class Product: """Product with validated ranges.""" price = RangeValidator('price', min_value=0) quantity = RangeValidator('quantity', min_value=0, max_value=10000) discount = RangeValidator('discount', min_value=0, max_value=100) def __init__(self, name, price, quantity, discount=0): self.name = name self.price = price self.quantity = quantity self.discount = discount @property def final_price(self): """Computed property.""" return self.price * (1 - self.discount / 100) # Usageproduct = Product("Laptop", 1000.0, 50, 10) print(product.final_price) # 900.0 # Validation works# product.price = -100 # ValueError: price must be >= 0# product.quantity = 20000 # ValueError: quantity must be <= 10000# product.discount = 150 # ValueError: discount must be <= 100
Ví Dụ Thực Tế
1. Configuration System
class ConfigProperty: """Descriptor for configuration values.""" def __init__(self, name, default=None, required=False, validator=None): self.name = name self.default = default self.required = required self.validator = validator self.storage_name = f'_{name}' def __get__(self, instance, owner): if instance is None: return self value = getattr(instance, self.storage_name, self.default) if value is None and self.required: raise ValueError(f"{self.name} is required") return value def __set__(self, instance, value): if self.validator and value is not None: if not self.validator(value): raise ValueError(f"Invalid value for {self.name}: {value}") setattr(instance, self.storage_name, value) class DatabaseConfig: """Database configuration with validation.""" host = ConfigProperty('host', default='localhost') port = ConfigProperty('port', default=5432, validator=lambda x: 1 <= x <= 65535) database = ConfigProperty('database', required=True) username = ConfigProperty('username', required=True) password = ConfigProperty('password', required=True) pool_size = ConfigProperty('pool_size', default=5, validator=lambda x: 1 <= x <= 100) def __init__(self, **kwargs): for key, value in kwargs.items(): if hasattr(self.__class__, key): setattr(self, key, value) @property def connection_string(self): """Build connection string.""" return (f"postgresql://{self.username}:{self.password}" f"@{self.host}:{self.port}/{self.database}") def validate(self): """Validate all required fields.""" # Accessing required fields triggers validation _ = self.database _ = self.username _ = self.password return True # Usageconfig = DatabaseConfig( database='mydb', username='admin', password='secret', pool_size=10) print(config.connection_string)# postgresql://admin:secret@localhost:5432/mydb # Validation# config.port = 99999 # ValueError: Invalid value# config2 = DatabaseConfig() # ValueError: database is required
2. Measurement Units System
class Measurement: """Base class for measurements with unit conversion.""" def __init__(self, value, unit): self._value = value self._unit = unit @property def value(self): return self._value @property def unit(self): return self._unit class Length(Measurement): """Length measurement with conversions.""" # Conversion factors to meters _CONVERSIONS = { 'mm': 0.001, 'cm': 0.01, 'm': 1.0, 'km': 1000.0, 'in': 0.0254, 'ft': 0.3048, 'yd': 0.9144, 'mi': 1609.34 } def __init__(self, value, unit='m'): if unit not in self._CONVERSIONS: raise ValueError(f"Unknown unit: {unit}") super().__init__(value, unit) @property def meters(self): """Convert to meters.""" return self._value * self._CONVERSIONS[self._unit] @property def kilometers(self): """Convert to kilometers.""" return self.meters / 1000 @property def feet(self): """Convert to feet.""" return self.meters / 0.3048 @property def miles(self): """Convert to miles.""" return self.meters / 1609.34 def convert_to(self, target_unit): """Convert to target unit.""" if target_unit not in self._CONVERSIONS: raise ValueError(f"Unknown unit: {target_unit}") meters = self.meters return Length(meters / self._CONVERSIONS[target_unit], target_unit) def __str__(self): return f"{self._value} {self._unit}" # Usagedistance = Length(100, 'cm') print(distance) # 100 cmprint(f"{distance.meters}m") # 1.0mprint(f"{distance.feet}ft") # 3.28...ft # Convertin_inches = distance.convert_to('in')print(in_inches) # 39.37... in
3. Smart Cache Property
from functools import wrapsimport time def cached_property(func): """ Decorator for cached property. Like @property but caches result. """ attr_name = f'_cached_{func.__name__}' @property @wraps(func) def wrapper(self): if not hasattr(self, attr_name): setattr(self, attr_name, func(self)) return getattr(self, attr_name) return wrapper class DataAnalyzer: """Analyze data with expensive computations.""" def __init__(self, data): self.data = data @cached_property def mean(self): """Compute mean (cached).""" print("Computing mean...") time.sleep(1) # Simulate expensive operation return sum(self.data) / len(self.data) @cached_property def median(self): """Compute median (cached).""" print("Computing median...") time.sleep(1) sorted_data = sorted(self.data) n = len(sorted_data) mid = n // 2 if n % 2 == 0: return (sorted_data[mid-1] + sorted_data[mid]) / 2 return sorted_data[mid] @cached_property def std_dev(self): """Compute standard deviation (cached).""" print("Computing std dev...") time.sleep(1) mean = self.mean # Uses cached value! variance = sum((x - mean) ** 2 for x in self.data) / len(self.data) return variance ** 0.5 # Usageanalyzer = DataAnalyzer([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) # First access - computesprint(analyzer.mean) # Computing mean... 5.5 # Second access - uses cacheprint(analyzer.mean) # 5.5 (no computing message) print(analyzer.median) # Computing median... 5.5print(analyzer.std_dev) # Computing std dev... 2.87...
Best Practices
# 1. Use properties for computed valuesclass Circle: def __init__(self, radius): self.radius = radius @property def area(self): return 3.14159 * self.radius ** 2 @property def circumference(self): return 2 * 3.14159 * self.radius # 2. Validate in settersclass Age: def __init__(self, value): self.value = value @property def value(self): return self._value @value.setter def value(self, val): if not 0 <= val <= 150: raise ValueError("Invalid age") self._value = val # 3. Use descriptors for reusable validationclass PositiveNumber: def __set_name__(self, owner, name): self.name = name self.storage_name = f'_{name}' def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.storage_name) def __set__(self, instance, value): if value <= 0: raise ValueError(f"{self.name} must be positive") setattr(instance, self.storage_name, value) # 4. Document property behaviorclass Temperature: @property def celsius(self): """ Temperature in Celsius. Raises: ValueError: If temperature below absolute zero. """ return self._celsius
Bài Tập Thực Hành
Bài 1: Email Validator
Tạo class Email với property validate format và domain.
Bài 2: Money Class
Tạo class Money với currency conversion properties.
Bài 3: Coordinate System
Tạo class Point với Cartesian và Polar properties.
Bài 4: File Size Descriptor
Tạo descriptor validate file size range.
Bài 5: Cached Database Query
Implement cached property cho database queries.
Tóm Tắt
✅ @property: Getter cho attributes
✅ @
✅ @
✅ Computed properties: Calculate on access
✅ Lazy properties: Compute once, cache result
✅ Descriptors: Reusable attribute logic
✅ Validation patterns: Type, range, format checks
Bài Tiếp Theo
Bài 1.2: Advanced OOP - Advanced OOP - Abstract Classes & Metaclasses (Phần 2)!
Remember:
- Properties = Pythonic getters/setters
- Use properties for computed values
- Validate in setters
- Descriptors for reusable logic
- Cache expensive computations! 🚀