__slots__ và weakref: tiết kiệm bộ nhớ và tránh giữ object quá lâu
Hai công cụ trong bài này giải quyết hai vấn đề bộ nhớ rất hay gặp:
__slots__: bạn có hàng triệu object nhỏ (điểm dữ liệu, node, bản ghi) và RAM không đủ.weakref: bạn muốn tham chiếu tới một object (cache, observer, con trỏ ngược lên cha) mà không giữ nó sống.
Trong bài này, bạn sẽ học:
__slots__tiết kiệm bộ nhớ thế nào, đo trên 1 triệu object- Cơ chế descriptor phía sau slots
- Các ràng buộc và bẫy của
__slots__khi kế thừa - Weak reference: tham chiếu mà không giữ object sống
- Ba ứng dụng thực tế: cache tự dọn, con trỏ ngược lên cha, observer không gây leak
weakref.finalize- cách dọn dẹp tài nguyên đáng tin hơn__del__
1. Mỗi instance thường có một __dict__
Phần tiêu đề “1. Mỗi instance thường có một __dict__”class Point: def __init__(self, x, y): self.x = x self.y = y
p = Point(1, 2)print(p.__dict__) # {'x': 1, 'y': 2}p.z = 3 # thêm thuộc tính tuỳ ý lúc chạy__dict__ cho phép thêm thuộc tính bất kỳ, nhưng phải trả giá bằng bộ nhớ cho bảng băm (dù CPython đã tối ưu rất nhiều, xem bài Dict và Set).
2. __slots__: khai báo trước thuộc tính
Phần tiêu đề “2. __slots__: khai báo trước thuộc tính”class SlotPoint: __slots__ = ("x", "y")
def __init__(self, x, y): self.x = x self.y = yVới __slots__, CPython không tạo __dict__. Thay vào đó, nó dành sẵn đúng 2 ô con trỏ ngay trong object, giống một struct trong C:
Point (có __dict__) SlotPoint (__slots__)┌──────────────────┐ ┌──────────────────┐│ refcnt, type │ │ refcnt, type ││ (inline values / │ │ x ──► 1 ││ __dict__ ...) │ │ y ──► 2 ││ __weakref__ │ └──────────────────┘└──────────────────┘Đo thực tế với 1 triệu object
Phần tiêu đề “Đo thực tế với 1 triệu object”import tracemallocfrom dataclasses import dataclass
class Point: def __init__(self, x, y): self.x = x self.y = y
class SlotPoint: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y
@dataclass(slots=True) # Python 3.10+class DataPoint: x: int y: int
def measure(cls): tracemalloc.start() objs = [cls(1, 2) for _ in range(1_000_000)] current, _ = tracemalloc.get_traced_memory() tracemalloc.stop() return current / 1e6
for cls in (Point, SlotPoint, DataPoint): print(f"{cls.__name__:10} {measure(cls):.1f} MB")Point 96.5 MBSlotPoint 56.4 MBDataPoint 56.4 MB(Mỗi con số đã bao gồm ~8 MB của list chứa con trỏ.) Tức là ~88 byte/object so với ~48 byte/object - giảm khoảng 45%. Trên Python 3.10 trở về trước, bản không có slots tốn tới ~152 byte/object, nên chênh lệch còn lớn hơn.
Truy cập thuộc tính qua slots cũng nhanh ngang hoặc hơn một chút so với qua dict.
3. Slots hoạt động nhờ descriptor
Phần tiêu đề “3. Slots hoạt động nhờ descriptor”print(SlotPoint.x) # <member 'x' of 'SlotPoint' objects>Mỗi tên trong __slots__ trở thành một descriptor (member_descriptor) đặt trên class. Khi bạn viết p.x, Python tìm thấy descriptor này trên class và gọi nó để đọc ô nhớ tương ứng ở offset cố định trong object. Cơ chế descriptor được giải thích kỹ trong bài Descriptor.
4. Những ràng buộc và bẫy của __slots__
Phần tiêu đề “4. Những ràng buộc và bẫy của __slots__”Không thêm được thuộc tính mới
Phần tiêu đề “Không thêm được thuộc tính mới”p = SlotPoint(1, 2)try: p.z = 3except AttributeError as e: print(e)# 'SlotPoint' object has no attribute 'z' and no __dict__ for setting new attributesĐây có thể là ưu điểm: gõ nhầm p.xx = 5 sẽ báo lỗi ngay thay vì âm thầm tạo thuộc tính mới.
Không dùng được weakref (trừ khi khai báo)
Phần tiêu đề “Không dùng được weakref (trừ khi khai báo)”import weakreftry: weakref.ref(p)except TypeError as e: print(e) # cannot create weak reference to 'SlotPoint' object
class WeakSlotPoint: __slots__ = ("x", "y", "__weakref__") # thêm ô cho weakrefKế thừa: mọi class trong chuỗi phải có slots
Phần tiêu đề “Kế thừa: mọi class trong chuỗi phải có slots”class Base: # không có __slots__ -> có __dict__ pass
class Child(Base): __slots__ = ("a",)
c = Child()c.b = 1 # vẫn được! vì Base đã có __dict__print(hasattr(c, "__dict__")) # True -> mất hết lợi ích tiết kiệmQuy tắc:
- Mọi class cha cũng phải khai báo
__slots__(có thể là__slots__ = ()). - Class con chỉ khai báo thuộc tính mới, không lặp lại tên đã có ở cha.
- Đa kế thừa từ hai class đều có slots không rỗng sẽ báo lỗi “multiple bases have instance lay-out conflict”.
Giá trị mặc định ở mức class không dùng được
Phần tiêu đề “Giá trị mặc định ở mức class không dùng được”class Bad: __slots__ = ("x",) x = 0 # ValueError: 'x' in __slots__ conflicts with class variableHãy gán mặc định trong __init__, hoặc dùng @dataclass(slots=True) - nó tự xử lý chuyện này.
Khi nào dùng __slots__?
Phần tiêu đề “Khi nào dùng __slots__?”- ✅ Class có rất nhiều instance (≥ hàng trăm nghìn) với tập thuộc tính cố định.
- ✅ Muốn chặn việc thêm thuộc tính ngoài ý muốn.
- ❌ Class chỉ có vài instance - lợi ích không đáng kể.
- ❌ Code dùng thư viện cần
__dict__(một số ORM,picklecũ, monkey-patching).
5. Weak reference: tham chiếu không giữ object sống
Phần tiêu đề “5. Weak reference: tham chiếu không giữ object sống”Tham chiếu thông thường tăng refcount → object không bị huỷ chừng nào bạn còn giữ. Weak reference trỏ tới object nhưng không tăng refcount. Khi object bị huỷ, weakref tự động trả về None.
import weakref
class Image: def __init__(self, name): self.name = name
img = Image("cat.png")r = weakref.ref(img)
print(r()) # <__main__.Image object ...> - gọi r() để lấy objectdel imgprint(r()) # None - object đã bị huỷLưu ý: int, str, tuple, list, dict không hỗ trợ weakref trực tiếp. Instance của class bạn tự viết thì có (trừ khi dùng slots mà không có __weakref__).
Ứng dụng 1: cache không gây leak
Phần tiêu đề “Ứng dụng 1: cache không gây leak”import weakref
class Image: def __init__(self, path): print("tải", path) self.path = path
_cache = weakref.WeakValueDictionary()
def load_image(path): img = _cache.get(path) if img is None: img = Image(path) _cache[path] = img return img
a = load_image("cat.png") # tải cat.pngb = load_image("cat.png") # lấy từ cache, không tải lạiprint(a is b) # Truedel a, bprint(len(_cache)) # 0 - không ai dùng nữa thì tự rời cacheChừng nào còn nơi dùng ảnh, mọi lời gọi dùng chung một object. Khi không ai dùng, ảnh tự biến mất khỏi cache - không bao giờ phình to vô hạn.
Tương tự có WeakKeyDictionary: gắn thêm dữ liệu vào object của người khác mà không kéo dài tuổi thọ của nó, và WeakSet: tập các object “đang sống”.
Ứng dụng 2: con trỏ ngược lên cha trong cây
Phần tiêu đề “Ứng dụng 2: con trỏ ngược lên cha trong cây”import weakref
class TreeNode: def __init__(self, value, parent=None): self.value = value self.children = [] self._parent = weakref.ref(parent) if parent else None
@property def parent(self): return self._parent() if self._parent else None
def add(self, value): child = TreeNode(value, parent=self) self.children.append(child) return child
root = TreeNode("root")child = root.add("child")print(child.parent.value) # rootCha giữ con bằng tham chiếu mạnh, con trỏ lên cha bằng tham chiếu yếu → không tạo vòng, cây được giải phóng ngay bằng reference counting mà không cần chờ GC.
Ứng dụng 3: observer/callback không giữ subscriber
Phần tiêu đề “Ứng dụng 3: observer/callback không giữ subscriber”import weakref
class EventBus: def __init__(self): self._subscribers = weakref.WeakSet()
def subscribe(self, obj): self._subscribers.add(obj)
def publish(self, msg): for s in list(self._subscribers): s.on_event(msg)Nếu dùng set thường, một màn hình UI đã đóng vẫn bị EventBus giữ lại mãi - một nguồn leak kinh điển. Với phương thức (bound method), dùng weakref.WeakMethod.
6. weakref.finalize - dọn dẹp đáng tin hơn __del__
Phần tiêu đề “6. weakref.finalize - dọn dẹp đáng tin hơn __del__”__del__ có nhiều vấn đề: không chắc được gọi khi thoát chương trình, có thể “hồi sinh” object, lỗi trong đó bị nuốt. weakref.finalize là lựa chọn an toàn hơn:
import weakref, tempfile, shutil
class TempWorkspace: def __init__(self): self.path = tempfile.mkdtemp() # Đăng ký hàm dọn dẹp: chạy khi object bị huỷ HOẶC khi chương trình thoát self._finalizer = weakref.finalize(self, shutil.rmtree, self.path)
def cleanup(self): self._finalizer() # gọi chủ động; finalize chỉ chạy tối đa một lần
@property def closed(self): return not self._finalizer.alive
ws = TempWorkspace()print(ws.path)del ws # thư mục tạm bị xoáBài tập
Phần tiêu đề “Bài tập”- Viết lại class
Point3D(x, y, z)bằng ba cách: class thường,__slots__,@dataclass(slots=True). Đo bộ nhớ của 1 triệu object bằngtracemalloc. - Viết
ImageCachedùngWeakValueDictionaryvà chứng minh ảnh bị xoá khỏi cache khi không còn tham chiếu nào. - Viết class
EventEmittervớion(event, callback)dùngWeakMethodcho bound method, để object lắng nghe bị huỷ thì callback tự biến mất.
Kết luận
Phần tiêu đề “Kết luận”Bạn đã đi qua những kiến thức cốt lõi của bài này:
| Công cụ | Dùng khi |
|---|---|
__slots__ / @dataclass(slots=True) |
Nhiều instance, thuộc tính cố định → giảm ~45% bộ nhớ |
weakref.ref |
Trỏ tới object mà không giữ nó sống |
WeakValueDictionary |
Cache tự dọn khi không còn ai dùng |
WeakKeyDictionary |
Gắn metadata vào object của người khác |
WeakSet / WeakMethod |
Danh sách observer/callback không gây leak |
weakref.finalize |
Dọn dẹp tài nguyên thay cho __del__ |
Bài tiếp theo: Buffer protocol, memoryview và array.