Tuần này trên Hacker News có một bài khá thú vị về sentinel values trong Python. Đọc xong mình nhớ lại một bug từng mất nửa ngày để tìm. Một hàm update_user(email=None) trong codebase cũ dùng None với nghĩa "không truyền gì thì không đổi". Rồi một ngày product yêu cầu cho phép xoá email, tức là set về None. Lúc đó cả team mới nhận ra None đang phải gánh hai nghĩa cùng lúc: "không có giá trị" và "không được truyền vào". Bài này chia sẻ cách mình xử lý vấn đề đó trong production, từ cách nhanh gọn đến cách mà type checker cũng chịu hiểu.
Vấn đề: None không phải lúc nào cũng có nghĩa là "không có gì"
Đây là pattern rất hay gặp, đặc biệt trong PATCH API:
def update_user(user_id: int, email: str | None = None, phone: str | None = None):
user = repo.get(user_id)
if email is not None:
user.email = email
if phone is not None:
user.phone = phone
repo.save(user)
# Client gửi JSON: {"email": null} -> muốn XOÁ email
# Nhưng hàm hiểu là "không truyền" -> bỏ qua. Bug!
Với PATCH request có ba trạng thái khác nhau: field vắng mặt (giữ nguyên), field là null (xoá) và field có giá trị (cập nhật). Chỉ dùng None thì bạn chỉ phân biệt được hai.
Vấn đề này cũng xuất hiện ở nhiều chỗ khác:
- Cache:
cache.get(key)trả vềNone. Key không tồn tại, hay giá trị được cache đúng làNone? - Hàm
getattr-style có tham sốdefault: user có thể truyềndefault=Nonemột cách có chủ đích. - Config merge: "không override" khác với "override thành None".
flowchart TD
A[Request PATCH] --> B{Field có trong payload?}
B -- Không --> C[MISSING: giữ nguyên]
B -- Có --> D{Giá trị là null?}
D -- Có --> E[None: xoá giá trị]
D -- Không --> F[Value: cập nhật]
Cách 1: object() — nhanh, gọn, nhưng hơi "xấu"
Cách kinh điển mà chính stdlib vẫn dùng ở nhiều chỗ:
_MISSING = object()
def get_config(key: str, default=_MISSING):
try:
return _store[key]
except KeyError:
if default is _MISSING:
raise
return default
object() tạo ra một instance duy nhất, so sánh bằng is nên không bao giờ trùng với giá trị nào user truyền vào. Nhưng cách này có ba nhược điểm mình gặp thực tế:
-
repr vô nghĩa: khi debug hay đọc
help()bạn sẽ thấydefault=<object object at 0x7f3a...>. Đọc log lúc 2 giờ sáng mà gặp dòng này thì rất khó chịu. -
Không pickle/copy an toàn:
copy.deepcopy(_MISSING) is _MISSINGtrả vềFalse. Nếu sentinel lọt vào một object bị deepcopy hay pickle quamultiprocessing, phép so sánhissẽ sai. -
Type checker không hiểu: mypy và pyright chỉ thấy kiểu
object, nên bạn không thể narrow type sau khi checkis _MISSING.
Cách 2: Enum một phần tử — cách mình khuyên dùng hiện tại
Đây là pattern được nhắc trong PEP 484 và được mypy/pyright hỗ trợ narrowing rất tốt:
from enum import Enum
from typing import Final, Literal, TypeVar
class _MissingType(Enum):
MISSING = 'MISSING'
def __repr__(self) -> str:
return 'MISSING'
MISSING: Final = _MissingType.MISSING
T = TypeVar('T')
def pick(value: T | None | Literal[_MissingType.MISSING], current: T | None) -> T | None:
if value is MISSING:
return current # type checker biết value đã bị loại MISSING
return value # kiểu ở đây là T | None
# Áp dụng cho PATCH
def update_user(user_id: int, *, email=MISSING, phone=MISSING) -> None:
user = repo.get(user_id)
user.email = pick(email, user.email)
user.phone = pick(phone, user.phone)
repo.save(user)
# Ở tầng HTTP: chỉ truyền field thực sự có trong payload
payload = {'email': None}
update_user(42, **{k: payload[k] for k in ('email', 'phone') if k in payload})
Vì sao Enum giải quyết được cả ba vấn đề ở trên:
-
reprđẹp, log hiển thịMISSINGthay vì địa chỉ bộ nhớ. - Enum member là singleton kể cả khi pickle/deepcopy, nên
copy.deepcopy(MISSING) is MISSINGtrả vềTrue. -
Literal[_MissingType.MISSING]cho phép mypy (từ 0.9xx trở đi) và pyright narrow type sauif value is MISSING.
Một lưu ý nhỏ: Enum member mặc định là truthy. Nếu bạn muốn if not value: hoạt động như None thì thêm def __bool__(self): return False. Tuy vậy mình khuyên luôn so sánh bằng is và không dựa vào truthiness, vì 0, '' và [] cũng falsy.
Cách 3: PEP 661 và typing_extensions.Sentinel
PEP 661 (tác giả Tal Einat) đề xuất một built-in Sentinel chuẩn cho Python. PEP này đã nằm ở trạng thái draft khá lâu và được thảo luận lại gần đây. Trong lúc chờ, typing_extensions (từ bản 4.14) đã có bản implementation thử nghiệm:
pip install -U 'typing_extensions>=4.14'
from typing_extensions import Sentinel
MISSING = Sentinel('MISSING')
def fetch(key: str, default: int | MISSING = MISSING) -> int:
if default is MISSING:
raise KeyError(key)
return default
Cú pháp gọn hơn hẳn Enum và dùng trực tiếp được trong type annotation. Có điều, mức độ hỗ trợ của type checker còn đang dần hoàn thiện, nên trước khi dùng trong một library public bạn hãy kiểm tra version pyright/mypy mà team đang chạy. Với code nội bộ thì mình thấy đáng thử.
flowchart LR
A[Cần sentinel] --> B{Code public hoặc cần typing chặt?}
B -- Không --> C[object]
B -- Có --> D{typing_extensions >= 4.14 và checker hỗ trợ?}
D -- Có --> E[typing_extensions.Sentinel]
D -- Không --> F[Enum một phần tử]
Học từ stdlib và các thư viện lớn
Nhìn cách các project lớn xử lý vấn đề này khá bổ ích:
-
dataclasses: dùng
dataclasses.MISSINGđể phân biệt "field không có default" vớidefault=None. -
Pydantic v2: model có
model_fields_setcho biết field nào được set tường minh. Với PATCH, chỉ cầnmodel.model_dump(exclude_unset=True)là bạn có đúng ba trạng thái mà không cần tự viết sentinel. -
attrs: có
attrs.NOTHING, cũng là một Enum member giống Cách 2. -
dict.pop(key, default): implement ở tầng C và dùng sentinel nội bộ, nên
d.pop('x', None)vẫn hoạt động đúng nghĩa.
Nếu đang dùng FastAPI với Pydantic thì exclude_unset=True thường là lời giải sạch nhất cho bài toán PATCH. Sentinel tự viết hợp hơn với service layer và library code, nơi không có Pydantic model.
Kết luận
Những điều mình rút ra sau vài lần dính bug:
-
Khi
Nonelà một giá trị hợp lệ thì đừng dùng nó làm default. Hãy hỏi: user có bao giờ muốn truyềnNonemột cách có chủ đích không? Nếu có, bạn cần sentinel. -
Script nhỏ, code nội bộ: dùng
_MISSING = object()là đủ. Nhớ đặt tên có dấu gạch dưới để không bị export. -
Library hoặc codebase có type checking: dùng Enum một phần tử với
Literal[...]. Cách này ổn định, pickle-safe và mypy/pyright đều hiểu. -
Muốn đi trước một bước: thử
typing_extensions.Sentinel, nhưng kiểm tra trước mức hỗ trợ của type checker. -
PATCH API với Pydantic: dùng
exclude_unset=Truethay vì tự viết sentinel. -
Luôn so sánh bằng
is, không dùng==hay truthiness.
Việc nên làm ngay: grep codebase tìm các pattern =None) đi kèm if x is not None: trong các hàm update/patch. Rất có thể bạn sẽ tìm thấy vài chỗ đang ngầm gộp hai nghĩa vào một None, và tốt nhất là sửa trước khi product yêu cầu tính năng "cho phép xoá field".
Top comments (0)