Descriptor: cơ chế đứng sau property, method, classmethod và __slots__
Bạn dùng @property, @classmethod, @staticmethod hằng ngày. Bạn gọi obj.method() mà không cần truyền self. Django model khai báo name = CharField() ở cấp class nhưng mỗi object lại có giá trị riêng. Tất cả những điều đó chạy trên một cơ chế duy nhất: descriptor.
Raymond Hettinger (core developer Python) gọi descriptor là thứ giúp bạn “hiểu Python sâu hơn bất kỳ tính năng nào khác”, vì nó giải thích cách mọi thuộc tính được tra cứu.
Trong bài này, bạn sẽ học:
- Descriptor protocol:
__get__,__set__,__delete__,__set_name__ - Khác biệt giữa data descriptor và non-data descriptor - và vì sao nó quan trọng
- Thứ tự tra cứu thuộc tính chính xác khi bạn viết
obj.x - Vì sao function trở thành bound method,
selftừ đâu ra - Tự viết lại
property,classmethod,cached_property - Xây dựng field có kiểm tra kiểu/giá trị có thể tái sử dụng (như ORM)
__getattr__vs__getattribute__
Vấn đề: lặp lại code kiểm tra dữ liệu
Phần tiêu đề “Vấn đề: lặp lại code kiểm tra dữ liệu”Giả sử bạn muốn các thuộc tính số của class Product luôn không âm:
class Product: def __init__(self, name, price, quantity): self.name = name self.price = price self.quantity = quantity
@property def price(self): return self._price
@price.setter def price(self, value): if value < 0: raise ValueError("price phải >= 0") self._price = value
@property def quantity(self): return self._quantity
@quantity.setter def quantity(self, value): if value < 0: raise ValueError("quantity phải >= 0") self._quantity = valueHai property gần như giống hệt nhau. Nếu có thêm weight, discount… bạn sẽ copy-paste mãi. Descriptor cho phép viết logic này một lần và dùng lại:
class Product: price = NonNegative() quantity = NonNegative() weight = NonNegative()Hãy xem cách làm điều đó.
Descriptor protocol
Phần tiêu đề “Descriptor protocol”Một descriptor là bất kỳ object nào có ít nhất một trong các phương thức sau, và được đặt làm thuộc tính của một class:
class Descriptor: def __set_name__(self, owner, name): ... # gọi khi class được tạo (3.6+) def __get__(self, instance, owner=None): ... # đọc: obj.attr def __set__(self, instance, value): ... # ghi: obj.attr = value def __delete__(self, instance): ... # xoá: del obj.attrinstance: object mà thuộc tính được truy cập qua (Nonenếu truy cập qua class, ví dụProduct.price).owner: class chứa descriptor.__set_name__: cho descriptor biết tên nó được gán vào ("price","quantity") - rất tiện để tạo tên lưu trữ và thông báo lỗi.
Descriptor đầu tiên
Phần tiêu đề “Descriptor đầu tiên”class NonNegative: def __set_name__(self, owner, name): self.public_name = name self.private_name = "_" + name
def __get__(self, instance, owner=None): if instance is None: # truy cập qua class: Product.price return self return getattr(instance, self.private_name)
def __set__(self, instance, value): if value < 0: raise ValueError(f"{self.public_name} phải >= 0, nhận được {value}") setattr(instance, self.private_name, value)
class Product: price = NonNegative() quantity = NonNegative()
def __init__(self, name, price, quantity): self.name = name self.price = price # gọi NonNegative.__set__ self.quantity = quantity
p = Product("Bút", 5000, 10)print(p.price) # 5000 -> NonNegative.__get__print(vars(p)) # {'name': 'Bút', '_price': 5000, '_quantity': 10}try: p.quantity = -1except ValueError as e: print(e) # quantity phải >= 0, nhận được -1Để ý: chỉ có một object NonNegative cho price, dùng chung cho mọi instance của Product. Descriptor biết đang làm việc với object nào nhờ tham số instance.
Bẫy: lưu dữ liệu trên chính descriptor
Phần tiêu đề “Bẫy: lưu dữ liệu trên chính descriptor”class Broken: def __get__(self, instance, owner=None): return self.value def __set__(self, instance, value): self.value = value # ❌ lưu trên descriptor -> DÙNG CHUNG cho mọi instance!
class A: x = Broken()
a1, a2 = A(), A()a1.x = 1a2.x = 2print(a1.x) # 2 (!) - a1 bị ghi đè bởi a2Dữ liệu của từng instance phải được lưu trên instance (trong __dict__ với tên riêng, như ví dụ NonNegative), hoặc trong một WeakKeyDictionary với key là instance.
Data descriptor và non-data descriptor
Phần tiêu đề “Data descriptor và non-data descriptor”- Data descriptor: có
__set__hoặc__delete__. Ví dụ:property, member của__slots__,NonNegativeở trên. - Non-data descriptor: chỉ có
__get__. Ví dụ: function (method),classmethod,staticmethod,functools.cached_property.
print(hasattr(property, "__set__")) # True -> dataprint(hasattr(type(lambda: 0), "__set__")) # False -> non-datafrom functools import cached_propertyprint(hasattr(cached_property, "__set__")) # False -> non-dataSự khác biệt này quyết định ai thắng khi instance có một giá trị trùng tên trong __dict__ của nó.
Thứ tự tra cứu thuộc tính chính xác
Phần tiêu đề “Thứ tự tra cứu thuộc tính chính xác”Khi bạn viết obj.x, Python gọi type(obj).__getattribute__(obj, "x"). Với object thông thường, thuật toán (trong object.__getattribute__) là:
1. Tìm "x" trong __dict__ của type(obj) và các class cha theo MRO -> gọi là attr2. Nếu attr là DATA descriptor -> return attr.__get__(obj, type(obj))3. Nếu "x" có trong obj.__dict__ -> return obj.__dict__["x"]4. Nếu attr là NON-DATA descriptor -> return attr.__get__(obj, type(obj))5. Nếu attr là giá trị thường -> return attr6. Không tìm thấy -> gọi type(obj).__getattr__(obj, "x") nếu có, ngược lại AttributeErrorVà khi gán obj.x = v:
1. Nếu type(obj) có DATA descriptor tên "x" -> attr.__set__(obj, v)2. Ngược lại -> obj.__dict__["x"] = vHệ quả thực tế:
- Data descriptor luôn thắng
__dict__của instance. Đó là lý do bạn không thể “ghi đè” một property bằng cách gán vào instance -p.price = 5luôn đi qua setter. __dict__của instance thắng non-data descriptor. Đây là cơ chế giúp bạn gánobj.method = somethingđể thay thế method của một object cụ thể (monkey-patching), và là nền tảng củacached_property.
Kiểm chứng:
class Demo: @property def data_attr(self): return "từ property"
def method(self): return "từ method"
d = Demo()d.__dict__["data_attr"] = "từ __dict__"d.__dict__["method"] = lambda: "từ __dict__"
print(d.data_attr) # từ property -> data descriptor thắngprint(d.method()) # từ __dict__ -> __dict__ thắng non-data descriptorFunction là descriptor: self từ đâu ra?
Phần tiêu đề “Function là descriptor: self từ đâu ra?”Đây là câu trả lời cho câu hỏi mà rất nhiều người học Python thắc mắc.
class C: def f(self): return 1
c = C()print(C.__dict__["f"]) # <function C.f at 0x...> - chỉ là function bình thườngprint(C.f) # <function C.f at 0x...> - qua class: __get__(None, C) trả về chính nóprint(c.f) # <bound method C.f of <C object>> - qua instance: một bound method!Function có phương thức __get__. Khi bạn truy cập c.f, bước 4 của thuật toán tra cứu gọi C.__dict__["f"].__get__(c, C), trả về một bound method - một object nhỏ giữ (function, instance). Khi gọi bound method, nó gọi function(instance, *args). Đó chính là self.
bound = C.__dict__["f"].__get__(c, C)print(bound) # <bound method C.f of ...>print(bound.__func__, bound.__self__)print(bound()) # 1 - tương đương C.f(c)Mô phỏng bằng Python thuần:
import types
class Function: """Mô phỏng cách function hoạt động như descriptor.""" def __init__(self, func): self.func = func
def __get__(self, instance, owner=None): if instance is None: return self.func return types.MethodType(self.func, instance) # gắn instance vào làm selfĐiều này cũng giải thích một chi tiết về hiệu năng: mỗi lần viết c.f, Python tạo một bound method mới (CPython 3.11+ tối ưu trường hợp gọi ngay c.f() để không cần tạo object này).
Tự viết lại các decorator quen thuộc
Phần tiêu đề “Tự viết lại các decorator quen thuộc”property
Phần tiêu đề “property”class Property: def __init__(self, fget=None, fset=None): self.fget = fget self.fset = fset
def __set_name__(self, owner, name): self.name = name
def __get__(self, instance, owner=None): if instance is None: return self return self.fget(instance)
def __set__(self, instance, value): if self.fset is None: raise AttributeError(f"thuộc tính '{self.name}' chỉ đọc") self.fset(instance, value)
def setter(self, fset): return type(self)(self.fget, fset) # trả về property MỚI có thêm setter
class Circle: def __init__(self, r): self.r = r
@Property def area(self): return 3.14159 * self.r ** 2
c = Circle(2)print(c.area) # 12.56636try: c.area = 10except AttributeError as e: print(e) # thuộc tính 'area' chỉ đọcĐể ý Property luôn có __set__ (kể cả khi không có setter) - để nó là data descriptor và không bị __dict__ của instance ghi đè.
classmethod và staticmethod
Phần tiêu đề “classmethod và staticmethod”import types
class ClassMethod: def __init__(self, func): self.func = func def __get__(self, instance, owner=None): if owner is None: owner = type(instance) return types.MethodType(self.func, owner) # gắn CLASS thay vì instance
class StaticMethod: def __init__(self, func): self.func = func def __get__(self, instance, owner=None): return self.func # không gắn gì cả
class Date: def __init__(self, y, m, d): self.y, self.m, self.d = y, m, d
@ClassMethod def from_string(cls, s): return cls(*map(int, s.split("-")))
@StaticMethod def is_leap(y): return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
print(vars(Date.from_string("2026-09-23"))) # {'y': 2026, 'm': 9, 'd': 23}print(Date.is_leap(2024)) # Truecached_property - tận dụng non-data descriptor
Phần tiêu đề “cached_property - tận dụng non-data descriptor”Một thuộc tính tính toán tốn kém, chỉ cần tính một lần cho mỗi object:
import time
class CachedProperty: def __init__(self, func): self.func = func
def __set_name__(self, owner, name): self.name = name
def __get__(self, instance, owner=None): if instance is None: return self value = self.func(instance) instance.__dict__[self.name] = value # lưu vào __dict__ với CÙNG TÊN return value # KHÔNG có __set__ -> non-data descriptor
class Report: @CachedProperty def data(self): print("đang tính toán...") time.sleep(1) return [1, 2, 3]
r = Report()print(r.data) # đang tính toán... [1, 2, 3]print(r.data) # [1, 2, 3] <- không gọi __get__ nữa!Lần đầu, __dict__ chưa có data → gọi __get__ (bước 4). __get__ ghi kết quả vào instance.__dict__["data"]. Lần sau, bước 3 thắng bước 4 - Python lấy thẳng từ __dict__, descriptor không còn bị gọi nữa. Chi phí sau lần đầu bằng đúng chi phí đọc một thuộc tính thường.
Muốn tính lại? del r.data - xoá khỏi __dict__, lần truy cập sau descriptor lại được gọi. Đây chính xác là cách functools.cached_property hoạt động (vì vậy nó không dùng được với class có __slots__ mà không có __dict__).
Ví dụ thực tế: hệ thống field kiểu ORM
Phần tiêu đề “Ví dụ thực tế: hệ thống field kiểu ORM”Kết hợp mọi thứ, ta xây một hệ thống validation nhỏ, có thể mở rộng - giống ý tưởng của Django model hay Pydantic:
from abc import ABC, abstractmethod
class Field(ABC): def __set_name__(self, owner, name): self.name = name self.storage = "_field_" + name
def __get__(self, instance, owner=None): if instance is None: return self return getattr(instance, self.storage)
def __set__(self, instance, value): self.validate(value) setattr(instance, self.storage, value)
@abstractmethod def validate(self, value): ...
class String(Field): def __init__(self, min_len=0, max_len=None): self.min_len, self.max_len = min_len, max_len
def validate(self, value): if not isinstance(value, str): raise TypeError(f"{self.name}: cần str, nhận {type(value).__name__}") if len(value) < self.min_len: raise ValueError(f"{self.name}: tối thiểu {self.min_len} ký tự") if self.max_len is not None and len(value) > self.max_len: raise ValueError(f"{self.name}: tối đa {self.max_len} ký tự")
class Number(Field): def __init__(self, min_value=None, max_value=None): self.min_value, self.max_value = min_value, max_value
def validate(self, value): if not isinstance(value, (int, float)) or isinstance(value, bool): raise TypeError(f"{self.name}: cần số, nhận {type(value).__name__}") if self.min_value is not None and value < self.min_value: raise ValueError(f"{self.name}: phải >= {self.min_value}") if self.max_value is not None and value > self.max_value: raise ValueError(f"{self.name}: phải <= {self.max_value}")
class OneOf(Field): def __init__(self, *options): self.options = set(options)
def validate(self, value): if value not in self.options: raise ValueError(f"{self.name}: phải là một trong {sorted(self.options)}")
class Student: name = String(min_len=2, max_len=50) age = Number(min_value=5, max_value=100) grade = OneOf("A", "B", "C", "D", "F")
def __init__(self, name, age, grade): self.name = name self.age = age self.grade = grade
s = Student("An", 16, "A")for bad in [("A", 16, "A"), ("Bình", "16", "A"), ("Chi", 16, "E")]: try: Student(*bad) except (TypeError, ValueError) as e: print(e)# name: tối thiểu 2 ký tự# age: cần số, nhận str# grade: phải là một trong ['A', 'B', 'C', 'D', 'F']Mỗi loại field được viết một lần, dùng khai báo ngắn gọn ở bất kỳ class nào. Đọc định nghĩa class Student là biết ngay ràng buộc của dữ liệu.
__getattr__ vs __getattribute__
Phần tiêu đề “__getattr__ vs __getattribute__”Hai phương thức này hay bị nhầm lẫn:
__getattribute__ |
__getattr__ |
|
|---|---|---|
| Khi nào được gọi | Mọi lần truy cập thuộc tính | Chỉ khi tra cứu thông thường thất bại |
| Mặc định | object.__getattribute__ (thuật toán ở trên) |
không có |
| Dùng khi | hiếm khi - rất dễ gây đệ quy vô hạn, chậm | proxy, wrapper, thuộc tính động |
Ví dụ __getattr__ để tạo proxy ghi log:
class LoggingProxy: def __init__(self, target): self._target = target # tìm thấy bình thường, không gọi __getattr__
def __getattr__(self, name): # chỉ gọi khi LoggingProxy không có thuộc tính name attr = getattr(self._target, name) if callable(attr): def wrapper(*args, **kwargs): print(f"gọi {name}{args}") return attr(*args, **kwargs) return wrapper return attr
items = LoggingProxy([3, 1, 2])items.append(0) # gọi append(0,)items.sort() # gọi sort()print(items._target) # [0, 1, 2, 3]Nếu buộc phải override __getattribute__, luôn gọi super().__getattribute__(name) hoặc object.__getattribute__(self, name) để lấy thuộc tính - truy cập self.x bên trong nó sẽ gọi lại chính nó và đệ quy vô hạn.
Khi nào nên viết descriptor?
Phần tiêu đề “Khi nào nên viết descriptor?”- ✅ Cùng một logic truy cập (validation, chuyển đổi, lazy load, log) lặp lại ở nhiều thuộc tính hoặc nhiều class.
- ✅ Viết framework/thư viện: ORM, form, config, serialization.
- ❌ Chỉ một thuộc tính trong một class →
@propertylà đủ và dễ đọc hơn. - ❌ Chỉ cần validation cho dataclass → cân nhắc thư viện như Pydantic/attrs đã làm sẵn.
Bài tập
Phần tiêu đề “Bài tập”- Viết descriptor
Typed(type_)đảm bảo giá trị gán đúng kiểu, rồi viếtPositivekế thừa và thêm điều kiện> 0. - Viết descriptor
ReadOnlyAfterInit: cho phép gán một lần (trong__init__), các lần gán sau némAttributeError. - Viết descriptor
Historyghi lại mọi giá trị từng được gán cho thuộc tính, lưu trongWeakKeyDictionary, và phương thứchistory(instance)trả về danh sách đó.
Kết luận
Phần tiêu đề “Kết luận”- Descriptor là object có
__get__/__set__/__delete__đặt ở cấp class. - Data descriptor thắng
__dict__của instance;__dict__thắng non-data descriptor. - Function là non-data descriptor -
__get__tạo bound method, đó là nguồn gốc củaself. property,classmethod,staticmethod,cached_property,__slots__đều là descriptor.- Lưu dữ liệu của từng instance trên instance, không phải trên descriptor.
__set_name__giúp descriptor biết tên của mình - dùng để tạo tên lưu trữ và thông báo lỗi.
Bài tiếp theo: Metaclass và __init_subclass__.