DEV Community

EME GUG
EME GUG

Posted on

Much Ado About None: Designing Proper Sentinel Values in Python

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!
Enter fullscreen mode Exit fullscreen mode

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ền default=None mộ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
Enter fullscreen mode Exit fullscreen mode

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ế:

  1. repr vô nghĩa: khi debug hay đọc help() bạn sẽ thấy default=<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.
  2. Không pickle/copy an toàn: copy.deepcopy(_MISSING) is _MISSING trả về False. Nếu sentinel lọt vào một object bị deepcopy hay pickle qua multiprocessing, phép so sánh is sẽ sai.
  3. 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 check is _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})
Enter fullscreen mode Exit fullscreen mode

Vì sao Enum giải quyết được cả ba vấn đề ở trên:

  • repr đẹp, log hiển thị MISSING thay vì địa chỉ bộ nhớ.
  • Enum member là singleton kể cả khi pickle/deepcopy, nên copy.deepcopy(MISSING) is MISSING trả về True.
  • Literal[_MissingType.MISSING] cho phép mypy (từ 0.9xx trở đi) và pyright narrow type sau if 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'
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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ới default=None.
  • Pydantic v2: model có model_fields_set cho biết field nào được set tường minh. Với PATCH, chỉ cần model.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:

  1. Khi None là 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ền None một cách có chủ đích không? Nếu có, bạn cần sentinel.
  2. 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.
  3. 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.
  4. 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.
  5. PATCH API với Pydantic: dùng exclude_unset=True thay vì tự viết sentinel.
  6. 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)