Bỏ qua để đến nội dung

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 descriptornon-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, self từ đâ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 = value

Hai 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 đó.

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.attr
  • instance: object mà thuộc tính được truy cập qua (None nế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.
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 = -1
except 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.

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 = 1
a2.x = 2
print(a1.x) # 2 (!) - a1 bị ghi đè bởi a2

Dữ 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: 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 -> data
print(hasattr(type(lambda: 0), "__set__")) # False -> non-data
from functools import cached_property
print(hasattr(cached_property, "__set__")) # False -> non-data

Sự 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ó.

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à attr
2. 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 attr
6. Không tìm thấy -> gọi type(obj).__getattr__(obj, "x") nếu có, ngược lại AttributeError

Và 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"] = v

Hệ 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 = 5 luôn đi qua setter.
  • __dict__ của instance thắng non-data descriptor. Đây là cơ chế giúp bạn gán obj.method = something để thay thế method của một object cụ thể (monkey-patching), và là nền tảng của cached_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ắng
print(d.method()) # từ __dict__ -> __dict__ thắng non-data descriptor

Đâ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ường
print(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).

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.56636
try:
c.area = 10
except 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 đè.

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)) # True

cached_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.

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.

  • ✅ 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 → @property là đủ 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.
  1. Viết descriptor Typed(type_) đảm bảo giá trị gán đúng kiểu, rồi viết Positive kế thừa và thêm điều kiện > 0.
  2. Viết descriptor ReadOnlyAfterInit: cho phép gán một lần (trong __init__), các lần gán sau ném AttributeError.
  3. Viết descriptor History ghi lại mọi giá trị từng được gán cho thuộc tính, lưu trong WeakKeyDictionary, và phương thức history(instance) trả về danh sách đó.
  • 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ủa self.
  • 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__.