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?

Worfklow for the automated hotdog stand
Figura 4-1. Flujo de trabajo del puesto automatizado de perritos calientes
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 Optionals, 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ólo True o False.

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 un SanitizedString, para detectar fallos como vulnerabilidades de inyección SQL. Al hacer de SanitizedString un NewType, me aseguré de que sólo se operaba con cadenas debidamente saneadas, eliminando la posibilidad de inyección SQL.

  • Rastreando un objeto User y LoggedInUser por separado. Al restringir Users con NewType desde LoggedInUser, 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 sentencias if.

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
    print(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.