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())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.
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.abcimport 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))Nombre: collections.CounterNombre corto: CounterPadre: dictEn la cadena: CounterEn la cadena: dictEn 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())Order: number (int) = 1042 customer (str) = María López total (float) = 86.4 discount (int) = 10Room: number (int) = 214 sea_view (bool) = Truediscount 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
- dump recibe un objeto Order y no sabe nada de su clase.
- type(obj) da la clase Order. También es un objeto.
- La primera línea es el nombre corto de la clase.
- vars(obj) da un diccionario con 4 campos: nombre y valor.
- En Python no hay campos privados: number llega directamente con su valor, 1042. El tipo es del valor: int.
- customer (str) = María López.
- total (float) = 86.4.
- discount: el campo que __str__ olvidó aparece solo, porque la reflexión obtiene todos los campos.
- 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 envars, solo que con otro nombre. - Una clase con
__slots__no tiene diccionario para sus campos, yvarslanzaTypeError.
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__)type(doc) da la clase real del objeto: Invoice. Su padre (__base__) es Document. Document no indica ningún padre entre paréntesis, así que su padre es object, el padre común del módulo de herencia. Para el propio object, __base__ es None: no hay nada por encima.Míralo
Objeto, método y campo privado por nombre
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())Precio con descuento: 8.0Café: 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 acoffee. Solo falta llamarlo con los argumentos.getattrysetattrleen y escriben cualquier campo, también_price._labeltambié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
list() ni .append:import builtins
items = getattr(builtins, "list")()add = getattr(items, "append")add("Café")add("Té")print(items)['Café', 'Té']list están en el módulo builtins.Pruébalo
El precio que no está
Elige la línea con el error.
El campo se llama _price, pero aquí pone "_prise". Es texto normal, y nadie lo comprueba antes de que el programa llegue a él. El error aparece solo durante la ejecución:
AttributeError: 'Product' object has no attribute '_prise'. Did you mean: '_price'?La pista «Did you mean» la añade Python 3.12 al imprimir el error.
Este es el precio principal de la reflexión:
- nadie comprueba de antemano los nombres ni los tipos, y el editor no los encuentra cuando cambias un nombre;
- el código es más lento que una llamada normal y más difícil de leer;
-
getattrysetattrse saltan la encapsulación: puedes romper una regla que la clase protege.
dataclasses, SQLAlchemy (lee los campos de la clase y rellena los objetos), Django. En el código normal se escribe poco.La idea
Etiquetas de campos
@ 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})@dataclassclass 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)}")Número: 501Huésped: Carlos GómezNoches: 3field(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 objetosField: cada uno tienename,typeymetadata.f.metadata.get("label")devuelve la etiqueta, oNonesi el campo no la tiene.internal_noteno 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í sabedataclassqué 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
@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}")FALLA extra_five_percent_from_100: esperaba 85.0, pero es 90.0OK ten_percent_offComprobaciones: 2, pasan: 1, fallan: 1unittest hace lo mismo, solo que con muchos más detalles:
- obtiene los nombres de la clase con
dir(cls)y los ordena, para que el orden sea siempre el mismo; - 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; - crea un objeto nuevo para cada prueba, así las pruebas no se estorban entre sí;
- obtiene el método del objeto con
getattry 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)@check encima de def es lo mismo que ten_percent_off = check(ten_percent_off). Sin return, check devuelve None, y en el lugar del método queda None. getattr(None, "is_check", False) es False, y no se ejecuta ni una comprobación. El programa funciona, simplemente no hace nada. Por eso todo decorador devuelve la función que recibió (o una nueva en su lugar).Ejercicio
Código que funciona con cualquier clase
Resuelve las tareas en tu editor.
1. Todos los campos, clase por clase. Escribeprint_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.@dataclassclass Vehicle: plate: str = "1234 BCD" year: int = 2019
@dataclassclass Truck(Vehicle): load_tons: float = 12.5plate = 1234 BCDyear = 2019load_tons = 12.5to_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{"seat": 14, "event": "Concierto", "price": 45.0, "vip": false}{"number": 214, "sea_view": true}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)OK withdraw_40OK withdraw_70FALLA withdraw_too_much: ValueError: Faltan 30 €Tests: 3, pasan: 2, fallan: 1Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas: append, remove y sort · Clases anidadas y datos · Lambdas · Tests con unittest
- Ejercicios sobre el tema: Programas en el mundo real