Het artikel bespreekt de problematische ambiguïteit van de waarde None in Python, die vaak voor te veel verschillende doeleinden wordt gebruikt (zoals foutsignalen, ontbrekende waarden of standaardinstellingen).
Kernpunten:
- Het probleem:
None kan niet onderscheiden worden tussen een waarde die expliciet op None is gezet en een waarde die volledig ontbreekt.
- Sentinel-waarden: Deze fungeren als unieke 'bewakers' die buiten het domein van geldige waarden liggen, waardoor botsingen worden voorkomen.
- Bestaande voorbeelden: Python gebruikt zelf al sentinels zoals
Ellipsis (...) en NotImplemented.
- Nieuwe standaard: Met de introductie van PEP 661 en Python 3.15 is er een ingebouwde
sentinel() functie beschikbaar. Dit lost eerdere problemen met type narrowing en statische type-checkers op die ontstonden bij het gebruik van object().
- Advies: De auteur adviseert om ambiguïteit te vermijden door sentinels te gebruiken, bij voorkeur de nieuwe ingebouwde functie of bestaande bibliotheek-sentinels.
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.
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.