enum, NamedTuple và TypedDict: chọn kiểu dữ liệu cho đúng việc

Trạng thái đơn hàng đang là chuỗi "dang_giao" rải khắp code, và một hôm ai đó gõ nhầm thành "dang giao". Hàm trả về tuple mà không ai nhớ phần tử thứ ba là gì. Dữ liệu JSON đọc vào là dict nhưng không ai biết nó có những khóa nào. Ba vấn đề, ba công cụ khác nhau trong thư viện chuẩn.

enum: khi giá trị chỉ được nằm trong một tập cố định

Trạng thái đơn hàng, vai trò người dùng, loại thanh toán — những thứ có số lượng hữu hạn và biết trước. Dùng chuỗi thì trình soạn thảo không giúp được gì và lỗi gõ nhầm chỉ lộ ra lúc chạy.

from enum import Enum

class TrangThai(Enum):
    CHO_XU_LY = "cho_xu_ly"
    DANG_GIAO = "dang_giao"
    HOAN_TAT  = "hoan_tat"

t = TrangThai.DANG_GIAO
print(t)            # TrangThai.DANG_GIAO
print(t.name)       # 'DANG_GIAO'
print(t.value)      # 'dang_giao'

Giá trị sai bị chặn ngay khi chuyển đổi, kèm thông báo rõ ràng:

TrangThai("hoan_tat")       # TrangThai.HOAN_TAT
TrangThai("bay_bien")       # ValueError: 'bay_bien' is not a valid TrangThai

Đây là điểm mạnh lớn nhất: dữ liệu bẩn từ API hay database bị chặn ngay tại cửa. Enum còn duyệt được và so sánh bằng is:

for tt in TrangThai:
    print(tt.name)          # CHO_XU_LY, DANG_GIAO, HOAN_TAT

if t is TrangThai.DANG_GIAO:
    print("Đang trên đường")

Khi giá trị cụ thể không quan trọng, auto() tự đánh số từ 1:

from enum import Enum, auto

class MucUuTien(Enum):
    THAP = auto()
    TRUNG = auto()
    CAO = auto()       # THAP=1, TRUNG=2, CAO=3

Hai biến thể hay dùng. IntEnum so sánh được với số nguyên, hợp với mức độ ưu tiên. Còn kế thừa str cho phép enum so sánh trực tiếp với chuỗi — rất tiện khi đổ ra JSON.

from enum import IntEnum

class Muc(IntEnum):
    THAP = 1
    CAO = 3

print(Muc.CAO > Muc.THAP)   # True

class Mau(str, Enum):
    DO = "do"

print(Mau.DO == "do")       # True

import json
print(json.dumps({"tt": TrangThai.HOAN_TAT.value}))   # {"tt": "hoan_tat"}

Lưu ý Enum thường không tự đổ ra JSON được — nhớ lấy .value, hoặc dùng cách kế thừa str ở trên.

NamedTuple: bản ghi nhỏ, bất biến, có gợi ý kiểu

Khi hàm cần trả về vài giá trị đi cùng nhau, tuple thường khiến người đọc phải đếm chỉ số. typing.NamedTuple cho phép khai báo bằng cú pháp class với kiểu dữ liệu rõ ràng.

from typing import NamedTuple

