Ir al contenido
wannacode

Reflexión y decoradores

En resumen: La reflexión es examinar tus propias clases y objetos mientras el programa se ejecuta: type, vars, getattr y setattr, y los decoradores sirven de marcadores.

Problema

El campo que __str__ olvidó

El __str__ del pedido está escrito a mano. Más tarde, alguien añade un campo para el descuento:

class Order:
def __init__(self):
self.number = 1042
self.customer = "María López"
self.total = 86.40
self.discount = 10 # añadido después
def __str__(self):
return f"Order[number={self.number}, customer={self.customer}, total={self.total}]"
print(Order())
Salida
Order[number=1042, customer=María López, total=86.4]

El descuento no está en la salida, porque nadie se acordó de actualizar __str__. Con diez clases, esto pasa constantemente.

Hay además otra pregunta del principio del módulo. Cuando escribes un método test_…, unittest lo encuentra y lo ejecuta, aunque nunca ha visto tu clase. ¿Cómo? Las dos cosas funcionan con la misma idea: un programa puede examinar sus propias clases mientras se ejecuta.

La idea

La clase como objeto

En Python todo es un objeto, también las clases: Order es un objeto de tipo type que describe la clase: nombre, padres, métodos. Examinar clases y objetos mientras el programa se ejecuta se llama reflexión (en inglés, reflection: el programa se ve a sí mismo como en un espejo). Hay tres formas de obtener la clase:

  • Order, cuando conoces la clase mientras escribes el código: el nombre de la clase es el propio objeto;
  • type(obj), cuando tienes un objeto;
  • getattr(importlib.import_module("collections"), "Counter"), cuando solo tienes los nombres como texto.
import collections.abc
import importlib
module = importlib.import_module("collections")
cls = getattr(module, "Counter")
print("Nombre:", cls.__module__ + "." + cls.__qualname__)
print("Nombre corto:", cls.__name__)
print("Padre:", cls.__base__.__name__)
for parent in cls.__mro__:
print("En la cadena:", parent.__name__)
print("¿Es un Mapping?", issubclass(cls, collections.abc.Mapping))
Salida
Nombre: collections.Counter
Nombre corto: Counter
Padre: dict
En la cadena: Counter
En la cadena: dict
En la cadena: object
¿Es un Mapping? True
__mro__ es la cadena de clases de las que hereda, en el orden en que Python busca los métodos. Python no tiene interfaces. En su lugar hay clases abstractas como collections.abc.Mapping, e issubclass comprueba si la clase es una de ellas. Si no hay ningún módulo o clase con ese nombre, import_module lanza ModuleNotFoundError, y getattr lanza AttributeError.

Míralo

Los campos de cualquier objeto

vars(obj) da los campos del objeto como un diccionario: de nombre a valor. El tipo de cada campo es el tipo de su valor, type(value): en Python los campos no tienen un tipo declarado.
def dump(obj):
cls = type(obj)
print(f"{cls.__name__}:")
for name, value in vars(obj).items():
print(f" {name} ({type(value).__name__}) = {value}")
class Room:
def __init__(self):
self.number = 214
self.sea_view = True
dump(Order())
dump(Room())
Salida
Order:
number (int) = 1042
customer (str) = María López
total (float) = 86.4
discount (int) = 10
Room:
number (int) = 214
sea_view (bool) = True
Una sola función imprime cualquier objeto, y el campo nuevo discount ya está ahí. En Python no hay campos privados: un _ o __ delante es solo una convención, y vars lo ve todo. Por eso no hace falta nada parecido a setAccessible.

def dump(obj):
cls = type(obj)
print(f"{cls.__name__}:")
for name, value in vars(obj).items():
print(f" {name} ({type(value).__name__}) = {value}")
Paso a paso, en palabras
  1. dump recibe un objeto Order y no sabe nada de su clase.
  2. type(obj) da la clase Order. También es un objeto.
  3. La primera línea es el nombre corto de la clase.
  4. vars(obj) da un diccionario con 4 campos: nombre y valor.
  5. En Python no hay campos privados: number llega directamente con su valor, 1042. El tipo es del valor: int.
  6. customer (str) = María López.
  7. total (float) = 86.4.
  8. discount: el campo que __str__ olvidó aparece solo, porque la reflexión obtiene todos los campos.
  9. La función no sabe nada de Order. Para Room imprime number y sea_view de la misma forma.
