PEP 661: valor centinela, aprobado después de 5 años
(peps.python.org)- PEP 661 propone el objeto invocable integrado de Python
sentinel()y la API de CPySentinel_New()para crear valores centinela distinguibles por separado en situaciones dondeNonees un valor válido - El modismo existente
_sentinel = object()puede causar problemas porque en la firma de funciones surepres 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 unreprcorto, y si se quiere compartir el mismo centinela, debe reutilizarse explícitamente asignándolo a una variable comoMISSING = sentinel('MISSING') - Se recomienda comparar los centinelas con
is, se evalúan como verdaderos,copy.copy()ycopy.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
Nonepara este propósito, pero en contextos dondeNoneen sí es un valor válido, se necesita un valor centinela aparte que pueda distinguirse deNone - 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 sureprera demasiado largo y poco informativo, hacía que la firma de la función fuera difícil de leer>>> 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) - 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
isfalla, 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
reprcorto 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
sentinelsosentinel, 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, ynamedebe ser obligatoriamente unstr- Si se pasa un valor que no sea una cadena, se produce un
TypeError namese usa como nombre y comoreprdel centinela- El objeto centinela tiene dos atributos públicos
__name__: nombre del centinela__module__: nombre del módulo donde se llamó asentinel()
sentinelno 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 conNone - La comparación con
==también funciona como se espera y solo devuelveTrueal compararlo consigo mismo - Una comprobación de identidad como
if value is MISSING:suele ser más apropiada que una comprobación booleana comoif value:oif 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
- Esto es igual al comportamiento predeterminado de una clase arbitraria y al valor booleano de
- Si se copia un objeto centinela con
copy.copy()ocopy.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
reprde un objeto centinela es elnamepasado asentinel(), sin agregar implícitamente un calificador de módulo - Si se necesita un
reprcalificado, 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
NoneMISSING = 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
isyis 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) - 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 objetotyping.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 centinelabool 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
sentinelproduce unNameErrorya 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
sentinelno se ven afectados - El código que ya usa el nombre
sentinelquizá 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: """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]- 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
repres 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
MISSINGoSentinel- 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
repradecuados 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
EllipsisexistenteEllipsisno 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
Enumde un solo valor- El modismo propuesto es el siguiente.
class NotGivenType(Enum): NotGiven = 'NotGiven' NotGiven = NotGivenType.NotGiven - La repetición es excesiva y el
repres demasiado largo, como<NotGivenType.NotGiven: 'NotGiven'>. - 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.
- El modismo propuesto es el siguiente.
-
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
reprclaro hace falta una metaclase o un decorador de clase.
class NotGiven(metaclass=SentinelMeta): pass@Sentinel class NotGiven: pass - Para obtener un
- 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
Sentinelen un nuevo módulosentinelsosentinellib. - 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
sentinelsya entra en conflicto con un paquete de PyPI de uso activo, y convertirlo en una función integrada evita ese problema de nombre.
- El borrador inicial proponía agregar una clase
-
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 aobject(). - 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 cuyorepresname.
-
Descubrir o pasar automáticamente el nombre del módulo
- El borrador inicial proponía un argumento opcional
module_namepara soportar el diseño basado en registro. - Al eliminarse el registro, el argumento público
module_namedeja 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
reprdel centinela. - Si se quiere un
reprque incluya el nombre del módulo o de la clase, se puede incluir explícitamente en el único argumentoname, por ejemplosentinel("mymodule.MISSING").
- El borrador inicial proponía un argumento opcional
-
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.
- Esto tenía la ventaja de permitir migrar valores centinela existentes a este enfoque sin cambiar su
-
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.
- En la discusión se consideró permitir que un centinela fuera explícitamente truthy, falsy o no convertible a
-
Usar
typing.Literalen 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 centinelaMISSING, 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
Noney 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
reprcalificado sea más claro, se debe pasar explícitamente el nombre calificado deseado.>>> class MyClass: ... NotGiven = sentinel('MyClass.NotGiven') >>> 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 aobject()en ese mismo ámbito. - El valor booleano de
NotImplementedesTrue, 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
NotImplementeddescritos en bpo-35712 [8]. - Si hace falta definir varios valores centinela relacionados o establecer un orden entre ellos, se debe usar
Enumo un enfoque similar. - Sobre el tipado de estos centinelas, se discutieron varias opciones en la lista de correo typing-sig [9].
1 comentarios
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
SENTINEL_Asea de un tipo distinto aSENTINEL_B, para poder preguntar si cierto valoris_a SENTINEL_ALos símbolos de Ruby no funcionan así:
:beef.is_a? :droog.class #=> trueLiteraly las cadenas literales para la mayoría de los casos de uso de los símbolos de LispLa 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
autojunto connonepara expresar casi cualquier interfaz de argumentos nombrados que uno quisieranonepor sí solo no encaja semánticamente como valor por defecto para la mayoría de los argumentos nombrados.nonefunciona 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 pasarnonees 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ámetroautoes un valor por defecto excelente porque literalmente significa “haz lo apropiado con la información disponible”. Una firmaauto | nonepuede usarse como una especie de booleano más explícito, yT | auto | noneda bastante información sobre cómo la función usará el valor. Por ejemplo, siTescolor, entoncesautoprobablemente elija un valor predeterminado como blanco/negro o herede del padre,Tfija explícitamente un color ynonepuede significar no fijar ningún color en absoluto o tratarlo como transparente, según el contextoInteresante, 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í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
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
Creo que habría sido mejor simplemente adoptar la API de
Symbolde JavaScript. Tiene utilidad general y además resuelve también el problema que se quiere resolver aquí