Skip to content

Descriptors & Properties Advanced

Descriptors = Bí mật đằng sau @property, @classmethod, @staticmethod

Learning Outcomes

Sau khi hoàn thành trang này, bạn sẽ:

  • ✅ Hiểu Descriptor Protocol (__get__, __set__, __delete__)
  • ✅ Phân biệt Data Descriptors vs Non-Data Descriptors
  • ✅ Hiểu cách @property hoạt động bên trong
  • ✅ Implement lazy attributes và cached properties
  • ✅ Tạo custom descriptors cho validation và logging
  • ✅ Tránh các production pitfalls với descriptors

Descriptor Protocol là gì?

Descriptor là object implement ít nhất một trong các methods: __get__, __set__, __delete__. Khi attribute lookup xảy ra, Python gọi các methods này thay vì trả về object trực tiếp.

python
class Descriptor:
    """Minimal descriptor example."""
    
    def __get__(self, obj, objtype=None):
        """Được gọi khi: instance.attr hoặc Class.attr"""
        print(f"__get__ called: obj={obj}, objtype={objtype}")
        return "value from descriptor"
    
    def __set__(self, obj, value):
        """Được gọi khi: instance.attr = value"""
        print(f"__set__ called: obj={obj}, value={value}")
    
    def __delete__(self, obj):
        """Được gọi khi: del instance.attr"""
        print(f"__delete__ called: obj={obj}")

class MyClass:
    attr = Descriptor()  # Descriptor được gán như class attribute

# Sử dụng
obj = MyClass()
obj.attr           # __get__ called: obj=<MyClass>, objtype=<class 'MyClass'>
obj.attr = 42      # __set__ called: obj=<MyClass>, value=42
del obj.attr       # __delete__ called: obj=<MyClass>
MyClass.attr       # __get__ called: obj=None, objtype=<class 'MyClass'>

Data vs Non-Data Descriptors

LoạiMethodsƯu tiên
Data Descriptor__get__ + __set__ (hoặc __delete__)Cao nhất
Non-Data DescriptorChỉ __get__Thấp hơn instance __dict__

Attribute Lookup Order

python
# Thứ tự tìm kiếm khi truy cập obj.attr:
# 1. Data descriptor trong class (và MRO)
# 2. Instance __dict__
# 3. Non-data descriptor trong class (và MRO)
# 4. Class __dict__
# 5. __getattr__ (nếu có)

class DataDescriptor:
    """Data descriptor - có cả __get__ và __set__."""
    def __get__(self, obj, objtype=None):
        return "from data descriptor"
    def __set__(self, obj, value):
        pass  # Có __set__ → Data descriptor

class NonDataDescriptor:
    """Non-data descriptor - chỉ có __get__."""
    def __get__(self, obj, objtype=None):
        return "from non-data descriptor"

class Demo:
    data = DataDescriptor()
    non_data = NonDataDescriptor()

obj = Demo()
obj.__dict__['data'] = "from instance"
obj.__dict__['non_data'] = "from instance"

print(obj.data)      # "from data descriptor" (data descriptor wins!)
print(obj.non_data)  # "from instance" (instance __dict__ wins!)

@property Internals

@property là một data descriptor được implement sẵn trong Python.

Cách @property hoạt động

python
# Khi bạn viết:
class User:
    @property
    def name(self):
        return self._name

# Python thực sự làm:
class User:
    def name(self):
        return self._name
    name = property(name)  # property là descriptor!

Implement property từ đầu

python
class MyProperty:
    """Custom implementation của @property."""
    
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc or (fget.__doc__ if fget else None)
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Truy cập từ class
        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 setter(self, fset):
        """Decorator để thêm setter."""
        return type(self)(self.fget, fset, self.fdel, self.__doc__)
    
    def deleter(self, fdel):
        """Decorator để thêm deleter."""
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

# Sử dụng giống hệt @property
class User:
    def __init__(self, name: str):
        self._name = name
    
    @MyProperty
    def name(self) -> str:
        return self._name
    
    @name.setter
    def name(self, value: str) -> None:
        self._name = value

user = User("HPN")
print(user.name)    # "HPN"
user.name = "Tom"
print(user.name)    # "Tom"

Lazy Attribute Patterns

Pattern 1: Lazy Loading với Descriptor

python
class LazyProperty:
    """Descriptor tính toán một lần, sau đó cache vào instance."""
    
    def __init__(self, func):
        self.func = func
        self.attr_name = None
    
    def __set_name__(self, owner, name):
        """Python 3.6+: Được gọi khi descriptor được gán vào class."""
        self.attr_name = name
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        
        # Tính toán và cache vào instance __dict__
        value = self.func(obj)
        obj.__dict__[self.attr_name] = value  # Bypass descriptor lần sau
        return value

