Decorator trong Python từ cơ bản tới có tham số
1. Tổng quan
Decorator là một trong những tính năng “ma thuật” nhất của Python nhưng cũng dễ gây hiểu nhầm nếu bạn chỉ copy-paste mà không hiểu bản chất. Bài này đi từ khái niệm hàm là first-class citizen, qua decorator cơ bản, decorator giữ metadata với functools.wraps, đến decorator nhận tham số và decorator viết bằng class. Đọc xong, bạn sẽ tự viết được decorator logging, timing, hay retry cho dự án của mình mà không bị “mất tên hàm gốc”.
2. Hàm là First-Class Citizen
Trước khi học decorator, bạn cần nắm một sự thật: trong Python, hàm cũng là object. Bạn có thể gán hàm cho biến, truyền hàm như tham số, hoặc trả hàm ra từ một hàm khác.
def greet(name: str) -> str:
return f"Hello, {name}!"
say_hello = greet
print(say_hello("Alice"))
Hello, Alice!
say_hello chỉ là một tên khác trỏ tới cùng object hàm greet. Nhờ đặc điểm này, Python cho phép viết hàm nhận hàm làm input và trả về hàm làm output — đó chính là nền tảng của decorator.
3. Decorator Cơ Bản: Hàm Bọc Hàm
Decorator đơn giản là một hàm nhận vào một hàm khác, trả về một hàm mới “bọc” (wrap) hành vi của hàm gốc.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_call(add)
print(add(2, 3))
Calling add with args=(2, 3), kwargs={}
add returned 5
5
log_callnhậnfunc(hàm gốcadd) và định nghĩawrapper— hàm nội bộ gọi lạifuncnhưng có thêm log trước/sau.log_call(add)trả vềwrapper, và ta gán ngược lại vàoadd. Từ đây, gọiadd(...)thực chất là gọiwrapper(...).
3.1. Cú Pháp @ — Syntactic Sugar
Viết func = decorator(func) thủ công khá dài dòng. Python cung cấp cú pháp @decorator để làm đúng việc đó, gọn hơn:
@log_call
def multiply(a: int, b: int) -> int:
return a * b
print(multiply(4, 5))
Calling multiply with args=(4, 5), kwargs={}
multiply returned 20
20
@log_call đặt ngay trên def multiply tương đương với multiply = log_call(multiply). Đây chỉ là syntactic sugar — không có ma thuật ẩn nào khác.
4. Vấn Đề Mất Metadata và functools.wraps
Khi decorator “thay thế” hàm gốc bằng wrapper, bạn sẽ mất các thuộc tính như __name__, __doc__ của hàm gốc.
@log_call
def subtract(a: int, b: int) -> int:
"""Trừ hai số."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
wrapper
None
Đây là lỗi phổ biến khi debug hoặc dùng công cụ sinh docs — bạn tưởng đang gọi subtract nhưng Python báo tên là wrapper. Khắc phục bằng functools.wraps:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def subtract(a: int, b: int) -> int:
"""Trừ hai số."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
subtract
Trừ hai số.
functools.wraps(func) là một decorator-của-decorator: nó copy __name__, __doc__, __module__… từ func sang wrapper.
Lưu ý: Luôn dùng
functools.wrapstrong decorator thực tế. Bỏ qua nó sẽ gây khó debug và làm hỏng introspection (ví dụhelp(), framework dựa vào tên hàm).
5. Decorator Nhận Tham Số
Nhiều lúc bạn muốn decorator có thể tùy biến, ví dụ @retry(times=3). Lúc này cần thêm một tầng hàm: hàm ngoài nhận tham số, trả về decorator thật, decorator đó mới nhận func.
import functools
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Attempt {attempt} failed: {exc}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
raise ConnectionError(f"Timeout for order {order_id}")
try:
call_payment_api(101)
except ConnectionError as exc:
print(f"Giving up: {exc}")
Attempt 1 failed: Timeout for order 101
Attempt 2 failed: Timeout for order 101
Giving up: Timeout for order 101
Cấu trúc ba tầng cần nhớ:
retry(times, delay)— hàm factory, nhận tham số cấu hình, trả vềdecorator.decorator(func)— hàm decorator thật, nhận hàm gốc.wrapper(*args, **kwargs)— hàm thực thi khi gọi hàm đã decorate.
@retry(times=2, delay=0) thực chất gọi retry(times=2, delay=0) trước (trả về decorator), sau đó mới áp decorator lên call_payment_api.
5.1. Decorator Vừa Dùng Có Tham Số Vừa Dùng Không Tham Số
Đôi khi bạn muốn @retry dùng được cả khi không truyền tham số (@retry thay vì @retry()). Cách làm sạch là dùng functools.partial kết hợp kiểm tra callable:
import functools
def retry(func=None, *, times: int = 3):
if func is None:
return functools.partial(retry, times=times)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
print(f"Attempt {attempt} failed: {exc}")
raise RuntimeError("All attempts failed")
return wrapper
@retry
def task_a():
return "ok"
@retry(times=5)
def task_b():
return "ok"
Khi dùng @retry (không gọi hàm), func nhận trực tiếp hàm task_a, nhánh if func is None không chạy. Khi dùng @retry(times=5), func là None ban đầu, hàm trả về functools.partial(retry, times=5) — một hàm chờ nhận func ở bước sau, chính là task_b.
6. Decorator Viết Bằng Class
Thay vì dùng hàm lồng hàm, bạn có thể viết decorator bằng class, triển khai __call__. Cách này phù hợp khi decorator cần giữ state phức tạp.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} called {self.call_count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id: int) -> None:
print(f"Processing order {order_id}")
process_order(1)
process_order(2)
print(process_order.call_count)
process_order called 1 time(s)
Processing order 1
process_order called 2 time(s)
Processing order 2
2
__init__lưu hàm gốc và khởi tạocall_count = 0.functools.update_wrapper(self, func)— tương đươngfunctools.wrapsnhưng cho class instance, copy metadata sang objectself.__call__khiến instanceprocess_order(lúc này là objectCountCalls, không còn là hàm) có thể gọi được như hàm thông thường.
Lưu ý: Class-based decorator không hoạt động tốt khi decorate method trong class khác (vì
selfcủa class gốc và decorator dễ xung đột). Với method, ưu tiên dùng hàm lồng hàm hoặcfunctools.singledispatchmethodnếu cần dispatch.
7. Decorate Nhiều Lớp Cùng Lúc (Stacking)
Bạn có thể chồng nhiều decorator lên một hàm. Thứ tự áp dụng là từ dưới lên trên (decorator gần hàm nhất chạy trước).
@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
if amount <= 0:
raise ValueError("Amount must be positive")
return f"Charged {amount}"
print(charge_card(10))
Calling wrapper
Charged 10
Ở đây retry(times=2, delay=0) áp dụng trước, bọc charge_card thành một hàm có khả năng retry; sau đó log_call bọc tiếp bên ngoài. Nếu không dùng functools.wraps đúng cách ở retry, bạn sẽ thấy log in ra wrapper thay vì charge_card — minh chứng thêm cho tầm quan trọng của mục 4.
8. Viết Test Cho Decorator
Decorator vẫn là hàm Python bình thường, nên bạn test được bằng pytest như mọi logic khác. Cài thư viện nếu chưa có:
pip install pytest
Test ví dụ cho decorator retry:
import pytest
def test_retry_raises_after_exhausting_attempts():
call_log = []
@retry(times=3, delay=0)
def always_fail():
call_log.append("called")
raise ValueError("boom")
with pytest.raises(ValueError):
always_fail()
assert len(call_log) == 3
def test_retry_succeeds_on_second_attempt():
attempts = {"count": 0}
@retry(times=3, delay=0)
def fail_once_then_succeed():
attempts["count"] += 1
if attempts["count"] < 2:
raise ValueError("boom")
return "ok"
result = fail_once_then_succeed()
assert result == "ok"
assert attempts["count"] == 2
Chạy test:
pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED
call_log và attempts đóng vai trò spy để kiểm tra số lần hàm gốc được gọi — đúng với hành vi retry mong đợi, không cần mock thư viện ngoài.
9. Lỗi Thường Gặp
- Quên
functools.wraps: hàm mất tên/docstring, gây khó debug và lỗi khi dùng với framework dựa vào__name__(ví dụ routing trong Flask). - Dùng mutable default argument trong wrapper: tương tự lỗi Python chung, tránh để
args=[]làm default. - Decorator có side-effect nặng ở thời điểm import: nếu decorator thực hiện I/O (gọi API, đọc file) ngay trong
decorator(func)thay vì trongwrapper, nó sẽ chạy mỗi khi module được import, không phải mỗi khi hàm được gọi. - Lạm dụng decorator cho logic phức tạp: nếu decorator chứa quá nhiều nhánh rẽ, code khó đọc hơn một hàm gọi trực tiếp. Khi logic phức tạp, cân nhắc context manager hoặc tách thành class riêng thay vì nhồi vào decorator.
Khi nào KHÔNG nên dùng decorator: nếu hành vi “bọc” chỉ dùng một lần, không tái sử dụng, viết decorator chỉ làm code khó trace hơn gọi hàm trực tiếp. Decorator phát huy giá trị khi áp dụng logic cắt ngang (cross-cutting concern) như logging, caching, retry, auth cho nhiều hàm khác nhau.
10. Kết luận
Decorator trong Python không có gì bí ẩn — nó chỉ là hàm nhận hàm, trả về hàm, dựa trên việc hàm là first-class citizen. Từ decorator cơ bản, bạn thêm functools.wraps để giữ metadata, thêm một tầng hàm để nhận tham số cấu hình, hoặc chuyển sang class-based khi cần giữ state phức tạp. Nắm vững các mẫu này, bạn có thể tự viết logging, caching, retry, hay validation decorator sạch sẽ và dễ test cho dự án thực tế.