Giao diện
Descriptors trong Python
Mỗi lần bạn viết @property, @classmethod, @staticmethod — bạn đang sử dụng descriptor mà không hề hay biết. Descriptor là cỗ máy ẩn phía sau toàn bộ cơ chế truy cập thuộc tính trong Python. Không phải decorator, không phải metaclass — chính descriptor mới là lớp nền tảng thực sự điều khiển cách Python đọc, ghi, và xóa attribute.
Trong production, descriptor xuất hiện khắp nơi: Django dùng descriptor cho model fields (CharField, IntegerField), SQLAlchemy xây dựng Column trên descriptor protocol, WTForms triển khai validators qua descriptor, và ngay cả function object cũng là descriptor (đó là lý do method binding hoạt động). Hiểu descriptor nghĩa là hiểu Python ở tầng sâu nhất.
Đây là ranh giới giữa "người dùng Python" và "người hiểu Python". Sau trang này, bạn sẽ nắm vững protocol, phân biệt rõ data vs non-data descriptor, tự tay implement validation framework, và đọc được source code của bất kỳ thư viện nào dùng descriptor pattern.
Bức tranh tư duy
Hãy hình dung descriptor như bảo vệ tòa nhà (security guard). Khi ai đó (code) muốn vào phòng (attribute), bảo vệ (descriptor) chặn lại — kiểm tra giấy tờ, ghi sổ ra vào, hoặc chuyển hướng sang phòng khác. Code không trực tiếp chạm vào attribute mà phải đi qua "bảo vệ" trước.
obj.attr (truy cập thuộc tính)
│
▼
┌───────────────────────┐
│ Descriptor intercept │ ← Bảo vệ chặn lại
│ __get__ / __set__ │
│ __delete__ │
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ Validate / Transform │ ← Kiểm tra, biến đổi
│ Log / Cache │
└──────────┬────────────┘
│
▼
Trả về giá trị hoặc raise exceptionSự khác biệt then chốt nằm ở quyền hạn của bảo vệ:
- Data descriptor = Bảo vệ toàn quyền. Dù instance
__dict__có ghi gì, bảo vệ vẫn kiểm soát. Không ai bypass được. - Non-data descriptor = Bảo vệ tư vấn. Nếu instance
__dict__đã có key, bảo vệ nhường quyền.
Phân biệt này quyết định toàn bộ thứ tự tra cứu thuộc tính — và là nguồn gốc của nhiều bug tinh vi trong production.
Cốt lõi kỹ thuật
Descriptor Protocol — __get__, __set__, __delete__
Một object chỉ cần implement ít nhất một trong ba method này là đã trở thành descriptor:
| Method | Signature | Được gọi khi |
|---|---|---|
__get__ | __get__(self, obj, objtype=None) | obj.attr hoặc Class.attr |
__set__ | __set__(self, obj, value) | obj.attr = value |
__delete__ | __delete__(self, obj) | del obj.attr |
Quy tắc: Descriptor chỉ hoạt động khi được gán như class attribute, không phải instance attribute.
python
class LoggedAccess:
"""Descriptor ghi log mọi truy cập attribute."""
def __set_name__(self, owner, name):
self.attr_name = f"_{name}"
self.public_name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = getattr(obj, self.attr_name, None)
print(f"[GET] {self.public_name} → {value!r}")
return value
def __set__(self, obj, value):
print(f"[SET] {self.public_name} = {value!r}")
setattr(obj, self.attr_name, value)
def __delete__(self, obj):
print(f"[DEL] {self.public_name}")
delattr(obj, self.attr_name)
class Server:
host = LoggedAccess()
port = LoggedAccess()
def __init__(self, host: str, port: int):
self.host = host # [SET] host = 'localhost'
self.port = port # [SET] port = 8080
srv = Server("localhost", 8080)
srv.host # [GET] host → 'localhost'
srv.port = 3000 # [SET] port = 3000
del srv.host # [DEL] hostData Descriptor vs Non-data Descriptor
| Tiêu chí | Data Descriptor | Non-data Descriptor |
|---|---|---|
| Methods | Có __set__ và/hoặc __delete__ | Chỉ có __get__ |
| Ưu tiên | Cao hơn instance __dict__ | Thấp hơn instance __dict__ |
| Override được? | Không — luôn thắng | Có — instance dict ghi đè |
| Ví dụ | property, member_descriptor | function, classmethod |
python
class DataDesc:
def __get__(self, obj, objtype=None):
return obj.__dict__.get("_val", "default") if obj else self
def __set__(self, obj, value):
obj.__dict__["_val"] = value
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "từ non-data descriptor" if obj else self
class Demo:
data_attr = DataDesc()
nondata_attr = NonDataDesc()
obj = Demo()
obj.__dict__["data_attr"] = "instance dict"
obj.__dict__["nondata_attr"] = "instance dict"
print(obj.data_attr) # "default" ← Data descriptor THẮNG
print(obj.nondata_attr) # "instance dict" ← Instance dict THẮNGTại sao quan trọng? function là non-data descriptor — bạn có thể gán giá trị vào obj.method_name và nó sẽ "che" method. Nhưng property là data descriptor — không thể bypass bằng instance dict.
Chuỗi tra cứu thuộc tính (Attribute Lookup Chain)
Khi Python thực thi obj.attr, object.__getattribute__ tuân theo thứ tự:
obj.attr
├─ 1. Data descriptor trong type(obj).__mro__? → gọi __get__ → DỪNG
├─ 2. Key trong obj.__dict__? → trả về value → DỪNG
├─ 3. Non-data descriptor trong type(obj).__mro__? → gọi __get__ → DỪNG
├─ 4. __getattr__ có định nghĩa? → gọi nó → DỪNG
└─ 5. Raise AttributeErrorCode chứng minh:
python
class DataD:
def __get__(self, obj, objtype=None):
return "Bước 1: Data descriptor"
def __set__(self, obj, value):
pass
class NonDataD:
def __get__(self, obj, objtype=None):
return "Bước 3: Non-data descriptor"
class MyClass:
step1 = DataD()
step3 = NonDataD()
def __getattr__(self, name):
if name == "step4":
return "Bước 4: __getattr__"
raise AttributeError(name)
obj = MyClass()
obj.__dict__["step1"] = "instance dict"
print(obj.step1) # "Bước 1: Data descriptor" — data desc thắng
obj.__dict__["step3"] = "Bước 2: Instance dict"
print(obj.step3) # "Bước 2: Instance dict" — instance dict thắng non-data
del obj.__dict__["step3"]
print(obj.step3) # "Bước 3: Non-data descriptor"
print(obj.step4) # "Bước 4: __getattr__" — fallback cuối cùng@property — descriptor ẩn mình
@property là syntactic sugar cho data descriptor. Khi bạn viết @property, Python thực thi name = property(fget=name_function). Đây là cách tái tạo property từ đầu:
python
class MyProperty:
"""Tái tạo built-in property từ descriptor protocol."""
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
if self.fget is None:
raise AttributeError("thuộc tính không đọc được")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("thuộc tính không ghi được")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("thuộc tính không xóa được")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
class Account:
def __init__(self, balance: float):
self._balance = balance
@MyProperty
def balance(self) -> float:
return self._balance
@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("Số dư không thể âm")
self._balance = value
acc = Account(1000)
print(acc.balance) # 1000
acc.balance = 500 # OK
acc.balance = -100 # ValueError: Số dư không thể âmChú ý setter() trả về instance mới — đây là lý do chain @balance.setter hoạt động: tạo property mới kế thừa fget cũ nhưng thêm fset mới.
__set_name__ — tự nhận biết tên (Python 3.6+)
Trước Python 3.6, descriptor không biết mình được gán cho attribute nào — phải truyền tên thủ công (viết tên 2 lần, dễ sai). __set_name__ giải quyết triệt để:
python
class AutoNamed:
def __set_name__(self, owner, name):
# owner = class chứa descriptor (vd: User)
# name = tên attribute (vd: "email")
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 User:
email = AutoNamed() # __set_name__(User, "email") tự động
username = AutoNamed() # __set_name__(User, "username") tự động__set_name__ là bắt buộc cho mọi descriptor production-grade. Không có nó, bạn phải truyền tên attribute thủ công — vi phạm DRY và dễ gây bug.
Thực chiến
Tình huống: Xây dựng validation framework cho API schema — tương tự Pydantic/Django, nhưng dùng pure descriptor protocol.
Bộ validator descriptors
python
from typing import Any
class FieldDescriptor:
"""Base class cho validated fields."""
def __init__(self, default: Any = None):
self.default = default
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_field_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, self.default)
def __set__(self, obj, value):
value = self.validate(value)
setattr(obj, self.private_name, value)
def validate(self, value: Any) -> Any:
return value
class TypeChecked(FieldDescriptor):
def __init__(self, expected_type: type, **kwargs):
super().__init__(**kwargs)
self.expected_type = expected_type
def validate(self, value: Any) -> Any:
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.public_name}: cần {self.expected_type.__name__}, "
f"nhận {type(value).__name__}"
)
return value
class RangeValidator(FieldDescriptor):
def __init__(self, min_val: float = None, max_val: float = None, **kwargs):
super().__init__(**kwargs)
self.min_val = min_val
self.max_val = max_val
def validate(self, value: Any) -> Any:
if not isinstance(value, (int, float)):
raise TypeError(f"{self.public_name}: cần số")
if self.min_val is not None and value < self.min_val:
raise ValueError(f"{self.public_name}: {value} < {self.min_val}")
if self.max_val is not None and value > self.max_val:
raise ValueError(f"{self.public_name}: {value} > {self.max_val}")
return value
class StringValidator(FieldDescriptor):
def __init__(self, min_length: int = 0, max_length: int = None, **kwargs):
super().__init__(**kwargs)
self.min_length = min_length
self.max_length = max_length
def validate(self, value: Any) -> str:
if not isinstance(value, str):
raise TypeError(f"{self.public_name}: cần str")
value = value.strip()
if len(value) < self.min_length:
raise ValueError(f"{self.public_name}: cần ít nhất {self.min_length} ký tự")
if self.max_length and len(value) > self.max_length:
raise ValueError(f"{self.public_name}: tối đa {self.max_length} ký tự")
return value
class EmailValidator(StringValidator):
def validate(self, value: Any) -> str:
value = super().validate(value)
if "@" not in value or "." not in value.split("@")[-1]:
raise ValueError(f"{self.public_name}: email không hợp lệ — {value!r}")
return value.lower()Schema sử dụng descriptors
python
class UserSchema:
username = StringValidator(min_length=3, max_length=30)
email = EmailValidator(min_length=5, max_length=254)
age = RangeValidator(min_val=0, max_val=150)
role = StringValidator(min_length=1)
def __init__(self, username: str, email: str, age: int, role: str = "user"):
self.username = username
self.email = email
self.age = age
self.role = role
def __repr__(self) -> str:
return (
f"UserSchema(username={self.username!r}, email={self.email!r}, "
f"age={self.age}, role={self.role!r})"
)
# ✅ Dữ liệu hợp lệ
user = UserSchema("nguyen_van_a", "NguyenA@Company.VN", 28, "admin")
print(user)
# UserSchema(username='nguyen_van_a', email='nguyena@company.vn', age=28, role='admin')
# ❌ Validation bắt lỗi tự động
try:
UserSchema("ab", "invalid", 200)
except ValueError as e:
print(e) # username: cần ít nhất 3 ký tự
try:
user.age = -5
except ValueError as e:
print(e) # age: -5 < 0So sánh: Pydantic v2 dùng Rust core cho performance, nhưng concept gốc tương tự descriptor. Descriptor approach cho nhiều quyền kiểm soát hơn và không phụ thuộc thư viện ngoài. Pydantic cho ecosystem mạnh hơn (JSON serialization, OpenAPI schema, nested models).
Sai lầm điển hình
❌ SAI: Gán descriptor vào instance thay vì class
python
class Validator:
def __get__(self, obj, objtype=None):
return "validated"
def __set__(self, obj, value):
print(f"Validating: {value}")
# ❌ SAI
class User:
def __init__(self):
self.name = Validator() # Gán vào instance!
u = User()
print(u.name) # <Validator object> — __get__ KHÔNG được gọi!python
# ✅ ĐÚNG — descriptor phải là class attribute
class User:
name = Validator()
u = User()
u.name = "Alice" # Validating: AliceHệ quả: Descriptor "im lặng" không hoạt động — validation bị bypass hoàn toàn. Data xấu đi thẳng vào database mà developer không hay biết.
❌ SAI: Lưu state trong descriptor (shared state)
python
# ❌ SAI — tất cả instances chia sẻ cùng giá trị
class BadDescriptor:
def __init__(self):
self.value = None # State thuộc descriptor!
def __get__(self, obj, objtype=None):
return self.value
def __set__(self, obj, value):
self.value = value
class Product:
price = BadDescriptor()
p1, p2 = Product(), Product()
p1.price = 100
print(p2.price) # 100 — Bug! p2 thấy giá trị của p1python
# ✅ ĐÚNG — lưu state vào instance
class GoodDescriptor:
def __set_name__(self, owner, name):
self.attr_name = f"_{name}"
def __get__(self, obj, objtype=None):
return getattr(obj, self.attr_name, None) if obj else self
def __set__(self, obj, value):
setattr(obj, self.attr_name, value)Hệ quả: Shared state gây race condition trong multi-threaded environment. User A thay đổi giá → User B thấy giá sai.
❌ SAI: Quên if obj is None trong __get__
python
# ❌ SAI — crash khi truy cập từ class level
class Descriptor:
def __set_name__(self, owner, name):
self.name = f"_{name}"
def __get__(self, obj, objtype=None):
return getattr(obj, self.name) # obj=None khi Class.attr!
class Config:
debug = Descriptor()
Config.debug # AttributeError: 'NoneType' has no attribute '_debug'python
# ✅ ĐÚNG
def __get__(self, obj, objtype=None):
if obj is None:
return self # Trả về descriptor object
return getattr(obj, self.name, None)Hệ quả: Django, Sphinx, pytest truy cập descriptor ở class level (introspection). Thiếu guard obj is None crash các tool này.
❌ SAI: Non-data descriptor bị ghi đè bất ngờ
python
# ❌ SAI — non-data descriptor (chỉ __get__) bị override
class ComputedField:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
return self.func(obj) if obj else self
class Invoice:
@ComputedField
def total(self) -> float:
return self.subtotal * (1 + self.tax_rate)
inv = Invoice()
inv.subtotal, inv.tax_rate = 1000, 0.1
inv.__dict__["total"] = 0 # Ghi đè!
print(inv.total) # 0 — Descriptor bị bypass!python
# ✅ ĐÚNG — thêm __set__ để bảo vệ
class ProtectedComputed(ComputedField):
def __set__(self, obj, value):
raise AttributeError("Không thể ghi đè computed field")Hệ quả: Serialization library (pickle, JSON) ghi trực tiếp vào __dict__, bypass non-data descriptor. Computed value bị "đông cứng".
Under the Hood
CPython object.__getattribute__ — pseudocode
Khi Python thực thi obj.attr, CPython gọi type(obj).__getattribute__(obj, "attr"):
python
def object_getattribute(obj, name):
# Tìm trong MRO
mro_result = None
for base in type(obj).__mro__:
if name in base.__dict__:
mro_result = base.__dict__[name]
break
# Bước 1: Data descriptor?
if mro_result is not None:
tp = type(mro_result)
has_get = hasattr(tp, "__get__")
has_set = hasattr(tp, "__set__") or hasattr(tp, "__delete__")
if has_get and has_set: # Data descriptor
return tp.__get__(mro_result, obj, type(obj))
# Bước 2: Instance __dict__?
try:
return obj.__dict__[name]
except (KeyError, AttributeError):
pass
# Bước 3: Non-data descriptor hoặc class attribute?
if mro_result is not None:
tp = type(mro_result)
if hasattr(tp, "__get__"):
return tp.__get__(mro_result, obj, type(obj))
return mro_result
# Bước 4: __getattr__
if hasattr(type(obj), "__getattr__"):
return type(obj).__getattr__(obj, name)
raise AttributeError(f"'{type(obj).__name__}' has no attribute '{name}'")Function là non-data descriptor
python
def greet(self):
return f"Xin chào, {self.name}"
print(hasattr(greet, "__get__")) # True
print(hasattr(greet, "__set__")) # False → non-data descriptor
class Person:
name = "Python"
say_hi = greet
p = Person()
# Python gọi greet.__get__(p, Person) → bound method
print(p.say_hi()) # "Xin chào, Python"Đây là cơ chế method binding: function.__get__ trả về bound method — callable đã "nhớ" instance self.
__slots__ = data descriptor
python
class Point:
__slots__ = ("x", "y")
print(type(Point.x)) # <class 'member_descriptor'>
print(hasattr(Point.x, "__get__")) # True
print(hasattr(Point.x, "__set__")) # True
print(hasattr(Point.x, "__delete__")) # True__slots__ tạo member_descriptor (data descriptor) cho mỗi slot và loại bỏ __dict__. Đây là lý do __slots__ tiết kiệm memory.
Performance benchmark
python
# Benchmark tương đối (CPython 3.12)
# Direct dict access: ~40ns/access
# __slots__ access: ~35ns/access (nhanh hơn ~12%)
# property descriptor: ~120ns/access (chậm hơn ~3x)
# Custom __get__: ~200ns/access (chậm hơn ~5x)Kết luận: Descriptor chậm hơn direct dict access, nhưng chênh lệch chỉ đáng kể trong tight loop hàng triệu lần. Trong production code thực tế, overhead descriptor không đáng kể so với I/O, database query, hay network latency.
✅ Checklist triển khai
Checklist ghi nhớ
Descriptor Protocol
- [ ] Descriptor implement ít nhất một trong
__get__,__set__,__delete__ - [ ] Descriptor chỉ hoạt động khi gán như class attribute
- [ ]
__get__(self, obj, objtype)—objlàNonekhi truy cập từ class - [ ]
__set_name__(self, owner, name)tự động gọi khi class được tạo (3.6+)
Data vs Non-data
- [ ] Data descriptor = có
__set__và/hoặc__delete__→ ưu tiên cao hơn instance__dict__ - [ ] Non-data descriptor = chỉ
__get__→ instance__dict__override được - [ ]
propertylà data descriptor;functionlà non-data descriptor - [ ]
__slots__tạomember_descriptor— data descriptor
Attribute Lookup
- [ ] Thứ tự: data descriptor → instance
__dict__→ non-data descriptor →__getattr__ - [ ]
object.__getattribute__cho instance;type.__getattribute__cho class - [ ]
__getattr__chỉ được gọi khi tất cả bước trước thất bại
Production Patterns
- [ ] Luôn lưu state vào instance (
setattr(obj, ...)) — không lưu trong descriptor - [ ] Luôn guard
if obj is None: return selftrong__get__ - [ ] Dùng
__set_name__thay vì truyền tên attribute thủ công - [ ] Thêm
__set__nếu cần bảo vệ descriptor khỏi bị override bởi instance dict
Bài tập luyện tập
Bài 1: CachedProperty — Lazy evaluation (Intermediate)
Implement descriptor CachedProperty: tính toán lần đầu, cache vào instance __dict__, hỗ trợ del để reset cache.
python
class CachedProperty:
# TODO: Implement
pass
class DataAnalyzer:
def __init__(self, data: list[float]):
self.data = data
@CachedProperty
def mean(self) -> float:
print("Tính mean...")
return sum(self.data) / len(self.data)
@CachedProperty
def variance(self) -> float:
print("Tính variance...")
m = self.mean
return sum((x - m) ** 2 for x in self.data) / len(self.data)
# Kỳ vọng:
analyzer = DataAnalyzer([1, 2, 3, 4, 5])
print(analyzer.mean) # "Tính mean..." → 3.0
print(analyzer.mean) # 3.0 (không tính lại)
del analyzer.mean # Xóa cache
print(analyzer.mean) # "Tính mean..." → 3.0💡 Lời giải Bài 1
python
class CachedProperty:
def __init__(self, func):
self.func = func
self.attr_name = None
self.__doc__ = func.__doc__
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 in obj.__dict__:
return obj.__dict__[self.attr_name]
value = self.func(obj)
obj.__dict__[self.attr_name] = value
return value
def __delete__(self, obj):
obj.__dict__.pop(self.attr_name, None)Giải thích: Trick ở đây — CachedProperty chủ ý để instance __dict__ "che" descriptor sau lần tính đầu. __delete__ xóa key khỏi obj.__dict__, lần truy cập sau __get__ lại được gọi.
Bài 2: TypeEnforced — Runtime type checking (Advanced)
Implement descriptor enforce type tại runtime, hỗ trợ union type và nullable:
python
class TypeEnforced:
# TODO: Implement
pass
class APIRequest:
url = TypeEnforced(str)
timeout = TypeEnforced((int, float), nullable=True)
headers = TypeEnforced(dict, default_factory=dict)
def __init__(self, url: str, timeout=None, headers=None):
self.url = url
self.timeout = timeout
if headers is not None:
self.headers = headers
# Kỳ vọng:
req = APIRequest("https://api.example.com")
req.timeout = 30 # OK
req.timeout = None # OK (nullable)
req.timeout = "30" # TypeError
req.url = 123 # TypeError💡 Lời giải Bài 2
python
class TypeEnforced:
def __init__(self, expected_types, nullable: bool = False,
default=None, default_factory=None):
if isinstance(expected_types, type):
expected_types = (expected_types,)
self.expected_types = expected_types
self.nullable = nullable
self.default = default
self.default_factory = default_factory
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_typed_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
if not hasattr(obj, self.private_name):
if self.default_factory:
value = self.default_factory()
setattr(obj, self.private_name, value)
return value
return self.default
return getattr(obj, self.private_name)
def __set__(self, obj, value):
if value is None:
if not self.nullable:
raise TypeError(f"{self.public_name}: không chấp nhận None")
setattr(obj, self.private_name, None)
return
if not isinstance(value, self.expected_types):
names = "/".join(t.__name__ for t in self.expected_types)
raise TypeError(
f"{self.public_name}: cần {names}, nhận {type(value).__name__}"
)
setattr(obj, self.private_name, value)
def __delete__(self, obj):
if hasattr(obj, self.private_name):
delattr(obj, self.private_name)Giải thích: default_factory giải quyết mutable default problem (tương tự dataclasses.field). Mỗi instance nhận copy riêng. Pattern nullable tách biệt "không có giá trị" với "giá trị sai type" — quan trọng cho API schema.
Quiz: Attribute Lookup
🧠 Quiz
Câu hỏi: Cho đoạn code sau, print(obj.x) in ra gì?
python
class Desc:
def __get__(self, obj, objtype=None):
return "descriptor"
class MyClass:
x = Desc()
obj = MyClass()
obj.__dict__["x"] = "instance"
print(obj.x)- A)
"descriptor" - B)
"instance" - C)
AttributeError - D)
None
Đáp án: B — Desc chỉ có __get__ → non-data descriptor. Instance __dict__ ưu tiên cao hơn. Nếu Desc có thêm __set__, đáp án sẽ là A.