class DataProcessor:
    def __init__(self, data: list):
        self.data = data
    
    @LazyProperty
    def expensive_result(self) -> float:
        """Tính toán tốn kém - chỉ chạy một lần."""
        print("Computing expensive result...")
        return sum(x ** 2 for x in self.data)

processor = DataProcessor([1, 2, 3, 4, 5])
print(processor.expensive_result)  # Computing... → 55
print(processor.expensive_result)  # 55 (không tính lại!)
print('expensive_result' in processor.__dict__)  # True (đã cache)

Pattern 2: functools.cached_property (Python 3.8+)

python
from functools import cached_property

class DataProcessor:
    def __init__(self, data: list):
        self.data = data
    
    @cached_property
    def expensive_result(self) -> float:
        """Built-in lazy property."""
        print("Computing...")
        return sum(x ** 2 for x in self.data)

# Hoạt động giống LazyProperty ở trên

Pattern 3: Resettable Lazy Property

python
class ResettableLazyProperty:
    """Lazy property có thể reset để tính lại."""
    
    def __init__(self, func):
        self.func = func
        self.attr_name = None
    
    def __set_name__(self, owner, name):
        self.attr_name = name
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        
        if self.attr_name not in obj.__dict__:
            obj.__dict__[self.attr_name] = self.func(obj)
        return obj.__dict__[self.attr_name]
    
    def __delete__(self, obj):
        """Reset cache khi del."""
        obj.__dict__.pop(self.attr_name, None)

class Config:
    def __init__(self, path: str):
        self.path = path
    
    @ResettableLazyProperty
    def settings(self) -> dict:
        print(f"Loading from {self.path}...")
        return {"loaded": True}

config = Config("config.yaml")
print(config.settings)  # Loading... → {'loaded': True}
print(config.settings)  # {'loaded': True} (cached)
del config.settings     # Reset cache
print(config.settings)  # Loading... (tính lại)

Validation Descriptor

python
class Validated:
    """Descriptor với validation."""
    
    def __init__(self, validator, default=None):
        self.validator = validator
        self.default = default
        self.attr_name = None
    
    def __set_name__(self, owner, name):
        self.attr_name = f"_validated_{name}"
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.attr_name, self.default)
    
    def __set__(self, obj, value):
        validated = self.validator(value)
        setattr(obj, self.attr_name, validated)

# Validators
def positive_int(value):
    value = int(value)
    if value <= 0:
        raise ValueError(f"Must be positive, got {value}")
    return value

def non_empty_string(value):
    value = str(value).strip()
    if not value:
        raise ValueError("Cannot be empty")
    return value

def email_validator(value):
    value = str(value).lower().strip()
    if "@" not in value:
        raise ValueError(f"Invalid email: {value}")
    return value

class User:
    name = Validated(non_empty_string)
    age = Validated(positive_int, default=0)
    email = Validated(email_validator)
    
    def __init__(self, name: str, age: int, email: str):
        self.name = name
        self.age = age
        self.email = email

# Sử dụng
user = User("HPN", 28, "HPN@Example.com")
print(user.name)   # "HPN"
print(user.email)  # "hpn@example.com" (normalized)

user.age = -5      # ValueError: Must be positive
user.name = "   "  # ValueError: Cannot be empty

Type-Checked Descriptor

python
from typing import Any, Type

class TypeChecked:
    """Descriptor với type checking."""
    
    def __init__(self, expected_type: Type, default: Any = None):
        self.expected_type = expected_type
        self.default = default
        self.attr_name = None
    
    def __set_name__(self, owner, name):
        self.attr_name = f"_typed_{name}"
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.attr_name, self.default)
    
    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.attr_name}: expected {self.expected_type.__name__}, "
                f"got {type(value).__name__}"
            )
        setattr(obj, self.attr_name, value)

class Config:
    host = TypeChecked(str, "localhost")
    port = TypeChecked(int, 8080)
    debug = TypeChecked(bool, False)
    
    def __init__(self, host: str = "localhost", port: int = 8080):
        self.host = host
        self.port = port

config = Config("0.0.0.0", 3000)
config.port = "8080"  # TypeError: expected int, got str

__set_name__ Hook (Python 3.6+)

python
class Descriptor:
    def __set_name__(self, owner, name):
        """
        Được gọi tự động khi descriptor được gán vào class.
        
        Args:
            owner: Class chứa descriptor
            name: Tên attribute được gán
        """
        print(f"Descriptor assigned to {owner.__name__}.{name}")
        self.public_name = name
        self.private_name = f"_{name}"
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name, None)
    
    def __set__(self, obj, value):
        setattr(obj, self.private_name, value)

