Las clases definidas por el usuario pueden comportarse como otros tipos gracias a la implementación de diversos métodos mágicos. Este tema ilustra cómo se implementan algunos de ellos para emular tipos numéricos, contenedores y funciones.
El tema finaliza con la implementación de iteradores, generadores y contextos, tipos todos ellos para los que Python tiene una sintaxis especial.
- La documentación de Python sobre métodos especiales lista todos los métodos mágicos.
Los métodos mágicos son invocados por el intérprete de Python al utilizar cierta clase de sintaxis o utilizar un cierto tipo de funcionalidad. Por ejemplo, cuando hacemos:
3 + 5
En realidad estamos haciendo:
int.__add__(3, 5)
O, también:
3 in [1, 2, 5]
Que se traduce a:
list.__contains__([1, 2, 5], 3)
Fíjate en como los parámetros no siempre se pasan en el orden en que aparecen en la sintaxis.
Sea como sea, los métodos mágicos siempre se llaman desde el tipo del valor,
pasando explícitamente los valores de self
y del resto de parámetros.
La implementación de métodos mágicos tiene que ocurrir en la clase. De nada sirve hacer algo así:
class NerdAlien:
pass
o = NerdAlien()
o.__repr__ = lambda: 'I am Ziltoid!'
repr(o)
La forma correcta es hacer:
class NerdAlien:
def __repr__(self):
return 'I am Ziltoid!'
o = NerdAlien()
repr(o)
¿Por qué la primera forma no funciona y la segunda sí?
Vamos a crear un vector bidimensional Vector2D
e implementaremos algunas
operaciones de los tipos numéricos.
-
Comencemos con lo básico:
class Vector2D: """A vector in a 2D euclidean space.""" def __init__(self, x=0, y=0): self.x = x self.y = y
Recuerda que habrás de modificar la clase entera cada vez que añadas un nuevo método.
-
Ahora crea un punto:
Vector2D(10, 10)
Mira la representación por defecto. Vamos a mejorarla implementando el método mágico
__repr__
:# inside the Vector2D class def __repr__(self): return f'{self.__class__.__name__}({self.x}, {self.y})'
-
Continuamos con la suma y resta:
# inside the Vector2D class def __add__(self, other): if isinstance(other, Vector2D): return Vector2D(self.x + other.x, self.y + other.y) return NotImplemented def __sub__(self, other): if isinstance(other, Vector2D): return Vector2D(self.x - other.x, self.y - other.y) return NotImplemented
Fíjate en
NotImplemented
. No lo confundas con la clase de exepciónNotImplementedError
.NotImplemented
es un valor que indica que una operación no se encuentra disponible para un determinado tipo de argumento. En nuestro caso, sólo implementamos la suma o la resta cuando el otro argumento es unVector2D
.Prueba ahora:
Vector2D(1, -5) + Vector2D(10, 10)
-
Continuemos con algo más interesante, la negación, la multiplicación por un escalar y la división (en su versión con decimales):
# inside the class Vector2D def __neg__(self): return Vector2D(-self.x, -self.y) def __mul__(self, scalar): import numbers if isinstance(scalar, numbers.Real): return Vector2D(self.x * scalar, self.y * scalar) return NotImplemented def __truediv__(self, scalar): import numbers if isinstance(scalar, numbers.Real): return Vector2D(self.x / scalar, self.y / scalar) return NotImplemented
Con esto podemos hacer cosas como:
-Vector2D(10, -10) Vector2D(1, 1) * 5 Vector2D(5, 5) / 5
Sin embargo, no podemos hacer:
5 * Vector2D(1, 1)
-
Esto se debe a que la expresión anterior es equivalente a:
int.__mul__(5, Vector2D(1, 1))
Y el tipo
int
no podía saber de antemano cómo sumar unVector2D
.Para poder arreglar esta situación, Python establece un protocolo: si una operación falla con
NotImplemented
, se llama a su versión "refleja" (mismo nombre del método mágico precedido de unar
). Sólo si esta también devuelveNotImplemented
se deduce que la operación no está soportada.Ahora sabemos que cuando la operación anterior falle, Python intentará:
Vector2D.__rmul__(Vector2D(1, 1), 5)
Así que implementamos
__rmul__
:# inside the Vector2D class def __rmul__(self, scalar): return self * scalar
Y ya podemos hacer cosas como:
5 * Vector2D(1, 1)
Consulta la documentación de los operadores "reflejos" para una lista eshaustiva.
Antes de continuar, prueba a implementar la división entera. ¿Tiene sentido implementar la división refleja para el tipo
Vector2D
? -
Existe toda una serie de operaciones in place (in situ):
5 += 1 7 -= 10 8 *= -1
Cuando el tipo es inmutable, estas operaciones no tienen más remedio que devolver un nuevo valor. Pero cuando los tipos son mutables, Python recomienda que, en la medida de los posible, la modificación se realice sobre el primer operando.
Si decidimos que
Vector2D
sea mutable, podemos implementar las versiones in situ de las operaciones que ya hemos implementado de la siguiente manera:# inside the Vector2D class def __iadd__(self, other): if isinstance(other, Vector2D): self.x += other.x self.y += other.y return self return NotImplemented
Consulta la documentación de las operaciones in situ e implementa el resto.
-
Cambia la definición de la multiplicación para que permita "multiplicar por un vector". Implementa el producto escalar para estos casos.
-
También podemos alterar la forma en que un tipo se comporta en una expresión booleana, es decir, cuándo se considera verdadero y cuándo falso. Por ejemplo, sería conveniente que el vector cero,
Vector2D()
fuera considerado falso. Sin embargo:origin = Vector2D() bool(origin)
Podemos cambiar este comportamiento, implementando el método mágico
__bool__
(que debe devolverTrue
oFalse
):# inside the Vector2D class def __bool__(self): return bool(self.x or self.y)
Comprueba que la modificación ha servido de algo.
En la documentación se muestra que además de
__bool__
, también puedes implementar la conversión a otros tipos.
- Documentación acerca de la emulación de tipos numéricos.
Continuamos simulando otros tipos, implementando los métodos mágicos típicos
de un contenedor. Nótese que la forma más sencilla sería heredar de alguna
de las clases abstractas de collections.abc
como hacíamos en el tema
anterior pero esta aproximación también sirve.
-
Comencemos por implementar la longitud de
Vector2D
:# inside the Vector2D class def __len__(self): return 2
Con lo que ya podemos hacer:
len(Vector2D(1, 1))
-
Permitamos ahora indexar los componentes del vector:
# inside the Vector2D class def __getitem__(self, index): index = self._correct_index(index) return self.x if index == 0 else self.y def __setitem__(self, index, value): index = self._correct_index(index) if index == 0: self.x = value else: self.y = value def _correct_index(self, index): if index < 0: index += len(self) if index < 0 or index >= len(self): raise IndexError return index
-
Nos faltaría el test de pertenencia y la iteración, es decir, las sintáxis:
v = Vector2D(10, -10) 10 in v for item in v: print(item)
Implementaremos aquí el primero, dejando el segundo para la última sección del tema:
# inside the Vector2D class def __contains__(self, item): return self.x == item or self.y == item
- Documentación acerca de la emulación de tipos contenedor
Python 3.7 añade un decorador @dataclass
cuyo objetivo es implementar
automáticamente algunos protocolo. Su motivación viene explicada en el
PEP 557.
La implementación automática es posible porque las clases orientadas a datos tienen una forma particular que consiste en una serie de campos de datos con tipo. Los protocolos implementados se realizan considerando el nombre de los campos y sus tipos.
En la biblioteca estándar de Python ya has encontrado otros intentos
destinados a reducir la la cantidad de código y andamiaje necesario para
implementar otros protocolos como el módulo abc
o el tipo namedtuple
.
En el ecosistema Python existen proyectos de software
attrs
con las mismas motivaciones pero
objetivos más ambiciosos.
-
Crea una clase como la siguiente:
import dataclasses from dataclasses import dataclass @dataclass class Vector3: x: float = 0 y: float = 0 z: float = 0
-
Con esto únicamente, tu clase tendrá un constructor que acepta estos parámetros:
zero = Vector3() y_axis = Vector3(y=1) corner = Vector3(x=1, y=1, z=1)
Además su representación será representativa:
print(zero)
-
Se puede convertir en tupla y diccionario:
dataclasses.asdict(corner) dataclasses.astuple(corner)
-
Dos instancias son iguales si sus tipos son iguales y sus miembros son iguales:
another_corner = Vector3(1, 1, 1) assert corner == another_corner
-
Con unas ligeras modificaciones, además:
import dataclasses from dataclasses import dataclass @dataclass(order=True, frozen=True) class Vector3: x: float = 0 y: float = 0 z: float = 0
-
Si el parámetro
order
del decorador esTrue
, la clase tiene un orden, definido en términos de sus elementos, en orden:v = Vector3(z=1) vv = Vector3(z=10) vvv = Vector3(x=5) assert v < vv < vvv
Esta opción es incompatible con proveer de alguno de los métodos mágicos de comparación:
@dataclass(order=True) class Whatever: def __lt__(self, other): ...
-
Si el parámetro
frozen
del decorador esTrue
, la clase es, además, inmutable:v.z = 3
Si el parámetro
eq
esTrue
(y lo es, por defecto) yfrozen
esTrue
, el elemento es además hashable y puede actuar como la clave de un mapa o añadirse a un conjunto. -
Se puede comprobar si una clase es una dataclass:
assert dataclasses.is_dataclass(Vector3) assert not dataclasses.is_dataclass(dict)
-
Se puede obtener un nuevo ejemplar de una dataclass indicando los cambios sobre otro:
corner = Vector3(1, 1, 1) further_corner = dataclasses.replace(corner, z=10) assert corner < further_corner
Se puede controlar el comportamiento de cada cambio para cada funcionalidad. Por ejemplo si quisiéramos excluir un campo de ser parte de la representación, o si quisiéramos exluir un campo de la comparación, o si quisiéramos crear un campo que depende del valor de otros campos:
-
Imagina que queremos precalcular el módulo del vector:
@dataclass(order=True, frozen=True) class Vector3: x: int = 0 y: int = 0 z: int = 0 module: int = dataclasses.field(init=False, repr=False, compare=False) def __post_init__(self): object.__setattr__(self, 'module', (self.x ** 2 + self.y ** 2 + self.z ** 2) ** .5)
No lo has visto en clase pero... ¿se te ocurre por qué tenemos que asignar de esta manera a
module
en vez de hacerself.module = ...
? -
Puedes obtener los campos de una dataclass con
dataclasses.fields()
para comprobar que realmente es un campo:corner = Vector3(1, 1, 1) assert corner.module == 3 ** .5 list(dataclasses.fields(corner))
-
Otro uso de la función
field()
es el de proporcionar una factoría de valores por defecto:@dataclass class Node: data = None children: list = dataclasses.field(default_factory=list)
El parámetro debe ser un invocable al que se llamará, sin argumentos, para generar el nuevo valor por defecto. De esta forma, cada nuevo nodo puede tener una lista de hijos diferente:
a = Node() b = Node() assert a == b assert a.children == b.children assert a.children is not b.children
Los invocables son aquellos objetos a los que podemos "llamar", es decir, acompañar de un par de paréntesis con la sintáxis de paso de parámetros que conocemos. Estos paréntesis con sus parámetros son reamente una invocación a un método mágico. Esto:
def add(a, b):
return a + b
add(5, 2)
Es en realidad, esto:
functiontype = type(add)
functiontype.__call__(add, 5, 2)
El método mágico __call__
convierte a un objeto en un invocable.
-
Considera la siguiente clase:
class Multiplier: def __init__(self, a): self.a = a def __call__(self, b): return self.a * b
-
La clase
Multiplier
crea multiplicadores. En su inicialización captura el número por el que multiplicará durante la invocación:nullify = Multiplier(0) once = Multiplier(1) double = Multiplier(2) triple = Multiplier(3) assert nullify(100) == 0 assert once(100) == 100 assert double(100) == 200 assert triple(100) == 300
Razona esta sintáxis, también válida:
assert Multiplier(4)(100) == 400
-
Vamos a utilizar un invocable para simular un decorador con parámetros. Antes, repasa cómo se hacía con funciones:
# capture the parameters def log(tag): # capture the function to decorate def _decorator(f): # capture the orginal arguments of the function def _decorated(*args, **kwargs): print(f'[{tag}] Calling {f.__name__} with {args} and {kwargs}') return f(*args, **kwargs) return _decorated return _decorator @log('test') def echo(something): return something echo(42)
-
Compara ahora cómo se haría con una clase:
class Log: # captures the parameters def __init__(self, tag): self.tag = tag # captures the function def __call__(self, f): self.f = f return self._decorated # captures de arguments of the original function def _decorated(self, *args, **kwargs): f = self.f tag = self.tag print(f'[{tag}] Calling {f.__name__} with {args} and {kwargs}') return f(*args, **kwargs) @Log('test') def echo(something): return something
-
Convéncete del funcionamiento de la clase utilizando el depurador.
- Documentación de la emulación de invocables
No confundir con la propuesta de protocolos pensada para Python 3.8.
Un protocolo, en Python, es una secuencia de acciones que se coordinan para lograr un fin. Normalmente es un tipo de sintaxis Python la que desencadena el protocolo.
Por ejemplo, una sentencia if
como:
if [1, 2, 3]:
...
Desencadena comprobar el valor de la lista como booleano. Sólo eso implica algo así:
def convert_to_bool(condition):
if hasattr(condition, '__bool__'):
result = type(condition).__bool__(condition)
if not isinstance(result, bool):
raise TypeError
return result
if hasattr(condition, '__len__'):
result = type(condition).__len__(condition)
if not isinstance(result, int):
raise TypeError
return result != 0
return object.__bool__(condition)
Como ves, interpretar la lista como un booleano va más allá de llamar a su
método mágico __bool__
, también supone una serie de pasos, señales,
comprobaciones de tipos, alternativas...
El protocolo iterador es el que entra en juego cuando se utiliza la sentencia
for ... in ...
:
for i in [1, 2, 3]:
print(i)
En realidad, lo que ocurre es algo así:
iterable = [1, 2, 3]
iterator = iter(iterable)
while True:
try:
i = next(iterador)
print(i)
except StopIteration:
break
Primero se extrae, mediante iter()
, el iterador por defecto de la lista,
luego ese iterador se usa con next()
para generar valores indefinidamente
hasta que se captura la excepción StopIteration
, momento en el cual, el
bucle termina.
- Un iterable es un objeto sobre el que se puede usar
iter()
, a través del método mágico__iter__
: la operación obtiene un iterador. - Un iterador es un objeto sobre el que se puede usar
next()
, a través del método mágico__next__
: la operación genera un valor o señala el fin de la generación de valores.
Las definiciones no bastan para implementar el protocolo correctamente, además:
- Un iterador es un iterable, es decir, implementa el método
__iter__
. - Su iterador por defecto es él mismo.
Veamos algunos ejemplos:
-
Comencemos por algo sencillo, la mayoría de las implementaciones de iteradores delegan en los iteradores de otros tipos. Así, podemos implementar
__iter__
para el tipoVector2D
como:# inside the Vector2D class def __iter__(self): return iter([self.x, self.y])
Con esto ya podemos hacer:
for component in Vector2D(5, 10): print(component)
-
Cambiemos de colección. Considera ahora el tipo
Polygon
, que representa un polígono en el espacio y puede iterarse para obtener los puntos que lo forman:class Polygon: def __init__(self, center, sides, radius=1): self.center = center self.sides = sides self.radius = radius def __iter__(self): sides = self.sides radius = self.radius return iter(self._make_points(sides, radius)) def _make_points(self, sides, radius): import math center = self.center points = [] for step in range(sides): angle = 2 * math.pi / sides * step point = center + Vector2D( math.cos(angle) * radius, math.sin(angle) * radius) points.append(point) return points
Con esto podemos hacer:
square = Polygon(Vector2D(), 4) for point in square: print(point)
-
Finalmente, considera el tipo
Circle
que implementa un círculo como un polígono de muchos lados:class Circle: def __init__(self, center, radius=1, step=0.00001): import math sides = math.floor(2 * math.pi / step) self._polygon = Polygon(center, sides, radius) def __iter__(self): return iter(self._polygon)
Con lo que podemos hacer:
circle = Circle(Vector2D()) for point in circle: print(point)
Fíjate, no obstante, en el pequeño retraso antes de comenzar a iterar. Este es uno de los límites que tiene delegar en listas: tener que generarla antes con la consumición de espacio y tiempo que implique.
-
Podemos solucionar este problema implementando nuestro propio iterador del polígono:
class PolygonPointsIterator: def __init__(self, polygon): self._polygon = polygon self._current = 0 def __next__(self): import math if self._current >= self._polygon.sides: raise StopIteration angle = 2 * math.pi / self._polygon.sides * self._current point = self._polygon.center + Vector2D( math.cos(angle) * self._polygon.radius, math.sin(angle) * self._polygon.radius) self._current += 1 return point def __iter__(self): return self
Ahora, la clase
Polygon
puede reimplementarse como:class Polygon: def __init__(self, center, sides, radius=1): self.center = center self.sides = sides self.radius = radius def __iter__(self): return PolygonPointsIterator(self)
Y recorrer el círculo funciona sin tener que hacer nada más:
for p in Circle(Vector2D()): print(p)
Además, gracias a la implementacion de
__iter__
por parte del iterador, podemos hacer cosas como:circle = Circle(Vector2D()) points = iter(circle) for p in points: print(p)
Razone el por qué.
Un generador no es un protocolo, sino una sintaxis conveniente para
producir iteradores. Un generador es una función que hace uso de yield
:
-
Considera la siguiente función:
def rgb(): yield 'red' yield 'green' yield 'blue'
Ahora puedes hacer:
for color in rgb(): print(color)
-
La mera presencia de la instrucción
yield
convierte a la función en un generador. Su invocación crea un objetogenerator
:f = rgb type(f) g = rgb() type(g) v = next(g) type(v)
-
Los generadores resultan muy convenientes a la hora de escribir iteradores, sin necesidad de crear una clase nueva:
class Polygon: def __init__(self, center, sides, radius=1): self.center = center self.sides = sides self.radius = radius def __iter__(self): import math for step in range(self.sides): angle = 2 * math.pi / self.sides * step yield self.center + Vector2D( math.cos(angle) * self.radius, math.sin(angle) * self.radius)
-
De hecho, existe una sintaxis intensional para generadores, sútilmente distinta de la de una lista intensional:
perfect_squares = (i**2 for i in range(100)) type(perfect_squares)
¿Adviertes la diferencia?
-
Un generador no es una lista, pero es un iterable y, por tanto, lo podemos convertir a una lista:
all_perfect_squares = list(perfect_squares)
Los generadores son de usar y tirar, si hemos gastado uno, no hay nada más que podamos hacer con él:
more_squares = list(perfect_squares) assert not more_squares next(perfect_squares)
-
Las secuencias infinitas, otra de las limitaciones de las listas, son fácilmente expresables con generadores:
def inifinite(start=0): while True: yield start start += 1
Con lo que podemos hacer:
g = infinite() next(g) next(g) next(g)
-
Un aspecto muy interesante de los generadores es que podemos comunicarnos con ellos y alterar su estado interno. Por ejemplo:
def infinite(start=0): while True: next_ = yield start start = next_ if next_ is not None else (start + 1) g = infinite() print(next(g)) print(next(g)) print(g.send(100)) # send also resumes the execution print(next(g))
-
Además podemos componer nuevos generadores en función de otros generadores:
def perfect_squares(start=0): for i in infinite(start): yield i ** 2 g = perfect_squares() print(next(g)) print(next(g)) print(g.send(100)) print(next(g))
¿Por qué el envío no funciona?
-
Aunque componerlos correctamente puede ser complicado:
def perfect_squares(start=0): g = iter(infinite(start)) next_ = None while True: next_ = yield g.send(next_) ** 2 g = perfect_squares() print(next(g)) print(next(g)) print(g.send(100)) print(next(g))
-
La sentencia
yield from
permite transferir el control a otro generador. Por ejemplo, en lugar de hacer:def chain(*iterables): for each_iterable in iterables: for item in each_iterable: yield item list(chain('abc', '123'))
Podemos escribir:
def chain(*iterables): for each_iterable in iterables: yield from each_iterable list(chain('abc', '123'))
Fíjate como
yield from
funciona, no sólo con generadores, sino con iterables, en general.
Gran parte de la potencia de generadores e iterables es su capacidad de
composición. El módulo itertools
ofrece multitud de funcionalidad afin.
-
Comienza importando el módulo:
import itertools
-
Familiarízate con la función
islice
(iterator slice) que selecciona determinados índices del generador, por ejemplo:for sq in itertools.islice(perfect_squares(), 10, 21): print(sq)
-
Otras función de selección interesante es
takewhile()
:def lower_than_250(x): return x < 250 for sq in itertools.takewhile(lower_than_250, perfect_squares()): print(sq)
-
También incluye algunos de los generadores que hemos estudiado anteriormente como
count()
ychain()
:numbers = itertools.islice(itertools.count(), 10, 21) squares = itertools.islice(perfect_squares(), 10, 21) for item in itertools.chain(numbers, squares): print(item)
-
Y un conjunto de utilidades para generar distintas formas de combinaciones:
def get_2d_grid(limit, step=1): yield from map( lambda p: Vector2D(*p), itertools.product(range(0, limit, step), repeat=2)) for p in get_2d_grid(10): print(p)
Además de
product()
también existenpermutations()
,combinations()
ycombinations_with_replacement()
de las que se puede encontrar un ejemplo en la documentación y que repetimos aquí:list(itertools.product('ABCD', repeat=2)) list(itertools.permutations('ABCD', 2)) list(itertools.combinations('ABCD', 2)) list(itertools.combinations_with_replacement('ABCD', 2))
- Documentación del
módulo
itertools
El PEP 343 introduce la sentencia
with
. Has utilizado esta sentencia con ficheros:
with open('./answer', 'w+') as f:
f.write('42')
Lo utilizabas para asegurar que el fichero quedaba cerrado se produjera, o no, una excepción.
Resulta que el valor devuelto por open
es un manejador de contextos que
implementa los métodos mágicos __enter__
y __exit__
.
-
Observa el funcionamiento básico:
class ObviousContext: def __enter__(self): print('entering') def __exit__(self, exc_type, exc_val, exc_tb): print('exception info:', (exc_type, exc_val, exc_tb)) print('exiting') with ObviousContext(): print('in the context')
Fíjate como los parámetros de
__exit__
sonNone
, los tres porque no se ha producido ninguna excepción en el contexto. -
Para usar la notación
as
dewith
, la función__enter__
tiene que devolver un objeto que será el que se enlace a la variable que sucede a la palabra claveas
:class ObviousContext: def __init__(self, something): self._something = something def __enter__(self): print('entering') return self._something def __exit__(self, exc_type, exc_val, exc_tb): print('exception info:', (exc_type, exc_val, exc_tb)) print('exiting') answer = 42 with ObviousContext(answer) as target: assert answer is target
-
Observa qué pasa si lanzamos una excepción:
with ObviousContext(): raise RuntimeError('a forced error')
En tal caso, los parámetros pasados a
__exit__
son el tipo de la excepción, su valor y la pila de llamadas. Lo importante es notar que, pese a la excepción, la función__exit__
se ha llamado.Los contextos nos permiten implementar código de inicialización y finalización específicos y controlar los errores que se produzcan en el código del bloque
with
(también llamado suite en el vocabulario de Python). -
Si el método
__exit__
devuelve un valor verdadero, la excepción se suprime:class VerboseSuppress: def __enter__(self): print('entering') def __exit__(self, exc_type, exc_val, exc_tb): print('exception info:', (exc_type, exc_val, exc_tb)) print('exiting') return True with VerboseSuppress(): raise RuntimeError('a forced error')
En ningún caso se debe re-lanzar la excepción capturada por
__exit__
, tan sólo devolver un valor falso para indicar al protocolo de contextos que no debe suprimir la excepción. -
Considera el siguiente contexto:
from functools import wraps class Inspect: def __init__(self, f): self._f = f def __enter__(self): @wraps(self._f) def _decorated(*args, **kwargs): result = self._f(*args, **kwargs) print(f'{self._f.__name__}(*{args}, **{kwargs}) -> {result}') return result return _decorated def __exit__(self, *_): pass
¿Qué hace?
-
¿Podrías diseñar un contexto, que reemplace un método de una clase por una función cualquiera, sólo durante la ejecución del contexto? Se utilizaría de esta forma:
class Echo: def __init__(self, something): self._something = something def tell(self): return self._something def answer(*_): return 42 class Replace: def __init__(self, cls, method_name, replacement): ... def __enter__(self): ... def __exit__(self): ... with Replace(Echo, 'tell', answer): o = Echo('I am Ziltoid!') assert o.tell() == 42 o = Echo('I am Ziltoid') assert o.tell() == 'I am Ziltoid'
- Documentación Python sobre los manejadores de contexto