Capítulo 4. Tipos de restricción
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Muchos desarrolladores aprenden las anotaciones de tipo básicas y se dan por satisfechos. Pero estamos lejos de haber terminado. Hay una gran cantidad de anotaciones de tipo avanzadas que tienen un valor incalculable. Estas anotaciones de tipo avanzadas te permiten restringir los tipos, limitando aún más lo que pueden representar. Su objetivo es hacer que los estados ilegales sean irrepresentables. Los desarrolladores no deben poder crear físicamente tipos que sean contradictorios o no válidos en tu sistema. No puedes tener errores en tu código si es imposible crear el error en primer lugar. Puedes utilizar las anotaciones de tipo para lograr este mismo objetivo, ahorrando tiempo y dinero. En este capítulo te enseñaré seis técnicas diferentes:
Optional
-
Utilízalo para sustituir las referencias de
None
en tu código base. Union
-
Utilízalo para presentar una selección de tipos.
Literal
-
Utilízalo para restringir los desarrolladores a valores muy específicos.
Annotated
-
Utilízalo para proporcionar una descripción adicional de tus tipos.
NewType
-
Sirve para restringir un tipo a un contexto concreto.
Final
-
Se utiliza para evitar que las variables se reboten a un nuevo valor.
Empecemos por tratar las referencias None
con tipos Optional
.
Tipo opcional
Las referencias nulas suelen ser conocido como el "error del billón de dólares", acuñado por C.A.R. Hoare:
Yo lo llamo mi error del billón de dólares. Fue la invención de la referencia nula en 1965. Por aquel entonces, estaba diseñando el primer sistema de tipos completo para referencias en un lenguaje orientado a objetos. Mi objetivo era garantizar que todo uso de referencias fuera absolutamente seguro, con una comprobación realizada automáticamente por el compilador. Pero no pude resistir la tentación de poner una referencia nula, simplemente porque era muy fácil de implementar. Esto ha dado lugar a innumerables errores, vulnerabilidades y fallos del sistema, que probablemente han causado mil millones de dólares de dolor y daños en los últimos cuarenta años.1
Aunque las referencias nulas empezaron en Algol, impregnarían otros innumerables lenguajes. C y C++ son a menudo objeto de burla por la desviación de puntero nulo (que produce un fallo de segmentación u otro fallo del programa). Java era bien conocido por exigir al usuario que capturara NullPointerException
en todo su código. No es exagerado decir que este tipo de errores tienen un precio que se mide en miles de millones. Piensa en el tiempo de los desarrolladores, la pérdida de clientes y los fallos del sistema debidos a punteros o referencias nulos accidentales.
Entonces, ¿por qué importa esto en Python? La cita de Hoare se refiere a los lenguajes compilados orientados a objetos de los años 60; Python ya debe ser mejor, ¿no? Lamento informarte de que este error multimillonario también está en Python. Se nos aparece con otro nombre: None
. Te mostraré una forma de evitar el costoso error de None
, pero antes, hablemos de por qué None
es tan malo.
Nota
Es especialmente esclarecedor que Hoare admita que las referencias nulas nacieron por conveniencia. Esto te demuestra que tomar el camino más rápido puede acarrearte todo tipo de dolores más adelante en tu ciclo de vida de desarrollo. Piensa en cómo tus decisiones a corto plazo de hoy afectarán negativamente a tu mantenimiento de mañana.
Consideremos un código que ejecuta un puesto automatizado de perritos calientes. Quiero que mi sistema coja un bollo, ponga una salchicha en el bollo, y luego eche un chorro de ketchup y mostaza a través de dispensadores automatizados, como se describe en la Figura 4-1. ¿Qué podría salir mal?
def
create_hot_dog
():
bun
=
dispense_bun
()
frank
=
dispense_frank
()
hot_dog
=
bun
.
add_frank
(
frank
)
ketchup
=
dispense_ketchup
()
mustard
=
dispense_mustard
()
hot_dog
.
add_condiments
(
ketchup
,
mustard
)
dispense_hot_dog_to_customer
(
hot_dog
)
Bastante sencillo, ¿no? Por desgracia, no hay forma de saberlo realmente. Es fácil pensar en el camino feliz, o en el flujo de control del programa cuando todo va bien, pero cuando se habla de código robusto, hay que tener en cuenta las condiciones de error. Si se tratara de un soporte automatizado sin intervención manual, ¿qué errores se te ocurren?
Aquí tienes una lista no exhaustiva de los errores que se me ocurren:
-
Sin ingredientes (bollos, salchichas o ketchup/mostaza).
-
Pedido cancelado a mitad de proceso.
-
Los condimentos se atascan.
-
Se interrumpe la alimentación.
-
El cliente no quiere ketchup ni mostaza e intenta mover el bollo a mitad del proceso.
-
Un vendedor rival cambia el ketchup por catsup; se desata el caos.
Ahora bien, tu sistema es de última generación y detectará todas estas condiciones, pero lo hace devolviendo None
cuando falla alguno de los pasos. ¿Qué significa esto para este código? Empiezas a ver errores como los siguientes
Traceback (most recent call last): File "<stdin>", line 4, in <module> AttributeError: 'NoneType' object has no attribute 'add_frank' Traceback (most recent call last): File "<stdin>", line 7, in <module> AttributeError: 'NoneType' object has no attribute 'add_condiments'
Sería catastrófico que estos errores llegaran a tus clientes; tú te enorgulleces de tener una interfaz de usuario limpia y no quieres feos retrocesos que ensucien tu interfaz. Para solucionar esto, empiezas a programar a la defensiva, es decir, a programar de tal forma que intentas prever todos los posibles casos de error y tenerlos en cuenta. La programación defensiva es algo bueno, pero conduce a un código como éste:
def
create_hot_dog
():
bun
=
dispense_bun
()
if
bun
is
None
:
print_error_code
(
"Bun unavailable. Check for bun"
)
return
frank
=
dispense_frank
()
if
frank
is
None
:
print_error_code
(
"Frank was not properly dispensed"
)
return
hot_dog
=
bun
.
add_frank
(
frank
)
if
hot_dog
is
None
:
print_error_code
(
"Hot Dog unavailable. Check for Hot Dog"
)
return
ketchup
=
dispense_ketchup
()
mustard
=
dispense_mustard
()
if
ketchup
is
None
or
mustard
is
None
:
print_error_code
(
"Check for invalid catsup"
)
return
hot_dog
.
add_condiments
(
ketchup
,
mustard
)
dispense_hot_dog_to_customer
(
hot_dog
)
Esto parece, bueno, tedioso. Dado que cualquier valor puede ser None
en Python, parece como si tuvieras que ponerte a programar a la defensiva y hacer una comprobación is None
antes de cada desreferencia. Esto es una exageración; la mayoría de los desarrolladores rastrearán la pila de llamadas y se asegurarán de que no se devuelve ningún valor None
a la persona que llama. Eso deja las llamadas a sistemas externos y quizá unas pocas llamadas en tu código base que siempre tienes que envolver con la comprobación None
. Esto es propenso a errores; no puedes esperar que todos los desarrolladores que toquen tu código sepan instintivamente dónde comprobar None
. Además, las suposiciones originales que has hecho al escribir (por ejemplo, esta función nunca devolverá None
) pueden romperse en el futuro, y ahora tu código tiene un error. Y aquí radica tu problema: contar con la intervención manual para detectar casos de error no es fiable.
La razón de que esto sea tan complicado (y tan costoso) es que None
se trata como un caso especial. Existe fuera de la jerarquía de tipos normal. Toda variable puede asignarse a None
. Para combatir esto, necesitas encontrar una forma de representar None
dentro de tu jerarquía de tipos. Necesitas tipos Optional
.
Optional
te ofrecen dos opciones: o tienes un valor o no lo tienes. En otras palabras, es opcional asignar un valor a la variable.
from
typing
import
Optional
maybe_a_string
:
Optional
[
str
]
=
"abcdef"
# This has a value
maybe_a_string
:
Optional
[
str
]
=
None
# This is the absence of a value
Este código indica que la variable maybe_a_string
puede contener opcionalmente una cadena. Ese código comprueba perfectamente si maybe_a_string
contiene "abcdef"
o None
.
A primera vista, no es evidente lo que esto te aporta. Sigues necesitando utilizar None
para representar la ausencia de un valor. Sin embargo, tengo buenas noticias para ti. Hay tres ventajas que asocio a los tipos Optional
.
En primer lugar, comunicas tu intención más claramente. Si un desarrollador ve un tipo Optional
en una firma de tipo, lo ve como una gran señal de alarma de que debe esperar que None
sea una posibilidad.
def
dispense_bun
()
->
Optional
[
Bun
]:
# ...
Si observas que una función devuelve un valor Optional
, presta atención y comprueba si hay valores None
.
En segundo lugar, puedes distinguir mejor la ausencia de valor de un valor vacío. Considera la lista inocua. ¿Qué ocurre si realizas una llamada a una función y recibes una lista vacía? ¿Es que no se te ha devuelto ningún resultado? ¿O es que se ha producido un error y tienes que tomar medidas explícitas? Si recibes una lista sin procesar, no lo sabrás sin rebuscar en el código fuente. Sin embargo, si utilizas un Optional
, estás transmitiendo una de estas tres posibilidades:
- Una lista con elementos
-
Datos válidos para operar
- Una lista sin elementos
-
No se produjo ningún error, pero no había datos disponibles (siempre que la ausencia de datos no sea una condición de error)
None
-
Se ha producido un error que debes solucionar
Por último, los verificadores de tipos pueden detectar los tipos de Optional
y asegurarse de que no se te escapen valores de None
.
Considéralo:
def
dispense_bun
()
->
Bun
:
return
Bun
(
'Wheat'
)
Añadamos algunos casos de error a este código:
def
dispense_bun
()
->
Bun
:
if
not
are_buns_available
():
return
None
return
Bun
(
'Wheat'
)
Cuando se ejecuta con un corrector tipográfico, se obtiene el siguiente error:
code_examples/chapter4/invalid/dispense_bun.py:12: error: Incompatible return value type (got "None", expected "Bun")
¡Excelente! El comprobador de tipos no te permitirá devolver un valor None
por defecto. Cambiando el tipo de retorno de Bun
a Optional[Bun]
, el código se comprobará correctamente. Esto dará pistas a los desarrolladores de que no deben devolver None
sin codificar la información en el tipo de retorno. Puedes detectar un error común y hacer que este código sea más robusto. Pero, ¿qué pasa con el código de llamada?
Resulta que el código de llamada también se beneficia de ello. Considéralo:
def
create_hot_dog
():
bun
=
dispense_bun
()
frank
=
dispense_frank
()
hot_dog
=
bun
.
add_frank
(
frank
)
ketchup
=
dispense_ketchup
()
mustard
=
dispense_mustard
()
hot_dog
.
add_condiments
(
ketchup
,
mustard
)
dispense_hot_dog_to_customer
(
hot_dog
)
Si dispense_bun
devuelve un Optional
, este código no realizará la comprobación tipográfica. Se quejará con el siguiente error:
code_examples/chapter4/invalid/hotdog_invalid.py:27: error: Item "None" of "Optional[Bun]" has no attribute "add_frank"
Advertencia
Dependiendo de tu corrector tipográfico, puede que necesites activar específicamente una opción para detectar este tipo de errores. Consulta siempre la documentación de tu corrector tipográfico para saber qué opciones están disponibles. Si hay un error que quieres detectar, deberías comprobar que tu corrector tipográfico lo detecta. Te recomiendo encarecidamente que compruebes específicamente el comportamiento de Optional
. En la versión de mypy
que estoy ejecutando (0.800), tengo que utilizar --strict-optional
como indicador de la línea de comandos para detectar este error.
Si te interesa silenciar el verificador de tipos, tienes que comprobar None
explícitamente y manejar el valor None
, o afirmar que el valor no puede ser None
. El siguiente código comprueba correctamente la tipología:
def
create_hot_dog
():
bun
=
dispense_bun
()
if
bun
is
None
:
print_error_code
(
"Bun could not be dispensed"
)
return
frank
=
dispense_frank
()
hot_dog
=
bun
.
add_frank
(
frank
)
ketchup
=
dispense_ketchup
()
mustard
=
dispense_mustard
()
hot_dog
.
add_condiments
(
ketchup
,
mustard
)
dispense_hot_dog_to_customer
(
hot_dog
)
None
Los valores son realmente un error multimillonario. Si se cuelan, los programas pueden fallar, los usuarios se frustran y se pierde dinero. Utiliza los tipos Optional
para indicar a otros desarrolladores que tengan cuidado con None
, y benefíciate de la comprobación automatizada de tus herramientas.
Tema de debate
¿Con qué frecuencia tratas con None
en tu código base? ¿Hasta qué punto estás seguro de que todos los valores posibles de None
se gestionan correctamente? Revisa los errores y las pruebas fallidas para ver cuántas veces has sido mordido por un manejo incorrecto de None
. Discute cómo los tipos Optional
ayudarán a tu código.
Tipos de sindicatos
Un tipo Union
es un tipo que indica que se pueden utilizar varios tipos dispares con la misma variable. Un Union[int,str]
significa que se puede utilizar un int
o un str
para una variable. Por ejemplo, considera el siguiente código:
def
dispense_snack
()
->
HotDog
:
if
not
are_ingredients_available
():
raise
RuntimeError
(
"Not all ingredients available"
)
if
order_interrupted
():
raise
RuntimeError
(
"Order interrupted"
)
return
create_hot_dog
()
Ahora quiero que mi puesto de perritos calientes entre en el lucrativo negocio de los pretzels. En lugar de intentar lidiar con una extraña herencia de clases (trataremos más sobre la herencia en la Parte II) que no corresponde entre perritos calientes y pretzels, simplemente puedes devolver un Union
de los dos.
from
typing
import
Union
def
dispense_snack
(
user_input
:
str
)
->
Union
[
HotDog
,
Pretzel
]:
if
user_input
==
"Hot Dog"
:
return
dispense_hot_dog
()
elif
user_input
==
"Pretzel"
:
return
dispense_pretzel
()
raise
RuntimeError
(
"Should never reach this code,"
"as an invalid input has been entered"
)
Nota
Optional
no es más que una versión especializada de Union
. Optional[int]
es exactamente lo mismo que Union[int, None]
.
Utilizar un Union
ofrece prácticamente las mismas ventajas que un Optional
. En primer lugar, obtienes las mismas ventajas de comunicación. Un desarrollador que se encuentre con un Union
sabe que debe ser capaz de manejar más de un tipo en su código de llamada. Además, un verificador de tipos es tan consciente de Union
como de Optional
.
Unions
te resultará útil en diversas aplicaciones:
-
Manejar tipos dispares devueltos en función de la entrada del usuario (como arriba)
-
Maneja los tipos de retorno de error a la
Optional
s, pero con más información, como una cadena o un código de error -
Manejar entradas de usuario diferentes (por ejemplo, si un usuario puede proporcionar una lista o una cadena)
-
Devolver diferentes tipos, por ejemplo por compatibilidad con versiones anteriores (devolver una versión antigua de un objeto o una versión nueva de un objeto en función de la operación solicitada)
-
Y cualquier otro caso en el que puedas tener legítimamente más de un valor representado
Supón que tienes un código que llama a la función dispense_snack
pero sólo espera que se devuelva un HotDog
(o None
):
from
typing
import
Optional
def
place_order
()
->
Optional
[
HotDog
]:
order
=
get_order
()
result
=
dispense_snack
(
order
.
name
)
if
result
is
None
print_error_code
(
"An error occurred"
+
result
)
return
None
# Return our HotDog
return
result
En cuanto dispense_snack
empieza a devolver Pretzels
, este código falla en la comprobación tipográfica.
code_examples/chapter4/invalid/union_hotdog.py:22: error: Incompatible return value type (got "Union[HotDog, Pretzel]", expected "Optional[HotDog]")
El hecho de que el comprobador de tipos dé error en este caso es fantástico. Si alguna función de la que dependes cambia para devolver un nuevo tipo, su firma de retorno debe actualizarse para Union
un nuevo tipo, lo que te obliga a actualizar tu código para manejar el nuevo tipo. Esto significa que tu código se marcará cuando tus dependencias cambien de un modo que contradiga tus suposiciones. Con las decisiones que tomes hoy, podrás detectar errores en el futuro. Esta es la marca del código robusto: estás haciendo cada vez más difícil que los desarrolladores cometan errores, lo que reduce sus tasas de error, lo que reduce el número de fallos que experimentarán los usuarios.
Hay otra ventaja fundamental de utilizar un Union
, pero para explicarla, tengo que enseñarte una pizca de teoría de tipos, que es una rama de las matemáticas en torno a los sistemas de tipos.
Tipos de producto y suma
Unions
son beneficiosas porque ayudan a restringir el espacio de estados representable. El espacio de estados representable es el conjunto de todas las combinaciones posibles que puede adoptar un objeto.
Toma este dataclass
:
from
dataclasses
import
dataclass
# If you aren't familiar with data classes, you'll learn more in chapter 10
# but for now, treat this as four fields grouped together and what types they are
@dataclass
class
Snack
:
name
:
str
condiments
:
set
[
str
]
error_code
:
int
disposed_of
:
bool
Snack
(
"Hotdog"
,
{
"Mustard"
,
"Ketchup"
},
5
,
False
)
Tengo un nombre, los condimentos que pueden ir encima, un código de error por si algo sale mal y, si algo sale mal, un booleano para saber si he eliminado el artículo correctamente o no. ¿Cuántas combinaciones diferentes de valores se pueden introducir en este diccionario? Un número potencialmente infinito, ¿verdad? Sólo en name
podría haber desde valores válidos ("perrito caliente" o "pretzel") a valores no válidos ("samosa", "kimchi" o "poutine") o absurdos ("12345", "" o "(╯°□°)╯︵ ┻━┻"). condiments
tiene un problema similar. Tal y como está, no hay forma de calcular las opciones posibles.
En aras de la simplicidad, limitaré artificialmente este tipo:
-
El nombre puede ser uno de estos tres valores: perrito caliente, pretzel o hamburguesa vegetariana
-
Los condimentos pueden estar vacíos, ser mostaza, ketchup o ambos.
-
Hay seis códigos de error (0-5); 0 indica éxito).
-
disposed_of
es sóloTrue
oFalse
.
Ahora bien, ¿cuántos valores diferentes pueden representarse en esta combinación de campos? La respuesta es 144, que es un número muy grande. Lo consigo de la siguiente manera
3 tipos posibles para el nombre × 4 tipos posibles para los condimentos × 6 códigos de error × 2 valores booleanos para saber si se ha eliminado la entrada = 3×4×6×2 = 144.
Si aceptaras que cualquiera de estos valores pudiera ser None
, el total se dispararía a 420. Aunque siempre debes pensar en None
mientras codificas (véase anteriormente en este capítulo sobre Optional
), para este ejercicio de reflexión, voy a ignorar None values
.
Este tipo de operación se conoce como tipo producto; el número de estados representables viene determinado por el producto de los valores posibles. El problema es que no todos estos estados son válidos. La variable disposed_of
sólo debe ponerse a True
si un código de error es distinto de cero. Los desarrolladores harán esta suposición, y confiarán en que el estado ilegal nunca aparezca. Sin embargo, un error inocente puede hacer que todo tu sistema se venga abajo. Considera el siguiente código:
def
serve
(
snack
):
# if something went wrong, return early
if
snack
.
disposed_of
:
return
# ...
En este caso, un programador está comprobando disposed_of
sin comprobar primero el código de error distinto de cero. Esto es una bomba lógica a punto de ocurrir. Este código funcionará completamente bien siempre que disposed_of
sea True
y el código de error sea distinto de cero. Si un bocadillo válido establece erróneamente la bandera disposed_of
en True
, este código empezará a producir resultados no válidos. Esto puede ser difícil de encontrar, ya que no hay ninguna razón para que un desarrollador que esté creando el bocadillo compruebe este código. Tal y como están las cosas, no tienes otra forma de detectar este tipo de error que inspeccionando manualmente cada caso de uso, lo cual es intratable para grandes bases de código. Al permitir que un estado ilegal sea representable, abres la puerta a un código frágil.
Para remediarlo, necesito hacer que este estado ilegal sea irrepresentable. Para ello, reelaboraré mi ejemplo y utilizaré un Union
:
from
dataclasses
import
dataclass
from
typing
import
Union
@dataclass
class
Error
:
error_code
:
int
disposed_of
:
bool
@dataclass
class
Snack
:
name
:
str
condiments
:
set
[
str
]
snack
:
Union
[
Snack
,
Error
]
=
Snack
(
"Hotdog"
,
{
"Mustard"
,
"Ketchup"
})
snack
=
Error
(
5
,
True
)
En este caso, snack
puede ser un Snack
(que es sólo un name
y un condiments
) o un Error
(que es sólo un número y un booleano). Con el uso de un Union
, ¿cuántos estados representables hay ahora?
Para Snack
, hay 3 nombres y 4 posibles valores de lista, lo que supone un total de 12 estados representables. Para ErrorCode
, puedo eliminar el código de error 0 (ya que sólo era para el éxito), lo que me da 5 valores para el código de error y 2 valores para el booleano, para un total de 10 estados representables. Como Union
es una construcción de tipo "o lo uno o lo otro", puedo tener 12 estados representables en un caso o 10 en el otro, lo que da un total de 22. Este es un ejemplo de tipo suma, ya que estoy sumando el número de estados representables en lugar de multiplicarlos.
Eso son 22 estados representables en total. Compáralo con los 144 estados que había cuando todos los campos estaban agrupados en una sola entidad. He reducido mi espacio de estados representables en casi un 85%. He hecho imposible mezclar y combinar campos que son incompatibles entre sí. Es mucho más difícil equivocarse, y hay muchas menos combinaciones que probar. Cada vez que utilizas un tipo de suma, como Union
, estás reduciendo drásticamente el número de posibles estados representables.
Tipos literales
Al calcular el número de estados representables, hice algunas suposiciones en la última sección. Limité el número de valores posibles, pero eso es hacer un poco de trampa, ¿no? Como he dicho antes, hay casi un número infinito de valores posibles. Afortunadamente, existe una forma de limitar los valores mediante Python: Literals
. Los tipos Literal
te permiten restringir la variable a un conjunto muy concreto de valores.
Cambiaré mi clase anterior Snack
para emplear los valores de Literal
:
from
typing
import
Literal
@dataclass
class
Error
:
error_code
:
Literal
[
1
,
2
,
3
,
4
,
5
]
disposed_of
:
bool
@dataclass
class
Snack
:
name
:
Literal
[
"Pretzel"
,
"Hot Dog"
,
"Veggie Burger"
]
condiments
:
set
[
Literal
[
"Mustard"
,
"Ketchup"
]]
Ahora bien, si intento instanciar estas clases de datos con valores erróneos:
Error
(
0
,
False
)
Snack
(
"Invalid"
,
set
())
Snack
(
"Pretzel"
,
{
"Mustard"
,
"Relish"
})
Recibo los siguientes errores del corrector tipográfico:
code_examples/chapter4/invalid/literals.py:14: error: Argument 1 to "Error" has incompatible type "Literal[0]"; expected "Union[Literal[1], Literal[2], Literal[3], Literal[4], Literal[5]]" code_examples/chapter4/invalid/literals.py:15: error: Argument 1 to "Snack" has incompatible type "Literal['Invalid']"; expected "Union[Literal['Pretzel'], Literal['Hotdog'], Literal['Veggie Burger']]" code_examples/chapter4/invalid/literals.py:16: error: Argument 2 to <set> has incompatible type "Literal['Relish']"; expected "Union[Literal['Mustard'], Literal['Ketchup']]"
Literals
se introdujeron en Python 3.8, y son una forma inestimable de restringir los posibles valores de una variable. Son un poco más ligeras que las enumeraciones de Python (que trataré en el Capítulo 8).
Tipos anotados
¿Y si quisiera profundizar aún más y especificar restricciones más complejas? Sería tedioso escribir cientos de literales, y algunas restricciones no se pueden modelar con los tipos Literal
. Con Literal
no hay forma de limitar una cadena a un tamaño determinado o de que coincida con una expresión regular específica. Aquí es donde entra en juego Annotated
. Con Annotated
, puedes especificar metadatos arbitrarios junto a tu anotación de tipo.
x
:
Annotated
[
int
,
ValueRange
(
3
,
5
)]
y
:
Annotated
[
str
,
MatchesRegex
(
'[0-9]
{4}
'
)]
Por desgracia, el código anterior no se ejecutará, ya que ValueRange
y MatchesRegex
no son tipos incorporados; son expresiones arbitrarias. Tendrás que escribir tus propios metadatos como parte de una variable Annotated
. En segundo lugar, no existen herramientas que comprueben los tipos por ti. Lo mejor que puedes hacer hasta que exista tal herramienta es escribir anotaciones ficticias o utilizar cadenas para describir tus restricciones. En este punto, Annotated
sirve mejor como método de comunicación.
NuevoTipo
Mientras esperas a que las herramientas admitan Annotated
, hay otra forma de representar restricciones más complicadas: NewType
. NewType
te permite, bueno, crear un nuevo tipo.
Supón que quiero separar el código de mi puesto de perritos calientes para manejar dos casos distintos: un perrito caliente en su forma no servible (sin plato, sin servilletas) y un perrito caliente listo para servir (emplatado, con servilletas). En mi código, existen algunas funciones que sólo deberían operar sobre el perrito caliente en uno u otro caso. Por ejemplo, un perrito caliente no servible nunca debe dispensarse al cliente.
class
HotDog
:
# ... snip hot dog class implementation ...
def
dispense_to_customer
(
hot_dog
:
HotDog
):
# note, this should only accept ready-to-serve hot dogs.
# ...
Sin embargo, nada impide que alguien pase un perrito caliente no servible. Si un programador comete un error y pasa un perrito caliente no servible a esta función, los clientes se llevarán una gran sorpresa al ver que sólo sale de la máquina su pedido, sin plato ni servilletas.
En lugar de confiar en que los desarrolladores detecten estos errores siempre que se produzcan, necesitas una forma de que tu corrector tipográfico los detecte. Para ello, puedes utilizar NewType
:
from
typing
import
NewType
class
HotDog
:
''' Used to represent an unservable hot dog'''
# ... snip hot dog class implementation ...
ReadyToServeHotDog
=
NewType
(
"ReadyToServeHotDog"
,
HotDog
)
def
dispense_to_customer
(
hot_dog
:
ReadyToServeHotDog
):
# ...
Un NewType
toma un tipo existente y crea un tipo totalmente nuevo que tiene todos los mismos campos y métodos que el tipo existente. En este caso, estoy creando un tipo ReadyToServeHotDog
que es distinto de HotDog
; no son intercambiables. Lo bonito de esto es que este tipo restringe las conversiones de tipo implícitas. No puedes utilizar un HotDog
en ningún lugar donde se espere un ReadyToServeHotDog
(aunque puedes utilizar un ReadyToServeHotDog
en lugar de HotDog
). En el ejemplo anterior, estoy restringiendo dispense_to_customer
para que sólo acepte valores ReadyToServeHotDog
como argumento. Esto evita que los desarrolladores invaliden las suposiciones. Si un desarrollador pasara un HotDog
a este método, el verificador de tipos le gritaría:
code_examples/chapter4/invalid/newtype.py:10: error: Argument 1 to "dispense_to_customer" has incompatible type "HotDog"; expected "ReadyToServeHotDog"
Es importante destacar la naturaleza unidireccional de esta conversión de tipos. Como desarrollador, puedes controlar cuándo tu tipo antiguo se convierte en tu tipo nuevo.
Por ejemplo, crearé una función que tome un HotDog
no servible y lo prepare para servir:
def
prepare_for_serving
(
hot_dog
:
HotDog
)
->
ReadyToServeHotDog
:
assert
not
hot_dog
.
is_plated
(),
"Hot dog should not already be plated"
hot_dog
.
put_on_plate
()
hot_dog
.
add_napkins
()
return
ReadyToServeHotDog
(
hot_dog
)
Fíjate en que devuelvo explícitamente un ReadyToServeHotDog
en lugar de un HotDog
normal. Esto actúa como una función "bendecida"; es la única forma autorizada en la que quiero que los desarrolladores creen un ReadyToServeHotDog
. Cualquier usuario que intente utilizar un método que tome un ReadyToServeHotDog
necesita crearlo primero utilizando prepare_for_serving
.
Es importante notificar a los usuarios que la única forma de crear tu nuevo tipo es mediante un conjunto de funciones "benditas". No querrás que los usuarios creen tu nuevo tipo en ninguna circunstancia que no sea un método predeterminado, ya que eso frustra el propósito.
def
make_snack
():
serve_to_customer
(
ReadyToServeHotDog
(
HotDog
()))
Desgraciadamente, Python no tiene una buena forma de decírselo a los usuarios, aparte de un comentario.
from
typing
import
NewType
# NOTE: Only create ReadyToServeHotDog using prepare_for_serving method.
ReadyToServeHotDog
=
NewType
(
"ReadyToServeHotDog"
,
HotDog
)
Aun así, NewType
es aplicable a muchos escenarios del mundo real. Por ejemplo, todas estas son situaciones con las que me he encontrado y que un NewType
resolvería:
-
Separando un
str
de unSanitizedString
, para detectar fallos como vulnerabilidades de inyección SQL. Al hacer deSanitizedString
unNewType
, me aseguré de que sólo se operaba con cadenas debidamente saneadas, eliminando la posibilidad de inyección SQL. -
Rastreando un objeto
User
yLoggedInUser
por separado. Al restringirUsers
conNewType
desdeLoggedInUser
, escribí funciones que sólo eran aplicables a los usuarios que estaban conectados. -
Rastreando un número entero que debería representar un ID de Usuario válido. Restringiendo el ID de usuario a un
NewType
, podía asegurarme de que algunas funciones sólo operaban con ID válidos, sin tener que comprobar las sentenciasif
.
En el Capítulo 10, verás cómo puedes utilizar clases e invariantes para hacer algo muy parecido, con una garantía mucho mayor de evitar estados ilegales. Sin embargo, NewType
sigue siendo un patrón útil que debes conocer, y es mucho más ligero que una clase completa.
Tipos finales
Por último, puede que quieras impedir que un tipo cambie su valor. Ahí es donde entra Final
. Final
introducido en Python 3.8, indica a un verificador de tipos que una variable no puede vincularse a otro valor. Por ejemplo, quiero empezar a franquiciar mi puesto de perritos calientes, pero no quiero que se cambie el nombre por accidente.
VENDOR_NAME
:
Final
=
"Viafore's Auto-Dog"
Si un desarrollador cambiara accidentalmente el nombre más adelante, vería un error.
def
display_vendor_information
():
vendor_info
=
"Auto-Dog v1.0"
# whoops, copy-paste error, this code should be vendor_info += VENDOR_NAME
VENDOR_NAME
+=
VENDOR_NAME
(
vendor_info
)
code_examples/chapter4/invalid/final.py:3: error: Cannot assign to final name "VENDOR_NAME" Found 1 error in 1 file (checked 1 source file)
En general, Final
se utiliza mejor cuando el ámbito de la variable abarca una gran cantidad de código, como un módulo. A los desarrolladores les resulta difícil hacer un seguimiento de todos los usos de una variable en ámbitos tan amplios; dejar que el verificador de tipos capte las garantías de inmutabilidad es una gran ayuda en estos casos.
Advertencia
Final
no dará error al mutar un objeto a través de una función. Sólo impide que la variable se rebote (se establezca un nuevo valor).
Reflexiones finales
En este capítulo has aprendido muchas formas diferentes de restringir tus tipos. Todas ellas sirven a un propósito específico, desde manejar None
con Optional
hasta restringir a valores específicos con Literal
o impedir que una variable se rebote con Final
. Utilizando estas técnicas, podrás codificar suposiciones y restricciones directamente en tu código, evitando que futuros lectores tengan que adivinar tu lógica. Los verificadores de tipos utilizarán estas anotaciones de tipos avanzadas para ofrecerte garantías más estrictas sobre tu código, lo que dará confianza a los mantenedores cuando trabajen en tu base de código. Con esta confianza, cometerán menos errores, y tu código será más robusto gracias a ello.
En el próximo capítulo, dejarás de anotar tipos de valores individuales y aprenderás a anotar correctamente tipos de colecciones. Los tipos colección impregnan la mayor parte de Python; debes tener cuidado de expresar también tus intenciones respecto a ellos. Debes conocer bien todas las formas de representar una colección, incluso en los casos en que debas crear una propia.
1 C.A.R. Hoare. "Referencias nulas: El error del billón de dólares". Históricamente Malas Ideas. Presentado en Qcon Londres 2009, s.f.
Get Python robusto now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.