1 puntos por GN⁺ 1 시간 전 | 1 comentarios | Compartir por WhatsApp
  • PEP 661 propone el objeto invocable integrado de Python sentinel() y la API de C PySentinel_New() para crear valores centinela distinguibles por separado en situaciones donde None es un valor válido
  • El modismo existente _sentinel = object() puede causar problemas porque en la firma de funciones su repr es largo y poco claro, y además puede presentar inconvenientes con firmas de tipo claras, copias y pickling
  • La llamada sentinel('MISSING') crea un nuevo objeto único con un repr corto, y si se quiere compartir el mismo centinela, debe reutilizarse explícitamente asignándolo a una variable como MISSING = sentinel('MISSING')
  • Se recomienda comparar los centinelas con is, se evalúan como verdaderos, copy.copy() y copy.deepcopy() devuelven el mismo objeto, y si pueden importarse por nombre desde un módulo, conservan su identidad incluso después de pickling
  • El sistema de tipos permite usar el propio centinela en expresiones de tipo como int | MISSING, y la documentación oficial más reciente está en la documentación de Python 3.15 para [sentinel](<https://docs.python.org/3.15/library/functions.html#sentinel "(in Python v3.15>)")

Contexto de introducción

  • Un valor centinela (sentinel value), que es un marcador único, se usa como valor predeterminado cuando no se proporciona un argumento de función, como valor de retorno para indicar que una búsqueda falló, o para representar datos faltantes
  • En Python normalmente existe el valor especial None para este propósito, pero en contextos donde None en sí es un valor válido, se necesita un valor centinela aparte que pueda distinguirse de None
  • En mayo de 2021, en la lista de correo python-dev, se debatió una mejor manera de implementar el valor centinela usado en traceback.print_exception
  • La implementación existente usaba el modismo común _sentinel = object(), pero como su repr era demasiado largo y poco informativo, hacía que la firma de la función fuera difícil de leer
    &gt;&gt;&gt; help(traceback.print_exception)  
    Help on function print_exception in module traceback:  
    
    print_exception(exc, /, value=&lt;object object at  
    0x000002825DF09650&gt;, tb=&lt;object object at 0x000002825DF09650&gt;,  
    limit=None, file=None, chain=True)  
    
  • Durante la discusión también se identificaron otros problemas de las implementaciones existentes de centinelas
    • Algunos centinelas no tienen un tipo propio, por lo que es difícil definir una firma de tipos clara para funciones que usan un centinela como valor predeterminado
    • Tras copiarlos se crean instancias separadas y la comparación con is falla, por lo que se comportan de forma distinta a lo esperado
    • Algunos modismos comunes presentan problemas similares incluso después de hacer pickling y luego unpickling
  • Victor Stinner proporcionó una lista de valores centinela usados en la biblioteca estándar de Python, y se confirmó que incluso dentro de la propia biblioteca estándar se usan varios métodos de implementación y que muchas implementaciones tienen uno o más de los problemas anteriores
  • La votación en discuss.python.org no pudo llegar a una conclusión clara con base en 39 votos
    • El 40% eligió “el estado actual está bien y no hace falta consistencia”
    • La mayoría eligió una o más soluciones estandarizadas
    • El 37% eligió la opción de “usar de forma consistente una nueva fábrica/clase/metaclase dedicada para centinelas y exponerla públicamente en la biblioteca estándar”
  • Debido a estos resultados divididos, se redactó el PEP, y se llegó a la conclusión de que una implementación simple y buena en la biblioteca estándar sería útil tanto dentro como fuera de ella
  • No es obligatorio cambiar todos los centinelas existentes de la biblioteca estándar a este enfoque; eso queda a criterio de cada mantenedor correspondiente
  • El documento del PEP es un documento histórico, y la documentación oficial más reciente está en la documentación de Python 3.15 para [sentinel](<https://docs.python.org/3.15/library/functions.html#sentinel "(in Python v3.15>)")

Criterios de diseño

  • Un objeto centinela, al compararse con el operador is, siempre debe ser idéntico a sí mismo y no debe ser idéntico a ningún otro objeto
  • La creación de un objeto centinela debe poder hacerse con una sola línea de código simple e intuitiva
  • Debe ser fácil definir tantos valores centinela distintos como sea necesario
  • El objeto centinela debe tener un repr corto y claro
  • Debe poder usarse una firma de tipos clara para el centinela
  • Debe funcionar correctamente incluso después de copiarse, y debe tener un comportamiento predecible al hacer pickling y unpickling
  • Debe funcionar en CPython 3.x y PyPy3, y si es posible también en otras implementaciones de Python
  • Tanto la implementación como el uso deben ser lo más simples e intuitivos posible, sin convertirse en otra noción especial que complique el aprendizaje de Python
  • La biblioteca estándar no puede depender de implementaciones de paquetes de PyPI como sentinels o sentinel, por lo que se necesita una implementación que pueda usarse dentro de la propia biblioteca estándar

Especificación de sentinel()

  • Se añade un nuevo objeto invocable integrado sentinel
    >>> MISSING = sentinel('MISSING')  
    >>> MISSING  
    MISSING  
    
  • sentinel() recibe un único argumento solo posicional, name, y name debe ser obligatoriamente un str
  • Si se pasa un valor que no sea una cadena, se produce un TypeError
  • name se usa como nombre y como repr del centinela
  • El objeto centinela tiene dos atributos públicos
    • __name__: nombre del centinela
    • __module__: nombre del módulo donde se llamó a sentinel()
  • sentinel no puede subclasificarse
  • Cada vez que se llama a sentinel(name), se devuelve un nuevo objeto centinela
  • Si se necesita usar el mismo centinela en varios lugares, hay que asignarlo a una variable y reutilizar explícitamente el mismo objeto, igual que con el modismo existente MISSING = object()
    MISSING = sentinel('MISSING')  
    
    def read_value(default=MISSING):  
        ...  
    
  • Para comprobar si un valor concreto es un centinela, se recomienda usar el operador is, igual que con None
  • La comparación con == también funciona como se espera y solo devuelve True al compararlo consigo mismo
  • Una comprobación de identidad como if value is MISSING: suele ser más apropiada que una comprobación booleana como if value: o if not value:
  • Los objetos centinela son truthy y su evaluación booleana da True
    • Esto es igual al comportamiento predeterminado de una clase arbitraria y al valor booleano de Ellipsis
    • Es distinto de None, que es falsy
  • Si se copia un objeto centinela con copy.copy() o copy.deepcopy(), se devuelve el mismo objeto
  • Un centinela importable por nombre desde el módulo donde fue definido conserva su identidad después de serializarse y deserializarse con el mecanismo estándar de pickle
    MISSING = sentinel('MISSING')  
    assert pickle.loads(pickle.dumps(MISSING)) is MISSING  
    
  • sentinel() registra el módulo de llamada en el atributo __module__ al crear el centinela
  • Pickle registra el centinela por módulo y nombre, y al deserializarlo importa el módulo y recupera el centinela por nombre
  • Los centinelas que no pueden importarse por módulo y nombre no pueden serializarse con pickle, como los creados en un ámbito local y que no se asignan a un nombre coincidente en un global de módulo o en un atributo de clase
  • El repr de un objeto centinela es el name pasado a sentinel(), sin agregar implícitamente un calificador de módulo
  • Si se necesita un repr calificado, debe incluirse explícitamente en el nombre
    >>> MyClass_NotGiven = sentinel('MyClass.NotGiven')  
    >>> MyClass_NotGiven  
    MyClass.NotGiven  
    
  • La comparación de orden de los objetos centinela no está definida
  • Los centinelas no admiten weakref

Tipado

  • Para que el uso de centinelas en código Python tipado sea claro y simple, se añade un tratamiento especial para los objetos centinela en el sistema de tipos
  • Los objetos centinela pueden usarse dentro de una expresión de tipo") como un valor que se representa a sí mismo
  • Esto es similar a cómo el sistema de tipos existente maneja None
    MISSING = sentinel('MISSING')  
    
    def foo(value: int | MISSING = MISSING) -> int:  
        ...  
    
  • Los verificadores de tipos deben reconocer una creación de centinela con la forma NAME = sentinel('NAME') como la creación de un nuevo objeto centinela
  • Si el nombre pasado a sentinel() no coincide con el nombre del destino de asignación, el verificador de tipos debe reportar un error
  • Los centinelas definidos con esta sintaxis pueden usarse en expresiones de tipo")
  • El tipo de ese centinela representa un tipo completamente estático") que tiene como único miembro al propio objeto centinela
  • Los verificadores de tipos deben admitir el estrechamiento de tipos unión que incluyen centinelas mediante los operadores is y 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)  
    
  • La implementación en tiempo de ejecución debe tener los métodos __or__ y __ror__ para admitir el uso en expresiones de tipo, y estos métodos devuelven un objeto typing.Union")
  • El Typing Council respalda la parte relacionada con tipos de esta propuesta

API de C

  • Los centinelas también pueden ser útiles en extensiones de C, por lo que se proponen dos nuevas funciones de la API de C
  • PyObject *PySentinel_New(const char *name, const char *module_name) crea un nuevo objeto centinela
  • bool PySentinel_Check(PyObject *obj) comprueba si un objeto es un centinela
  • El código C puede usar el operador == para comprobar si es un centinela concreto

Compatibilidad y seguridad

  • Al añadir un nuevo nombre integrado, el código que actualmente asume que el bare name sentinel produce un NameError ya no verá el mismo resultado
  • Esta es una consideración de compatibilidad habitual al añadir nuevos nombres integrados
  • Los nombres locales, globales o importados ya existentes llamados sentinel no se ven afectados
  • El código que ya usa el nombre sentinel quizá deba ajustarse para usar el nuevo objeto integrado, y podría recibir nuevas advertencias de linters que avisan sobre conflictos con nombres integrados
  • Se considera suficiente la documentación habitual para nuevas funciones integradas, como docstrings, documentación de biblioteca y la sección “What’s New”
  • Se considera que esta propuesta no tiene implicaciones de seguridad

Implementación de referencia y backport

  • La implementación de referencia se proporciona como un pull request de CPython [10]
  • La implementación de referencia anterior está en un repositorio separado de GitHub [7]
  • El esquema del comportamiento previsto es el siguiente
    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]  
    
    • El módulo typing-extensions tiene un backport, pero actualmente no coincide exactamente con el comportamiento de la iteración actual del PEP