class MyClass:
    x = Descriptor()  # Output: Descriptor assigned to MyClass.x
    y = Descriptor()  # Output: Descriptor assigned to MyClass.y

Production Pitfalls 🚨

Pitfall 1: Descriptor không hoạt động trên instance

python
# ❌ BUG: Gán descriptor vào instance không hoạt động
class Validator:
    def __get__(self, obj, objtype=None):
        return "validated"
    def __set__(self, obj, value):
        print(f"Validating: {value}")

class User:
    pass

user = User()
user.name = Validator()  # Gán vào instance, không phải class!
print(user.name)  # <Validator object> - không gọi __get__!

# ✅ FIX: Gán vào class
class User:
    name = Validator()  # Gán vào class

user = User()
user.name = "HPN"  # Validating: HPN

Pitfall 2: Non-data descriptor bị override

python
# ❌ BUG: Non-data descriptor bị instance __dict__ override
class Method:
    def __get__(self, obj, objtype=None):
        return lambda: "method called"

class MyClass:
    action = Method()

obj = MyClass()
print(obj.action())  # "method called"

obj.__dict__['action'] = "overridden"
print(obj.action)    # "overridden" - descriptor bị bypass!

# ✅ FIX: Dùng data descriptor (thêm __set__)
class Method:
    def __get__(self, obj, objtype=None):
        return lambda: "method called"
    def __set__(self, obj, value):
        raise AttributeError("Cannot override method")

Pitfall 3: Quên xử lý class-level access

python
# ❌ BUG: Crash khi truy cập từ class
class Descriptor:
    def __get__(self, obj, objtype=None):
        return obj.value  # obj là None khi truy cập từ class!

class MyClass:
    attr = Descriptor()

MyClass.attr  # AttributeError: 'NoneType' has no attribute 'value'

# ✅ FIX: Kiểm tra obj is None
class Descriptor:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Trả về descriptor khi truy cập từ class
        return obj.value

Pitfall 4: Shared state giữa instances

python
# ❌ BUG: Tất cả instances share cùng data
class Descriptor:
    def __init__(self):
        self.value = None  # Shared giữa tất cả instances!
    
    def __get__(self, obj, objtype=None):
        return self.value
    
    def __set__(self, obj, value):
        self.value = value

class User:
    name = Descriptor()

u1 = User()
u2 = User()
u1.name = "Alice"
print(u2.name)  # "Alice" - Oops! Shared state!

# ✅ FIX: Lưu data vào instance
class Descriptor:
    def __set_name__(self, owner, name):
        self.attr_name = f"_{name}"
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.attr_name, None)
    
    def __set__(self, obj, value):
        setattr(obj, self.attr_name, value)  # Lưu vào instance!

Pitfall 5: cached_property với mutable objects

python
from functools import cached_property

# ❌ BUG: Mutable object được cache
class DataLoader:
    @cached_property
    def items(self) -> list:
        return []  # Trả về list rỗng

loader = DataLoader()
loader.items.append(1)
loader.items.append(2)
print(loader.items)  # [1, 2] - Mutated cached value!

# ✅ FIX: Trả về copy hoặc immutable
class DataLoader:
    @cached_property
    def items(self) -> tuple:
        return tuple(self._load_items())  # Immutable
    
    def _load_items(self):
        return [1, 2, 3]

Bảng Tóm tắt

python
# === DESCRIPTOR PROTOCOL ===
class Descriptor:
    def __get__(self, obj, objtype=None):
        """obj.attr hoặc Class.attr"""
        pass
    
    def __set__(self, obj, value):
        """obj.attr = value"""
        pass
    
    def __delete__(self, obj):
        """del obj.attr"""
        pass
    
    def __set_name__(self, owner, name):
        """Được gọi khi gán vào class (Python 3.6+)"""
        pass

# === DATA vs NON-DATA ===
# Data Descriptor: có __set__ hoặc __delete__ → ưu tiên cao
# Non-Data Descriptor: chỉ có __get__ → instance __dict__ có thể override

# === LAZY PROPERTY ===
from functools import cached_property

@cached_property
def expensive(self):
    return compute_once()

# === VALIDATION PATTERN ===
class Validated:
    def __set_name__(self, owner, name):
        self.name = f"_{name}"
    
    def __get__(self, obj, objtype=None):
        return getattr(obj, self.name, None) if obj else self
    
    def __set__(self, obj, value):
        validated = self.validate(value)
        setattr(obj, self.name, validated)