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_call nhận func (hàm gốc add) và định nghĩa wrapper — hàm nội bộ gọi lại func nhưng có thêm log trước/sau.
  • log_call(add) trả về wrapper, và ta gán ngược lại vào add. Từ đây, gọi add(...) thực chất là gọi wrapper(...).

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.wraps trong 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ớ:

  1. retry(times, delay) — hàm factory, nhận tham số cấu hình, trả về decorator.
  2. decorator(func) — hàm decorator thật, nhận hàm gốc.
  3. 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ạo call_count = 0.
  • functools.update_wrapper(self, func) — tương đương functools.wraps nhưng cho class instance, copy metadata sang object self.
  • __call__ khiến instance process_order (lúc này là object CountCalls, 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ì self của class gốc và decorator dễ xung đột). Với method, ưu tiên dùng hàm lồng hàm hoặc functools.singledispatchmethod nế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ì trong wrapper, 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ế.

pythondecoratorclosurefunctional-programmingclean-code