Alternativas rechazadas

  • Usar NotGiven = object()

    • Este enfoque tiene todos los inconvenientes tratados en los criterios de diseño del PEP.
    • El repr es largo y poco claro, es difícil dejar clara la firma de tipos y puede causar problemas relacionados con copias o pickling.
  • Agregar un único valor centinela nuevo como MISSING o Sentinel

    • Si un mismo valor se usa en muchos lugares y para varios propósitos, no siempre es fácil asegurar que en cierto caso de uso ese valor en sí no será un valor válido.
    • Los valores centinela dedicados y distintos pueden usarse con más confianza sin tener que considerar posibles casos límite.
    • Los valores centinela deben poder ofrecer un nombre significativo y un repr adecuados al contexto en que se usan.
    • Esta opción fue muy poco popular y solo obtuvo el 12% de los votos.
  • Usar el valor centinela Ellipsis existente

    • Ellipsis no fue pensado originalmente para este uso.
    • Aunque cada vez se usa más para definir clases o bloques de funciones vacíos en lugar de pass, no puede usarse con la misma confianza en todos los casos que un valor centinela dedicado y distinto.
  • Usar un Enum de un solo valor

    • El modismo propuesto es el siguiente.
    class NotGivenType(Enum):  
      NotGiven = &#039;NotGiven&#039;  
      NotGiven = NotGivenType.NotGiven  
    
  • La repetición es excesiva y el repr es demasiado largo, como &lt;NotGivenType.NotGiven: &#039;NotGiven&#039;&gt;.
  • Se puede definir un `repr`` más corto, pero eso aumenta todavía más el código y la repetición.
  • Fue la menos popular de las 9 opciones de la votación y la única que no recibió ningún voto.
  • Decorador de clase centinela

    • El modismo propuesto es el siguiente.
      @sentinel  
      class NotGivenType: pass  
      NotGiven = NotGivenType()  
      
    • La implementación del decorador en sí puede ser simple y clara, pero el modismo es demasiado verboso, repetitivo y difícil de recordar.
  • Usar objetos de clase

    • Como las clases son esencialmente singletons, la idea de usarlas como valores centinela es posible.
    • La forma más simple es la siguiente.
      class NotGiven: pass  
      
      • Para obtener un repr claro hace falta una metaclase o un decorador de clase.
      class NotGiven(metaclass=SentinelMeta): pass  
      
      @Sentinel  
      class NotGiven: pass  
      
    • Usar una clase de esta forma es poco habitual y puede resultar confuso.
    • Sin comentarios es difícil entender la intención del código y aparecen comportamientos inesperados e indeseables, como que el centinela se vuelva invocable.
  • Definir solo un modismo estándar recomendado sin implementación

    • La mayoría de los modismos existentes comunes tienen desventajas importantes.
    • Hasta ahora no se ha encontrado un modismo claro y conciso que evite esas desventajas.
    • En la votación relacionada, la opción de recomendar un modismo fue poco popular, y hasta la opción más votada solo alcanzó el 25%.
  • Usar un módulo nuevo de la biblioteca estándar

    • El borrador inicial proponía agregar una clase Sentinel en un nuevo módulo sentinels o sentinellib.
    • Agregar un módulo nuevo para un único objeto invocable público es innecesario.
    • Usar un módulo hace que la funcionalidad sea más incómoda de usar que el modismo existente object().
    • El Steering Council también recomendó explícitamente que se convierta en una característica integrada para que sea tan fácil de usar como object().
    • El nombre sentinels ya entra en conflicto con un paquete de PyPI de uso activo, y convertirlo en una función integrada evita ese problema de nombre.
  • Usar un registro de nombres de centinelas por módulo

    • El borrador inicial proponía hacer que los nombres de centinelas fueran únicos dentro del módulo.
    • En este diseño, si se llama repetidamente a sentinel("MISSING") en el mismo módulo, se devuelve el mismo objeto mediante un registro global del proceso que usa como clave el nombre del módulo y el nombre del centinela.
    • Este comportamiento fue rechazado por ser demasiado implícito.
    • Si se necesita un centinela compartido, basta con definir uno explícitamente como en MISSING = object() y reutilizarlo por nombre.
    • En un ámbito local quizá se quiera un centinela nuevo en cada llamada o repetición, por lo que las llamadas repetidas a sentinel(name) deben crear objetos distintos, como ocurre con las llamadas repetidas a object().
    • Al eliminar el registro, tanto la implementación como el modelo mental se simplifican, y solo queda la regla de que sentinel(name) crea un objeto nuevo y único cuyo repr es name.
  • Descubrir o pasar automáticamente el nombre del módulo

    • El borrador inicial proponía un argumento opcional module_name para soportar el diseño basado en registro.
    • Al eliminarse el registro, el argumento público module_name deja de ser necesario para la propuesta principal.
    • La implementación registra internamente el módulo llamador, de forma similar a TypeVar, para que pickling pueda serializar los centinelas importables por módulo y nombre.
    • El nombre interno del módulo no afecta el repr del centinela.
    • Si se quiere un repr que incluya el nombre del módulo o de la clase, se puede incluir explícitamente en el único argumento name, por ejemplo sentinel("mymodule.MISSING").
  • Permitir personalizar repr

    • Esto tenía la ventaja de permitir migrar valores centinela existentes a este enfoque sin cambiar su repr.
    • Pero se excluyó al considerar que no valía la pena la complejidad adicional.
  • Permitir personalizar la evaluación booleana

    • En la discusión se consideró permitir que un centinela fuera explícitamente truthy, falsy o no convertible a bool.
    • Algunos centinelas de terceros ofrecen comportamiento falsy como parte de su API pública.
    • Varios participantes consideraron que lanzar una excepción en contexto booleano obligaría mejor a hacer comprobaciones de identidad.
    • El PEP mantiene simple la propuesta inicial al conservar el comportamiento truthy predeterminado de los objetos normales y recomendar comprobaciones de identidad.
    • El comportamiento booleano personalizado podría considerarse más adelante si se concluye que vale la pena asumir la complejidad adicional de API y tipado.
  • Usar typing.Literal en anotaciones de tipos

    • Varias personas lo propusieron en la discusión y al principio el PEP también adoptó este enfoque.
    • Sin embargo, Literal["MISSING"] puede causar confusión porque no se refiere hacia adelante al valor centinela MISSING, sino al valor de cadena "MISSING".
    • El uso de un nombre simple también se propuso con frecuencia en la discusión.
    • El enfoque de nombre simple sigue el precedente establecido por None y un patrón bien conocido, no requiere importaciones y es mucho más breve.

Guía adicional de uso

  • Al definir un centinela en el ámbito de una clase, al evitar conflictos de nombres o cuando un repr calificado sea más claro, se debe pasar explícitamente el nombre calificado deseado.
    &gt;&gt;&gt; class MyClass:  
    ...    NotGiven = sentinel(&#039;MyClass.NotGiven&#039;)  
    &gt;&gt;&gt; MyClass.NotGiven  
    MyClass.NotGiven  
    
  • Está permitido crear centinelas dentro de funciones o métodos.
  • Como cada llamada a sentinel() crea un objeto distinto, los centinelas creados en ámbitos locales se comportan como valores creados llamando a object() en ese mismo ámbito.
  • El valor booleano de NotImplemented es True, pero usarlo de esa manera está obsoleto desde Python 3.9 y produce una advertencia de deprecación.
  • Esta deprecación se debe a problemas propios de NotImplemented descritos en bpo-35712 [8].
  • Si hace falta definir varios valores centinela relacionados o establecer un orden entre ellos, se debe usar Enum o un enfoque similar.
  • Sobre el tipado de estos centinelas, se discutieron varias opciones en la lista de correo typing-sig [9].

1 comentarios

 
GN⁺ 1 시간 전
Opiniones en Lobste.rs
  • Se siente raro que el nombre elegido sea demasiado estrecho en significado
    Solo por el nombre, parecería que algo como un símbolo único habría sido un bloque base más flexible. En la práctica probablemente se comporte casi como un símbolo, así que se podrá usar así, pero ponerle el nombre “Sentinels” se siente extraño. Quizá lo percibo así por estar acostumbrado a Lisp

    • Parece que la meta es que SENTINEL_A sea de un tipo distinto a SENTINEL_B, para poder preguntar si cierto valor is_a SENTINEL_A
      Los símbolos de Ruby no funcionan así: :beef.is_a? :droog.class #=> true
    • La forma de pensar tipo Lisp aplica. Se asume que usarlo para fines amplios es deseable y un problema que vale la pena resolver, pero en Python ya existen Literal y las cadenas literales para la mayoría de los casos de uso de los símbolos de Lisp
      La razón de que estos sean sentinels con nombre es que los sentinel values son un concepto y patrón común en Python, y los sentinels buscan resolver de forma acotada algunos problemas que aparecen al usar ese patrón. Justo como se explica en las secciones “Motivation” y “Rationale”
      Además, los sentinels no tienen semántica de valor, así que incluso dos sentinels con el mismo nombre son valores distintos y no son iguales entre sí. Por eso tampoco funcionan como símbolos, y no deberían usarse como tales
  • Para el problema de valores por defecto en argumentos nombrados, en Typst bastaría con agregar un valor auto junto con none para expresar casi cualquier interfaz de argumentos nombrados que uno quisiera
    none por sí solo no encaja semánticamente como valor por defecto para la mayoría de los argumentos nombrados. none funciona bien como valor de retorno por defecto, pero al entrar como argumento de función muchas veces no transmite el significado correcto como sustantivo. matrix(axes=None) deja ambiguo si significa quitar los ejes o dejarlos como siempre. Tampoco queda claro si pasar none es distinto de no pasar nada. Si uno intenta distinguir la presencia del parámetro con despacho múltiple, entonces se pierde un lugar central donde documentar el comportamiento de ese parámetro
    auto es un valor por defecto excelente porque literalmente significa “haz lo apropiado con la información disponible”. Una firma auto | none puede usarse como una especie de booleano más explícito, y T | auto | none da bastante información sobre cómo la función usará el valor. Por ejemplo, si T es color, entonces auto probablemente elija un valor predeterminado como blanco/negro o herede del padre, T fija explícitamente un color y none puede significar no fijar ningún color en absoluto o tratarlo como transparente, según el contexto

  • Interesante, y me da curiosidad cómo cambiará la semántica de algunos paquetes. Por ejemplo, en vez de devolver Item | None, podría usarse algo así

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

    Claro, también se podría codificar significado adicional con varios sentinels. Ya se podía hacer antes, pero no había una forma “oficialmente recomendada” de documentarlo. Esto podría empujar a quienes escriben paquetes en otra dirección

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

    Es un ejemplo algo forzado, pero en este caso se puede distinguir entre la situación donde el ID sí existe pero no tiene un valor asociado, y aquella donde el fallo ocurre porque ese ID ni siquiera existe. La forma “pythónica” probablemente sería usar excepciones, pero esto se ve más como un enfoque funcional de lo habitual al escribir Python

    • Se ve como una forma más limpia de usar los singletons que antes se resolvían creando una clase dummy e instanciándola por módulo
      class _MissingId: ...
      
      MISSING_ID = _MissingId()
      
      # elsewhere  
      from ... import MISSING_ID  
      
      Me recuerda a Symbols
    • En el PEP dicen que si quieres definir varios valores sentinel relacionados o incluso darles un orden entre sí, entonces deberías usar Enum o algo parecido
  • Creo que habría sido mejor simplemente adoptar la API de Symbol de JavaScript. Tiene utilidad general y además resuelve también el problema que se quiere resolver aquí