Protocol trong Python: Static Duck Typing để kiểm tra kiểu tĩnh và tối ưu kiến trúc mã nguồn

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Khi nào cần Protocol thay vì ABC hay isinstance()?

Mình dùng Python làm automation tool cho hầu hết task hàng ngày, từ deploy script đến monitoring alert. Một vấn đề mình hay gặp là: code viết xong, test pass, nhưng lúc tích hợp component mới vào hệ thống thì runtime error lại xuất hiện — thường do một class thiếu method mà code mong đợi.

Python vốn là ngôn ngữ duck typing: “nếu nó có thể quạc như vịt, thì nó là vịt”. Python không quan tâm đối tượng thuộc class gì, chỉ cần nó có method cần dùng là được. Vấn đề là kiểm tra này chỉ xảy ra lúc runtime — lúc code chạy thật, không phải lúc viết.

Abstract Base Class (ABC) giải quyết một phần, nhưng yêu cầu kế thừa tường minh — không phù hợp khi làm việc với thư viện bên ngoài hoặc khi muốn giữ code loosely coupled.

Protocol (PEP 544, Python 3.8+) ra đời để giải quyết đúng vấn đề này: static duck typing — kiểm tra duck typing tại thời điểm viết code với type checker như mypy, không phải lúc runtime.

Cài đặt môi trường

Protocol có sẵn trong module typing từ Python 3.8. Nếu đang dùng Python 3.7 trở xuống, cần cài thêm typing_extensions:

pip install typing_extensions mypy

Kiểm tra phiên bản Python và mypy:

python --version
# Python 3.8+ là đủ, Protocol có sẵn trong stdlib

pip install mypy
mypy --version

Mypy là công cụ kiểm tra kiểu tĩnh chính thức cho Python — dùng nó để bắt lỗi Protocol ngay lúc viết code.

Định nghĩa và sử dụng Protocol — Cấu hình chi tiết

1. Protocol cơ bản

Thay vì kế thừa ABC và register(), bạn định nghĩa Protocol mô tả “hình dạng” mong đợi từ đối tượng:

from typing import Protocol

class Serializable(Protocol):
    def to_json(self) -> str:
        ...

    def from_json(self, data: str) -> None:
        ...

Body của method trong Protocol thường là .... Đây là định nghĩa interface, không phải implementation.

2. Dùng Protocol trong function signature

def save_to_file(obj: Serializable, filepath: str) -> None:
    data = obj.to_json()
    with open(filepath, 'w') as f:
        f.write(data)

# Class này KHÔNG kế thừa Serializable,
# nhưng có đủ method — mypy vẫn chấp nhận
class UserConfig:
    def __init__(self, username: str, theme: str):
        self.username = username
        self.theme = theme

    def to_json(self) -> str:
        import json
        return json.dumps({"username": self.username, "theme": self.theme})

    def from_json(self, data: str) -> None:
        import json
        d = json.loads(data)
        self.username = d["username"]
        self.theme = d["theme"]

config = UserConfig("admin", "dark")
save_to_file(config, "/tmp/config.json")  # OK với mypy!

Đây là điểm cốt lõi: UserConfig không cần class UserConfig(Serializable). Chỉ cần có đúng method là mypy chấp nhận.

3. Protocol với @runtime_checkable

Mặc định Protocol chỉ hoạt động với type checker tĩnh. Nếu muốn dùng isinstance() lúc runtime, thêm decorator @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None:
        ...

class DatabaseConnection:
    def close(self) -> None:
        print("DB connection closed")

db = DatabaseConnection()
print(isinstance(db, Closeable))      # True
print(isinstance("hello", Closeable)) # False

Lưu ý quan trọng: isinstance() với @runtime_checkable chỉ kiểm tra sự tồn tại của method, không kiểm tra signature (kiểu tham số, kiểu trả về). Kiểm tra signature đầy đủ là việc của mypy.

4. Ví dụ thực tế: Plugin system cho alert handler

Mình hay dùng pattern này khi viết tool có thể mở rộng — điển hình là monitoring alert system:

from typing import Protocol
from dataclasses import dataclass

@dataclass
class AlertEvent:
    level: str      # "info", "warning", "critical"
    message: str
    source: str

class AlertHandler(Protocol):
    def handle(self, event: AlertEvent) -> bool:
        """Returns True nếu xử lý thành công"""
        ...

    def is_available(self) -> bool:
        ...

# Handler 1: Gửi Telegram
class TelegramHandler:
    def __init__(self, bot_token: str, chat_id: str):
        self.bot_token = bot_token
        self.chat_id = chat_id

    def handle(self, event: AlertEvent) -> bool:
        print(f"[Telegram] {event.level}: {event.message}")
        return True

    def is_available(self) -> bool:
        return bool(self.bot_token and self.chat_id)

# Handler 2: Ghi log file
class FileLogHandler:
    def __init__(self, log_path: str):
        self.log_path = log_path

    def handle(self, event: AlertEvent) -> bool:
        with open(self.log_path, 'a') as f:
            f.write(f"[{event.level}] {event.source}: {event.message}\n")
        return True

    def is_available(self) -> bool:
        import os
        return os.access(os.path.dirname(self.log_path) or '.', os.W_OK)

# dispatch_alert nhận list[AlertHandler] — không cần kế thừa gì cả
def dispatch_alert(event: AlertEvent, handlers: list[AlertHandler]) -> None:
    for handler in handlers:
        if handler.is_available():
            success = handler.handle(event)
            if not success:
                print(f"{type(handler).__name__} failed")

# Dùng như thế này
event = AlertEvent(level="critical", message="CPU > 95%", source="server-01")
handlers: list[AlertHandler] = [
    TelegramHandler("TOKEN", "CHAT_ID"),
    FileLogHandler("/var/log/alerts.log"),
]
dispatch_alert(event, handlers)

Ưu điểm rõ ràng: để thêm handler mới (Slack, PagerDuty…), bạn chỉ cần tạo class có đúng 2 method, không cần sửa code có sẵn — đúng với Open/Closed Principle.

Kiểm tra và Monitoring với mypy

Chạy mypy phát hiện lỗi sớm

# Kiểm tra toàn bộ project
mypy .

# Strict mode — bắt nhiều lỗi hơn
mypy alert_system.py --strict

Ví dụ lỗi mypy sẽ báo khi truyền object không thỏa Protocol:

class BadHandler:
    def handle(self, event: AlertEvent) -> None:  # Sai! Protocol yêu cầu -> bool
        pass
    # Thiếu is_available()

# mypy báo:
# error: Argument 2 to "dispatch_alert" has incompatible type
#        "list[BadHandler]"; expected "list[AlertHandler]"

Cấu hình mypy với pyproject.toml

[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

Tích hợp vào CI/CD

# Trong GitHub Actions
pip install mypy
mypy . --exit-code  # exit code != 0 nếu có lỗi type

# Kết hợp với pytest
pytest tests/ && mypy .

Mình thường thêm mypy vào pre-commit hook để bắt lỗi trước khi commit:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

Khi nào dùng Protocol, khi nào dùng ABC?

  • Protocol: Muốn loosely coupled, không muốn bắt buộc kế thừa, làm việc với code third-party
  • ABC: Muốn strongly coupled, cần @abstractmethod bắt buộc implement, muốn share code qua super()
  • TypedDict: Làm việc với dict có structure cố định, không phải object

Với mình, Protocol là lựa chọn mặc định khi thiết kế interface cho plugin system hoặc component cần tách biệt rõ ràng. ABC chỉ dùng khi muốn share implementation qua kế thừa.

Share: