1 points par GN⁺ 1 일 전 | 1 commentaires | Partager sur WhatsApp
  • PEP 661 propose l’objet appelable intégré à Python sentinel() et l’API C PySentinel_New() pour créer une valeur sentinelle distincte dans les cas où None est une valeur valide
  • L’idiome existant _sentinel = object() pose problème, car son repr est 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 un repr court ; pour partager la même sentinelle, il faut l’assigner à une variable et la réutiliser explicitement, par exemple MISSING = sentinel('MISSING')
  • Il est recommandé de comparer les sentinelles avec is, elles sont évaluées comme vraies, copy.copy() et copy.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ù None est 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 son repr é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 is et 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 repr court 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 sentinels ou sentinel, une implémentation utilisable directement dans la bibliothèque standard est nécessaire

Spécification de sentinel()

  • Un nouvel objet appelable intégré sentinel est ajouté
    >>> MISSING = sentinel('MISSING')  
    >>> MISSING  
    MISSING  
    
  • sentinel() prend un unique argument positionnel uniquement, name, qui doit obligatoirement être une str
  • Si une valeur non textuelle est transmise, une TypeError est levée
  • name sert de nom de la sentinelle et de repr
  • Les objets sentinelles ont deux attributs publics
    • __name__ : le nom de la sentinelle
    • __module__ : le nom du module où sentinel() a été appelé
  • sentinel ne 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 pour None
  • La comparaison == se comporte aussi comme attendu, en ne renvoyant True que 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 comme if value: ou if 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
  • Copier un objet sentinelle avec copy.copy() ou copy.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 repr d’un objet sentinelle est le name transmis à sentinel(), sans qualification implicite par le module
  • Si un repr qualifié 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 None est traité dans le système de types existant
    MISSING = 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 is et is not
    from 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 objet typing.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 sentinelle
  • bool 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 sentinel déclenche une NameError n’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 sentinel déjà existants ne sont pas affectés
  • Le code qui utilise déjà le nom sentinel pourrait 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:  
        &quot;&quot;&quot;Unique sentinel values.&quot;&quot;&quot;  
    
        __slots__ = (&quot;__name__&quot;, &quot;_module_name&quot;)  
    
        def __init_subclass__(cls):  
            raise TypeError(&quot;type &#039;sentinel&#039; is not an acceptable base type&quot;)  
    
        def __init__(self, name, /):  
            if not isinstance(name, str):  
                raise TypeError(&quot;sentinel name must be a string&quot;)  
            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 repr est 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 MISSING ou Sentinel

    • 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 repr adapté à 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 Ellipsis

    • Ellipsis n’é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 = &#039;NotGiven&#039;  
      NotGiven = NotGivenType.NotGiven  
    
  • La répétition est excessive, et le repr est beaucoup trop long, par exemple &lt;NotGivenType.NotGiven: &#039;NotGiven&#039;&gt;
  • Il est possible de définir un repr plus 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
  • 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 repr clair, il faut une métaclasse ou un décorateur de classe
      class NotGiven(metaclass=SentinelMeta): pass  
      
      @Sentinel  
      class NotGiven: pass  
      
    • 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 Sentinel dans un nouveau module sentinels ou sentinellib
    • 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 sentinels entre déjà en conflit avec un package PyPI activement utilisé, et en faire une fonctionnalité intégrée évite ce problème de nommage
  • 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 le repr est name
  • Découverte ou transmission automatique du nom du module

    • Le brouillon initial proposait un argument optionnel module_name pour prendre en charge la conception fondée sur le registre
    • Avec la suppression du registre, l’argument public module_name n’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 repr de la sentinelle
    • Si l’on souhaite un repr incluant un nom de module ou de classe, il suffit de l’inclure explicitement dans l’unique argument name, par exemple sentinel("mymodule.MISSING")
  • 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
  • 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
  • Utiliser typing.Literal dans 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 sentinelle MISSING
    • 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 None et 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 repr qualifié est plus clair, il faut transmettre explicitement le nom qualifié souhaité
    &gt;&gt;&gt; class MyClass:  
    ...    NotGiven = sentinel(&#039;MyClass.NotGiven&#039;)  
    &gt;&gt;&gt; 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 NotImplemented est True, 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 Enum ou une approche similaire
  • Plusieurs options concernant le typage de telles sentinelles sont discutées sur la liste de diffusion typing-sig [9]

1 commentaires

 
GN⁺ 1 일 전
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

    • L’objectif semble être que SENTINEL_A soit d’un type différent de SENTINEL_B, afin qu’on puisse demander si une valeur est is_a SENTINEL_A
      Les symboles Ruby ne fonctionnent pas comme ça : :beef.is_a? :droog.class #=> true
    • Le raisonnement façon Lisp se tient. Mais même si l’on part du principe qu’un usage large est souhaitable et qu’il s’agit d’un problème à résoudre, Python a déjà Literal et les chaînes littérales pour la plupart des cas d’usage des symboles Lisp
      S’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é de none pour exprimer quasiment toutes les interfaces d’arguments nommés souhaitées
    none seul ne porte pas bien le sens de la plupart des valeurs par défaut d’arguments nommés. none convient 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 passer none est 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ètre
    auto est une excellente valeur par défaut, car elle signifie directement « fais ce qui convient avec les informations disponibles ». Une signature auto | none peut servir de booléen plus explicite, et T | auto | none donne déjà beaucoup d’informations sur la façon dont la fonction utilisera la valeur. Par exemple, si T est color, auto choisira probablement une valeur par défaut comme blanc/noir ou héritera du parent, T définira explicitement une couleur, et none pourra, selon le contexte, soit ne pas définir de couleur du tout, soit la traiter comme transparente

  • C’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 ceci

    NOT_FOUND = sentinel("NOT_FOUND")  
    def get_item(iid: str) -> Item | NOT_FOUND: ...  
    

    Bien 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

    MISSING_ID = sentinel("MISSING_ID")  
    MISSING_VALUE = sentinel("MISSING_VALUE")
    
    def get_item(iid: str) -> Item | MISSING_ID | MISSING_VALUE: ...  
    

    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

    • Avant, cela ressemblait à une manière plus propre d’utiliser des singletons, où l’on créait une classe factice puis une instance par module
      class _MissingId: ...
      
      MISSING_ID = _MissingId()
      
      # elsewhere  
      from ... import MISSING_ID  
      
      Ça fait penser aux Symbols
    • Le PEP dit que si l’on veut définir plusieurs sentinelles liées entre elles, ou même leur donner un ordre, il vaut mieux utiliser un Enum ou quelque chose de similaire
  • Il aurait peut-être mieux valu introduire directement l’API Symbol de JavaScript. C’est utile de manière générale, et cela résout aussi le problème visé ici