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
@.setter: Setter với validation
@.deleter: Custom delete behavior
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! 🚀