class Diem(NamedTuple):
    x: float
    y: float
    nhan: str = ""

    def khoang_cach(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

d = Diem(3, 4)
print(d)                    # Diem(x=3, y=4, nhan='')
print(d.x, d.khoang_cach()) # 3 5.0

Nó vẫn là tuple thật, nên giải nén và dùng như tuple bình thường:

x, y, nhan = d              # giải nén được
print(isinstance(d, tuple)) # True
print(len(d))               # 3

Và nó bất biến — đây là lý do chính để chọn nó:

d.x = 9                     # AttributeError: can't set attribute
moi = d._replace(nhan="A")  # muốn đổi thì tạo bản mới
print(d._asdict())          # {'x': 3, 'y': 4, 'nhan': ''}

Vì bất biến và có thể băm, NamedTuple dùng làm key của dict hoặc phần tử của set được. So với collections.namedtuple, bản trong typing cho bạn khai báo kiểu, đặt giá trị mặc định tự nhiên hơn và thêm phương thức dễ hơn.

TypedDict: mô tả hình dạng của một dict

Dữ liệu từ JSON, từ API, từ file cấu hình đến với bạn dưới dạng dict và sẽ tiếp tục là dict. Bạn không muốn đổi nó thành class, chỉ muốn ghi lại nó có những khóa nào.

from typing import TypedDict

class NguoiDung(TypedDict):
    ten: str
    tuoi: int
    email: str

u: NguoiDung = {"ten": "An", "tuoi": 30, "email": "[email protected]"}
print(u["ten"])             # An
print(type(u))              # <class 'dict'> — vẫn là dict thuần

Đây chính là điểm mấu chốt: TypedDict không tạo ra kiểu mới lúc chạy. Đối tượng bạn nhận vẫn là dict, truyền thẳng vào json.dumps() hay bất kỳ hàm nào nhận dict đều được. Khóa không bắt buộc thì khai báo với total=False:

class CauHinh(TypedDict, total=False):
    host: str
    port: int

c: CauHinh = {"host": "127.0.0.1"}    # thiếu port vẫn hợp lệ

TypedDict không kiểm tra lúc chạy

Điều quan trọng nhất cần hiểu, và cũng là chỗ nhiều người hiểu nhầm:

u: NguoiDung = {"ten": "C", "tuoi": "ba mươi"}   # tuoi sai kiểu, thiếu email
print(u)        # Chạy bình thường, không có lỗi nào!

Python không hề kiểm tra. Lợi ích chỉ đến khi bạn chạy công cụ kiểm tra kiểu tĩnh như mypy hay pyright — lúc đó chúng sẽ chỉ ra cả hai lỗi trên. Nếu bạn cần chặn dữ liệu sai lúc chạy, đó là việc của Pydantic, không phải TypedDict.

So với dataclass

Site đã có một bài riêng về dataclass, nên ở đây chỉ so sánh chỗ khác nhau. dataclass tạo ra một class thật, đối tượng có thuộc tính, mặc định thay đổi được, và bạn thêm phương thức hay kế thừa thoải mái. Đó là lựa chọn mặc định cho dữ liệu nội bộ của chương trình.

Bạn chọn khác đi khi có lý do cụ thể: cần bất biến và giải nén được như tuple thì NamedTuple gọn hơn (dataclass cũng làm được với frozen=True nhưng không giải nén được); cần giữ nguyên kiểu dict vì dữ liệu đến và đi dưới dạng JSON thì TypedDict đúng hơn, vì đổi sang dataclass nghĩa là phải chuyển đổi qua lại hai lần.

Chọn cái nào

  • Một giá trị chỉ được nằm trong tập hữu hạn, biết trước — enum.
  • Một bản ghi nhỏ, bất biến, trả về từ hàm, dùng làm key — NamedTuple.
  • Dữ liệu vốn là dict và phải ở lại dạng dict — TypedDict.
  • Đối tượng dữ liệu thông thường, có thể sửa, có phương thức — dataclass.
  • Cần kiểm tra và ép kiểu thật lúc chạy — Pydantic.

Vài nguyên tắc nên nhớ

  1. Thấy cùng một chuỗi lặp lại ở nhiều nơi để chỉ trạng thái, hãy đổi nó thành Enum.
  2. Lấy .value khi ghi enum ra JSON hay database, đừng ghi cả đối tượng.
  3. NamedTuple bất biến — muốn đổi thì _replace tạo bản mới.
  4. TypedDict chỉ có tác dụng khi bạn thật sự chạy mypy hoặc pyright.
  5. Chưa có lý do rõ ràng để chọn khác thì cứ dùng dataclass.

Chọn đúng kiểu dữ liệu không làm chương trình chạy nhanh hơn, nhưng làm ý định của bạn hiện rõ trong code — và người đọc lại sau sáu tháng thường chính là bạn.

Lên đầu trang