AttributeError: ‘Post’ object has no attribute ‘title’
Este artículo es una adaptación y actualización de esta charla que dí en la PyConAr 2018 mientras algunos hinchas de River le daban una cálida bienvenida al micro de Boca.
Si alguna vez escribiste algo en Python, seguro te cruzaste con algún
AttributeError: 'Perro' object has no attribute 'maullar'
El mensaje puede parecer un poco confuso, pero refleja un problema típico de los lenguajes dinámicamente tipados: el intérprete no sabe de antemano el tipo de los objetos que usamos, con lo cual podríamos mandarles mensajes que no saben responder (en este caso perro.maullar()
). En este artículo vamos a hablar sobre cómo podemos anotar nuestro código para que la IDE nos ayude a prevenir este tipo de problemas (y otras cosas incluso mejores).
El Counter de los jueves
Para divertirnos y socializar sin romper el aislamiento, en Eryx estamos jugando bastante al Counter-Strike (al 1.6, bien retro) al terminar la jornada laboral. Supongamos que queremos escribir un programa que nos permita recolectar datos de los partidos para armar una tabla de posiciones (spoiler: schouhy nos pasa el trapo). Y que une programadore amigue nos pasa un código para eso:
Simple, ¿no? Una clase para los partidos, otra para los kills y un par de métodos para calcular el puntaje como lo calculaba el Counter original.
Más despacio cerebrito. ¿Cómo lo uso?
Como bien nota nuestre queride lectore imaginarie, si queremos usar el código sin mirar nada más nos va a faltar información. ¿De qué tipo son los players? Podrían ser un str
, un int
, un objeto más complejo. A decir verdad, ni siquiera tenemos seguridad de que killer
y victim
sean del mismo tipo. Y la IDE tampoco lo sabe: si escribimos player.
el autocomplete va a dar sugerencias genéricas que no van a servir de mucho.
Bueno, ya fue, ¿no? Que sean del tipo que nosotros queramos.
Podríamos elegir cualquiera, es verdad. Pero si este código es parte de algo mayor, nos estamos arriesgando a romper el resto del programa.
La mejor manera que tenemos para poder responder esta pregunta es ir a ver el código que usa estas clases, idealmente los tests. Por suerte, como todes hacemos TDD (¿no?), nos pasaron también un test:
¡Genial! Ahora sabemos que los players son representados por strings. Aunque tuvimos que ir a mirar otro código para enterarnos. Y la IDE, aunque quizás podría usar esta información, no parece estar haciéndolo muy bien.
Extendiendo el modelo
Supongamos que ahora queremos saber también la IP desde donde están entrando los jugadores. Ya no nos alcanza con usar strings. Vamos a agregarle a nuestro modelo una abstracción para les jugadores:
class Player:
def __init__(self, nick, ip):
self.nick = nick
self.ip = ip
Y, como no queremos que el puntaje dependa de la IP, empezar a indexar el puntaje solo por el nick:
for kill in self.events:
score[kill.killer.nick] += 1
score[kill.victim.nick] -= 1
return score
Por suerte tenemos tests que nos avisan que este cambio probablemente rompa el código que usa a estos objetos:
Error
Traceback (most recent call last):
(...)
File "/home/laski/eryx/medium/counter.py", line 28, in score_table
score[kill.killer.nick] += 1
AttributeError: 'str' object has no attribute 'nick'
porque, claro, un str
ing no sabe responder al mensaje nick
.
¿Podría la IDE haberme avisado incluso antes de que yo ejecute los tests? Aún mejor, ¿podría haberme avisado qué otras partes tendría que cambiar para que todo funcione?
Anotando por la vida
Uno de los cambios que introdujo Python 3 fue una sintaxis para “anotar”, en la declaración de un método, los tipos de los parámetros que el método espera y el tipo del objeto que va a devolver. Veamos cómo:
Vemos varias cosas nuevas. De a una:
- Los tipos básicos (
int
,str
) se llaman igual que sus constructores, pero los tipos compuestos (list
,dict
) hace falta escribirlos con mayúscula e importarlos detyping
. - Los tipos internos de un tipo compuesto (los elementos de una lista, las claves y valores de un diccionario, etc.) van entre
[corchetes]
. - El tipo de un parámetro se anota con
:
. El tipo de retorno de un método, con una simpática flechita->
. - Si un método no devuelve nada (o sea, devuelve
None
), se anota-> None
. - Los métodos
__init__
nunca devuelven nada, con lo cual no hace falta explicitarlo (aunque tampoco está prohibido). - La mayoría de las veces no hace falta anotar explícitamente el tipo de las variables, porque se puede inferir a partir de lo que hay a la derecha del
=
. Sin embargo, hay casos donde eso no es suficiente, por ejemplo cuando creamos una colección vacía. Para estos casos, a partir de Python3.6 podemos anotar la variable también, por ejemplo enself.events: List[KillEvent] = []
. - En líneas generales se siguen respetando las reglas de espaciado de PEP8, aunque vale la pena volver a leerlo porque hay un par de casos interesantes.
Lo bueno y lo no tan bueno
Gracias a esos cambios, mi IDE ya empieza a ver algunos errores:
Ahora que tiene más información, el autocomplete ofrece mejores opciones:
Además, el código se hace más autoexplicativo: podemos ver rápidamente el tipo de los objetos sin ponernos a buscar en dónde se definieron por primera vez (lo cual podría ser mucho más arriba en el stack de llamados).
Y quizás lo más importante de todo: tanto las herramientas de find usages/go to definition (el famoso ctrl+clic) como las de refactor/renaming automático hacen mucho mejor su trabajo cuando pueden distinguir el tipo de los objetos con los que trabajan.
Todo muy lindo, pero ¿dónde está la trampa? Tiene que haber alguna.
Para empezar, escribir y mantener las anotaciones toma trabajo. Más abajo hablaremos un poco sobre cómo minimizarlo.
También hay quienes opinan que el código se vuelve más difícil de leer. En mi experiencia uno se acostumbra rápido y, como termina ayudando más veces que las que no, lo terminás agradeciendo. Pero no me sorprendería ver en el futuro una herramienta que permita “ocultar” las anotaciones de tipos.
Y, hablando de eso, en runtime las anotaciones se ignoran, por lo que si te estás preguntando por la performance, el impacto es prácticamente nulo.
Chequeo de tipos
Uno de los motivos más populares de agregar anotaciones de tipos a un lenguaje es mejorar y facilitar el uso de herramientas de chequeo de tipos. A uno le gustaría creer que con una buena cobertura de tests esto ya debería estar bastante bien cubierto, pero eso podría ser falso. Además, validar las anotaciones es la única manera de garantizar que no queden desactualizadas (recordemos que el intérprete las ignora).
En particular para Python existe una herramienta llamada mypy que analiza el código estáticamente (es decir sin ejecutarlo) en busca de errores de tipos. En el código que pegué arriba, por ejemplo, mypy descubrió algo que ni PyCharm ni yo detectamos:
$ mypy counter.py
counter.py:35: error: Invalid index type "Player" for "Dict[str, int]"; expected type "str"
Found 1 error in 1 file (checked 1 source file)
Cuando cambiamos los jugadores de str
a Player
nos olvidamos de modificar el método score_for
.
def score_for(self, player: Player) -> int:
score_table = self.score_table()
return score_table[player] # debería decir player.nick
Algo bueno de mypy es que “entiende” que la mayoría de los mortales nunca va a tener absolutamente todo el código anotado, ya sea porque importamos bibliotecas ajenas, porque es un proceso largo o simplemente porque no nos interesa. mypy valida solamente el código que está anotado e ignora el que no. Eso nos permite ir anotando progresivamente, y agregar desde el principio mypy a nuestro pipeline de CI/CD (al lado de nuestra suite de tests) para que nos alerte de los posibles errores de tipos antes de un deploy.
Automaticemos todo
Seamos sinceros: anotar los tipos de un código que ya está andando parece una tarea mecánica y aburrida. ¿Y qué nos gusta hacer a les programadores con las tareas mecánicas y aburridas? Adivinaste (o leíste el subtítulo de arriba).
Nobleza obliga, no somos muy originales: este paper de 1978 (¡hace más de 40 años!) ya hablaba sobre cómo hacer inferencia de tipos en lenguajes dinámicamente tipados.
Volviendo a Python, tanto Dropbox como Instagram hicieron bibliotecas para anotar automáticamente su código: pyannotate por un lado y MonkeyType por otro. La idea es la misma: ejecutar los tests recordando qué tipos toman las diferentes variables y luego anotarlas. Como dicen en el README de MonkeyType, esto puede dar resultados demasiado específicos, pero es un buen punto de partida que debería ahorrar bastante trabajo.
Resumen de los links que deberías guardarte
- Mi propia charla sobre el tema, donde también respondo algunas preguntas que por cuestiones de síntesis no incluyo acá.
- Esta charla de Hernán Wilkinson que ejemplifica muy bien las ventajas de que la IDE entienda los tipos de los objetos (y muestra cómo lo automatizaron en Smalltalk sin modificar la sintaxis).
- Esta charla (en inglés) que fundamenta un poco mejor por qué esto vale la pena y muestra algunos casos más avanzados de anotaciones en Python.
- Algunos PEPs relacionados: 3107, 484, 526, 612.
- La documentación de typing.
- La documentación de mypy.
- Plugins de mypy para PyCharm y para VS Code.
Y dos herramientas que todavía no probé pero que se ven prometedoras:
- Pyre (una herramienta de Facebook tipo mypy que agrega también análisis de seguridad).
- MonkeyType (pyannotate funciona pero luce medio abandonada).