PEP 661 – Les valeurs sentinelles, approuvées après 5 ans
(peps.python.org)- PEP 661 propose l’objet appelable intégré à Python
sentinel()et l’API CPySentinel_New()pour créer une valeur sentinelle distincte dans les cas oùNoneest une valeur valide - L’idiome existant
_sentinel = object()pose problème, car sonreprest long et peu clair dans les signatures de fonction, et il peut aussi entraîner des difficultés avec les signatures de type explicites, la copie et le pickling - L’appel
sentinel('MISSING')crée un nouvel objet unique avec unreprcourt ; pour partager la même sentinelle, il faut l’assigner à une variable et la réutiliser explicitement, par exempleMISSING = sentinel('MISSING') - Il est recommandé de comparer les sentinelles avec
is, elles sont évaluées comme vraies,copy.copy()etcopy.deepcopy()renvoient le même objet, et leur identité est préservée après pickling si elles peuvent être importées par leur nom depuis un module - Le système de types permet d’utiliser la sentinelle elle-même dans une expression de type, comme
int | MISSING, et la documentation officielle la plus récente figure dans la documentation Python 3.15 de [sentinel](<https://docs.python.org/3.15/library/functions.html#sentinel "(in Python v3.15>)")
Contexte
- Une valeur sentinelle (sentinel value), c’est-à-dire une valeur de remplacement unique, sert comme valeur par défaut quand un argument de fonction n’est pas fourni, comme valeur de retour pour signaler l’échec d’une recherche, ou encore pour représenter une donnée manquante
- Python dispose généralement pour cela de la valeur spéciale
None, mais dans les contextes oùNoneest lui-même une valeur valide, il faut une sentinelle distincte pour le différencier - En mai 2021, la liste de diffusion python-dev a discuté d’une meilleure manière d’implémenter la valeur sentinelle utilisée par
traceback.print_exception - L’implémentation existante utilisait l’idiome courant
_sentinel = object(), mais sonreprétait trop long et pas assez informatif, ce qui rendait les signatures de fonction difficiles à lire>>> help(traceback.print_exception) Help on function print_exception in module traceback: print_exception(exc, /, value=<object object at 0x000002825DF09650>, tb=<object object at 0x000002825DF09650>, limit=None, file=None, chain=True) - La discussion a aussi mis en évidence d’autres problèmes des implémentations existantes de sentinelles
- certaines sentinelles n’ont pas de type propre, ce qui rend difficile la définition d’une signature de type claire pour une fonction qui les utilise comme valeur par défaut
- après copie, une instance distincte peut être créée, ce qui fait échouer les comparaisons avec
iset produit un comportement inattendu - certains idiomes courants présentent des problèmes similaires après pickling puis unpickling
- Victor Stinner a fourni une liste des valeurs sentinelles utilisées dans la bibliothèque standard Python, montrant qu’elle emploie déjà plusieurs approches différentes et que beaucoup d’implémentations présentent au moins un des problèmes ci-dessus
- Le vote sur discuss.python.org n’a pas permis de dégager une conclusion claire sur 39 votes
- 40 % ont choisi « l’état actuel convient et il n’y a pas besoin de cohérence »
- une majorité a choisi une ou plusieurs solutions standardisées
- 37 % ont choisi l’option consistant à « utiliser de manière cohérente une nouvelle fabrique/classe/métaclasse dédiée aux sentinelles et la fournir publiquement dans la bibliothèque standard »
- Ces résultats divergents ont conduit à la rédaction du PEP, qui conclut qu’une implémentation simple et de qualité dans la bibliothèque standard serait utile aussi bien à l’intérieur qu’à l’extérieur de celle-ci
- Il n’est pas obligatoire de remplacer toutes les sentinelles existantes de la bibliothèque standard par cette approche ; cela reste à la discrétion des responsables de maintenance concernés
- Le document PEP est un document historique, et la documentation officielle la plus récente figure dans la documentation Python 3.15 de [
sentinel](<https://docs.python.org/3.15/library/functions.html#sentinel "(in Python v3.15>)")
Critères de conception
- Un objet sentinelle doit toujours être identique à lui-même lorsqu’il est comparé avec l’opérateur
is, et ne doit être identique à aucun autre objet - La création d’un objet sentinelle doit tenir en une seule ligne de code, de façon simple et intuitive
- Il doit être facile de définir autant de valeurs sentinelles différentes que nécessaire
- Un objet sentinelle doit avoir un
reprcourt et clair - Il doit être possible d’utiliser une signature de type explicite pour une sentinelle
- L’objet doit continuer à se comporter correctement après une copie, et avoir un comportement prévisible lors du pickling et de l’unpickling
- Il doit fonctionner sur CPython 3.x et PyPy3, et si possible sur d’autres implémentations Python
- L’implémentation comme l’usage doivent rester aussi simples et intuitifs que possible, sans ajouter une notion spéciale de plus qui alourdirait l’apprentissage de Python
- La bibliothèque standard ne pouvant pas dépendre de paquets PyPI comme
sentinelsousentinel, une implémentation utilisable directement dans la bibliothèque standard est nécessaire
Spécification de sentinel()
- Un nouvel objet appelable intégré
sentinelest ajouté>>> MISSING = sentinel('MISSING') >>> MISSING MISSING sentinel()prend un unique argument positionnel uniquement,name, qui doit obligatoirement être unestr- Si une valeur non textuelle est transmise, une
TypeErrorest levée namesert de nom de la sentinelle et derepr- Les objets sentinelles ont deux attributs publics
__name__: le nom de la sentinelle__module__: le nom du module oùsentinel()a été appelé
sentinelne peut pas être sous-classé- Chaque appel à
sentinel(name)renvoie un nouvel objet sentinelle - Si la même sentinelle doit être utilisée à plusieurs endroits, il faut l’assigner à une variable puis réutiliser explicitement le même objet, comme avec l’idiome existant
MISSING = object()MISSING = sentinel('MISSING') def read_value(default=MISSING): ... - Pour vérifier si une valeur donnée est une sentinelle, il est recommandé d’utiliser l’opérateur
is, comme pourNone - La comparaison
==se comporte aussi comme attendu, en ne renvoyantTrueque lors d’une comparaison avec elle-même - Un test d’identité comme
if value is MISSING:est généralement plus approprié qu’un test booléen commeif value:ouif not value: - Les objets sentinelles sont truthy et leur évaluation booléenne vaut
True- Cela correspond au comportement par défaut des classes arbitraires ainsi qu’à la valeur booléenne de
Ellipsis - Cela diffère de
None, qui est falsy
- Cela correspond au comportement par défaut des classes arbitraires ainsi qu’à la valeur booléenne de
- Copier un objet sentinelle avec
copy.copy()oucopy.deepcopy()renvoie le même objet - Une sentinelle importable par nom depuis le module où elle est définie conserve son identité après pickling et unpickling selon le mécanisme de pickle standard
MISSING = sentinel('MISSING') assert pickle.loads(pickle.dumps(MISSING)) is MISSING sentinel()enregistre le module appelant dans l’attribut__module__lors de la création de la sentinelle- Le pickling enregistre la sentinelle par module et par nom, et l’unpickling importe le module puis récupère la sentinelle par son nom
- Les sentinelles qui ne peuvent pas être importées par module et par nom, comme celles créées dans une portée locale et non assignées à un nom correspondant au niveau global du module ou comme attribut de classe, ne peuvent pas être picklées
- Le
reprd’un objet sentinelle est lenametransmis àsentinel(), sans qualification implicite par le module - Si un
reprqualifié est nécessaire, il faut l’inclure explicitement dans le nom>>> MyClass_NotGiven = sentinel('MyClass.NotGiven') >>> MyClass_NotGiven MyClass.NotGiven - Les comparaisons d’ordre sur les objets sentinelles ne sont pas définies
- Les sentinelles ne prennent pas en charge les weakrefs
Typage
- Afin de rendre l’usage des sentinelles clair et simple dans le code Python typé, un traitement spécial pour les objets sentinelles est ajouté au système de types
- Un objet sentinelle peut être utilisé, dans une expression de type"), comme une valeur représentant lui-même
- Cela est similaire à la manière dont
Noneest traité dans le système de types existantMISSING = sentinel('MISSING') def foo(value: int | MISSING = MISSING) -> int: ... - Les vérificateurs de type doivent reconnaître la création d’une sentinelle sous la forme
NAME = sentinel('NAME')comme la création d’un nouvel objet sentinelle - Si le nom passé à
sentinel()ne correspond pas au nom de la cible d’assignation, le vérificateur de type doit signaler une erreur - Les sentinelles définies avec cette syntaxe peuvent être utilisées dans des expressions de type")
- Le type de cette sentinelle représente un type entièrement statique") dont l’unique membre est l’objet sentinelle lui-même
- Les vérificateurs de type doivent prendre en charge le rétrécissement des types union contenant une sentinelle à l’aide des opérateurs
isetis notfrom typing import assert_type MISSING = sentinel('MISSING') def foo(value: int | MISSING) -> None: if value is MISSING: assert_type(value, MISSING) else: assert_type(value, int) - L’implémentation à l’exécution doit disposer des méthodes
__or__et__ror__pour prendre en charge l’usage dans les expressions de type, et ces méthodes doivent renvoyer un objettyping.Union") - Le Typing Council a soutenu la partie de cette proposition relative au typage
API C
- Les sentinelles pouvant aussi être utiles dans les extensions C, deux nouvelles fonctions de l’API C sont proposées
PyObject *PySentinel_New(const char *name, const char *module_name)crée un nouvel objet sentinellebool PySentinel_Check(PyObject *obj)vérifie si un objet est une sentinelle- Le code C peut utiliser l’opérateur
==pour vérifier s’il s’agit d’une sentinelle donnée
Compatibilité et sécurité
- L’ajout d’un nouveau nom intégré signifie que le code qui suppose actuellement qu’un nom nu
sentineldéclenche uneNameErrorn’obtiendra plus le même résultat - Il s’agit d’une considération de compatibilité courante lors de l’ajout d’un nouveau nom intégré
- Les noms locaux, globaux ou importés
sentineldéjà existants ne sont pas affectés - Le code qui utilise déjà le nom
sentinelpourrait devoir être adapté pour utiliser le nouvel objet intégré, et pourrait recevoir de nouveaux avertissements de la part des linters signalant les conflits avec les noms intégrés - Il est considéré que les moyens de documentation habituels pour une nouvelle fonctionnalité intégrée — docstring, documentation de bibliothèque et section « What’s New » — sont suffisants
- Cette proposition est considérée comme n’ayant aucune incidence sur la sécurité
Implémentation de référence et backport
- L’implémentation de référence est fournie sous la forme d’une pull request CPython [10]
- L’implémentation de référence précédente se trouve dans un dépôt GitHub séparé [7]
- Voici une esquisse du comportement prévu
class sentinel: """Unique sentinel values.""" __slots__ = ("__name__", "_module_name") def __init_subclass__(cls): raise TypeError("type 'sentinel' is not an acceptable base type") def __init__(self, name, /): if not isinstance(name, str): raise TypeError("sentinel name must be a string") self.__name__ = name self._module_name = sys._getframemodulename(1) @property def __module__(self): return self._module_name def __repr__(self): return self.__name__ def __reduce__(self): return self.__name__ def __copy__(self): return self def __deepcopy__(self, memo): return self def __or__(self, other): return typing.Union[self, other] def __ror__(self, other): return typing.Union[other, self]- Le module typing-extensions propose un backport, mais il ne correspond pas encore exactement au comportement de l’itération actuelle de la PEP
Alternatives rejetées
-
Utiliser
NotGiven = object()- Cette approche présente tous les inconvénients traités dans les critères de conception de la PEP
- Son
represt long et peu explicite, il est difficile d’exprimer clairement la signature de type, et des problèmes liés à la copie ou au pickling peuvent survenir
-
Ajouter une seule nouvelle valeur sentinelle comme
MISSINGouSentinel- Si une même valeur est utilisée à plusieurs endroits pour plusieurs usages, il n’est pas toujours facile d’être certain que, dans un cas d’usage donné, cette valeur elle-même ne sera pas valide
- Des valeurs sentinelles dédiées et distinctes peuvent être utilisées avec plus de confiance sans avoir à envisager de potentiels edge cases
- Les valeurs sentinelles doivent pouvoir fournir un nom parlant et un
repradapté à leur contexte d’utilisation - Cette option n’a été choisie que par 12 % des votants et était très peu populaire
-
Utiliser la valeur sentinelle existante
EllipsisEllipsisn’était pas prévu à l’origine pour cet usage- Son usage s’est développé pour définir des blocs de classes ou de fonctions vides à la place de
pass, mais il ne peut pas être utilisé avec la même assurance dans tous les cas qu’une valeur sentinelle dédiée et distincte
-
Utiliser un
Enumà valeur unique- L’idiome proposé est le suivant
class NotGivenType(Enum): NotGiven = 'NotGiven' NotGiven = NotGivenType.NotGiven - La répétition est excessive, et le
represt beaucoup trop long, par exemple<NotGivenType.NotGiven: 'NotGiven'> - Il est possible de définir un
reprplus court, mais cela ajoute encore plus de code et de répétition - C’était l’option la moins populaire des 9 proposées au vote, la seule à n’avoir reçu aucune voix
-
Décorateur de classe sentinelle
- L’idiome proposé est le suivant
@sentinel class NotGivenType: pass NotGiven = NotGivenType() - L’implémentation du décorateur lui-même peut être simple et claire, mais l’idiome est trop verbeux, répétitif et difficile à retenir
- L’idiome proposé est le suivant
-
Utiliser des objets classe
- Une classe étant intrinsèquement un singleton, l’idée de l’utiliser comme valeur sentinelle est envisageable
- La forme la plus simple est la suivante
class NotGiven: pass- Pour obtenir un
reprclair, il faut une métaclasse ou un décorateur de classe
class NotGiven(metaclass=SentinelMeta): pass@Sentinel class NotGiven: pass - Pour obtenir un
- Utiliser une classe de cette manière est inhabituel et peut prêter à confusion
- Sans commentaire, il est difficile de comprendre l’intention du code, et cela introduit des comportements inattendus et indésirables, comme le fait que la sentinelle devienne appelable
-
Définir seulement un idiome standard recommandé, sans implémentation
- La plupart des idiomes existants courants présentent des inconvénients importants
- Jusqu’à présent, aucun idiome clair et concis évitant ces inconvénients n’a été trouvé
- Dans le vote concerné, l’option consistant à recommander un idiome était peu populaire, et même l’option ayant obtenu le plus de voix n’a atteint que 25 %
-
Utiliser un nouveau module de la bibliothèque standard
- Le brouillon initial proposait d’ajouter la classe
Sentineldans un nouveau modulesentinelsousentinellib - Ajouter un nouveau module pour un seul callable public est inutile
- Utiliser un module rend l’usage de cette fonctionnalité moins pratique que l’idiome
object()existant - Le Steering Council a également recommandé explicitement d’en faire une fonctionnalité intégrée, aussi simple à utiliser que
object() - Le nom
sentinelsentre déjà en conflit avec un package PyPI activement utilisé, et en faire une fonctionnalité intégrée évite ce problème de nommage
- Le brouillon initial proposait d’ajouter la classe
-
Utiliser un registre de noms de sentinelles par module
- Le brouillon initial proposait de rendre les noms de sentinelles uniques à l’intérieur d’un module
- Dans cette conception, répéter l’appel à
sentinel("MISSING")dans le même module renverrait le même objet grâce à un registre global au processus, indexé par le nom du module et le nom de la sentinelle - Ce comportement a été rejeté car trop implicite
- Si une sentinelle partagée est nécessaire, il suffit d’en définir explicitement une, comme avec
MISSING = object(), puis de la réutiliser par son nom - Dans un scope local, on peut vouloir une nouvelle sentinelle à chaque appel ou répétition ; ainsi, répéter
sentinel(name)doit produire des objets distincts, comme des appels répétés àobject() - En supprimant le registre, l’implémentation et le modèle mental deviennent plus simples, et il ne reste que la règle selon laquelle
sentinel(name)crée un nouvel objet unique dont lereprestname
-
Découverte ou transmission automatique du nom du module
- Le brouillon initial proposait un argument optionnel
module_namepour prendre en charge la conception fondée sur le registre - Avec la suppression du registre, l’argument public
module_namen’est plus nécessaire pour la proposition principale - L’implémentation enregistre néanmoins en interne le module appelant afin que le pickling puisse sérialiser, par module et par nom, les sentinelles importables, à la manière de
TypeVar - Le nom du module interne n’a pas d’effet sur le
reprde la sentinelle - Si l’on souhaite un
reprincluant un nom de module ou de classe, il suffit de l’inclure explicitement dans l’unique argumentname, par exemplesentinel("mymodule.MISSING")
- Le brouillon initial proposait un argument optionnel
-
Autoriser la personnalisation de
repr- Cela avait l’avantage de permettre de migrer des valeurs sentinelles existantes vers cette approche sans modifier leur
repr - Mais cela a été écarté, car le gain ne justifiait pas la complexité supplémentaire
- Cela avait l’avantage de permettre de migrer des valeurs sentinelles existantes vers cette approche sans modifier leur
-
Autoriser la personnalisation de l’évaluation booléenne
- La discussion a envisagé la possibilité de rendre une sentinelle explicitement truthy, falsy ou non convertible via
bool - Certaines sentinelles tierces exposent un comportement falsy comme partie de leur API publique
- Plusieurs participants ont estimé que lever une exception dans un contexte booléen renforcerait mieux l’usage des tests d’identité
- La PEP conserve la proposition initiale simple en gardant le comportement truthy par défaut des objets ordinaires et en recommandant les tests d’identité
- Un comportement booléen personnalisable pourra être étudié plus tard s’il est jugé qu’il vaut la peine d’ajouter de la complexité d’API et de typage
- La discussion a envisagé la possibilité de rendre une sentinelle explicitement truthy, falsy ou non convertible via
-
Utiliser
typing.Literaldans les annotations de type- Plusieurs personnes l’ont proposé dans la discussion, et la PEP a d’abord adopté cette approche
- Cependant,
Literal["MISSING"]peut prêter à confusion, car il désigne la valeur chaîne"MISSING"et non une référence anticipée vers la valeur sentinelleMISSING - L’usage d’un nom nu a aussi été fréquemment proposé dans la discussion
- L’approche par nom nu suit le précédent créé par
Noneet un pattern bien connu, ne nécessite aucun import et est bien plus courte
Instructions d’utilisation supplémentaires
- Pour définir une sentinelle dans le scope d’une classe, éviter les conflits de noms ou lorsqu’un
reprqualifié est plus clair, il faut transmettre explicitement le nom qualifié souhaité>>> class MyClass: ... NotGiven = sentinel('MyClass.NotGiven') >>> MyClass.NotGiven MyClass.NotGiven - Il est permis de créer des sentinelles dans une fonction ou une méthode
- Chaque appel à
sentinel()crée un objet distinct ; ainsi, les sentinelles créées dans un scope local se comportent comme des valeurs créées par appel àobject()dans ce même scope - La valeur booléenne de
NotImplementedestTrue, mais son utilisation dans ce sens est obsolète depuis Python 3.9 et déclenche un avertissement de dépréciation - Cette dépréciation est due à des problèmes propres à
NotImplemented, décrits dans bpo-35712 [8] - S’il faut définir plusieurs valeurs sentinelles liées entre elles, ou définir un ordre entre elles, il convient d’utiliser
Enumou une approche similaire - Plusieurs options concernant le typage de telles sentinelles sont discutées sur la liste de diffusion typing-sig [9]
1 commentaires
Avis sur Lobste.rs
Le nom choisi semble étrange, car sa signification est trop étroite
Rien qu’au nom, on aurait dit qu’un élément de base plus souple, proche d’un symbole unique, aurait été plus adapté. En pratique, ça se comportera presque comme un symbole, donc on pourra l’utiliser ainsi, mais l’appeler « Sentinels » reste maladroit. C’est peut-être simplement mon habitude de Lisp qui me fait le voir comme ça
SENTINEL_Asoit d’un type différent deSENTINEL_B, afin qu’on puisse demander si une valeur estis_a SENTINEL_ALes symboles Ruby ne fonctionnent pas comme ça :
:beef.is_a? :droog.class #=> trueLiteralet les chaînes littérales pour la plupart des cas d’usage des symboles LispS’ils s’appellent sentinelles nommées, c’est parce que les sentinel values sont un concept et un pattern courants en Python, et que ces sentinelles visent à résoudre de manière ciblée certains problèmes issus de l’usage de ce pattern. C’est exactement ce qui est expliqué dans les sections « Motivation » et « Rationale »
En outre, les sentinelles n’ont pas de sémantique de valeur : deux sentinelles portant le même nom restent deux valeurs différentes et ne sont pas égales. Elles ne se comportent donc pas comme des symboles et ne doivent pas être utilisées comme tels
Pour le problème des valeurs par défaut des arguments nommés, dans Typst il suffirait presque d’ajouter une valeur
autoà côté denonepour exprimer quasiment toutes les interfaces d’arguments nommés souhaitéesnoneseul ne porte pas bien le sens de la plupart des valeurs par défaut d’arguments nommés.noneconvient bien comme valeur de retour par défaut, mais quand il est passé à une fonction, il arrive souvent qu’il n’exprime pas correctement le sens attendu en tant que nom.matrix(axes=None)est ambigu : cela veut-il dire supprimer les axes, ou les conserver comme d’habitude ? On ne sait pas non plus clairement si passernoneest différent de ne rien passer du tout. Si l’on part sur du multi-dispatch pour distinguer la présence du paramètre, on perd alors l’endroit central où documenter le comportement de ce paramètreautoest une excellente valeur par défaut, car elle signifie directement « fais ce qui convient avec les informations disponibles ». Une signatureauto | nonepeut servir de booléen plus explicite, etT | auto | nonedonne déjà beaucoup d’informations sur la façon dont la fonction utilisera la valeur. Par exemple, siTestcolor,autochoisira probablement une valeur par défaut comme blanc/noir ou héritera du parent,Tdéfinira explicitement une couleur, etnonepourra, selon le contexte, soit ne pas définir de couleur du tout, soit la traiter comme transparenteC’est intéressant, et je me demande comment la sémantique de certains packages va évoluer. Par exemple, au lieu de renvoyer
Item | None, on pourrait écrire ceciBien sûr, on peut aussi porter un sens supplémentaire avec plusieurs sentinelles. C’était déjà possible avant, mais il n’existait pas de manière « officiellement recommandée » de le faire dans la documentation. Cela pourrait orienter les auteurs de packages dans une autre direction
L’exemple est un peu forcé, mais ici on peut distinguer le cas où l’ID existe mais n’a pas de valeur associée, et celui où l’échec vient du fait que cet ID n’existe même pas. La manière « pythonique » serait sans doute plutôt d’utiliser des exceptions, mais cela donne une approche plus fonctionnelle que ce qu’on voit habituellement en Python
Il aurait peut-être mieux valu introduire directement l’API
Symbolde JavaScript. C’est utile de manière générale, et cela résout aussi le problème visé ici