Más precisamente, para curiosos
  • vars(obj) da solo los campos del objeto, sin los métodos y sin los atributos de la clase. dir(obj) da todos los nombres: campos, métodos, heredados y los especiales con __.
  • El orden es el orden en que los campos recibieron un valor. Los diccionarios de Python conservan el orden, y eso está garantizado.
  • A un campo con dos guiones bajos, self.__price, Python le cambia el nombre a _Product__price. También se ve en vars, solo que con otro nombre.
  • Una clase con __slots__ no tiene diccionario para sus campos, y vars lanza TypeError.

Pruébalo

¿De qué clase es?

¿Qué imprimirá el programa?

class Document:
pass
class Invoice(Document):
pass
doc = Invoice()
print(type(doc).__name__)
print(type(doc).__base__.__name__)
print(Document.__base__.__name__)
Respuesta

Míralo

Objeto, método y campo privado por nombre

Con reflexión puedes crear un objeto, llamar a un método y cambiar un campo conociendo solo sus nombres:
class Product:
def __init__(self, name, price):
self.name = name
self._price = price
def apply_discount(self, percent):
self._price = self._price * (100 - percent) / 100
def _label(self):
return f"{self.name}: {self._price} €"
cls = globals()["Product"]
coffee = cls("Café", 10.0)
discount = getattr(coffee, "apply_discount")
discount(20)
print("Precio con descuento:", getattr(coffee, "_price"))
setattr(coffee, "_price", 9.5)
label = getattr(coffee, "_label")
print(label())
Salida
Precio con descuento: 8.0
Café: 9.5 €
  • globals()["Product"] encuentra la clase por su nombre en el módulo. La clase se llama como una función, y eso es el constructor.
  • getattr(coffee, "apply_discount") da el método, ya ligado a coffee. Solo falta llamarlo con los argumentos.
  • getattr y setattr leen y escriben cualquier campo, también _price. _label también se puede llamar: «privado» en Python solo significa «no lo llames desde fuera».

No se pasan los tipos de los parámetros: en Python, los métodos se encuentran solo por el nombre.

Más precisamente, para curiosos
Una lista, creada y rellenada sin que en el código aparezca list() ni .append:
import builtins
items = getattr(builtins, "list")()
add = getattr(items, "append")
add("Café")
add("Té")
print(items)
Salida
['Café', 'Té']
Las clases incorporadas como list están en el módulo builtins.

Pruébalo

El precio que no está

El programa arranca, pero se detiene. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Etiquetas de campos

En Java, las etiquetas con @ en campos y métodos se llaman anotaciones. En Python, «anotación» significa otra cosa: el tipo después de los dos puntos, guest: str. Python no tiene etiquetas con @ en los campos. Para un campo de una dataclass, la etiqueta es el metadata de field. La función label crea un campo así, y print_labeled imprime solo los campos con etiqueta:
def label(text, default):
return field(default=default, metadata={"label": text})
@dataclass
class Booking:
number: int = label("Número", 501)
guest: str = label("Huésped", "Carlos Gómez")
nights: int = label("Noches", 3)
internal_note: str = "VIP"
def print_labeled(obj):
for f in fields(obj):
text = f.metadata.get("label")
if text is not None:
print(f"{text}: {getattr(obj, f.name)}")
Salida
Número: 501
Huésped: Carlos Gómez
Noches: 3
  • field(default=…, metadata={…}) crea un campo de dataclass con un valor por defecto y un diccionario con lo que quieras.
  • fields(obj) da los campos de la dataclass como objetos Field: cada uno tiene name, type y metadata.
  • f.metadata.get("label") devuelve la etiqueta, o None si el campo no la tiene. internal_note no tiene etiqueta y no se imprime.
Más precisamente, para curiosos
  • Las anotaciones de Python (los tipos de los campos) también se leen con reflexión: inspect.get_annotations(Booking) da {'number': <class 'int'>, 'guest': <class 'str'>, …}. Así sabe dataclass qué campos hay.
  • Las etiquetas de métodos y clases en Python son los decoradores con @. Los verás en la siguiente tarjeta.

Míralo

Cómo encuentra unittest las pruebas

Aquí tienes un pequeño ejecutor de pruebas. El marcador es un decorador: @check encima del método llama a la función check con el método, y la función le pone un atributo is_check y lo devuelve. La clase de pruebas comprueba el descuento de la primera lección del módulo (con el viejo error price > 100):
def check(method):
method.is_check = True
return method
class DiscountChecks:
@check
def ten_percent_off(self):
self.expect(45.0, apply_discount(50, 10))
@check
def extra_five_percent_from_100(self):
self.expect(85.0, apply_discount(100, 10))
def expect(self, expected, actual):
if expected != actual:
raise AssertionError(f"esperaba {expected}, pero es {actual}")
def run_checks(cls):
passed = 0
failed = 0
for name in sorted(dir(cls)):
if not getattr(getattr(cls, name), "is_check", False):
continue
instance = cls()
try:
getattr(instance, name)()
print(f"OK {name}")
passed += 1
except Exception as e:
print(f"FALLA {name}: {e}")
failed += 1
print(f"Comprobaciones: {passed + failed}, pasan: {passed}, fallan: {failed}")
Salida
FALLA extra_five_percent_from_100: esperaba 85.0, pero es 90.0
OK ten_percent_off
Comprobaciones: 2, pasan: 1, fallan: 1

