Pues verás, estaba yo programando tranquilamente (sí, yo soy de esos que todavía programa a mano, sin preguntarle a ninguna IA), una pequeña, muy pequeña herramienta relacionada con mi anterior entrada, sobre la organización de marcadores web.
El progama utiliza la librería estándar de Python para interpretar HTML, de manera que el archivo de marcadores terminaría representado en memoria como un árbol cuya raiz es BookMarklist, y que dentro puede tener marcadores (Bookmark), u otras listas (BookmarkList). Así que, cuando se llega a un <dl>, debemos crear una nueva lista, y el comportamiento es distinto si estamos en una subrama, o si, en cambio, todavía no tenemos la lista raíz. Tal que así:
new_list = BookmarkList(self._current_name, self._curr)
if not self._bms:
self._bms = new_list
if not self._curr:
self._curr.add(new_list)
self._curr = new_list
El atributo self._bms es la lista de marcadores raíz, mientras que self._curr es la lista que está siendo rellenada con nuevos marcadores.
El problema era que el código hacía cosas raras. De hecho, ¡asignaba dos veces la lista raíz! ¿Cómo es posible? Llegué a depurar el código, y comprobé que, aunque self._bms apuntaba a un objeto, estaba entrando en el cuerpo del if, y reemplazándolo por una nueva lista de todas maneras.
Miraba la pantalla, incrédulo. ¿He encontrado un error en el intérprete de Python? Me contesté a mi mismo: "Mmm... posible, pero improbable, muchacho."
Como pasa siempre, aquel hack de cinco minutos ya iba por la hora... con lo que resolví irme al baño. Y estaba entrando por la puerta cuando lo vi claro: ¿y si Python piensa que self._bms es una lista? Y es que efectivamente, me había disparado yo mismo en el pie.
Cuando escribimos if x: ... o if not x: ..., normalmente, esperamos estar comparando x con None, el equivalente a null de Python. Esto está bien en general, pero podemos encontrarnos las siguientes conversiones a bool extras (también conocidas como expresiones truthy o falsy):
| Expresión | Valor booleano |
|---|---|
| "" | False |
| " " | True |
| "balta" | True |
| 0 | False |
| 1 | True |
| 42 | True |
| 0.0 | False |
| 1.0 | True |
| 42.0 | True |
| [] | False |
| [None] | True |
El problema es: ¿a partir de cuándo un objeto se considera una lista?
Veámoslo.
from dataclasses import dataclass
@dataclass
class Markdown:
name: str
url: str
class MarkdownList:
def __init__(self):
self._l = []
def add(self, m: {Markdown|MarkdownList}):
self._l.append(m)
def __getitem__(self, item):
return self._l[item]
def __str__(self):
return str.join(", ", [str(x) for x in self._l])
if __name__ == "__main__":
m0 = None
m1 = MarkdownList()
m2 = MarkdownList()
m2.add(Markdown("dev.to", "http://dev.to/"))
print(f"{bool(m0)=} {isinstance(m0, list)=}")
print(f"{bool(m1)=} {isinstance(m1, list)=}")
print(f"{bool(m2)=} {isinstance(m2, list)=}")
La salida de este programa es la que esperamos. Es decir, si la referencia apunta a un objeto, True, si apunta a None, False.
bool(m0)=False isinstance(m0, list)=False
bool(m1)=True isinstance(m1, list)=False
bool(m2)=True isinstance(m2, list)=False
No es la sobrecarga del operador [] (mediante __getitem__) lo que hace que Python entienda el objeto MarkdownList como una lista. Podemos crear el método __iter__ para que se pueda hacer directamente un bucle for con el objeto MarkdownList, por ejemplo, si m1 es una referencia a un objeto MarkdownList: for x in m1: print(x). Pues aunque lo incluyamos, Python no lo considerará una lista, y la salida será igual a la anterior. Lo que hace que Python considere una clase como una lista (veremos má detalles sobre esto más adelante), es el método __len__, lo que hace que podamos utilizar len(m1) directamente, devolviéndonos el número de elementos almacenados.
class MarkdownList:
def __init__(self):
self._l = []
def add(self, m: {Markdown|MarkdownList}):
self._l.append(m)
def __getitem__(self, item):
return self._l[item]
def __iter__(self):
return (x for x in self._l)
# el "culpable"
def __len__(self):
return len(self._l)
def __str__(self):
return str.join(", ", [str(x) for x in self._l])
Ahora la salida es:
bool(m0)=False isinstance(m0, list)=False
bool(m1)=False isinstance(m1, list)=False
bool(m2)=True isinstance(m2, list)=False
Nótese que Python cambia el significado de truthy/falsy, pero desde luego no lo considera un objeto derivado de list.
Investigando un poco (ahora sí, con la ayuda de la IA, que es fantástica para resumir documentación), resulta que Python maneja el concepto de protocolo; en este caso, el protocolo Sequence se activa si la clase contiene el método __len__. Aún así, la clase base de MarkdownList no cambia, sigue siendo Object.
Pero sí que hay una clase que de repente reconoce a MarkdownList como suya, y se trata de collections.abc.Sized. Así, si cambiamos los print del final del código anterior como sigue:
from collections.abc import Sized
# ...más cosas...
print(f"{bool(m0)=} {isinstance(m0, Sized)=}")
print(f"{bool(m1)=} {isinstance(m1, Sized)=}")
print(f"{bool(m2)=} {isinstance(m2, Sized)=}")
La salida pasa a ser:
bool(m0)=False isinstance(m0, Sized)=False
bool(m1)=False isinstance(m1, Sized)=True
bool(m2)=True isinstance(m2, Sized)=True
Aunque, insisto de nuevo, la clase base de MarkdownList no ha cambiado por el hecho de tener el método __len__ la clase Sized la reconoce como suya mediante un mecanismo que se basa en el método __subclasshook__. Este método comprueba la presencia de __len__ en la clase, y esto hace que pase a reconocerla como subclase, y que isinstance(m1, Sized) pase a devolver True.
Es desde luego muy curioso. Igual que el hecho de que no es necesario que una clase defina el método __iter__ para que sea iterable, es suficiente con el método __getitem__. Algo similar a lo que sucede con la clase collections.abc.Sized, sucede con la clase collections.abc.Iterable, que reconoce una clase como subclase si define el método __iter__. Según parece, esto último es el comportamiento deseado, pero en las versiones anteriores del lenguaje el bucle for x in l, en caso de que l no tuviese un método __iter__, pasaban a usar __getitem__ como último recurso, desde 0 hasta la captura de IndexError; y este comportamiento se mantuvo hasta hoy. Curioso, teniendo en cuenta la ruptura con el pasado que se hizo a partir de Python 3.0.
Ahora que sé esto, puedo seguir utilizando if a esperando False si a es None, pero teniendo el cuidado de respetar aquellas clases que definan __len__.
Afortunadamente, hay una forma de comprobar si una referecia apunta a None que va a tener siempre el mismo comportamiento:
m0 = None
m1 = MarkdownList()
if m0 is not None:
# The following line is not executed
m0.add(Markdown("dev.to", "http://dev.to/")
if m1 is not None:
# Yes! This is now executed.
m1.add(Markdown("dev.to", "http://dev.to/")
Esto es explícito y funciona siempre. Pero tengo que reconocer que me he llevado una sorpresa desagradable con todo este asunto. Python es relativamente puro y está lejos de ser un lenguaje de programación que te sorprenda desagradablemente según el escenario (como *C++, por poner un archiconocido ejemplo), y sin embargo, he caído en esta trampa, que no me esperaba.
Si seguimos el principio de que un lenguaje debe darte el menor número de sorpresas de este tipo, entonces podemos decir que Python me ha decepcionado un tanto.
Top comments (0)