Decorator là hàm bọc một hàm khác
Câu đó nghe trừu tượng, nhưng cơ chế thì đơn giản. Một decorator nhận vào một hàm và trả ra một hàm. Hàm được trả ra thường gọi hàm gốc, với một chút hành vi bổ sung bọc quanh lời gọi đó.
Ví dụ ngắn nhất có thể:
shout là decorator. Nó nhận một hàm (greet), tạo một hàm mới (wrapper) gọi hàm gốc rồi viết hoa kết quả, và trả hàm mới đó về. Gán lại greet = shout(greet) thay hàm gốc bằng phiên bản đã được bọc.
Mẫu gán lại này phổ biến tới mức Python dành riêng cho nó một cú pháp.
Dấu @ là cách viết gọn của phép gán lại
@name ở dòng ngay trên def tương đương với name = name(...) ngay sau khi hàm được định nghĩa:
@shout được đọc là "áp dụng decorator shout cho hàm này". Python chạy greet = shout(greet) ngay sau def: cơ chế y như trước, chỉ đỡ phải gõ.
Mỗi khi thấy @name, hãy thầm thay nó bằng function = name(function). Cú pháp chỉ có nghĩa như vậy.
Xử lý đối số
Phần lớn hàm đều nhận đối số. Một decorator dùng được phải chuyển chúng qua. Cách viết quen thuộc là *args, **kwargs (cách Python nhận mọi đối số), vì hàm bọc không nên quan tâm hàm được bọc cần gì:
*args bắt mọi đối số vị trí. **kwargs bắt mọi đối số từ khóa. Hàm bọc chuyển nguyên vẹn mọi thứ sang hàm được bọc, rồi làm phần việc bổ sung mà decorator đảm nhận, ở đây là viết hoa kết quả.
Đây là hình dạng của phần lớn decorator thực tế.
Một ví dụ hữu ích hơn: đo thời gian
In ra một hàm chạy mất bao lâu:
Mẫu này (chạy gì đó trước lời gọi, chạy gì đó sau lời gọi) là việc mà phần lớn decorator rốt cuộc đều làm. Ghi log, kiểm tra xác thực, thử lại và kiểm tra đầu vào đều theo cùng hình dạng đó.
Giữ lại danh tính gốc: functools.wraps
Gắn decorator cho một hàm nghĩa là thay thế hàm đó, nên hàm được bọc mất đi các thuộc tính __name__ và __doc__ gốc:
greet.__name__ giờ là "wrapper" và docstring đã biến mất. Điều đó làm hỏng help(), traceback và mọi công cụ soi xét hàm.
Cách sửa chỉ một dòng: @functools.wraps(func) đặt trên hàm bên trong sẽ sao chép siêu dữ liệu sang.
Luôn thêm @wraps(func) cho hàm bên trong. Nó không tốn gì cả và giúp bạn tránh những buổi gỡ lỗi đầy bất ngờ sau này.
Decorator có đối số
Đôi khi chính decorator cần được cấu hình: "thử lại hàm này tối đa 3 lần", "ghi log ở mức DEBUG". Khi đó cần thêm một lớp lồng nữa: một hàm bên ngoài nhận đối số và trả về một decorator.
Ba lớp nghe có vẻ nhiều. Hãy đọc từ ngoài vào:
repeat(times=3)là một lời gọi hàm. Nó trả vềdecorator.decoratorlà decorator thực sự: nó nhận một hàm và trả về hàm đã được bọc.wrapperlà hàm đã được bọc, chạy vào lúc gọi.
Hình dạng này là nền tảng của @retry(times=5), @cache(maxsize=100) và các decorator của framework như @app.route("/users"). Khi đã nhận ra mẫu ba lớp, bạn sẽ đọc cả họ decorator này theo cùng một cách.
Xếp chồng decorator
Bạn có thể áp dụng nhiều decorator cho một hàm. Chúng xếp chồng từ dưới lên: decorator gần def nhất được áp dụng trước:
add_exclaim bọc trước, thêm dấu !. Sau đó shout bọc lớp đó, viết hoa tất cả. Đầu ra là HI, ROSA!.
Thứ tự có ý nghĩa. Đảo ngược thứ tự cho bạn HI, ROSA! với dấu chấm than được thêm sau khi viết hoa: nhìn thì giống hệt ở đây, nhưng hãy tưởng tượng một decorator định dạng JSON: chạy nó trước hay sau một decorator ghi log đầu vào có thể cho kết quả rất khác nhau.
Những decorator có sẵn bạn sẽ gặp
Python và thư viện chuẩn có sẵn một vài decorator mà bạn sẽ gặp trong code thực tế:
@propertybiến một phương thức thành thuộc tính được tính toán.@staticmethodđánh dấu một phương thức không dùngselfhaycls.@classmethodnhận lớp vào làmclsthay vì một instance, rất hợp cho các hàm khởi tạo thay thế.@functools.lru_cacheghi nhớ kết quả, nên những lần gọi lặp lại với cùng đối số sẽ lấy từ cache.
Các decorator của framework (@app.route, @pytest.fixture, @dataclass) dùng đúng cơ chế này. Không có gì đặc biệt: chỉ là những hàm bọc hàm.
Khi nào nên viết, khi nào không
Hãy viết decorator khi bạn muốn áp dụng cùng một hành vi cho nhiều hàm: đo thời gian, ghi log, thử lại, kiểm tra quyền. Mục đích chính là để hành vi đó nằm ngoài thân hàm.
Đừng dùng decorator khi:
- Hành vi đó chỉ thuộc về một hàm cụ thể. Hãy đặt nó trong hàm.
- Bạn chỉ cần nó để kiểm thử. Một fixture hoặc một tham số sẽ rõ ràng hơn.
- Bạn bị cám dỗ xếp chồng bốn năm cái. Tới lúc đó, luồng điều khiển bị giấu trong chuỗi decorator: người đọc phải gỡ từng lớp mới thấy thứ gì thật sự chạy. Một hàm hỗ trợ đơn giản có thể dễ đọc hơn.
Decorator là công cụ sắc bén. Dùng tốt, chúng giữ code không lặp lại (DRY) và ý định rõ ràng. Dùng tệ, chúng che giấu những gì chương trình đang làm. Khi phân vân có nên dùng hay không, hãy nghiêng về phía "rõ ràng".
Tiếp theo: type hint
Decorator là nơi bạn hay gặp type hint trong code thực tế: các hàm wrapper thường chú thích kiểu cho chữ ký của chúng. Type hint là một tính năng nhỏ nhưng nhanh chóng mang lại lợi ích, và đó là chủ đề tiếp theo.
Câu hỏi thường gặp
Decorator trong Python là gì?
Decorator là một hàm nhận vào một hàm khác và trả về một hàm mới, thường là hàm bọc hàm gốc với hành vi bổ sung. Bạn áp dụng nó bằng @decorator_name ở dòng ngay trên def. Cú pháp @ là cách viết tắt của func = decorator_name(func).
Decorator trong Python dùng để làm gì?
Để thêm hành vi xung quanh một hàm mà không sửa thân hàm: ghi log, đo thời gian, cache, kiểm tra xác thực, kiểm tra đầu vào, thử lại. Các framework dùng chúng rất nhiều: @app.route(...) trong Flask, @pytest.fixture trong pytest, còn @property và @staticmethod thì có sẵn.
Tôi có thể tự viết decorator không?
Có. Decorator chỉ là một hàm nhận một hàm và trả về một hàm. Phần lớn decorator tự viết bọc lời gọi gốc trong một hàm nhỏ bên trong, làm gì đó trước, sau hoặc xung quanh lời gọi. Hãy dùng functools.wraps trên hàm bên trong để giữ lại tên và docstring của hàm gốc.