unittest hace lo mismo, solo que con muchos más detalles:

  1. obtiene los nombres de la clase con dir(cls) y los ordena, para que el orden sea siempre el mismo;
  2. se salta los que no tienen el marcador. unittest no usa un marcador sino el nombre: ejecuta los métodos cuyo nombre empieza por test;
  3. crea un objeto nuevo para cada prueba, así las pruebas no se estorban entre sí;
  4. obtiene el método del objeto con getattr y lo llama. Si la prueba lanza una excepción, esta llega directamente, sin envoltorio como en Java.

assertEqual en unittest también es un método que lanza AssertionError cuando los valores no coinciden. Igual que expect aquí.

Pruébalo

El marcador que desapareció

check no tiene return method al final, y todo lo demás es igual:

¿Qué imprimirá run_checks?

def check(method):
method.is_check = True
class DiscountChecks:
@check
def ten_percent_off(self): ...
@check
def extra_five_percent_from_100(self): ...
run_checks(DiscountChecks)
Respuesta

Ejercicio

Código que funciona con cualquier clase

Resuelve las tareas en tu editor.

1. Todos los campos, clase por clase. Escribe print_fields(obj) para una dataclass, que imprima cada campo como nombre = valor, incluidos los heredados, recorriendo clase por clase. Empieza por type(obj).__mro__ de atrás hacia delante y, para cada clase, toma solo sus propios campos con inspect.get_annotations(cls); los campos del padre van primero.
@dataclass
class Vehicle:
plate: str = "1234 BCD"
year: int = 2019
@dataclass
class Truck(Vehicle):
load_tons: float = 12.5
Salida para Truck()
plate = 1234 BCD
year = 2019
load_tons = 12.5
2. Mini JSON. Escribe to_json(obj), que devuelva los campos como JSON: los nombres entre comillas, el texto entre comillas, los números y true/false sin comillas. Usa vars, isinstance(value, str) e isinstance(value, bool): str(False) es False, pero JSON necesita false. (La versión ya hecha es json.dumps(vars(obj))).
class Ticket:
def __init__(self):
self.seat = 14
self.event = "Concierto"
self.price = 45.0
self.vip = False
Salida para Ticket() y Room()
{"seat": 14, "event": "Concierto", "price": 45.0, "vip": false}
{"number": 214, "sea_view": true}
3. Tu propio ejecutor de pruebas. Crea los decoradores my_test y my_before_each (como check) y un ejecutor run(cls). Para cada método con my_test: un objeto nuevo, luego el método con my_before_each (si lo hay) y luego la prueba. Si la prueba lanza una excepción, imprime el tipo (type(e).__name__) y el mensaje de la excepción. Pruébalo con estas pruebas:
def my_test(method):
method.is_test = True
return method
def my_before_each(method):
method.is_before_each = True
return method
class Account:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError(f"Faltan {amount - self.balance} €")
self.balance -= amount
class AccountChecks:
@my_before_each
def fresh(self):
self.account = Account(100)
@my_test
def withdraw_40(self):
self.account.withdraw(40)
if self.account.balance != 60:
raise AssertionError(f"esperaba 60, pero es {self.account.balance}")
@my_test
def withdraw_70(self):
self.account.withdraw(70)
if self.account.balance != 30:
raise AssertionError(f"esperaba 30, pero es {self.account.balance}")
@my_test
def withdraw_too_much(self):
self.account.withdraw(130)
Salida
OK withdraw_40
OK withdraw_70
FALLA withdraw_too_much: ValueError: Faltan 30 €
Tests: 3, pasan: 2, fallan: 1

Test

Ponte a prueba

1. ¿Qué te da type(obj)?
2. ¿Qué tienes que hacer antes de leer el campo _price con getattr(obj, "_price")?
3. En getattr(tea, "_prise") hay una errata. ¿Cuándo te enterarás?
4. ¿Por qué el decorador check termina con return method?
5. ¿Cómo encuentra unittest las pruebas?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·