Veel lawaai om None: Sentinel-waarden in Python

Het probleem van ambiguïteit

Het probleem doet zich op veel plekken voor. Een eenvoudige cache retourneert None zowel voor een ontbrekende sleutel als voor een sleutel die daadwerkelijk naar None verwijst. Betekent timeout=None in een configuratie-update "geen timeout" of "behoud de huidige waarde"? En een functie die een TimeoutError opvangt en None retourneert, gebruikt de waarde als een foutsignaal.

Sentinel-waarden lossen dit op door te fungeren als een bewaker buiten het domein van geldige waarden, waardoor ze nooit kunnen botsen met een werkelijke waarde.

Bestaande sentinels in Python

Python zelf vertrouwt sterk op sentinels die vaak onopgemerkt blijven:

  • Ellipsis (...) wordt gebruikt om dimensies over te slaan bij NumPy-slicing en om stubs te markeren.
  • NotImplemented betekent "zoek de implementatie elders", wat double dispatch voor binaire operatoren mogelijk maakt via methoden zoals rsub.
  • Pydantic’s MISSING maakt onderscheid tussen een veld dat expliciet op null is gezet en een veld dat helemaal niet is verzonden. Deze functionaliteit is in Pydantic 2.14 net uit de experimentele fase gekomen.

De uitdagingen bij eigen sentinels

Het wordt ingewikkeld zodra je eigen sentinels aanmaakt. De klassieke methode MISSING = object() werkt perfect tijdens runtime, maar gaat ten koste van type narrowing in statische type-checkers.

Verschillende omwegen, zoals TypeIs-guards, magische Literal-strings of de truc met een Enum met één lid, brengen allemaal eigen nadelen met zich mee. Bovendien gaan identiteitscontroles (is) kapot wanneer sentinels worden gekopieerd of gepickled.

Een nieuwe standaard: PEP 661

Er is echter een ontwikkeling geweest. Tijdens PyConDE in april leek het erop dat PEP 661, het voorstel om sentinels te standaardiseren, was uitgesteld. Slechts negen dagen later werd het geaccepteerd door de Steering Council.

Sinds Python 3.15 is sentinel een ingebouwde functie. Type-checkers kunnen dit nu op dezelfde manier vernauwen (narrow) als None:

MISSING = sentinel("MISSING")

def update_email(user_id: int, email: str | None | MISSING = MISSING) -> None:
    if email is MISSING:
        return  # niets om bij te werken
    elif email is None:
        db.delete_email(user_id)
    else:
        db.set_email(user_id, email.lower())  # type is hier vernauwd tot str

Voor wie een oudere Python-versie gebruikt, is er een backport beschikbaar via typing_extensions. Let wel op dat de ondersteuning vanuit type-checkers nog in ontwikkeling is; controleer je type-checker voordat je hier volledig op vertrouwt.

Conclusie en advies

Mijn belangrijkste les: wees je bewust van de ambiguïteit van None en denk goed na over wat je precies wilt uitdrukken.

  • Gebruik sentinels om ambiguïteit op te lossen.
  • Geef de voorkeur aan sentinels die al door je gebruikte bibliotheken worden aangeboden.
  • Vervang oude object()- en Enum-hacks door de nieuwe ingebouwde sentinel.

Mocht je geneigd zijn om None te retourneren in plaats van een exception te throwen, dan is mijn presentatie over exceptions en result-types wellicht ook interessant.