Clean Code in Python - Es

Descargar como pdf o txt
Descargar como pdf o txt
Está en la página 1de 638

Código limpio en Python

Refactorice su base de código heredada

Mariano Anaya

BIRMINGHAM - BOMBAY
Código limpio en Python
Copyright © 2018 Packt Publishing

Reservados todos los derechos. Ninguna parte de este libro puede reproducirse, almacenarse en un sistema de
recuperación o transmitirse de ninguna forma ni por ningún medio sin el permiso previo por escrito del editor,
excepto en el caso de citas breves incrustadas en artículos críticos o reseñas.

Se ha hecho todo lo posible en la preparación de este libro para garantizar la exactitud de la información presentada.
Sin embargo, la información contenida en este libro se vende sin garantía, ya sea expresa o implícita. Ni el autor, ni
Packt Publishing ni sus comerciantes y distribuidores serán responsables de los daños causados o presuntamente
causados directa o indirectamente por este libro.

Packt Publishing se ha esforzado por proporcionar información de marcas registradas sobre todas las empresas y
productos mencionados en este libro mediante el uso apropiado de capitales. Sin embargo, Packt Publishing no
puede garantizar la exactitud de esta información.

Editor de puesta en marcha: Merint Mathew


Editor de adquisiciones: Denim Pinto
Editor de desarrollo de contenido: Priyanka
Sawant
Editor técnico: Gaurav Gala
Editor de copias: Safis Editing
Project Coordinador: Vaidehi Sawant
Corrector: Safis Editing
Indexer: Rekha Nair
Gráficos: Jason Monteiro
Coordinador de producción: Shantanu Zagade

Primera publicación: agosto de 2018

Referencia de producción: 1270818

Publicado por Packt Publishing Ltd.


Livery Place
35 Livery Street
Birmingham
B3 2PB, Reino
Unido.

ISBN 978-1-78883-583-1

www.packtpub.com
A mi familia y amigos, por su amor y apoyo incondicional.
–Mariano Anaya
mapt.io

Mapt es una biblioteca digital en línea que le brinda acceso completo a más de 5000 libros y
videos, así como herramientas líderes en la industria para ayudarlo a planificar su
desarrollo personal y avanzar en su carrera. Para obtener más información, por favor visite
nuestro sitio web.

¿Por qué suscribirse?


Pase menos tiempo aprendiendo y más tiempo codificando con libros electrónicos
y videos prácticos de más de 4000 profesionales de la industria

Mejore su aprendizaje con Planes de habilidades creados especialmente para


usted

Obtenga un libro electrónico o video gratis todos los meses

Mapt se puede buscar por completo

Copie y pegue, imprima y marque contenido

PacktPub.com
¿Sabía que Packt ofrece versiones de libros electrónicos de cada libro publicado, con
archivos PDF y ePub disponibles? Puede actualizar a la versión del libro electrónico en
www.PacktPub.comy, como cliente del libro impreso, tiene derecho a un descuento en la copia
del libro electrónico. Póngase en contacto con nosotros en [email protected] para
obtener más detalles.
En www.PacktPub.com, también puede leer una colección de artículos técnicos gratuitos,
suscribirse a una variedad de boletines gratuitos y recibir descuentos y ofertas exclusivos
en libros y libros electrónicos de Packt.
Colaboradores

Sobre el Autor
Mariano Anaya es un ingeniero de software que pasa la mayor parte de su tiempo creando
software con Python y asesorando a otros programadores. Las principales áreas de interés
de Mariano además de Python son la arquitectura de software, la programación funcional,
los sistemas distribuidos y hablar en conferencias.

Fue ponente en Euro Python 2016 y 2017. Para saber más de él, puedes consultar su cuenta
de GitHub con el nombre de usuario rmariano.

Su nombre de usuario en speakerdeck es rmarian.


Sobre el revisor
Nimesh Kiran Verma tiene una doble titulación en Matemáticas e Informática del IIT de
Delhi y ha trabajado con empresas como LinkedIn, Paytm e ICICI durante unos 5 años en
desarrollo de software y ciencia de datos.

Cofundó una empresa de micropréstamos, Upwards Fintech, y actualmente se desempeña


como su CTO. Le encanta programar y domina Python y sus marcos populares, Django y
Flask.

Aprovecha ampliamente los servicios web de Amazon, los patrones de diseño, las
bases de datos SQL y NoSQL para crear arquitecturas confiables, escalables y de
baja latencia.

A mi mamá, Nutan Kiran Verma, quien me hizo lo que soy hoy y me dio la confianza para
perseguir todos mis sueños. Gracias papá, Naveen y Prabhat, quienes me motivaron a robar
tiempo para este libro cuando en realidad se suponía que lo pasaría con ellos. El apoyo de
Ulhas y de todo el equipo de Packt fue tremendo. Gracias Varsha Shetty por presentarme a
Packt.

Packt está buscando autores como tú


Si está interesado en convertirse en autor de Packt, visite authors.packtpub.com y presente su
solicitud hoy. Hemos trabajado con miles de desarrolladores y profesionales de la
tecnología, como usted, para ayudarlos a compartir sus conocimientos con la comunidad
tecnológica mundial. Puede hacer una solicitud general, postularse para un tema
candente específico para el que estamos reclutando un autor o enviar su propia idea.
Tabla de contenido
Prefacio 1

Capítulo 1: Introducción, formato de código y 7


herramientas El significado del código limpio 8
8
La importancia de tener un código limpio
9
La función del formato de código en el código limpio 10
Adherirse a una guía de estilo de codificación en su 12
proyecto 13
Docstrings y anotaciones 16
Docstrings 18
Anotaciones 20
20
¿Las anotaciones reemplazan docstrings? 21
Configuración de las herramientas para hacer cumplir 21
las puertas de calidad básicas Sugerencias de tipo con 24
Mypy
Verificación del código con Pylint 2
Setup para verificaciones automáticas 5
Resumen 2
6
Capítulo 2: Código Pythonic 2
Índices y segmentos 8
Creación de sus propias secuencias Administradores de 2
contexto Implementación 9
3
de administradores de contexto
2
Propiedades, atributos y diferentes tipos de métodos para 3
objetos Guiones bajos en Python 5
Propiedades 3
Objetos iterables Creación de 5
objetos iterables 3
Creación de secuencias 8
4
Objetos contenedores
0
Atributos dinámicos para objetos Objetos 4
invocables 0
Resumen de magia métodos 4
Advertencias en Python 3
Argumentos predeterminados mutables 4
Ampliación de tipos integrados 5
4
Resumen
6
Referencias 4
Capítulo 3: Características generales 8
4
Tabla de contenido

previas Condiciones posteriores 5


Contratos 8
pitónicos 5
9
Diseño por contrato: conclusiones
5
Programación defensiva 9
Manejo de errores 5
Sustitución de valores 9
Manejo de excepciones 6
Manejar excepciones en el nivel correcto de abstracción 0
No exponer rastros 6
Evitar bloques excepto vacíos 1
Incluir la excepción original 6
Usar aserciones en Python 1
Separación de preocupaciones 6
Cohesión y acoplamiento 2
6
Siglas para vivir por 4
DRY/OAOO 6
YAGNI 6
KIS 6
EAFP/LBYL 7
6
Composición y herencia 8
Cuando la herencia es una buena decisión 6
Anti-patrones para herencia Herencia 9
múltiple en Python 7
Orden de resolución de métodos (MRO) 0
Mixins 7
Argumentos en funciones y métodos 1
7
Cómo funcionan los argumentos de función en Python
2
Cómo se copian los argumentos en funciones
7
Número variable de argumentos 2
El número de argumentos en las funciones 7
Argumentos de función y acoplamiento 4
Firmas de funciones compactas que toman demasiados 7
argumentos Comentarios finales sobre buenas 4
prácticas para el diseño de software 7
6
Ortogonalidad en el software 7
Estructuración del código 7
Resumen 7
Referencias 8
7
Capítulo 4: Los principios SOLID Principio 9
de responsabilidad única 8
Una clase con demasiadas responsabilidades 2
Distribución de responsabilidades [ii] 8
2
El principio abierto/cerrado 8
Ejemplo de peligros de mantenibilidad por no seguir el principio 4
abierto/cerrado Refactorización del sistema de eventos para la 8
Tabla de contenido

Reflexiones finales sobre el principio de sustitución de 10


OCP 9
Liskov 11
0
Detectar problemas de LSP con herramientas
11
Detectar tipos de datos incorrectos en firmas de métodos con
1
Mypy Detectar firmas incompatibles con Pylint Casos 11
más sutiles de violaciones de LSP 1
Comentarios sobre la 11
segregación de la interfaz LSP 3
Una interfaz que proporciona demasiado 11
Cuanto más pequeña es la interfaz, mejor 3
11
¿Qué tan pequeña debe ser una interfaz?
6
Inversión de dependencia 11
Un caso de dependencias 7
rígidas 11
Invertir las dependencias 8
Resumen 11
8
Referencias 11
Capítulo 5: Uso de decoradores para mejorar 9
nuestro código ¿Qué son los decoradores en 11
9
Python? 12
Decorar funciones 0
Decorar clases 12
Otros tipos de decoradores 1
Pasar argumentos a decoradores 122
Decoradores con funciones anidadas 123
Objetos decoradores
12
Buenos usos para decoradores 4
Transformación de parámetros 12
Código de seguimiento 4
Decoradores efectivos: evitar errores comunes 12
Preservar datos sobre el objeto envuelto original 6
Tratar con efectos secundarios en decoradores 12
Manejo incorrecto de lado -efectos en un decorador 7
Requerir decoradores con efectos secundarios 13
1
Crear decoradores que siempre funcionen
13
El principio DRY con decoradores 1
Decoradores y separación de preocupaciones 13
Analizando buenos decoradores 2
13
Resumen 4
Referencias 13
[iii] 5
Capítulo 6: Obtener más de nuestros objetos con
13
descriptores Un primer vistazo a los descriptores 5
La maquinaria detrás de los descriptores 13
Tabla de contenido

__delete__(self, instancia) 159


__set_name__(self, propietario, nombre) 161
Tipos de descriptores Descriptores 16
sin datos Descriptores de 3
16
datos 3
Descriptores en acción 16
Una aplicación de descriptores 5
Un primer intento sin usar descriptores La 168
implementación idiomática 16
Diferentes formas de implementar 8
descriptores El problema del estado global 16
8
compartido
16
Acceder al diccionario del objeto 9
Usar referencias débiles 17
Más consideraciones sobre descriptores 2
Reutilizar código 17
Evitar decoradores de clase 2
Análisis de descriptores 17
3
Cómo Python usa los descriptores 17
internamente Funciones y métodos 4
Decoradores integrados para métodos 17
Ranuras 5
Implementar descriptores en decoradores 17
5
Resumen 17
Referencias 6
Capítulo 7: Uso de generadores 18
0
Requisitos técnicos 18
Creación de generadores 0
Un primer vistazo a los generadores 18
0
Expresiones de generador
18
Iteración idiomática 3
Modismos para iteración 18
La función next() 5
Uso de un generador 18
Itertools 5
Simplificación de código a través de iteradores 186
Iteraciones 187
repetidas
188
Bucles anidados
188
El patrón de iterador en Python
18
La interfaz para iteración
9
Objetos de secuencia como iterables
18
Corrutinas [iv] 9
Los métodos de la interfaz del generador 19
close() 2
throw(ex_type[, ex_value[, ex_traceback]]) 193
send(value) 19
Tabla de contenido

Delegar en rutinas más pequeñas: el rendimiento de la 21


sintaxis El uso más simple del rendimiento de 0
Capturar el valor devuelto por un subgenerador 21
Enviar y recibir datos hacia y desde un subgenerador 1
21
Programación asíncrona 2
Resumen 21
Referencias 3
215
Capítulo 8: Pruebas unitarias y refactorización 217
Principios de diseño y pruebas unitarias 218
Una nota sobre otras formas de pruebas 219
automatizadas Pruebas unitarias y desarrollo ágil de 219
software 221
Pruebas unitarias y diseño de software 222
Definición de los límites de qué probar 222
Frameworks y herramientas para testing 225
Frameworks y bibliotecas para unit testing 226
unittest 22
Pruebas 6
parametrizadas pytest 22
Casos de prueba básicos con pytest 8
Pruebas parametrizadas 23
Accesorios 0
23
Cobertura de código 2
Configuración de la cobertura restante 23
Advertencias de cobertura de prueba 3
Objetos simulados 23
Una advertencia justa sobre parches y 4
simulacros Uso de objetos simulados 23
Tipos de simulacros 5
Un caso de uso para dobles de prueba 23
6
Refactorización 23
Evolución de nuestro código 6
El código de producción es t lo único que evoluciona 23
Más sobre pruebas unitarias Pruebas 7
basadas en propiedades Pruebas de 23
8
mutación 23
Una breve introducción al desarrollo basado 9
en pruebas Resumen 23
9
Referencias 24
Capítulo 9: Patrones de diseño comunes 0
24
Consideraciones para los patrones de diseño 1
en Python Patrones de diseño en acción 243
[v]
Patrones de creación 244
Fábricas 246
Singleton y estado compartido (monoestado) 247
Estado compartido 248
Tabla de contenido

Constructor 26
Patrones estructurales 2
Adaptador 26
2
Compuesto
26
Decorador 3
Fachada 26
Patrones de comportamiento 4
Cadena de responsabilidad 26
El método de plantilla 6
Comando 26
Estado 8
26
El patrón de objeto nulo 9
Reflexiones finales sobre los 27
patrones de diseño La influencia de 0
27
los patrones sobre el diseño Nombres 2
en nuestros modelos 27
Resumen 3
Referencias 27
4
Capítulo 10: Arquitectura 280
limpia Del código limpio a la 28
arquitectura limpia Separación de 2
28
preocupaciones 2
Abstracciones 28
Componentes de software 3
Paquetes 284
Contenedores 285
Caso de uso 286
El código 286
Modelos de dominio 287
Llamadas desde la aplicación 288
Adaptadores 290
Los servicios 290
Análisis 293
El flujo de dependencia 29
Limitaciones Capacidad de prueba 5
Revelación de 29
Índice 311
6
intenciones
29
Resumen 6
Referencias 29
Resumiendo todo 8
30
Otros libros que puede disfrutar 0
[ vi ] 30
0
30
4
30
Prefacio
Este es un libro sobre principios de ingeniería de software aplicados a Python.

Hay muchos libros sobre ingeniería de software y muchos recursos disponibles con
información sobre Python. Sin embargo, la intersección de esos dos conjuntos es algo
que requiere acción, y esa es la brecha que este libro intenta salvar.

No sería realista cubrir todos los temas posibles sobre ingeniería de software en un solo
libro porque el campo es tan amplio que hay libros completos dedicados a ciertos temas.
Este libro se enfoca en las principales prácticas o principios de la ingeniería de software que
nos ayudarán a escribir un código más mantenible y cómo escribirlo aprovechando las
características de Python al mismo tiempo.

Una palabra para los sabios: no existe una solución única para un problema de software.
Por lo general, se trata de compensaciones. Cada solución tendrá ventajas y desventajas,
y se deben seguir algunos criterios para elegir entre ellas, aceptando los costos y
obteniendo los beneficios. Por lo general, no existe una mejor solución única, pero hay
principios que se deben seguir, y mientras los sigamos, estaremos caminando por un
camino mucho más seguro. Y de eso se trata este libro: de inspirar a los lectores a seguir
principios y tomar las mejores decisiones, porque aún frente a las dificultades, estaremos
mucho mejor si hemos seguido buenas prácticas.

Y, hablando de buenas prácticas, mientras algunas de las explicaciones siguen principios


establecidos y probados, otras partes son obstinadas. Pero eso no significa que deba hacerse
de esa manera en particular solamente. El autor no pretende ser ningún tipo de autoridad
en materia de código limpio, porque tal título no puede existir. Se alienta al lector a
involucrarse en el pensamiento crítico: tome lo que funciona mejor para su proyecto y
siéntase libre de estar en desacuerdo. Se fomentan las diferencias de opiniones siempre que
produzcan un debate esclarecedor.

Mi intención detrás de este libro es compartir las alegrías de Python y los modismos que
he aprendido de la experiencia, con la esperanza de que los lectores los encuentren útiles
para elevar su experiencia con el lenguaje.

El libro explica los temas a través de ejemplos de código. Estos ejemplos asumen que se
usa la última versión de Python en el momento de escribir este artículo, a saber, Python
3.7, aunque las versiones futuras también deberían ser compatibles. No hay
peculiaridades en el código que lo vinculen a ninguna plataforma en particular, por lo
tanto, con un intérprete de Python, los ejemplos de código se pueden probar en cualquier
sistema operativo.
Prefacio

En la mayoría de los ejemplos, con el objetivo de mantener el código lo más simple posible,
las implementaciones y sus pruebas están escritas en Python usando solo las bibliotecas
estándar. En algunos capítulos se necesitaban bibliotecas adicionales, y para poder ejecutar
los ejemplos de esos casos, se han proporcionado instrucciones junto con el respectivo
archivo requirements.txt .

A lo largo de este libro, descubriremos todas las características que Python tiene para
ofrecer para hacer que nuestro código sea mejor, más legible y más fácil de mantener. Lo
hacemos no solo explorando las características del lenguaje, sino también analizando cómo
se pueden aplicar las prácticas de ingeniería de software en Python. El lector notará que
algunas de las implementaciones de referencia difieren en Python, otros principios o
patrones cambian ligeramente y otros pueden no ser aplicables en todo momento.
Comprender cada caso representa una oportunidad para entender Python más
profundamente.

para quien es este libro


Este libro es adecuado para todos los profesionales de la ingeniería de software que
estén interesados en el diseño de software o en aprender más sobre Python. Se supone
que el lector ya está familiarizado con los principios del diseño de software orientado a
objetos y tiene algo de experiencia escribiendo código.

En términos de Python, el libro es apto para todos los niveles. Es bueno para aprender
Python porque está organizado de tal manera que el contenido está en orden creciente de
complejidad. Los primeros capítulos cubrirán los conceptos básicos de Python, que es una
buena manera de aprender los principales modismos, funciones y utilidades disponibles
en el lenguaje. La idea no es solo resolver algunos problemas con Python, sino hacerlo de
forma idiomática.

Los programadores experimentados también se beneficiarán de los temas de este libro,


ya que algunas secciones cubren temas avanzados en Python, como decoradores,
descriptores y una introducción a la programación asíncrona. Ayudará al lector a
descubrir más sobre Python porque algunos de los casos se analizan desde el interior del
propio lenguaje.

Vale la pena enfatizar la palabra practicantes en la primera oración de esta sección. Este es un
libro pragmático. Los ejemplos se limitan a lo que requiere el caso de estudio, pero también
pretenden parecerse al contexto de un proyecto de software real. No es un libro académico
y, como tal, las definiciones que se hacen, los comentarios que se hacen y las
recomendaciones que se dan deben tomarse con cautela. Se espera que el lector examine
estas recomendaciones de manera crítica y pragmática en lugar de dogmática. Después de
todo, la practicidad vence a la pureza.

[2]
Prefacio

Lo que cubre este libro


El Capítulo 1 , Introducción, formato de código y herramientas , es una introducción a las
principales herramientas que necesita para configurar un entorno de desarrollo en Python.
Cubrimos los conceptos básicos que se recomienda que un desarrollador de Python sepa
para comenzar a trabajar con el lenguaje, así como algunas pautas para mantener un
código legible en el proyecto, como herramientas para análisis estático, documentación,
verificación de tipos y formato de código.

El Capítulo 2 , Código Pythonic , analiza los primeros modismos en Python, que continuaremos
usando en los siguientes capítulos. Cubrimos las características particulares de Python,
cómo deben usarse y comenzamos a generar conocimiento en torno a la idea de que el
código Pythonic es, en general, un código de mucho mejor calidad.

El Capítulo 3 , Rasgos generales de un buen código , revisa los principios generales de la


ingeniería de software que se enfocan en escribir código mantenible. Exploramos la idea y
aplicamos los conceptos con las herramientas del lenguaje.

capítulo 4 , Los principios de SOLID , cubre un conjunto de principios de diseño para el


diseño de software orientado a objetos. Estas siglas forman parte del lenguaje o jerga de
la ingeniería de software, y vemos cómo se pueden aplicar cada una de ellas a Python.
Podría decirse que no todos ellos son completamente aplicables debido a la naturaleza
del idioma.

El Capítulo 5 , Uso de decoradores para mejorar nuestro código , analiza una de las mejores
características de Python. Después de entender cómo crear decoradores (para funciones
y clases), los ponemos en acción para reutilizar código, separar responsabilidades y crear
funciones más granulares.

El Capítulo 6 , Obtener más de nuestros objetos con descriptores , explora los descriptores en
Python, que llevan el diseño orientado a objetos a un nuevo nivel. Si bien esta es una
característica más relacionada con marcos y herramientas, podemos ver cómo mejorar la
legibilidad de nuestro código con descriptores y también reutilizar código.

El Capítulo 7 , Uso de generadores , muestra que los generadores son probablemente la mejor
característica de Python. El hecho de que la iteración sea un componente central de Python
podría hacernos pensar que conduce a un nuevo paradigma de programación. Al usar
generadores e iteradores en general, podemos pensar en la forma en que escribimos
nuestros programas. Con las lecciones aprendidas de los generadores, vamos más allá y
aprendemos sobre corrutinas en Python y los conceptos básicos de la
programación asíncrona.

[3]
Prefacio

El Capítulo 8 , Pruebas unitarias y refactorización , analiza la importancia de las pruebas


unitarias en cualquier base de código que afirme ser mantenible. El capítulo revisa la
importancia de las pruebas unitarias y exploramos los marcos principales para esto (
unittest y pytest ).

El Capítulo 9 , Patrones de diseño comunes , revisa cómo implementar los patrones de diseño
más comunes en Python, no desde el punto de vista de resolver un problema, sino
examinando cómo resuelven los problemas aprovechando una solución mejor y más fácil de
mantener. El capítulo menciona las peculiaridades de Python que han hecho invisibles
algunos de los patrones de diseño y adopta un enfoque pragmático para implementar
algunos de ellos.

El capítulo 10 , Arquitectura limpia , se centra en la idea de que el código limpio es la base


de una buena arquitectura. Todos esos detalles que mencionamos en el primer
capítulo, y todo lo demás revisado en el camino, desempeñará un papel fundamental
en todo el diseño cuando se implemente el sistema.

Para aprovechar al máximo este libro


El lector debe estar familiarizado con la sintaxis de Python y tener instalado un intérprete
de Python válido, que se puede descargar desde https://www.python.org/downloads/

Se recomienda seguir los ejemplos del libro y probar el código localmente. Para ello, es muy
recomendable crear un entorno virtual con Python 3.7 y ejecutar el código con este
intérprete. Las instrucciones para crear un entorno virtual se pueden encontrar en https:/./
docs.python.org/3/tutorial/venv.html

Descargue los archivos de código de ejemplo


Puede descargar los archivos de código de ejemplo para este libro desde su
cuenta en www.packtpub.com. Si compró este libro en otro lugar, puede visitarlo
www.packtpub.com/supporty registrarse para recibir los archivos directamente por
correo electrónico.

Puede descargar los archivos de código siguiendo estos pasos:

1. Inicie sesión o regístrese en www.packtpub.com.


2. Seleccione la pestaña SOPORTE .
3. Haga clic en Descargas de códigos y erratas .
4. Introduzca el nombre del libro en el cuadro de búsqueda y siga las
instrucciones en pantalla.

[4]
Prefacio

Una vez descargado el archivo, asegúrese de descomprimir o extraer la carpeta con la


última versión de:

WinRAR/7-Zip para Windows


Zipeg/iZip/UnRarX para Mac
7-Zip/PeaZip para Linux

El paquete de código para el libro también está alojado en GitHub en


https://github.com/PacktPublishing/Clean-Code-in-Python. En caso de que haya una actualización
del código, se actualizará en el repositorio de GitHub existente.

También tenemos otros paquetes de códigos de nuestro rico catálogo de libros y


videos disponibles en https://github.com/PacktPublishing/. ¡Échales un vistazo!

Convenciones utilizadas
Hay una serie de convenciones de texto utilizadas a lo largo de este libro.

CodeInText : indica palabras de código en texto, nombres de tablas de bases de datos,


nombres de carpetas, nombres de archivos, extensiones de archivos, nombres de rutas,
direcciones URL ficticias, entrada de usuario y identificadores de Twitter. Aquí hay un
ejemplo: "Entonces, solo ejecutar el comando pylint es suficiente para verificarlo en el
código".

Un bloque de código se establece de la siguiente manera:


clase Punto:
def __init__(self, lat, long):
self.lat = lat
self.long = long

Cuando deseamos llamar su atención sobre una parte particular de un bloque de código, las
líneas o elementos relevantes se muestran en negrita:

setup(
name="apptool",
description="Descripción de la intención del paquete",
long_description=long_description,

Cualquier entrada o salida de la línea de comandos se escribe de la siguiente manera:


>>> localizar.__anotaciones__
{'latitud': flotante, 'longitud': flotante, 'retorno': __principal__.Punto}
Negrita : indica un nuevo término, una palabra importante o palabras que ve en
pantalla. Por ejemplo, las palabras en los menús o cuadros de diálogo aparecen en el
texto de esta manera. Este es un ejemplo: "Seleccione Información del sistema en el panel
de administración ".

[5]
Prefacio

Las advertencias o notas importantes aparecen así.

Los consejos y trucos aparecen así.

Ponerse en contacto
Los comentarios de nuestros lectores es siempre bienvenido.

Comentarios generales : envíe un correo electrónico a [email protected] y mencione


el título del libro en el asunto de su mensaje. Si tiene preguntas sobre cualquier aspecto de
este libro, envíenos un correo electrónico a [email protected] .

Errata : Aunque hemos tomado todas las precauciones para garantizar la precisión de
nuestro contenido, los errores ocurren. Si ha encontrado un error en este libro, le
agradeceríamos que nos lo informara. Visite www.packtpub.com/submit-errata, seleccione su
libro, haga clic en el enlace Formulario de envío de erratas e ingrese los detalles.

Piratería : si encuentra copias ilegales de nuestros trabajos en cualquier forma en


Internet, le agradeceríamos que nos proporcionara la dirección de la ubicación o el
nombre del sitio web. Póngase en contacto con nosotros en [email protected] con
un enlace al material.

Si está interesado en convertirse en autor : si hay un tema en el que tiene experiencia y


está interesado en escribir o contribuir a un libro, visite
authors.packtpub.com.

Reseñas
Por favor, deje una reseña. Una vez que haya leído y usado este libro, ¿por qué no deja una
reseña en el sitio donde lo compró? Los lectores potenciales pueden ver y usar su opinión
imparcial para tomar decisiones de compra, nosotros en Packt podemos entender lo que
piensa sobre nuestros productos y nuestros autores pueden ver sus comentarios sobre su
libro. ¡Gracias!

Para obtener más información sobre Packt, visite packtpub.com.


[6]
Introducción, Formato de

código, 
y herramientas
En este capítulo exploraremos los primeros conceptos relacionados con el código limpio,
empezando por qué es y qué significa. El punto principal de este capítulo es comprender
que el código limpio no es solo algo agradable de tener o un lujo en los proyectos de
software. es una necesidad Sin código de calidad, el proyecto enfrentará los peligros de
fallar debido a una deuda técnica acumulada.

En la misma línea, pero profundizando un poco más, están los conceptos de formato y
documentación del código. Esto también puede sonar como un requisito o tarea
superflua, pero nuevamente, descubriremos que juega un papel fundamental para
mantener la base del código mantenible y viable.

Analizaremos la importancia de adoptar una buena pauta de codificación para este


proyecto. Al darnos cuenta de que mantener el código alineado con la referencia es una
tarea continua, veremos cómo podemos obtener ayuda de herramientas automatizadas
que facilitarán nuestro trabajo. Por esta razón, analizamos rápidamente cómo configurar
las herramientas principales para que se ejecuten automáticamente en el proyecto como
parte de la compilación.

Después de leer este capítulo, tendrá una idea de qué es el código limpio, por qué es
importante, por qué formatear y documentar el código son tareas cruciales y cómo
automatizar este proceso. A partir de esto, debe adquirir la mentalidad para organizar
rápidamente la estructura de un nuevo proyecto, con el objetivo de lograr una buena
calidad de código.

Después de leer este capítulo, habrá aprendido lo siguiente:


Ese código limpio realmente significa algo mucho más importante que
formatear en la construcción de software.
Que aún así, tener un formato estándar es un componente clave a tener en
un proyecto de software, en aras de su mantenibilidad
Introducción, formato de código y herramientas Capítulo 1

Cómo hacer que el código se autodocumente usando las características que


Python
proporciona
Cómo configurar herramientas para ayudar a organizar el diseño del código de
manera consistente para que los miembros del equipo puedan concentrarse en la
esencia del problema

El significado del código limpio


No existe una definición única o estricta de código limpio. Además, probablemente no haya
forma de medir formalmente el código limpio, por lo que no puede ejecutar una
herramienta en un repositorio que pueda decirle qué tan bueno, malo, mantenible o no es
ese código. Claro, puede ejecutar herramientas como verificadores, linters, analizadores
estáticos, etc. Y esas herramientas son de mucha ayuda. Son necesarios, pero no suficientes.
El código limpio no es algo que una máquina o un script pueda decir (hasta ahora), sino
algo que nosotros, como profesionales, podemos decidir.

Durante décadas de usar los términos lenguajes de programación, pensamos que eran
lenguajes para comunicar nuestras ideas a la máquina, para que pueda ejecutar nuestros
programas. Estuvimos equivocados. Esa no es la verdad, pero parte de la verdad. El
lenguaje real detrás de los lenguajes de programación es comunicar nuestras ideas a otros
desarrolladores.

Aquí es donde radica la verdadera naturaleza del código limpio. Depende de otros
ingenieros poder leer y mantener el código. Por lo tanto, nosotros, como profesionales,
somos los únicos que podemos juzgar esto. Piénsalo; como desarrolladores, pasamos
mucho más tiempo leyendo código que escribiéndolo. Cada vez que queramos hacer un
cambio o añadir una nueva característica, primero tenemos que leer todo el entorno del
código que tenemos que modificar o ampliar. El lenguaje (Python), es el que usamos para
comunicarnos entre nosotros.

Entonces, en lugar de darle una definición (o mi definición) de código limpio, lo invito a


leer el libro, leer todo sobre Python idiomático, ver la diferencia entre el código bueno y el
malo, identificar los rasgos del buen código y la buena arquitectura, y luego inventa tu
propia definición. Después de leer este libro, podrá juzgar y analizar el código por sí
mismo, y tendrá una comprensión más clara del código limpio. Sabrás qué es y qué
significa, independientemente de la definición que se te dé.
La importancia de tener un código limpio
Hay una gran cantidad de razones por las que un código limpio es importante. La mayoría
de ellos giran en torno a las ideas de mantenibilidad, reducción de la deuda técnica, trabajo
eficaz con desarrollo ágil y gestión de un proyecto exitoso.

[8]
Introducción, formato de código y herramientas Capítulo 1

La primera idea que me gustaría explorar tiene que ver con el desarrollo ágil y la entrega
continua. Si queremos que nuestro proyecto sea capaz de ofrecer características
constantemente a un ritmo constante y predecible, entonces es imprescindible tener una
base de código buena y mantenible.

Imagine que está conduciendo un automóvil por una carretera hacia un destino al que
desea llegar en un momento determinado. Tienes que estimar tu hora de llegada para avisar
a la persona que te está esperando. Si el automóvil funciona bien y la carretera es plana y
perfecta, entonces no veo por qué perdería su estimación por un amplio margen. Ahora
bien, si el camino está roto y tiene que salir para quitar rocas del camino, o evitar grietas,
detenerse para revisar el motor cada pocos kilómetros, etc., entonces es muy poco probable
que sepa con certeza cuándo. vas a llegar (o si vas a llegar). Creo que la analogía es clara; el
camino es el código. Si desea moverse a un ritmo constante, constante y predecible, el
código debe ser mantenible y legible. Si no es así, cada vez que la gestión de productos
solicite una nueva función, deberá detenerse para refactorizar y arreglar la deuda técnica.

La deuda técnica se refiere al concepto de problemas en el software como resultado de un


compromiso y una mala decisión. En cierto modo, es posible pensar en la deuda técnica de
dos maneras. Del presente al pasado. ¿Qué pasa si los problemas que enfrentamos
actualmente son el resultado de un código incorrecto escrito previamente? Del presente al
futuro : si decidimos tomar el atajo ahora, en lugar de invertir tiempo en una solución
adecuada, ¿qué problemas nos estamos creando en el futuro?

La palabra deuda es una buena elección. Es una deuda porque el código será más difícil de
cambiar en el futuro de lo que sería cambiarlo ahora. Ese costo incurrido son los intereses
de la deuda.
Incurrir en deuda técnica significa que mañana, el código será más duro y más caro (esto
sería posible incluso medirlo) que hoy, y más caro aún al día siguiente, y así sucesivamente.

Cada vez que el equipo no puede entregar algo a tiempo y tiene que detenerse para
arreglar y refactorizar el código, está pagando el precio de la deuda técnica.

Lo peor de la deuda técnica es que representa un problema subyacente y de largo plazo.


No es algo que levante una gran alarma. En cambio, es un problema silencioso, disperso
en todas las partes del proyecto, que un día, en un momento determinado, se despertará
y se convertirá en un obstáculo.

El papel del formato de código en código limpio


¿El código limpio se trata de formatear y estructurar el código de acuerdo con algunos
estándares (por ejemplo, PEP-8 o un estándar personalizado definido por las pautas del
proyecto)? La respuesta corta es no.

[9]
Introducción, formato de código y herramientas Capítulo 1

El código limpio es algo más que va mucho más allá de los estándares de codificación, el
formato, las herramientas de linting y otras comprobaciones relacionadas con el diseño del
código. El código limpio se trata de lograr un software de calidad y construir un sistema
que sea robusto, mantenible y que evite la deuda técnica. Un fragmento de código o un
componente de software completo podría cumplir al 100 % con PEP-8 (o cualquier otra
directriz) y aun así no cumplir estos requisitos.

Sin embargo, no prestar atención a la estructura del código tiene algunos peligros. Por esta
razón, primero analizaremos los problemas con una estructura de código incorrecta, cómo
abordarlos y luego veremos cómo configurar y usar herramientas para proyectos de Python
para
verificar y corregir problemas automáticamente.

Para resumir esto, podemos decir que el código limpio no tiene nada que ver con cosas
como PEP-8 o estilos de codificación. Va mucho más allá y significa algo más
significativo para la mantenibilidad del código y la calidad del software. Sin embargo,
como veremos, formatear el código correctamente es importante para que funcione de
manera eficiente.

Adherirse a una guía de estilo de codificación en


su proyecto
Una pauta de codificación es lo mínimo que debe tener un proyecto para ser considerado
como desarrollado bajo estándares de calidad. En esta sección, exploraremos las razones
detrás de esto, por lo que en las siguientes secciones, podemos comenzar a buscar formas de
hacer cumplir esto automáticamente por medio de herramientas.

Lo primero que me viene a la mente cuando trato de encontrar buenas características en un


diseño de código es la consistencia. Esperaría que el código esté estructurado de manera
consistente para que sea más fácil de leer y seguir. Si el código no es correcto o está
estructurado de manera consistente, y todos en el equipo están haciendo las cosas a su
manera, terminaremos con un código que requerirá un esfuerzo y concentración adicionales
para seguirlo correctamente. Será propenso a errores, engañoso, y los errores o sutilezas
pueden pasar desapercibidos fácilmente.

Queremos evitar eso. Lo que queremos es exactamente lo contrario : un código que


podamos leer y comprender lo más rápido posible de un solo vistazo.
Si todos los miembros del equipo de desarrollo acuerdan una forma estandarizada de
estructurar el código, el código resultante parecería mucho más familiar. Como resultado,
identificará patrones rápidamente (más sobre esto en un segundo), y con estos patrones en
mente, será mucho más fácil comprender las cosas y detectar errores. Por ejemplo, cuando
algo anda mal, notará que, de alguna manera, hay algo extraño en los patrones que está
acostumbrado a ver, lo que llamará su atención. ¡Mirarás más de cerca y lo más probable es
que detectes el error!

[ 10 ]
Introducción, formato de código y herramientas Capítulo 1

Como se afirma en el libro clásico Code Complete , un interesante análisis de esto se hizo en el
artículo titulado Perceptions in Chess (1973), donde se realizó un experimento para identificar
cómo diferentes personas pueden entender o memorizar diferentes posiciones de ajedrez. El
experimento se realizó en jugadores de todos los niveles (principiantes, intermedios y
maestros de ajedrez) y con diferentes posiciones de ajedrez en el tablero. Descubrieron que
cuando la posición era aleatoria, los novatos lo hacían tan bien como los maestros de
ajedrez; era solo un
ejercicio de memorización que cualquiera podía hacer razonablemente al mismo nivel.
Cuando las posiciones siguieron una secuencia lógica que podría ocurrir en un juego real
(nuevamente, consistencia, adhiriéndose a un patrón), entonces los maestros de ajedrez se
desempeñaron mucho mejor que el resto.

Ahora imagina esta misma situación aplicada al software. Nosotros, como ingenieros de
software expertos en Python, somos como los maestros de ajedrez del ejemplo anterior.
Cuando el código está estructurado al azar, sin seguir ninguna lógica o adherirse a ningún
estándar, sería tan difícil para nosotros detectar errores como un desarrollador novato. Por
otro lado, si estamos acostumbrados a leer código de forma estructurada y hemos
aprendido a obtener rápidamente las ideas del código siguiendo patrones, entonces
tenemos una ventaja considerable.

En particular, para Python, el tipo de estilo de codificación que debe seguir es PEP-8.
Puedes ampliarlo o adaptar algunas de sus partes a las particularidades del proyecto en el
que estés trabajando (por ejemplo, la longitud de la línea, las notas sobre las cadenas, etc.).
Sin embargo, sugiero que, independientemente de si está usando PEP-8 simple o si lo está
ampliando, realmente debería ceñirse a él en lugar de intentar crear otro estándar
diferente desde cero.

La razón de esto es que este documento ya tiene en cuenta muchas de las particularidades
de la sintaxis de Python (que normalmente no se aplicarían a otros lenguajes), y fue creado
por los principales desarrolladores de Python que en realidad contribuyeron a la sintaxis de
Python. Por esta razón, es difícil pensar que la precisión de PEP-8 pueda igualarse, por no
mencionar, mejorarse.

En particular, PEP-8 tiene algunas características que conllevan otras mejoras


agradables cuando se trata de código, como las siguientes:

Grepability : esta es la capacidad de grep tokens dentro del código; es decir,


buscar en ciertos archivos (y en qué parte de esos archivos) la cadena en
particular que estamos buscando. Uno de los elementos que introduce este
estándar es algo que diferencia la forma de escribir la asignación de valores a las
variables, de los argumentos de palabras clave que se pasan a las funciones.

[ 11 ]
Introducción, formato de código y herramientas Capítulo 1

Para verlo mejor, usemos un ejemplo. Digamos que estamos depurando y


necesitamos encontrar dónde se pasa el valor a un parámetro llamado ubicación .
Podemos ejecutar el siguiente comando grep , y el resultado nos dirá el archivo y
la línea que estamos buscando:
$ grep -nr "ubicación=".
./core.py:13: ubicación=ubicación_actual,

Ahora, queremos saber dónde se le está asignando este valor a esta variable, y el
siguiente comando también nos dará la información que estamos buscando:
$ grep -nr "ubicación =".
./core.py:10: ubicación_actual = obtener_ubicación()

PEP-8 establece la convención de que, al pasar argumentos por palabra clave a


una función, no usamos espacios, pero sí cuando asignamos variables. Por eso,
podemos adaptar nuestros criterios de búsqueda (sin espacios alrededor del = en
la primera búsqueda y un espacio en la segunda) y ser más eficientes en nuestra
búsqueda. Esa es una de las ventajas de seguir una convención.

Consistencia : si el código parece un formato uniforme, la lectura del mismo será


mucho más fácil. Esto es particularmente importante para la incorporación, si
desea dar la bienvenida a nuevos desarrolladores a su proyecto, o incluso
contratar programadores nuevos (y probablemente menos experimentados) en su
equipo, y necesitan familiarizarse con el código (que incluso podría consistir en
varios repositorios ). Les hará la vida mucho más fácil si el diseño del código, la
documentación, la convención de nomenclatura y demás son idénticos en todos
los archivos que abren, en todos los repositorios.
Calidad del código: al mirar el código de forma estructurada, será más
competente para comprenderlo de un vistazo (nuevamente, como en Perception in
Chess ), y detectará errores y fallas más fácilmente. Además de eso, las
herramientas que verifican la calidad del código también insinuarán posibles
errores. El análisis estático del código podría ayudar a reducir la proporción de
errores por línea de código.

Docstrings y anotaciones
Esta sección trata sobre la documentación del código en Python, desde dentro del código.
Un buen código se explica por sí mismo, pero también está bien documentado. Es una
buena idea explicar lo que se supone que debe hacer (no cómo).
[ 12 ]
Introducción, formato de código y herramientas Capítulo 1

Una distinción importante; documentar el código no es lo mismo que agregarle


comentarios. Los comentarios son malos y deben evitarse. Por documentación nos referimos
al hecho de explicar los tipos de datos, dar ejemplos de los mismos y anotar las variables.

Esto es relevante en Python, porque al estar tipeado dinámicamente, puede ser fácil
perderse en los valores de variables u objetos a través de funciones y métodos. Por esta
razón, indicar esta información facilitará la tarea a los futuros lectores del código.

Hay otra razón que se relaciona específicamente con las anotaciones. También pueden
ayudar a ejecutar algunas comprobaciones automáticas, como sugerencias de tipo, a través
de herramientas como Mypy. Descubriremos que, al final, agregar anotaciones vale la pena.

cadenas de documentación
En términos simples, podemos decir que las cadenas de documentación son básicamente
documentación incrustada en el código fuente. Una cadena de documentación es
básicamente una cadena literal, colocada en algún lugar del código, con la intención de
documentar esa parte de la lógica.

Note el énfasis en la palabra documentación . Esta sutileza es importante porque


pretende representar una explicación, no una justificación. Las cadenas de documentos
no son comentarios; son documentación.

Tener comentarios en el código es una mala práctica por múltiples razones. Primero, los
comentarios representan nuestra incapacidad para expresar nuestras ideas en el código. Si
realmente tenemos que explicar por qué o cómo estamos haciendo algo, entonces ese código
probablemente no sea lo suficientemente bueno. Para empezar, no se explica por sí mismo.
En segundo lugar, puede ser engañoso. Peor que tener que pasar algún tiempo leyendo una
sección complicada es leer un comentario sobre cómo se supone que funciona y darse
cuenta de que el código en realidad hace algo diferente. Las personas tienden a olvidarse de
actualizar los comentarios cuando cambian el código, por lo que el comentario junto a la
línea que se acaba de cambiar estará desactualizado, lo que resultará en una dirección
errónea peligrosa.

A veces, en contadas ocasiones, no podemos evitar tener comentarios. Tal vez haya un
error en una biblioteca de terceros que debemos sortear. En esos casos, puede ser
aceptable colocar un comentario pequeño pero descriptivo.

Con docstrings, sin embargo, la historia es diferente. Nuevamente, no representan


comentarios, sino la documentación de un componente particular (un módulo, clase,
método o función) en el código. Su uso no solo es aceptado sino también fomentado. Es
una buena práctica agregar docstrings siempre que sea posible.

[ 13 ]
Introducción, formato de código y herramientas Capítulo 1

La razón por la que es bueno tenerlos en el código (o tal vez incluso necesarios, según los
estándares de su proyecto) es que Python se escribe dinámicamente. Esto significa que, por
ejemplo, una función puede tomar cualquier cosa como valor para cualquiera de sus
parámetros. Python no impondrá ni comprobará nada de esto. Entonces, imagina que
encuentras una función en el código que sabes que tendrás que modificar. Incluso tiene la
suerte de que la función tenga un nombre descriptivo y que sus parámetros también lo
tengan. Es posible que aún no esté muy claro qué tipos debe pasarle. Incluso si este es el
caso, ¿cómo se espera que se utilicen?

Aquí es donde una buena cadena de documentación podría ser de ayuda. Documentar la
entrada y la salida esperadas de una función es una buena práctica que ayudará a los
lectores de esa función a comprender cómo se supone que funciona.

Considere este buen ejemplo de la biblioteca estándar:


En [1]: dict.update??
Docstring:
D.update([E, ]**F) -> Ninguno. Actualice D desde dict/iterable E y F.
Si E está presente y tiene un método .keys(), entonces: para k en E: D[k] = E[k]
Si E está presente y no tiene un método .keys(), entonces: para k, v en E: D[k] = v
En cualquier caso, esto es seguido por: for k en F: D[k] = F[k]
Tipo: method_descriptor

Aquí, la cadena de documentación para el método de actualización en los diccionarios nos


brinda información útil y nos dice que podemos usarlo de diferentes maneras:

1. Podemos pasar algo con un método .keys() (por ejemplo, otro


diccionario), y actualizará el diccionario original con las claves del objeto
pasado por parámetro:

>>> d = {}
>>> d.update({1: "uno", 2: "dos"})
>>> d
{1: 'uno', 2: 'dos'}

2. Podemos pasar un iterable de pares de claves y valores, y los


desempaquetaremos para actualizar :

>>> d.update([(3, "tres"), (4, "cuatro")]) >>> d


{1: 'uno', 2: 'dos', 3: 'tres', 4: 'cuatro'}
[ 14 ]
Introducción, formato de código y herramientas Capítulo 1

En cualquier caso, el diccionario se actualizará con el resto de los argumentos de palabras


clave que se le pasan.

Esta información es crucial para alguien que tiene que aprender y comprender cómo
funciona una nueva función y cómo puede aprovecharla.

Tenga en cuenta que en el primer ejemplo, obtuvimos la cadena de documentación de la


función usando el signo de interrogación doble ( dict.update?? ). Esta es una característica
del intérprete interactivo de IPython. Cuando se llama esto, imprimirá la cadena de
documentación del objeto que está esperando.
Ahora, imagina que de la misma manera obtuvimos ayuda de esta función de la biblioteca
estándar; ¿Cuánto más fácil podría hacer la vida de sus lectores (los usuarios de su código),
si coloca cadenas de documentación en las funciones que escribe para que otros puedan
entender su funcionamiento de la misma manera?

El docstring no es algo separado o aislado del código. Se convierte en parte del código y
puede acceder a él. Cuando un objeto tiene definido un docstring, este se convierte en
parte de él a través de su atributo __doc__ :

>>> def my_function():


... """Ejecutar algunos cálculos"""
... devuelve Ninguno
...
>>> my_function.__doc__
'Ejecutar algunos cálculos'

Esto significa que incluso es posible acceder a él en tiempo de ejecución e incluso generar
o compilar documentación a partir del código fuente. De hecho, hay herramientas para
eso. Si ejecuta Sphinx, creará el andamio básico para la documentación de su proyecto.
Con la extensión autodoc ( sphinx.ext.autodoc ) en particular, la herramienta tomará las
cadenas de documentación del código y las colocará en las páginas que documentan la
función.

Una vez que tenga las herramientas para crear la documentación, hágala pública para que
se convierta en parte del proyecto en sí. Para proyectos de código abierto, puede usar read
the docs, que generará la documentación automáticamente por rama o versión
(configurable).
Para empresas o proyectos, puedes tener las mismas herramientas o configurar estos
servicios on-premise, pero independientemente de esta decisión, lo importante es que
la documentación esté lista y disponible para todos los miembros del equipo.
Hay, lamentablemente, una desventaja de docstrings, y es que, como sucede con toda la
documentación, requiere un mantenimiento manual y constante. A medida que cambia el
código, tendrá que ser actualizado. Otro problema es que para que las cadenas de
documentos sean realmente útiles, deben estar detalladas, lo que requiere varias líneas.

[ 15 ]
Introducción, formato de código y herramientas Capítulo 1

Mantener la documentación adecuada es un desafío de ingeniería de software del que no


podemos escapar. También tiene sentido ser así. Si lo piensa bien, la razón por la que la
documentación se escribe manualmente es porque está destinada a ser leída por otros
humanos. Si estuviera automatizado, probablemente no sería de mucha utilidad. Para que
la documentación tenga algún valor, todos los miembros del equipo deben estar de acuerdo
en que es algo que requiere intervención manual, de ahí el esfuerzo requerido. La clave es
comprender que el software no se trata solo de código. La documentación que lo acompaña
también forma parte del entregable. Por lo tanto, cuando alguien está haciendo un cambio
en una función, es igualmente importante actualizar también la parte correspondiente de la
documentación al código que se acaba de cambiar, independientemente de si es un wiki, un
manual de usuario, un archivo LÉAME o varios. cadenas de documentación.

Anotaciones
PEP-3107 introdujo el concepto de anotaciones. La idea básica de ellos es sugerir a los
lectores del código qué esperar como valores de argumentos en funciones. El uso de la
palabra pista no es casual; las anotaciones permiten la sugerencia de tipo, que discutiremos
más adelante en este capítulo, después de la primera introducción a las anotaciones.

Las anotaciones le permiten especificar el tipo esperado de algunas variables que se han
definido. En realidad, no se trata solo de los tipos, sino de cualquier tipo de metadatos que
puedan ayudarlo a tener una mejor idea de lo que realmente representa esa variable.

Considere el siguiente ejemplo:


clase Punto:
def __init__(self, lat, long):
self.lat = lat
self.long = long

def localizar(latitud: float, longitud: float) -> Punto: """Buscar un


objeto en el mapa por sus coordenadas"""

Aquí, usamos float para indicar los tipos esperados de latitud y longitud . Esto es
meramente informativo para el lector de la función para que pueda hacerse una idea de
estos tipos esperados. Python no verificará estos tipos ni los aplicará.

También podemos especificar el tipo esperado del valor devuelto por la función. En este
caso, Point es una clase definida por el usuario, por lo que significará que todo lo que se
devuelva será una instancia de Point .
[ dieciséis ]
Introducción, formato de código y herramientas Capítulo 1

Sin embargo, los tipos o incorporados no son el único tipo de cosa que podemos usar
como anotaciones. Básicamente, todo lo que es válido en el ámbito del intérprete de
Python actual podría colocarse allí. Por ejemplo, una cadena que explique la intención de
la variable, un invocable que se usará como función de devolución de llamada o
validación, etc.

Con la introducción de anotaciones, también se incluye un nuevo atributo especial, y es


__annotations__ . Esto nos dará acceso a un diccionario que mapea el nombre de las
anotaciones (como claves en el diccionario) con sus valores correspondientes, que son los
que hemos definido para ellas. En nuestro ejemplo, esto se verá así:

>>> localizar.__anotaciones__
{'latitud': flotante, 'longitud': flotante, 'retorno': __principal__.Punto}

Podríamos usar esto para generar documentación, ejecutar validaciones o hacer cumplir
controles en nuestro código si creemos que es necesario.

Hablando de verificar el código a través de anotaciones, aquí es cuando entra en juego PEP-
484. Este PEP especifica los conceptos básicos de la sugerencia de tipo; la idea de verificar
los tipos de nuestras funciones a través de anotaciones. Solo para ser claros nuevamente, y
citando al propio PEP-484:

"Python seguirá siendo un lenguaje tipificado dinámicamente, y los autores no tienen


ningún deseo de hacer que las sugerencias de tipo sean obligatorias, incluso por
convención".

La idea de la sugerencia de tipos es tener herramientas adicionales (independientes del


intérprete) para verificar y evaluar el uso correcto de los tipos en todo el código y dar pistas
al usuario en caso de que se detecten incompatibilidades. La herramienta que ejecuta estas
comprobaciones, Mypy, se explica con más detalle en una sección posterior, donde
hablaremos sobre el uso y la configuración de las herramientas para el proyecto. Por ahora,
puede considerarlo como una especie de linter que verificará la semántica de los tipos
utilizados en el código. Esto a veces ayuda a encontrar errores desde el principio, cuando se
ejecutan las pruebas y comprobaciones. Por esta razón, es una buena idea configurar Mypy
en el proyecto y usarlo al mismo nivel que el resto de herramientas de análisis estático.

Sin embargo, la sugerencia de tipo significa más que una herramienta para verificar los
tipos en el código. A partir de Python 3.5, se introdujo el nuevo módulo de escritura, y
esto mejoró significativamente la forma en que definimos los tipos y las anotaciones en
nuestro código de Python.
La idea básica detrás de esto es que ahora la semántica se extiende a conceptos más
significativos, haciéndonos aún más fácil para nosotros (los humanos) entender lo que
significa el código, o lo que se espera en un punto dado. Por ejemplo, podría tener una
función que trabajara con listas o tuplas en uno de sus parámetros, y habría puesto uno de
estos dos tipos como anotación, o incluso una cadena que lo explicara. Pero con este
módulo, es posible decirle a Python que espera un iterable o una secuencia. Incluso puede
identificar el tipo o los valores en él; por ejemplo, que toma una secuencia de números
enteros.

[ 17 ]
Introducción, formato de código y herramientas Capítulo 1

Hay una mejora adicional realizada con respecto a las anotaciones al momento de escribir
este libro, y es que a partir de Python 3.6, es posible anotar variables directamente, no solo
parámetros de función y tipos de devolución. Esto fue introducido en PEP-526, y la idea es
que se pueden declarar los tipos de algunas variables definidas sin necesariamente
asignarles un valor, como se muestra en el siguiente listado:

clase Punto:
lat: float
long: float

>>> Punto.__anotaciones__
{'lat': <clase 'float'>, 'long': <clase 'float'>}

¿Las anotaciones reemplazan las cadenas de


documentación?
Esta es una pregunta válida, ya que en versiones anteriores de Python, mucho antes de que
se introdujeran las anotaciones, la forma de documentar los tipos de los parámetros de
funciones o atributos se hacía poniendo docstrings sobre ellos. Incluso existen algunas
convenciones sobre formatos sobre cómo estructurar cadenas de documentos para incluir la
información básica de una función, incluidos los tipos y el significado de cada parámetro, el
tipo y el significado del resultado, y las posibles excepciones que la función podría generar.

La mayor parte de esto ya se ha abordado de una manera más compacta mediante


anotaciones, por lo que uno podría preguntarse si realmente vale la pena tener cadenas de
documentos también. La respuesta es sí, y esto se debe a que se complementan.

Es cierto que una parte de la información contenida anteriormente en el docstring ahora se


puede mover a las anotaciones. Pero esto solo debería dejar más espacio para una mejor
documentación en la cadena de documentación. En particular, para los tipos de datos
dinámicos y anidados, siempre es una buena idea proporcionar ejemplos de los datos
esperados para que podamos tener una mejor idea de lo que estamos tratando.

Considere el siguiente ejemplo. Digamos que tenemos una función que espera que un
diccionario valide algunos datos:

def data_from_response(respuesta: dict) -> dict: if


respuesta["status"] != 200:
aumentar ValueError
return {"data": respuesta["payload"]}
[ 18 ]
Introducción, formato de código y herramientas Capítulo 1

Aquí podemos ver una función que toma un diccionario y devuelve otro diccionario.
Potencialmente, puede generar una excepción si el valor debajo de la clave "estado" no
es el esperado. Sin embargo, no tenemos mucha más información al respecto. Por
ejemplo, ¿cómo es una instancia correcta de un objeto de respuesta ? ¿Cómo sería una
instancia de resultado ? Para responder a estas dos preguntas, sería una buena idea
documentar ejemplos de los datos que se espera que pase un parámetro y que esta
función devuelva.

Veamos si podemos explicar esto mejor con la ayuda de una cadena de documentación:
def data_from_response(response: dict) -> dict: """Si la respuesta
está bien, devuelve su carga útil.

- respuesta: Un dictado como::

{
"status": 200, # <int>
"timestamp": "....", # cadena de formato ISO de la fecha y hora actual
"carga útil": { ... } # dict con los datos devueltos
}

- Devuelve un diccionario como::

{"datos": { .. } }

- Genera:
- ValueError si el estado HTTP es != 200 """
if respuesta["status"] != 200:
aumentar ValueError
return {"data": respuesta["payload"]}

Ahora, tenemos una mejor idea de lo que se espera que reciba y devuelva esta función. La
documentación sirve como información valiosa, no solo para comprender y tener una idea
de lo que se transmite, sino también como una fuente valiosa para las pruebas unitarias.
Podemos derivar datos como este para usarlos como entrada, y sabemos cuáles serían los
valores correctos e incorrectos para usar en las pruebas. En realidad, las pruebas también
funcionan como
documentación procesable para nuestro código, pero esto se explicará con más detalle.

El beneficio es que ahora sabemos cuáles son los posibles valores de las claves, así como
sus tipos, y tenemos una interpretación más concreta de cómo se ven los datos. El costo
es que, como mencionamos anteriormente, ocupa muchas líneas y debe ser verboso y
detallado para que sea efectivo.
[ 19 ]
Introducción, formato de código y herramientas Capítulo 1

Configuración de las herramientas para hacer


cumplir las puertas de calidad básicas
En esta sección, exploraremos cómo configurar algunas herramientas básicas y ejecutar
automáticamente verificaciones en el código, con el objetivo de aprovechar parte de las
verificaciones repetitivas.

Este es un punto importante: recuerde que el código es para que nosotros, las personas,
entendamos, por lo que solo nosotros podemos determinar qué es un código bueno o malo.
Deberíamos invertir tiempo en revisiones de código, pensando en qué es un buen código y
qué tan legible y comprensible es. Al mirar el código escrito por un compañero, debe hacer
estas preguntas:

¿Es este código fácil de entender y seguir para un compañero programador?


¿Habla en términos del dominio del problema?
¿Una nueva persona que se una al equipo podría entenderlo y trabajar con él de
manera efectiva?

Como vimos anteriormente, el formato del código, el diseño coherente y la sangría


adecuada son características necesarias, pero no suficientes, para tener en una base de
código. Además, esto es algo que nosotros, como ingenieros con un alto sentido de la
calidad, daríamos por sentado, por lo que leeríamos y escribiríamos código mucho más
allá de los conceptos básicos de su diseño. Por lo tanto, no estamos dispuestos a perder el
tiempo revisando este tipo de elementos, por lo que podemos invertir nuestro tiempo de
manera más efectiva observando los patrones reales en el código para comprender su
verdadero significado y brindar resultados valiosos.

Todos estos controles deben ser automatizados. Deben ser parte de las pruebas o la lista de
verificación, y esto, a su vez, debe ser parte de la construcción de integración continua. Si
estas comprobaciones no pasan, haga que la compilación falle. Esta es la única forma de
garantizar realmente la continuidad de la estructura del código en todo momento. También
sirve como parámetro objetivo para que el equipo lo tenga como referencia. En lugar de que
algunos ingenieros o el líder del equipo siempre tengan que decir los mismos comentarios
sobre PEP-8 en las revisiones de código, la compilación fallará automáticamente, lo que la
convierte en algo objetivo.

Escriba sugerencias con Mypy


Mypy ( http://mypy-lang.org/) es la herramienta principal para la comprobación opcional de
tipos estáticos en Python. La idea es que, una vez que lo instales, analizará todos los
archivos de tu proyecto, buscando inconsistencias en el uso de los tipos. Esto es útil ya que,
la mayoría de las veces, detectará errores reales temprano, pero a veces puede dar falsos
positivos.

[ 20 ]
Introducción, formato de código y herramientas Capítulo 1

Puede instalarlo con pip y se recomienda incluirlo como una dependencia para el
proyecto en el archivo de instalación:
$ pip instalar mypy

Una vez que está instalado en el entorno virtual, solo tiene que ejecutar el comando anterior
e informará todos los hallazgos en las comprobaciones de tipo. Intente adherirse a su
informe tanto como sea posible, porque la mayoría de las veces, la información
proporcionada por él ayuda a evitar errores que, de lo contrario, podrían colarse en la
producción. Sin embargo, la herramienta no es perfecta, por lo que si cree que está
reportando un falso positivo, puede ignorar esa línea con el siguiente marcador como
comentario:
type_to_ignore = "algo" # tipo: ignorar

Comprobando el código con Pylint


Hay muchas herramientas para verificar la estructura del código (básicamente, esto es
cumplimiento con PEP-8) en Python, como pycodestyle (anteriormente conocido como
PEP-8), Flake8 y muchas más. Todos son configurables y son tan fáciles de usar como
ejecutar el comando que proporcionan. Entre todos ellos, Pylint me ha parecido el más
completo (y estricto). También es configurable.

De nuevo, solo tienes que instalarlo en el entorno virtual con pip :


$ pip instalar pylint

Entonces, simplemente ejecutar el comando pylint sería suficiente para verificarlo en el


código.

Es posible configurar Pylint a través de un archivo de configuración llamado pylintrc .

En este archivo, puede decidir las reglas que le gustaría habilitar o deshabilitar, y
parametrizar otras (por ejemplo, para cambiar la longitud máxima de la columna).

Configuración para controles automáticos


En entornos de desarrollo Unix, la forma más común de trabajar es a través de makefiles.
Los Makefiles son herramientas poderosas que nos permiten configurar comandos para
que se ejecuten en el proyecto, principalmente para compilar, ejecutar, etc. Además de esto,
podemos usar un archivo MAKE en la raíz de nuestro proyecto, con algunos comandos
configurados para ejecutar verificaciones de formato y convenciones en el código,
automáticamente.

[ 21 ]
Introducción, formato de código y herramientas Capítulo 1

Un buen enfoque para esto sería tener objetivos para las pruebas, y cada prueba en
particular, y luego tener otra que se ejecute por completo. Por ejemplo:

tipo:
mypy src/tests/

prueba:
pruebas pytest/

pelusa:
pylint src/pruebas/

lista de verificación: prueba de sugerencia de tipo de pelusa

.PHONY: lista de verificación de pelusa de prueba de tipificación

Aquí, el comando que debemos ejecutar (tanto en nuestras máquinas de desarrollo


como en las compilaciones del entorno de integración continua) es el siguiente:
hacer una lista de verificación

Esto ejecutará todo en los siguientes pasos:

1. Primero verificará el cumplimiento de la pauta de codificación (PEP-8, por


ejemplo)
2. Luego verificará el uso de tipos en el código.
3. Finalmente, ejecutará las pruebas.

Si alguno de estos pasos falla, considere que todo el proceso es un fracaso.

Además de configurar estas comprobaciones automáticamente en la compilación, también


es una buena idea que el equipo adopte una convención y un enfoque automático para
estructurar el código. Herramientas como Black ( https://github.com/ambv/black) dan formato
automáticamente al código. Hay muchas herramientas que editarán el código
automáticamente, pero lo interesante de Black es que lo hace de una forma única. Es
obstinado y determinista, por lo que el código siempre terminará organizado de la misma
manera.

Por ejemplo, las cadenas negras siempre estarán entre comillas dobles y el orden de los
parámetros siempre seguirá la misma estructura. Esto puede sonar rígido, pero es la única
forma de garantizar que las diferencias en el código sean mínimas. Si el código siempre
respeta la misma estructura, los cambios en el código solo se mostrarán en las solicitudes
de incorporación de cambios con los cambios reales que se realizaron y sin modificaciones
cosméticas adicionales. Es más restrictivo que PEP-8, pero también es conveniente porque,
al formatear el código directamente a través de una herramienta, no tenemos que
preocuparnos por eso y podemos concentrarnos en el quid del problema en cuestión.

[ 22 ]
Introducción, formato de código y herramientas Capítulo 1

Al momento de escribir este libro, lo único que se puede configurar es la longitud de las
líneas. Todo lo demás se corrige según los criterios del proyecto.

El siguiente código es PEP-8 correcto, pero no sigue las convenciones de black :


def mi_funcion(nombre):
"""
>>> mi_funcion('negro')
'recibido Negro'
"""
return 'recibido {0}'.format(nombre.titulo())

Ahora, podemos ejecutar el siguiente comando para formatear el archivo:


negro -l 79 *.py

Ahora, podemos ver lo que ha escrito la herramienta:


def mi_funcion(nombre):
"""
>>> mi_funcion('negro')
'recibido Negro'
"""
return "recibido {0}".format(nombre.titulo())

En un código más complejo, habría cambiado mucho más (comas finales y más), pero la
idea se puede ver claramente. Nuevamente, es obstinado, pero también es una buena idea
tener una herramienta que se ocupe de los detalles por nosotros. También es algo que la
comunidad de Golang aprendió hace mucho tiempo, hasta el punto de que existe una
biblioteca de herramientas estándar, fmt , que formatea automáticamente el código de
acuerdo con las convenciones del lenguaje. Es bueno que Python tenga algo como esto
ahora.

Estas herramientas (Black, Pylint, Mypy y muchas más) se pueden integrar con el editor o
IDE de su elección para facilitar aún más las cosas. Es una buena inversión configurar tu
editor para hacer este tipo de modificaciones ya sea al guardar el archivo o mediante un
atajo.
[ 23 ]
Introducción, formato de código y herramientas Capítulo 1

Resumen
Ahora tenemos una primera idea de lo que es el código limpio y una interpretación
viable del mismo, que nos servirá como punto de referencia para el resto de este libro.

Más importante aún, entendimos que el código limpio es algo mucho más importante que
la estructura y el diseño del código. Tenemos que centrarnos en cómo se representan las
ideas en el código para ver si son correctas. El código limpio tiene que ver con la legibilidad,
la capacidad de mantenimiento del código, mantener la deuda técnica al mínimo y
comunicar de manera efectiva nuestras ideas en el código para que otros puedan entender
lo mismo que pretendíamos escribir en primer lugar.

Sin embargo, discutimos que la adherencia a los estilos o pautas de codificación es


importante por múltiples razones. Hemos coincidido en que esta es una condición
necesaria, pero no suficiente, y como es un requisito mínimo que todo proyecto sólido debe
cumplir, está claro que es algo que mejor dejamos a las herramientas. Por lo tanto, la
automatización de todas estas comprobaciones se vuelve crítica y, en este sentido, debemos
tener en cuenta cómo configurar herramientas como Mypy, Pylint y más.

El próximo capítulo se centrará más en el código específico de Python y en cómo expresar


nuestras ideas en Python idiomático. Exploraremos las expresiones en Python que hacen
que el código sea más compacto y eficiente. En este análisis, veremos que, en general,
Python tiene diferentes ideas o diferentes formas de lograr cosas en comparación con otros
lenguajes.
[ 24 ]
Código pitónico 
En este capítulo, exploraremos la forma en que se expresan las ideas en Python, con sus
propias particularidades. Si está familiarizado con las formas estándar de realizar algunas
tareas en programación (como obtener el último elemento de una lista, iterar, buscar, etc.), o
si proviene de lenguajes de programación más tradicionales (como C, C++ y Java),
encontrará que, en general, Python proporciona su propio mecanismo para las tareas más
comunes.

En programación, un idioma es una forma particular de escribir código para realizar una
tarea específica. Es algo común que repite y sigue la misma estructura cada vez. Algunos
incluso podrían discutir y llamarlos un patrón, pero tenga cuidado porque no son patrones
diseñados (que exploraremos más adelante). La principal diferencia es que los patrones de
diseño son ideas de alto nivel, independientes del lenguaje (más o menos), pero no se
traducen en código inmediatamente. Por otro lado, los modismos en realidad están
codificados. Es la forma en que se deben escribir las cosas cuando queremos realizar una
determinada tarea.

Como los modismos son código, dependen del idioma. Cada idioma tendrá sus propios
modismos, lo que significa la forma en que se hacen las cosas en ese idioma en particular
(por ejemplo, cómo abriría y escribiría un archivo en C, C++, etc.). Cuando el código sigue
estos modismos, se conoce como idiomático, lo que en Python a menudo se denomina
Pythonic .

Hay múltiples razones para seguir estas recomendaciones y escribir código Pythonic
primero (como veremos y analizaremos), escribir código de forma idiomática suele
funcionar mejor. También es más compacto y más fácil de entender. Estos son rasgos que
siempre queremos en nuestro código para que funcione de manera efectiva. En segundo
lugar, como se presentó en el capítulo anterior, es importante que todo el equipo de
desarrollo pueda acostumbrarse a los mismos patrones y estructura del código porque esto
les ayudará a enfocarse en la verdadera esencia del problema y les ayudará a evitar cometer
errores. .
Código pitónico Capitulo 2

Los objetivos de este capítulo son los siguientes:

Comprender los índices y las porciones, e implementar correctamente los


objetos que se pueden indexar
Para implementar secuencias y otros iterables.
Aprender buenos casos de uso para administradores de contexto
Implementar más código idiomático a través de métodos mágicos.
Para evitar errores comunes en Python que provocan efectos secundarios no
deseados

Índices y cortes
En Python, como en otros lenguajes, algunas estructuras o tipos de datos admiten el acceso
a sus elementos por índice. Otra cosa que tiene en común con la mayoría de los lenguajes de
programación es que el primer elemento se coloca en el índice número cero. Sin embargo, a
diferencia de esos lenguajes, cuando queremos acceder a los elementos en un orden
diferente al habitual, Python proporciona características adicionales.

Por ejemplo, ¿cómo accedería al último elemento de una matriz en C? Esto es algo que
hice la primera vez que probé Python. Pensando de la misma manera que en C, obtendría
el elemento en la posición de la longitud de la matriz menos uno. Esto podría funcionar,
pero también podríamos usar un número de índice negativo, que comenzará a contar
desde el último, como se muestra en los siguientes comandos:
>>> mis_numeros = (4, 5, 3, 9)
>>> mis_numeros[-1]
9
>>> mis_numeros[-3]
5

Además de obtener un solo elemento, podemos obtener muchos usando slice , como se
muestra en los siguientes comandos:

>>> mis_numeros = (1, 1, 2, 3, 5, 8, 13, 21) >>>


mis_numeros[2:5]
(2, 3, 5)

En este caso, la sintaxis de los corchetes significa que obtenemos todos los elementos de la
tupla, desde el índice del primer número (incluido) hasta el índice del segundo (sin
incluirlo). Las rebanadas funcionan de esta manera en Python excluyendo el final del
intervalo seleccionado.

[ 26 ]
Código pitónico Capitulo 2

Puede excluir cualquiera de los intervalos, iniciar o detener, y en ese caso, actuará desde el
principio o el final de la secuencia, respectivamente, como se muestra en los siguientes
comandos:

>>> mis_numeros[:3]
(1, 1, 2)
>>> mis_numeros[3:]
(3, 5, 8, 13, 21)
>>> mis_numeros[::]
(1, 1, 2, 3 , 5, 8, 13, 21)
>>> mis_numeros[1:7:2]
(1, 3, 8)

En el primer ejemplo, obtendrá todo hasta el índice en la posición número 3 . En el segundo


ejemplo, obtendrá todos los números desde la posición 3 (inclusive), hasta el final. En el
penúltimo ejemplo, donde se excluyen ambos extremos, en realidad se está creando una
copia de la tupla original.

El último ejemplo incluye un tercer parámetro, que es el paso. Esto indica cuántos
elementos saltar al iterar sobre el intervalo. En este caso, se trataría de conseguir los
elementos entre las posiciones uno y siete, saltando de dos en dos.

En todos estos casos, cuando pasamos intervalos a una secuencia, lo que en realidad sucede
es que estamos pasando slice . Tenga en cuenta que slice es un objeto integrado en Python
que puede crear usted mismo y pasar directamente:

>>> intervalo = sector(1, 7, 2)


>>> mis_numeros[intervalo]
(1, 3, 8)

>>> intervalo = sector(Ninguno, 3)


>>> mis_numeros[intervalo] == mis_numeros[:3] True

Tenga en cuenta que cuando falta uno de los elementos (inicio, parada o paso), se
considera que no existe.

Siempre debe preferir usar esta sintaxis integrada para los segmentos, en
lugar de intentar iterar manualmente la tupla, la cadena o la lista dentro
de un bucle for , excluyendo los elementos a mano.
[ 27 ]
Código pitónico Capitulo 2

Creando tus propias secuencias


La funcionalidad que acabamos de discutir funciona gracias a un método mágico llamado
__getitem__ . Este es el método que se llama, cuando se llama a algo como myobject[key] ,
pasando la clave (valor dentro de los corchetes) como parámetro. Una secuencia, en
particular, es un objeto que implementa tanto __getitem__ como __len__ y, por esta razón, se
puede iterar. Las listas, las tuplas y las cadenas son ejemplos de objetos de secuencia en la
biblioteca estándar.

En esta sección, nos preocupamos más por obtener elementos particulares de un objeto
mediante una clave que por construir secuencias u objetos iterables, que es un tema
explorado en el Capítulo 7 , Uso de generadores .

Si va a implementar __getitem__ en una clase personalizada en su dominio, deberá tener


en cuenta algunas consideraciones para seguir un enfoque Pythonic.

En el caso de que su clase sea un contenedor alrededor de un objeto de biblioteca estándar,


también podría delegar el comportamiento tanto como sea posible al objeto subyacente.
Esto significa que si su clase es en realidad un contenedor en la lista, llame a todos los
mismos métodos en esa lista para asegurarse de que siga siendo compatible. En el siguiente
listado, podemos ver un ejemplo de cómo un objeto envuelve una lista, y para los métodos
que nos interesan, simplemente delegamos a su versión correspondiente en el objeto de la
lista :

Elementos de clase:
def __init__(self, *valores):
self._values = lista(valores)

def __len__(self):
return len(self._values)

def __getitem__(self, item):


return self._values.__getitem__(item)

Este ejemplo utiliza encapsulación. Otra forma de hacerlo es mediante herencia, en cuyo
caso tendremos que extender la clase base
collections.UserList , con las consideraciones y salvedades mencionadas en la última parte de
este capítulo.

Sin embargo, si está implementando su propia secuencia, que no es un contenedor o no


depende de ningún objeto integrado debajo, tenga en cuenta los siguientes puntos:
Al indexar por un rango, el resultado debe ser una instancia del mismo tipo de
la clase
En el rango provisto por el segmento , respete la semántica que usa Python,
excluyendo el elemento al final

[ 28 ]
Código pitónico Capitulo 2

El primer punto es un error sutil. Piénselo : cuando obtiene una porción de una lista, el
resultado es una lista; cuando solicita un rango en una tupla, el resultado es una tupla; y
cuando solicita una subcadena, el resultado es una cadena. Tiene sentido en cada caso que
el resultado sea del mismo tipo del objeto original. Si está creando, digamos, un objeto que
representa un intervalo de fechas y solicita un rango en ese intervalo, sería un error
devolver una lista o tupla, y muchos más. En su lugar, debería devolver una nueva
instancia de la misma clase con el nuevo conjunto de intervalos. El mejor ejemplo de esto
está en la biblioteca estándar, con la función de rango.
Anteriormente, en Python 2, la función de rango se usaba para crear una lista. Ahora, si
llama a range con un intervalo, construirá un objeto iterable que sabe cómo producir los
valores en el rango seleccionado. Cuando especifica un intervalo para el rango, obtiene un
nuevo rango (lo cual tiene sentido), no una lista:

>>> rango(1, 100)[25:50]


rango(26, 51)

La segunda regla también tiene que ver con la coherencia : los usuarios de su código lo
encontrarán más familiar y más fácil de usar si es coherente con Python. Como
desarrolladores de Python, ya estamos acostumbrados a la idea de cómo funcionan los
segmentos, cómo funciona la función de rango , etc. Hacer una excepción en una clase
personalizada creará confusión, lo que significa que será más difícil de recordar y podría
generar errores.

Administradores de contexto
Los administradores de contexto son una característica distintivamente útil que
proporciona Python. La razón por la que son tan útiles es que responden correctamente a
un patrón. El patrón es en realidad cada situación en la que queremos ejecutar algún
código y tiene condiciones previas y posteriores, lo que significa que queremos ejecutar
cosas antes y después de una determinada acción principal.

La mayoría de las veces, vemos administradores de contexto en torno a la gestión de


recursos. Por ejemplo, en situaciones en las que abrimos archivos, queremos asegurarnos
de que estén cerrados después del procesamiento (para que no se filtren los descriptores
de archivos), o si abrimos una conexión a un servicio (o incluso a un socket), también
queremos para asegurarse de cerrarlo en consecuencia, o al eliminar archivos temporales,
etc.
[ 29 ]
Código pitónico Capitulo 2

En todos estos casos, normalmente tendría que recordar liberar todos los recursos que se
asignaron y eso es solo pensar en el mejor de los casos , pero ¿qué pasa con las excepciones
y el manejo de errores? Dado que manejar todas las combinaciones posibles y las rutas de
ejecución de nuestro programa dificultan la depuración, la forma más común de abordar
este problema es colocar el código de limpieza en un bloque finalmente para asegurarnos de
no perderlo. Por ejemplo, un caso muy simple sería el siguiente:

fd = abrir (nombre de
archivo)
intente: procesar_archivo
(fd)
finalmente:
fd.close ()

No obstante, hay una forma mucho más elegante y pitónica de lograr lo mismo:
con abierto (nombre de archivo)
como fd:
process_file (fd)

La instrucción with (PEP-343) ingresa al administrador de contexto. En este caso, la


función abrir implementa el protocolo del administrador de contexto, lo que significa que
el archivo se cerrará automáticamente cuando finalice el bloque, incluso si se produce una
excepción.

Los administradores de contexto constan de dos métodos mágicos: __enter__ y __exit__ . En


la primera línea del administrador de contexto, la instrucción with llamará al primer
método, __enter__ , y lo que sea que devuelva este método se asignará a la variable
etiquetada después como . Esto es opcional : realmente no necesitamos devolver nada
específico en el método __enter__ , e incluso si lo hacemos, todavía no hay una razón
estricta para asignarlo a una variable si no es necesario.

Después de ejecutar esta línea, el código ingresa a un nuevo contexto, donde se puede
ejecutar cualquier otro código de Python. Una vez finalizada la última declaración en ese
bloque, se saldrá del contexto, lo que significa que Python llamará al método __exit__ del
objeto administrador de contexto original que invocamos primero.

Si hay una excepción o un error dentro del bloque del administrador de contexto, se
seguirá llamando al método __exit__ , lo que lo hace conveniente para administrar de
manera segura las condiciones de limpieza. De hecho, este método recibe la excepción que
se activó en el bloque en caso de que queramos manejarlo de forma personalizada.
A pesar de que los administradores de contexto se encuentran muy a menudo cuando se
trata de recursos (como el ejemplo que mencionamos con archivos, conexiones, etc.), esta no
es la única aplicación que tienen. Podemos implementar nuestros propios administradores
de contexto para manejar la lógica particular que necesitamos.

[ 30 ]
Código pitónico Capitulo 2

Los administradores de contexto son una buena manera de separar preocupaciones y aislar
partes del código que deben mantenerse independientes, porque si las mezclamos, la lógica
será más difícil de mantener.

Como ejemplo, considere una situación en la que queremos ejecutar una copia de seguridad
de nuestra base de datos con un script. La salvedad es que la copia de seguridad está fuera
de línea, lo que significa que solo podemos hacerla mientras la base de datos no se está
ejecutando, y para ello tenemos que detenerla. Después de ejecutar la copia de seguridad,
queremos asegurarnos de que comenzamos el proceso nuevamente, independientemente de
cómo haya ido el proceso de la copia de seguridad. Ahora, el primer enfoque sería crear una
gran función monolítica que intente hacer todo en el mismo lugar, detenga el servicio,
realice la tarea de respaldo, maneje las excepciones y todos los casos extremos posibles, y
luego intente reiniciar el servicio nuevamente. Puede imaginar una función de este tipo y,
por esa razón, le ahorraré los detalles y, en su lugar, propondré directamente una posible
forma de abordar este problema con los administradores de contexto:

def stop_database():
ejecutar("systemctl detener postgresql.servicio")

def start_database():
run("systemctl start postgresql.service")

clase DBHandler:
def __enter__(self):
stop_database()
return self

def __exit__(self, exc_type, ex_value, ex_traceback): start_database()

def db_backup():
ejecutar("base de datos pg_dump")

def main():
con DBHandler():
db_backup()

En este ejemplo, no necesitamos el resultado del administrador de contexto dentro del


bloque, y por eso podemos considerar que, al menos para este caso particular, el valor de
retorno de __enter__ es irrelevante. Esto es algo a tener en cuenta al diseñar
administradores de contexto : ¿qué necesitamos una vez que se inicia el bloque? Como regla
general, debería ser una buena práctica (aunque no obligatorio), devolver siempre algo en el
__enter__ .

[ 31 ]
Código pitónico Capitulo 2

En este bloque solo ejecutamos la tarea de la copia de seguridad, independientemente de


las tareas de mantenimiento, como vimos anteriormente. También mencionamos que
incluso si la tarea de copia de seguridad tiene un error, se seguirá llamando a __exit__ .

Observe la firma del método __exit__ . Recibe los valores de la excepción que se generó en
el bloque. Si no hubo una excepción en el bloque, todos son ninguno.

El valor de retorno de __exit__ es algo a considerar. Normalmente, querríamos dejar el


método como está, sin devolver nada en particular. Si este método devuelve True , significa
que la excepción que se generó potencialmente; no se propagará a la persona que llama y se
detendrá allí. A veces, este es el efecto deseado, tal vez incluso dependiendo del tipo de
excepción que se planteó, pero en general no es una buena idea tragarse la excepción.
Recuerda: los errores nunca deben pasar en silencio.

Tenga en cuenta que no debe devolver True accidentalmente al __exit__ . Si


lo hace, asegúrese de que esto es exactamente lo que quiere y que hay una
buena razón para ello.

Implementación de administradores de contexto


En general, podemos implementar administradores de contexto como el del ejemplo
anterior. Todo lo que necesitamos es solo una clase que implemente los métodos mágicos
__enter__ y __exit__ , y luego ese objeto podrá admitir el protocolo del administrador de
contexto. Si bien esta es la forma más común de implementar los administradores de
contexto, no es la única.

En esta sección, veremos no solo formas diferentes (a veces más compactas) de


implementar administradores de contexto, sino también cómo aprovecharlos al máximo
utilizando la biblioteca estándar, en particular con el módulo contextlib .

El módulo contextlib contiene una gran cantidad de funciones auxiliares y objetos para
implementar administradores de contexto o utilizar algunos ya proporcionados que pueden
ayudarnos a escribir un código más compacto.

Comencemos mirando el decorador del administrador de contexto .

Cuando el decorador contextlib.contextmanager se aplica a una función, convierte el código


de esa función en un administrador de contexto. La función en cuestión tiene que ser un
tipo particular de función llamada función generadora , que separará las declaraciones en
lo que va a ser en los métodos mágicos __enter__ y __exit__ , respectivamente.
[ 32 ]
Código pitónico Capitulo 2

Si en este punto no está familiarizado con decoradores y generadores, esto no es un


problema porque los ejemplos que veremos serán independientes, y la receta o el idioma
se pueden aplicar y comprender independientemente. Estos temas se tratan en detalle en
el Capítulo 7 , Uso de generadores .

El código equivalente del ejemplo anterior se puede reescribir con el decorador


contextmanager de esta manera:

importar contextlib

@contextlib.contextmanager
def db_handler():
stop_database()
yield
start_database()

con db_handler():
db_backup()

Aquí, definimos la función generadora y le aplicamos el decorador


@contextlib.contextmanager . La función contiene una declaración de rendimiento , lo que la
convierte en una función generadora. Nuevamente, los detalles sobre los generadores no
son relevantes en este caso. Todo lo que necesitamos saber es que cuando se aplica este
decorador, todo lo anterior a la declaración de rendimiento se ejecutará como si fuera parte
del método __enter__ . Entonces, el valor obtenido será el resultado de la evaluación del
administrador de contexto (lo que devolverá __enter__ ) y lo que se asignará a la variable si
elegimos asignarla como x: — en este caso, no se obtiene nada ( lo que significa que el valor
generado será ninguno, implícitamente), pero si quisiéramos, podríamos generar una
declaración que se convertiría en algo que querríamos usar dentro del bloque del
administrador de contexto.

En ese momento, se suspende la función del generador y se ingresa al administrador de


contexto, donde, nuevamente, ejecutamos el código de respaldo para nuestra base de datos.
Una vez que esto se completa, la ejecución se reanuda, por lo que podemos considerar que
cada línea que viene después de la instrucción yield será parte de la lógica __exit__ .

Escribir administradores de contexto como este tiene la ventaja de que es más fácil
refactorizar funciones existentes, reutilizar código y, en general, es una buena idea cuando
necesitamos un administrador de contexto que no pertenezca a ningún objeto en
particular. Agregar los métodos mágicos adicionales haría que otro objeto de nuestro
dominio estuviera más acoplado, con más responsabilidades y respaldando algo que
probablemente no debería. Cuando solo necesitamos una función de administrador de
contexto, sin preservar muchos estados, y completamente aislada e independiente del
resto de nuestras clases, esta es probablemente una buena forma de hacerlo.

[ 33 ]
Código pitónico Capitulo 2

Sin embargo, hay más formas en las que podemos implementar el administrador de
contexto y, una vez más, la respuesta está en el paquete contextlib de la biblioteca
estándar.

Otro ayudante que podríamos usar es contextlib.ContextDecorator . Esta es una clase base
mixta que proporciona la lógica para aplicar un decorador a una función que hará que se
ejecute dentro del administrador de contexto, mientras que la lógica para el administrador
de contexto en sí debe proporcionarse mediante la implementación de los métodos mágicos
antes mencionados.

Para usarlo, tenemos que extender esta clase e implementar la lógica en los métodos
requeridos:
clase dbhandler_decorator(contextlib.ContextDecorator): def
__enter__(self):
stop_database()

def __exit__(self, ext_type, ex_value, ex_traceback): start_database()

@dbhandler_decorator()
def offline_backup():
ejecutar("base de datos pg_dump")

¿Notas algo diferente a los ejemplos anteriores? No hay declaración con . Solo tenemos
que llamar a la función y offline_backup() se ejecutará automáticamente dentro de un
administrador de contexto. Esta es la lógica que proporciona la clase base para usarla
como un decorador que envuelve la función original para que se ejecute dentro de un
administrador de contexto.

El único inconveniente de este enfoque es que, por la forma en que funcionan los objetos,
son
completamente independientes (lo cual es una buena característica) : el decorador no sabe
nada sobre la función que está decorando, y viceversa. Esto, aunque bueno, significa que
no puede obtener un objeto que le gustaría usar dentro del administrador de contexto (por
ejemplo, asignar con offline_backup() como bp:) , por lo que si realmente necesita usar el
objeto devuelto por el método __exit__ , uno de los enfoques anteriores deberá ser el de
elección.

Al ser un decorador, esto también presenta la ventaja de que la lógica se define solo una
vez, y podemos reutilizarla tantas veces como queramos simplemente aplicando los
decoradores a otras funciones que requieren la misma lógica invariante.
una última característica de contextlib , para ver qué podemos esperar de los
administradores de contexto y tener una idea del tipo de cosas para las que
podríamos usarlos.

[ 34 ]
Código pitónico Capitulo 2

Tenga en cuenta que contextlib.suppress es un paquete de utilidad que ingresa a un


administrador de contexto que, si se genera una de las excepciones proporcionadas, no falla.
Es similar a ejecutar ese mismo código en un bloque try / except y pasar una excepción o
registrarla, pero la diferencia es que llamar al método de supresión lo hace más explícito que
aquellas excepciones que se controlan como parte de nuestra lógica.

Por ejemplo, considere el siguiente código:


importar contextlib

con contextlib.suppress(DataConversionException):
parse_data(input_json_or_dict)

Aquí, la presencia de la excepción significa que los datos de entrada ya están en el


formato esperado, por lo que no hay necesidad de conversión, por lo que es seguro
ignorarlos.

Propiedades, atributos y diferentes tipos de


métodos para objetos
Todas las propiedades y funciones de un objeto son públicas en Python, lo cual es diferente
de otros lenguajes donde las propiedades pueden ser públicas, privadas o protegidas. Es
decir, no tiene sentido evitar que los objetos llamadores invoquen cualquier atributo que
tenga un objeto. Esta es otra diferencia con respecto a otros lenguajes de programación en
los que puedes marcar algunos atributos como privados o protegidos.

No hay una aplicación estricta, pero hay algunas convenciones. Un atributo que comienza
con un guión bajo está destinado a ser privado para ese objeto, y esperamos que ningún
agente externo lo llame (pero nuevamente, no hay nada que lo impida).

Antes de pasar a los detalles de las propiedades, vale la pena mencionar algunas
características de los guiones bajos en Python, comprender la convención y el
alcance de los atributos.

Guiones bajos en Python


Hay algunas convenciones y detalles de implementación que hacen uso de guiones bajos
en Python, que es un tema interesante que vale la pena analizar.
[ 35 ]
Código pitónico Capitulo 2

Como mencionamos anteriormente, por defecto todos los atributos de un objeto son
públicos. Considere el siguiente ejemplo para ilustrar esto:

>>> class Conector:


... def __init__(self, source):
... self.source = source
... self._timeout = 60
...
>>> conn = Conector("postgresql://localhost")
>>> conn.fuente
'postgresql://localhost'
>>> conn._timeout
60
>>> conn.__dict__
{'fuente': 'postgresql:/ /localhost', '_tiempo de espera': 60}

Aquí, un objeto Connector se crea con source y comienza con dos atributos : source y timeout
antes mencionados . El primero es público y el segundo privado. Sin embargo, como
podemos ver en las siguientes líneas cuando creamos un objeto como este, en realidad
podemos acceder a ambos.

La interpretación de este código es que se debe acceder a _timeout solo dentro del propio
conector y nunca desde una persona que llama. Esto significa que debe organizar el código
de manera que pueda refactorizar de forma segura el tiempo de espera en todas las
ocasiones en que sea necesario, confiando en el hecho de que no se llama desde fuera del
objeto (solo internamente), por lo tanto, preservando la misma interfaz como antes. Cumplir
con estas reglas hace que el código sea más fácil de mantener y más robusto porque no
tenemos que preocuparnos por los efectos dominó al refactorizar el código si mantenemos
la interfaz del objeto. El mismo principio se aplica también a los métodos.

Los objetos solo deben exponer aquellos atributos y métodos que son
relevantes para un objeto llamador externo, es decir, lo que implica su
interfaz. Todo lo que no sea estrictamente parte de la interfaz de un objeto
debe tener un prefijo con un solo guión bajo.
Esta es la forma Pythonic de delimitar claramente la interfaz de un objeto. Sin embargo,
existe una idea errónea común de que algunos atributos y métodos se pueden hacer
privados. Esto es, de nuevo, un concepto erróneo. Imaginemos que ahora el atributo de
tiempo de espera se define con un doble guión bajo en su lugar:

>>> clase Conector:


... def __init__(self, source):
... self.source = source
... self.__timeout = 60
...
... def conectar(auto):

[ 36 ]
Código pitónico Capitulo 2

... print("conectando con {0}s".format(self.__timeout)) ... # ...


...
>>> conn = Connector("postgresql://localhost")
>>> conn.connect()
conectando con 60s
>>> conn.__timeout
Rastreo (última llamada más reciente):
Archivo "<stdin>", línea 1, en <módulo>
AttributeError: el objeto 'Conector' no tiene atributo '__timeout'

Algunos desarrolladores usan este método para ocultar algunos atributos, pensando, como
en este ejemplo, que el tiempo de espera ahora es privado y que ningún otro objeto puede
modificarlo. Ahora, eche un vistazo a la excepción que se genera al intentar acceder a
__timeout . Es AttributeError , diciendo que no existe. No dice algo como "esto es privado" o
"no se puede acceder a esto" , etc. Dice que no existe. Esto debería darnos una pista de que, de
hecho, algo diferente está sucediendo y que este comportamiento es solo un efecto
secundario, pero no el efecto real que queremos.

Lo que sucede en realidad es que con los guiones bajos dobles, Python crea un nombre
diferente para el atributo (esto se denomina manipulación de nombres ). Lo que hace es
crear el atributo con el siguiente nombre: "_<class-name>__<attribute-name>" . En este caso, se
creará un atributo denominado '_Connector__timeout' y se podrá acceder a dicho atributo (y
modificarlo) de la siguiente manera:

>>> vars(conn)
{'source': 'postgresql://localhost', '_Connector__timeout': 60} >>>
conn._Connector__timeout
60
>>> conn._Connector__timeout = 30
>>> conn.connect()
conectando con 30

Observe el efecto secundario que mencionamos anteriormente : el atributo solo existe con
un nombre diferente y, por esa razón , se generó AttributeError en nuestro primer intento de
acceder a él.

La idea del guión bajo doble en Python es completamente diferente. Fue creado como un
medio para anular diferentes métodos de una clase que se extenderá varias veces, sin el
riesgo de colisiones con los nombres de los métodos. Incluso ese es un caso de uso
demasiado descabellado como para justificar el uso de este mecanismo.
Los guiones bajos dobles son un enfoque no Pythonic. Si necesita definir atributos
como privados, use un solo guión bajo y respete la convención Pythonic de que es un
atributo privado.

[ 37 ]
Código pitónico Capitulo 2

No utilice guiones bajos dobles.

Propiedades
Cuando el objeto solo necesita contener valores, podemos usar atributos regulares. A
veces, es posible que queramos hacer algunos cálculos basados en el estado del objeto y
los valores de otros atributos. La mayoría de las veces, las propiedades son una buena
opción para esto.

Las propiedades deben usarse cuando necesitamos definir el control de acceso a algunos
atributos en un objeto, que es otro punto en el que Python tiene su propia forma de hacer
las cosas. En otros lenguajes de programación (como Java), crearía métodos de acceso
(getters y setters), pero Python idiomático usaría propiedades en su lugar.

Imagina que tenemos una aplicación donde los usuarios pueden registrarse y queremos
proteger cierta información sobre el usuario para que no sea incorrecta, como su correo
electrónico, como se muestra en el siguiente código:
importar re

EMAIL_FORMAT = re.compilar(r"[^@]+@[^@]+\.[^@]+")

def es_email_válido(correo_electrónico_potencialmente_válido: str):


devuelve re.match(FORMATO_EMAIL, correo_electrónico_potencialmente_válido) no es
Ninguno

clase Usuario:
def __init__(self, nombre de usuario):
self.username = nombre de usuario
self._email = Ninguno

@property
def email(self):
return self._email

@email.setter
def email(self, new_email):
if not is_valid_email(new_email):
raise ValueError(f"No se puede configurar {new_email} porque no es un
correo electrónico válido")
self._email = new_email

[ 38 ]
Código pitónico Capitulo 2

Al poner el correo electrónico debajo de una propiedad, obtenemos algunas ventajas de


forma gratuita. En este ejemplo, el primer método @property devolverá el valor que tiene el
atributo privado email . Como se mencionó anteriormente, el guión bajo inicial determina
que este atributo está destinado a ser utilizado como privado y, por lo tanto, no se debe
acceder desde fuera de esta clase.

Luego, el segundo método usa @email.setter , con la propiedad ya definida del método
anterior. Este es el que se llamará cuando <user>.email = <new_email> se ejecute desde el
código de la persona que llama, y <new_email> se convertirá en el parámetro de este método.
Aquí, definimos explícitamente una validación que fallará si el valor que se intenta
establecer no es una dirección de correo electrónico real. Si es así, actualizará el atributo con
el nuevo valor de la siguiente manera:

>>> u1 = Usuario("jsmith")
>>> u1.email = "jsmith@"
Rastreo (última llamada más reciente):
...
ValueError: no se puede configurar jsmith@ porque no es un correo
electrónico válido >>> u1.email = "[email protected]"
>>> u1.email
'[email protected]'

Este enfoque es mucho más compacto que tener métodos personalizados con el prefijo get_
o set_ . Está claro lo que se espera porque es solo correo electrónico .

No escriba métodos get_* y set_* personalizados para todos los atributos de


sus objetos. La mayoría de las veces, dejarlos como atributos regulares es
suficiente.
Si necesita modificar la lógica para cuando se recupera o modifica
Es posible queun atributo,que
encuentre utilice las propiedades.
las propiedades son una buena manera de lograr la
separación de comandos y consultas (CC08). La separación de comandos y consultas
establece que un método de un objeto debe responder a algo o hacer algo, pero no ambos.
Si un método de un objeto está haciendo algo y al mismo tiempo devuelve un estado que
responde a una pregunta de cómo fue esa operación, entonces está haciendo más de una
cosa, violando claramente el principio de que las funciones deben hacer una cosa y solo
una cosa. .

Dependiendo del nombre del método, esto puede crear aún más confusión, lo que
dificulta que los lectores entiendan cuál es la intención real del código. Por ejemplo, si un
método se llama set_email y lo usamos como si fuera self.set_email("[email protected]"): ... , ¿qué está
haciendo ese código? ¿Está configurando el correo electrónico a [email protected] ? ¿Está
comprobando si el correo electrónico ya está configurado con ese valor? ¿Ambos
(configurar y luego verificar si el estado es correcto)?

[ 39 ]
Código pitónico Capitulo 2

Con propiedades, podemos evitar este tipo de confusión. El decorador @property es la


consulta que responderá a algo, y el @<property_name>.setter es el comando que hará algo.

Otro buen consejo derivado de este ejemplo es el siguiente : no haga más de una cosa en un
método. Si desea asignar algo y luego verificar el valor, divídalo en dos o más oraciones.

Los métodos deben hacer una sola cosa. Si tiene que ejecutar una acción y
luego verificar el estado, de modo que en métodos separados que son
llamados por diferentes declaraciones.

Objetos iterables
En Python, tenemos objetos que se pueden iterar de forma predeterminada. Por ejemplo,
las listas, las tuplas, los conjuntos y los diccionarios no solo pueden contener datos en la
estructura que queremos, sino que también se pueden iterar en un bucle for para obtener
esos valores repetidamente.

Sin embargo, los objetos iterables integrados no son el único tipo que podemos tener en un
bucle for . También podríamos crear nuestro propio iterable, con la lógica que definimos
para la iteración.

Para conseguirlo nos apoyamos, una vez más, en métodos mágicos.

La iteración funciona en Python por su propio protocolo (a saber, el protocolo de iteración).


Cuando intenta iterar un objeto en forma de e en myobject:... , lo que Python comprueba a un
nivel muy alto son las siguientes dos cosas, en orden:

Si el objeto contiene uno de los métodos iteradores : __next__ o __iter__


Si el objeto es una secuencia y tiene __len__ y __getitem__

Por lo tanto, como mecanismo alternativo, las secuencias se pueden iterar, por lo que hay
dos formas de personalizar nuestros objetos para poder trabajar con bucles for .

Creación de objetos iterables


Cuando intentamos iterar un objeto, Python llamará a la función iter() sobre él. Una de las
primeras cosas que comprueba esta función es la presencia del método __iter__ en ese
objeto, que, si está presente, se ejecutará.
[ 40 ]
Código pitónico Capitulo 2

El siguiente código crea un objeto que permite iterar sobre un rango de fechas,
produciendo un día a la vez en cada ronda del bucle:

desde fecha y hora importación timedelta

class DateRangeIterable:
"""Un iterable que contiene su propio objeto iterador."""

def __init__(self, start_date, end_date): self.start_date =


start_date self.end_date = end_date self._present_day
=
start_date

def __iter__(auto):
retornar auto

def __next__(self):
if self._present_day >= self.end_date: aumentar
StopIteration
today = self._present_day
self._present_day += timedelta(days=1) return today

Este objeto está diseñado para crearse con un par de fechas y, cuando se itera, producirá
cada día en el intervalo de fechas especificadas, que se muestra en el siguiente código:

>>> para el día en DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)): ... print(day)


...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>>

Aquí, el bucle for está iniciando una nueva iteración sobre nuestro objeto. En este punto, Python
llamará a la función iter() , que a su vez llamará al método mágico __iter__ . En este método,
se define para devolver self, lo que indica que el objeto es iterable en sí mismo, por lo que
en ese punto, cada paso del bucle llamará a la función next() en ese objeto, que delega al
método __next__ . En este método, decidimos cómo producir los elementos y devolverlos uno
a la vez. Cuando no hay nada más que producir, tenemos que señalar esto a Python
generando la excepción StopIteration .
[ 41 ]
Código pitónico Capitulo 2

Esto significa que lo que realmente está sucediendo es similar a cuando Python llama a
next() cada vez en nuestro objeto hasta que hay una excepción StopIteration , en la que sabe
que tiene que detener el bucle for :

>>> r = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)) >>> next(r)


datetime.date(2018, 1, 1)
>>> next(r)
datetime .fecha(2018, 1, 2)
>>> siguiente(r)
fechahora.fecha(2018, 1, 3)
>>> siguiente(r)
fechahora.fecha(2018, 1, 4)
>>> siguiente(r)
Rastreo (última llamada más reciente):
Archivo "<stdin>", línea 1, en <módulo>
Archivo... __siguiente__
aumentar StopIteration
StopIteration
>>>

Este ejemplo funciona, pero tiene un pequeño problema : una vez agotado, el iterable
seguirá estando vacío, por lo que se activa StopIteration . Esto significa que si usamos esto en
dos o más bucles for consecutivos , solo funcionará el primero, mientras que el segundo
estará vacío:

>>> r1 = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)) >>> ",


".join(map(str, r1))
'2018-01-01, 2018- 02-01, 03-01-2018, 04-01-2018'
>>> max(r1)
Rastreo (última llamada más reciente):
Archivo "<stdin>", línea 1, en <módulo>
ValueError: max() arg es una secuencia vacía
>>>

Esto se debe a la forma en que funciona el protocolo de iteración : un iterable construye un


iterador, y este es el que se itera. En nuestro ejemplo, __iter__ acaba de devolver self , pero
podemos hacer que cree un nuevo iterador cada vez que se llame. Una forma de solucionar
esto sería crear nuevas instancias de DateRangeIterable , que no es un problema terrible, pero
podemos hacer que __iter__ use un generador (que son objetos iteradores), que se crea cada
vez:

class DateRangeContainerIterable:
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date

[ 42 ]
Código pitónico Capitulo 2

def __iter__(self):
día_actual = self.fecha_inicial
while día_actual < self.fecha_final: yield
día_actual
día_actual += timedelta(días=1)

Y esta vez, funciona:


>>> r1 = DateRangeContainerIterable(date(2018, 1, 1), date(2018, 1, 5)) >>> ", ".join(map(str,
r1))
'2018-01-01, 2018- 01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
fechahora.fecha(2018, 1, 4)
>>>

La diferencia es que cada ciclo for está llamando a __iter__ nuevamente, y cada uno de
ellos está creando el generador nuevamente.

Esto se llama contenedor iterable.

En general, es una buena idea trabajar con contenedores iterables cuando


se trata de generadores.

Los detalles sobre los generadores se explicarán con más detalle en el Capítulo 7 , Uso de
generadores .

Creando secuencias
Tal vez nuestro objeto no defina el método __iter__() , pero aún queremos poder iterar sobre
él. Si __iter__ no está definido en el objeto, la función iter() buscará la presencia de
__getitem__ y, si no lo encuentra, generará TypeError .

Una secuencia es un objeto que implementa __len__ y __getitem__ y espera poder obtener los
elementos que contiene, uno a la vez, en orden, comenzando en cero como el primer índice.
Esto significa que debe tener cuidado con la lógica para implementar correctamente
__getitem__ para esperar este tipo de índice, o la iteración no funcionará.

El ejemplo de la sección anterior tenía la ventaja de que usa menos memoria. Esto significa
que solo tiene una fecha a la vez y sabe cómo producir los días uno por uno. Sin embargo,
tiene el inconveniente de que si queremos obtener el n -ésimo elemento, no tenemos otra forma
de hacerlo que iterar n-veces hasta llegar a él. Esta es una compensación típica en
informática entre la memoria y el uso de la CPU.
[ 43 ]
Código pitónico Capitulo 2

La implementación con un iterable usará menos memoria, pero se necesita hasta O(n) para
obtener un elemento, mientras que la implementación de una secuencia usará más
memoria (porque tenemos que contener todo a la vez), pero admite la indexación en
tiempo constante, O(1) .

Así es como se vería la nueva implementación:


class DateRangeSequence:
def __init__(self, start_date, end_date):
self.start_date =
start_date
self.end_date = end_date self._range = self._create_range()

def _create_range(self):
days = []
current_day = self.start_date
while current_day < self.end_date:
days.append(current_day)
current_day += timedelta(days=1) return days

def __getitem__(self, day_no):


return self._range[day_no]

def __len__(auto):
return len(auto._rango)

Así es como se comporta el objeto:


>>> s1 = DateRangeSequence(date(2018, 1, 1), date(2018, 1, 5)) >>> for day in s1:
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>> s1[0]
fechahora.fecha(2018, 1, 1)
>>> s1[3]
fechahora.fecha(2018, 1, 4)
>>>s1[-1]
fechahora.fecha(2018, 1, 4)

En el código anterior, podemos ver que los índices negativos también funcionan. Esto
se debe a que el objeto DateRangeSequence delega todas las operaciones a su objeto
envuelto (una lista ), que es la mejor manera de mantener la compatibilidad y un
comportamiento coherente.

[ 44 ]
Código pitónico Capitulo 2

Evalúe el equilibrio entre la memoria y el uso de la CPU al decidir cuál


de las dos implementaciones posibles usar. En general es preferible la
iteración (y más aún los generadores), pero hay que tener en cuenta los
requisitos de cada caso.

Objetos contenedores
Los contenedores son objetos que implementan un método __contains__ (que
generalmente devuelve un valor booleano). Este método se llama en presencia de la
palabra clave in de Python.

Algo como lo siguiente:


elemento en contenedor

Cuando se usa en Python se convierte en esto:


contenedor.__contiene__(elemento)

Puede imaginar cuánto más legible (¡y pitónico!) El código puede ser cuando este
método se implementa correctamente.

Pongamos que tenemos que marcar unos puntos en un mapa de un juego que tiene
coordenadas bidimensionales. Podríamos esperar encontrar una función como la
siguiente:

def marcar_coordenada(cuadrícula, coord):


if 0 <= coord.x < cuadrícula.ancho and 0 <= coord.y < cuadrícula.alto:
cuadrícula[coord] = MARCADO

Ahora, la parte que verifica la condición de la primera instrucción if parece enrevesada; no


revela la intención del código, no es expresivo y, lo peor de todo, requiere la duplicación
de código (cada parte del código donde necesitamos verificar los límites antes de
continuar tendrá que repetir esa declaración ).

¿Qué pasaría si el mapa en sí mismo (llamado cuadrícula en el código) pudiera responder


a esta pregunta? Aún mejor, ¿qué pasaría si el mapa pudiera delegar esta acción a un
objeto aún más pequeño (y por lo tanto más cohesivo)? Por lo tanto, podemos
preguntarle al mapa si contiene una coordenada, y el propio mapa puede tener
información sobre su límite, y preguntarle a este objeto lo siguiente:

Límites de clase:
def __init__(self, ancho, alto):
self.width = ancho
self.height = alto

def __contiene__(self, coord):

[ 45 ]
Código pitónico Capitulo 2

x, y = coord
return 0 <= x < self.width and 0 <= y < self.height

class Grid:
def __init__(self, ancho, alto):
self.width = ancho
self.height = alto
self.limits = Boundaries(ancho, alto)

def __contains__(self, coord):


devuelve coord en self.limits

Este código solo es una implementación mucho mejor. Primero, está haciendo una
composición simple y está usando la delegación para resolver el problema. Ambos objetos
son realmente cohesivos, teniendo la mínima lógica posible; los métodos son breves y la
lógica habla por sí sola : coord en self.limits es más o menos una declaración del problema a
resolver, expresando la intención del código.

Desde el exterior, también podemos ver los beneficios. Es casi como si Python nos
estuviera resolviendo el problema:

def marcar_coordenada(cuadrícula, coord):


if coord en cuadrícula:
cuadrícula[coord] = MARCADO

Atributos dinámicos para objetos


Es posible controlar la forma en que se obtienen los atributos de los objetos mediante el
método mágico __getattr__ . Cuando llamamos a algo como <myobject>.<myattribute> , Python
buscará <myattribute> en el diccionario del objeto, llamando a
__getattribute__ en él. Si no se encuentra (es decir, el objeto no tiene el atributo que estamos
buscando), entonces se llama al método extra, __getattr__ , pasando el nombre del atributo (
myattribute ) como parámetro. Al recibir este valor, podemos controlar la forma en que las
cosas deben devolverse a nuestros objetos. Incluso podemos crear nuevos
atributos, y así sucesivamente.

En la siguiente lista, se demuestra el método __getattr__ :


clase DynamicAttributes:

def __init__(self, atributo):


self.atributo = atributo
[ 46 ]
Código pitónico Capitulo 2

def __getattr__(self, attr):


if attr.startswith("fallback_"):
nombre = attr.replace("fallback_", "")
return f"[fallback resuelto] {name}"
raise AttributeError(
f"{self. __class__.__name__} no tiene atributo {attr}" )

Aquí hay algunas llamadas a un objeto de esta clase:


>>> dyn = DynamicAttributes("valor")
>>> dyn.atributo
'valor'

>>> dyn.fallback_test
'[fallback resuelto] prueba'

>>> dyn.__dict__["fallback_new"] = "nuevo valor" >>>


dyn.fallback_new
'nuevo valor'

>>> getattr(dyn, "algo", "predeterminado")


'predeterminado'

La primera llamada es sencilla : solo solicitamos un atributo que tiene el objeto y, como
resultado, obtenemos su valor. El segundo es donde este método entra en acción porque el
objeto no tiene nada llamado fallback_test , por lo que __getattr__ se ejecutará con ese valor.
Dentro de ese método, colocamos el código que devuelve una cadena y lo que obtenemos es
el resultado de esa transformación.

El tercer ejemplo es interesante porque allí se crea un nuevo atributo llamado


fallback_new (en realidad, esta llamada sería lo mismo que ejecutar dyn.fallback_new =
"nuevo valor" ) , así que cuando solicitamos ese atributo, observe que la lógica que
ponemos
en __getattr__ no se aplica, simplemente porque ese código nunca se llama.

Ahora, el último ejemplo es el más interesante. Aquí hay un detalle sutil que hace una gran
diferencia. Eche otro vistazo al código en el método __getattr__ . Observe la excepción que
genera cuando el valor no es recuperable AttributeError . Esto no es solo por consistencia (así
como el mensaje en la excepción), sino que también lo requiere la función incorporada
getattr() . Si esta excepción hubiera sido cualquier otra, se generaría y no se devolvería el
valor predeterminado.
[ 47 ]
Código pitónico Capitulo 2

Tenga cuidado al implementar un método tan dinámico como


__getattr__ y utilícelo con precaución. Al implementar __getattr__ ,
genere AttributeError .

Objetos invocables
Es posible (ya menudo conveniente) definir objetos que puedan actuar como funciones.
Una de las aplicaciones más comunes para esto es crear mejores decoradores, pero no se
limita a eso.

El método mágico __call__ será llamado cuando intentemos ejecutar nuestro objeto como si
fuera una función normal. Cada argumento que se le pase se pasará al método __call__ .

La principal ventaja de implementar funciones de esta manera, a través de objetos, es


que los objetos tienen estados, por lo que podemos guardar y mantener información
entre llamadas.

Cuando tenemos un objeto, una declaración como esta object(*args, **kwargs) se traduce en
Python a object.__call__(*args, **kwargs) .

Este método es útil cuando queremos crear objetos invocables que funcionarán
como funciones parametrizadas o, en algunos casos, funciones con memoria.

La siguiente lista utiliza este método para construir un objeto que, cuando se llama
con un parámetro, devuelve la cantidad de veces que se ha llamado con el mismo
valor:
desde colecciones importar predeterminadodict

clase Contador de llamadas:

def __init__(self):
self._counts = defaultdict(int)

def __call__(self, argumento):


self._counts[argumento] += 1
return self._counts[argumento]

Algunos ejemplos de esta clase en acción son los siguientes:


>>> cc = CuentaLlamadas()
>>> cc(1)
1
>>> cc(2)
1

[ 48 ]
Código pitónico Capitulo 2

>>> cc(1)
2
>>> cc(1)
3
>>> cc("algo")
1

Más adelante en este libro, descubriremos que este método resulta útil al crear
decoradores.

Resumen de métodos mágicos.


Podemos resumir los conceptos que describimos en las secciones anteriores en forma de
hoja de trucos como la que se presenta a continuación. Para cada acción en Python, se
presenta el método mágico involucrado, junto con el concepto que representa:

Declaración método mágico concepto de pitón


obj[clave]
obj[i:j] __getitem__(clave) Objeto suscribible
obj[i:j:k]
con objeto: ... __entrar__ / __salir__ administrador de contexto

para i en obj: ... __iter__ / __siguiente__ Objeto iterable


__len__ / __getitem__ Secuencia
obj.<atributo> __getattr__ Recuperación de atributos
dinámicos
obj(*argumentos, **kwargos) __llamar__(*argumentos, **kwargos) Objeto invocable

Advertencias en Python
Además de comprender las características principales del lenguaje, ser capaz de escribir
código idiomático también implica ser consciente de los problemas potenciales de algunos
modismos y cómo evitarlos.
En esta sección, exploraremos los problemas comunes que pueden ocasionar largas
sesiones de depuración si lo toman desprevenido.

La mayoría de los puntos discutidos en esta sección son cosas que se deben evitar por
completo, y me atrevería a decir que casi no hay escenario posible que justifique la
presencia del antipatrón (o modismo, en este caso). Por lo tanto, si encuentra esto en el
código base en el que está trabajando, siéntase libre de refactorizarlo de la forma sugerida.
Si encuentra estas características mientras realiza una revisión del código, esta es una clara
indicación de que algo debe cambiar.
[ 49 ]
Código pitónico Capitulo 2

Argumentos predeterminados mutables


En pocas palabras, no use objetos mutables como argumentos predeterminados de las
funciones. Si usa objetos mutables como argumentos predeterminados, obtendrá
resultados que no son los esperados.

Considere la siguiente definición de función errónea:


def wrong_user_display(user_metadata: dict = {"nombre": "Juan", "edad": 30}): nombre =
usuario_metadata.pop("nombre")
edad = usuario_metadata.pop("edad")

return f"{nombre} ({edad})"

Esto tiene dos problemas, en realidad. Además del argumento mutable predeterminado, el
cuerpo de la función está mutando un objeto mutable, creando así un efecto secundario.
Pero el problema principal es el argumento predeterminado para user_medatada .

En realidad, esto solo funcionará la primera vez que se llame sin argumentos. Por segunda
vez, lo llamamos sin pasar algo explícitamente a user_metadata . Fallará con un KeyError ,
así:

>>> visualización_usuario_incorrecto()
'Juan (30)'
>>> visualización_usuario_incorrecto({"nombre": "Jane", "edad":
25}) 'Jane (25)'
>>> visualización_usuario_incorrecto() Rastreo (
llamada más reciente último):
Archivo "<stdin>", línea 1, en <módulo>
Archivo ... en nombre de pantalla de usuario
incorrecto = metadatos_de_usuario.pop("nombre")
KeyError: 'nombre'

La explicación es simple : al asignar el diccionario con los datos predeterminados a


user_metadata en la definición de la función, este diccionario se crea una vez y la variable
user_metadata apunta a él. El cuerpo de la función modifica este objeto, que permanece vivo
en la memoria mientras se ejecuta el programa. Cuando le pasemos un valor, este tomará el
lugar del argumento predeterminado que acabamos de crear. Cuando no queremos este
objeto, se vuelve a llamar y se ha modificado desde la ejecución anterior; la próxima vez que
lo ejecutemos, no contendrá las claves ya que fueron eliminadas en la llamada anterior.
[ 50 ]
Código pitónico Capitulo 2

La solución también es simple : necesitamos usar Ninguno como valor centinela


predeterminado y asignar el valor predeterminado en el cuerpo de la función. Debido
a que cada función tiene su propio alcance y ciclo de vida, user_metadata se asignará al
diccionario cada vez que aparezca Ninguno :

def user_display(user_metadata: dict = Ninguno):


user_metadata = user_metadata o {"nombre": "Juan", "edad": 30}

nombre = user_metadata.pop("nombre")
edad = user_metadata.pop("edad")

return f"{nombre} ({edad})"

Ampliación de tipos integrados


La forma correcta de ampliar los tipos integrados, como listas, cadenas y diccionarios,
es mediante el módulo de colecciones .

Si crea una clase que extiende directamente dict, por ejemplo, obtendrá resultados que
probablemente no sean los que espera. La razón de esto es que en CPython los métodos de
la clase no se llaman entre sí (como deberían), por lo que si anula uno de ellos, esto no se
reflejará en el resto, lo que generará resultados inesperados. Por ejemplo, es posible que
desee anular __getitem__ y luego, cuando itera el objeto con un bucle for , notará que la lógica
que ha puesto en ese método no se aplica.

Todo esto se resuelve mediante el uso de collections.UserDict , por ejemplo, que


proporciona una interfaz transparente para los diccionarios reales y es más robusto.

Digamos que queremos una lista que se creó originalmente a partir de números para
convertir los valores en cadenas, agregando un prefijo. El primer enfoque puede parecer
que resuelve el problema, pero es erróneo:
class BadList(lista):
def __getitem__(self, index):
value = super().__getitem__(index) if index % 2 == 0:
prefix = "par"
else:
prefix = "impar"
return f"[{prefix }] {valor}"
[ 51 ]
Código pitónico Capitulo 2

A primera vista, parece que el objeto se comporta como queremos. Pero luego, si tratamos
de iterarlo (después de todo, es una lista), encontramos que no obtenemos lo que
queríamos:

>>> bl = BadList((0, 1, 2, 3, 4, 5))


>>> bl[0]
'[par] 0'
>>> bl[1]
'[impar] 1'
>>> "".join(bl) Rastreo (
última llamada más reciente):
...
TypeError: elemento de secuencia 0: instancia de cadena esperada, int encontrado

La función de unión intentará iterar (ejecutar un bucle for ) en la lista, pero espera valores de
tipo cadena. Esto debería funcionar porque es exactamente el tipo de cambio que hicimos
en la lista, pero aparentemente cuando se itera la lista, no se llama a nuestra versión
modificada de __getitem__ .

Este problema es en realidad un detalle de implementación de CPython (una optimización


de C), y en otras plataformas como PyPy no sucede (vea las diferencias entre PyPy y
CPython en las referencias al final de este capítulo).

Independientemente de esto, debemos escribir un código que sea portátil y


compatible en todas las implementaciones, por lo que lo arreglaremos extendiendo
no desde la lista sino desde UserList :

desde colecciones importar UserList

class GoodList(UserList):
def __getitem__(self, index):
value = super().__getitem__(index) if index % 2 == 0:
prefijo = "par"
else:
prefijo = "impar"
return f"[{prefijo }] {valor}"

Y ahora las cosas se ven mucho mejor:


>>> gl = GoodList((0, 1, 2))
>>> gl[0]
'[par] 0'
>>> gl[1]
'[impar] 1'
>>> "; ".join( gl)
'[par] 0; [impar] 1; [incluso] 2'

[ 52 ]
Código pitónico Capitulo 2

No extienda directamente desde dict, use collections.UserDict en su lugar.


Para listas, use collections.UserList y para cadenas,
use collections.UserString .

Resumen
En este capítulo, hemos explorado las principales características de Python, con el
objetivo de
comprender sus características más distintivas, aquellas que hacen de Python un
lenguaje peculiar en comparación con el resto. En este camino, hemos explorado
diferentes métodos de Python, protocolos y su mecánica interna.

A diferencia del capítulo anterior, este está más centrado en Python. Una conclusión clave
de los temas de este libro es que el código limpio va más allá de seguir las reglas de formato
(que, por supuesto, son esenciales para una buena base de código). Son una condición
necesaria, pero no suficiente. En los próximos capítulos, veremos ideas y principios que se
relacionan más con el código, con el objetivo de lograr un mejor diseño e implementación
de nuestra solución de software.

Con los conceptos y las ideas de este capítulo, exploramos el núcleo de Python: sus
protocolos y métodos mágicos. Debería estar claro ahora que la mejor manera de tener
código idiomático Pythonic no es solo siguiendo las convenciones de formato, sino
también aprovechando al máximo todas las características que Python tiene para ofrecer.
Esto significa que a veces debe usar un método mágico particular, implementar un
administrador de contexto y más.

En el próximo capítulo, pondremos en práctica estos conceptos, relacionando


conceptos generales de ingeniería de software con la forma en que se pueden escribir
en Python.

Referencias
El lector encontrará más información sobre algunos de los temas que hemos cubierto en este
capítulo en las siguientes referencias. La decisión de cómo funcionan los índices en Python
se basa en (EWD831), que analiza varias alternativas para rangos en matemáticas y
lenguajes de programación:
EWD831 : Por qué la numeración debe comenzar en cero ( https:/
)/www.cs.utexas.edu/ users/EWD/transcriptions//EWD831.html
PEP-343 : La declaración "con" ( https:/)/www.python.org/dev/peps/pep-0343/

[ 53 ]
Código pitónico Capitulo 2

CC08 : El libro escrito por Robert C. Martin llamado Clean Code: A Handbook of
Artesanía ágil de software
Documentación de Python, la función iter() (https://docs.python.org/3/
library/)functions.html#iter
Diferencias entre PyPy y CPython (https://pypy.readthedocs.io/en/
latest/)cpython_differences.html#subclasses-of-built-in-types

[ 54 ]
Rasgos generales del buen

código 
Este es un libro sobre la construcción de software con Python. Un buen software se
construye a partir de un buen diseño. Al decir cosas como código limpio, uno podría pensar
que exploraremos buenas prácticas que se relacionan solo con los detalles de
implementación del software, en lugar de su diseño. Sin embargo, esta suposición sería
incorrecta ya que el código no es algo diferente del diseño : el código es el diseño.

El código es probablemente la representación más detallada del diseño. En los primeros dos
capítulos, discutimos por qué era importante estructurar el código de manera consistente, y
vimos modismos para escribir código más compacto e idiomático. Ahora es el momento de
comprender que el código limpio es eso y mucho más : el objetivo final es hacer que el
código sea lo más sólido posible y escribirlo de una manera que minimice los defectos o los
haga completamente evidentes, en caso de que ocurran.

Este capítulo y el siguiente se centran en los principios de diseño a un nivel más alto
de abstracción. Estas ideas no solo se relacionan con Python en particular, sino que
son principios generales de ingeniería de software.

En particular, para este capítulo, revisaremos diferentes principios que contribuyen a un


buen diseño de software. El software de buena calidad debe construirse en torno a estas
ideas y servirán como herramientas de diseño. Esto no quiere decir que siempre se deban
aplicar todos; de hecho, algunos de ellos representan diferentes puntos de vista (tal es el
caso del enfoque Design by Contract ( DbC ), en contraposición a la programación
defensiva). Algunos de ellos dependen del contexto y no siempre son aplicables.

Un código de alta calidad es un concepto que tiene múltiples dimensiones. Podemos pensar
en esto de manera similar a cómo pensamos en los atributos de calidad de una arquitectura
de software. Por ejemplo, queremos que nuestro software sea seguro y tenga buen
rendimiento, confiabilidad y mantenibilidad, solo por nombrar algunos.
Rasgos generales del buen código Capítulo 3

Los objetivos de este capítulo son los siguientes:

Entender los conceptos detrás del software robusto


Para aprender a lidiar con datos erróneos durante el flujo de trabajo de la
aplicación
Diseñar software mantenible que pueda ampliarse y adaptarse fácilmente a
nuevos requisitos
Para diseñar software reutilizable.
Escribir código efectivo que mantendrá alta la productividad del equipo de
desarrollo.

Diseño por contrato


Algunas partes del software en el que estamos trabajando no están destinadas a ser
llamadas directamente por los usuarios, sino por otras partes del código. Tal es el caso
cuando dividimos las responsabilidades de la aplicación en diferentes componentes o
capas, y tenemos que pensar en la interacción entre ellos.

Tendremos que encapsular alguna funcionalidad detrás de cada componente y exponer


una interfaz a los clientes que van a utilizar esa funcionalidad, a saber, una interfaz de
programación de aplicaciones ( API ). Las funciones, clases o métodos que escribimos
para ese componente tienen una forma particular de trabajar bajo ciertas consideraciones
que, si no se cumplen, harán que nuestro código se cuelgue. Por el contrario, los clientes
que llaman a ese código esperan una respuesta particular, y cualquier falla de nuestra
función para proporcionar esto representaría un defecto.

Es decir que si, por ejemplo, tenemos una función que se espera que funcione con una
serie de parámetros de tipo enteros, y alguna otra función invoca nuestras cadenas de
paso, está claro que no debería funcionar como se espera, pero en En realidad, la función
no debería ejecutarse en absoluto porque se llamó incorrectamente (el cliente cometió un
error). Este error no debe pasar en silencio.

Por supuesto, al diseñar una API, se deben documentar la entrada, la salida y los efectos
secundarios esperados. Pero la documentación no puede imponer el comportamiento del
software en tiempo de ejecución.
Estas reglas, lo que cada parte del código espera para funcionar correctamente y lo que la
persona que llama espera de ellas, debe ser parte del diseño, y aquí es donde entra en juego
el concepto de contrato .
La idea detrás de DbC es que, en lugar de incluir implícitamente en el código lo que cada
parte espera, ambas partes acuerdan un contrato que, si se viola, generará una excepción,
indicando claramente por qué no puede continuar.

[ 56 ]
Rasgos generales del buen código Capítulo 3

En nuestro contexto, un contrato es una construcción que impone algunas reglas que
deben cumplirse durante la comunicación de componentes de software. Un contrato
implica principalmente
condiciones previas y posteriores, pero en algunos casos, también se describen
invariantes y efectos secundarios:

Condiciones previas : podemos decir que estas son todas las verificaciones que el
código hará antes de ejecutarse. Verificará todas las condiciones que deben
realizarse antes de que la función pueda continuar. En general, se implementa
validando el conjunto de datos proporcionado en los parámetros pasados, pero
nada debería impedirnos ejecutar todo tipo de validaciones (por ejemplo, validar
un conjunto en una base de datos, un archivo, otro método que se haya llamado
antes, etc. on) si consideramos que sus efectos secundarios quedan eclipsados por
la importancia de tal validación. Tenga en cuenta que esto impone una restricción
a la persona que llama.
Postcondiciones : lo opuesto a las condiciones previas, aquí, las validaciones se
realizan después de que se devuelve la llamada a la función. Las validaciones
posteriores a la condición se ejecutan para validar lo que la persona que llama
espera de este componente.
Invariantes : Opcionalmente, sería una buena idea documentar, en el docstring de
una función, los invariantes, las cosas que se mantienen constantes mientras se
ejecuta el código de la función, como una expresión de la lógica de la función
para ser correcta. .
Efectos secundarios : opcionalmente, podemos mencionar cualquier efecto
secundario de nuestro código en la cadena de documentación.

Si bien conceptualmente todos estos elementos forman parte del contrato de un


componente de software, y esto es lo que debe incluirse en la documentación de dicha
pieza, solo los dos primeros (precondiciones y poscondiciones) deben aplicarse en un
nivel bajo (código).

La razón por la que diseñaríamos por contrato es que si ocurren errores, deben ser fáciles de
detectar (y al notar si fue la condición previa o posterior la que falló, encontraremos al
culpable con mucha facilidad) para que puedan corregirse rápidamente. . Más
importante aún, queremos partes críticas del código para evitar que se ejecuten bajo
suposiciones incorrectas. Esto debería ayudar a marcar claramente los límites de las
responsabilidades y los errores si ocurren, en lugar de algo que diga : esta parte de la
aplicación está fallando... Pero el código de la persona que llama proporcionó los
argumentos incorrectos, entonces, ¿dónde deberíamos aplicar la corrección?
La idea es que las condiciones previas vinculan al cliente (tienen la obligación de cumplirlas
si quieren ejecutar alguna parte del código), mientras que las condiciones posteriores
vinculan el componente en cuestión a algunas garantías que el cliente puede verificar y
hacer cumplir.

De esta manera, podemos identificar rápidamente las responsabilidades. Si la condición


previa falla, sabemos que se debe a un defecto del cliente. Por otro lado, si la verificación
de la poscondición falla, sabemos que el problema está en la rutina o clase (proveedor)
en sí.

[ 57 ]
Rasgos generales del buen código Capítulo 3

Específicamente con respecto a las condiciones previas, es importante resaltar que se


pueden verificar en tiempo de ejecución y, si ocurren, el código que se está llamando no
debe ejecutarse en absoluto (no tiene sentido ejecutarlo porque sus condiciones no se
cumplen, y además, hacerlo podría terminar empeorando las cosas).

condiciones previas
Las condiciones previas son todas las garantías que una función o método espera recibir
para funcionar correctamente. En términos generales de programación, esto generalmente
significa proporcionar datos que se forman correctamente, por ejemplo, objetos que se
inicializan, valores no nulos y muchos más.
Para Python, en particular, al estar tipado dinámicamente, esto también significa que a
veces necesitamos verificar el tipo exacto de datos que se proporcionan. Esto no es
exactamente lo mismo que la verificación de tipos, el tipo que mypy haría esto, sino que
verificaría los valores exactos que se necesitan.

Parte de estas comprobaciones se pueden detectar desde el principio mediante el uso de


herramientas de análisis estático, como mypy , que ya presentamos en el Capítulo 1 ,
Introducción, Formato de código y Herramientas , pero estas comprobaciones no son
suficientes. Una función debe tener una validación adecuada para la información que va a
manejar.

Ahora, esto plantea la pregunta de dónde colocar la lógica de validación, dependiendo de


si dejamos que los clientes validen todos los datos antes de llamar a la función, o
permitimos que este valide todo lo que recibió antes de ejecutar su propia lógica. El
primero corresponde a un enfoque tolerante (porque la función en sí aún permite
cualquier dato, también datos potencialmente malformados), mientras que el segundo
corresponde a un enfoque exigente.

A los fines de este análisis, preferimos un enfoque exigente cuando se trata de DbC, porque
suele ser la opción más segura en términos de robustez y suele ser la práctica más común en
la industria.

Independientemente del enfoque que decidamos tomar, siempre debemos tener en cuenta
el principio de no redundancia, que establece que el cumplimiento de cada condición previa
para una función debe ser realizado por solo una de las dos partes del contrato, pero no por
ambas. Esto significa que ponemos la lógica de validación en el cliente, o se la dejamos a la
función en sí, pero en ningún caso debemos duplicarla (que también se relaciona con el
principio DRY, que discutiremos más adelante en este capítulo).
[ 58 ]
Rasgos generales del buen código Capítulo 3

poscondiciones
Las condiciones posteriores son la parte del contrato que es responsable de hacer cumplir el
estado después de que el método o función haya regresado.

Suponiendo que la función o método ha sido llamado con las propiedades correctas (es
decir, con sus condiciones previas cumplidas), las condiciones posteriores garantizarán
que se conserven ciertas propiedades.

La idea es usar condiciones posteriores para verificar y validar todo lo que un cliente
pueda necesitar. Si el método se ejecutó correctamente y las validaciones de la condición
posterior pasan, cualquier cliente que llame a ese código debería poder trabajar con el
objeto devuelto sin problemas, ya que el contrato se ha cumplido.

Contratos pitónicos
En el momento de escribir este libro, se aplaza un PEP-316, denominado Programación por
contrato para Python. Esto no significa que no podamos implementarlo en Python, porque,
como se presentó al principio del capítulo, este es un principio de diseño general.

Probablemente, la mejor manera de hacer cumplir esto es agregando mecanismos de control


a nuestros métodos, funciones y clases, y si generan una excepción RuntimeError o
ValueError . Es difícil idear una regla general para el tipo correcto de excepción, ya que eso
dependería en gran medida de la aplicación en particular. Estas excepciones mencionadas
anteriormente son los tipos de excepción más comunes, pero si no se ajustan exactamente al
problema, la mejor opción sería crear una excepción personalizada.

También nos gustaría mantener el código lo más aislado posible. Es decir, el código de las
condiciones previas en una parte, el de las condiciones posteriores en otra y el núcleo de la
función separados. Podríamos lograr esta separación creando funciones más pequeñas,
pero en algunos casos implementar un decorador sería una alternativa interesante.

Diseño por contrato – conclusiones


El valor principal de este principio de diseño es identificar efectivamente dónde está el
problema. Al definir un contrato, cuando algo falla en el tiempo de ejecución, quedará claro
qué parte del código está rota y qué rompió el contrato.
Como resultado de seguir este principio, el código será más robusto. Cada componente
impone sus propias restricciones y mantiene algunas invariantes, y se puede demostrar
que el programa es correcto siempre que se conserven estas invariantes.

[ 59 ]
Rasgos generales del buen código Capítulo 3

También sirve para aclarar mejor la estructura del programa. En lugar de intentar ejecutar
validaciones ad hoc o intentar superar todos los posibles escenarios de falla, los contratos
especifican explícitamente qué espera cada función o método para funcionar correctamente
y qué se espera de ellos.

Por supuesto, seguir estos principios también agrega trabajo adicional, porque no solo
estamos programando la lógica central de nuestra aplicación principal, sino también los
contratos. Además, también podríamos querer considerar agregar pruebas unitarias para
estos contratos. Sin embargo, la calidad ganada por este enfoque vale la pena a largo
plazo; por lo tanto, es una buena idea implementar este principio para los componentes
críticos de la aplicación.

No obstante, para que este método sea efectivo, debemos pensar cuidadosamente qué
estamos dispuestos a validar, y esto tiene que ser un valor significativo. Por ejemplo, no
tendría mucho sentido definir contratos que solo verifiquen los tipos de datos correctos de
los parámetros proporcionados a una función. Muchos programadores argumentarían que
esto sería como tratar de hacer de Python un lenguaje de tipo estático.
Independientemente de esto, herramientas como Mypy, en combinación con el uso de
anotaciones, cumplirían este propósito mucho mejor y con menos esfuerzo. Con eso en
mente, diseñe contratos para que realmente tengan valor, verificando, por ejemplo, las
propiedades de los objetos que se pasan y devuelven, las condiciones que deben cumplir,
etc.

programación defensiva
La programación defensiva sigue un enfoque un tanto diferente al de DbC; en lugar de
establecer todas las condiciones que deben cumplirse en un contrato, que si no se
cumplen generarán una excepción y harán que el programa falle, se trata más de hacer
que todas las partes del código (objetos, funciones o métodos) puedan protegerse contra
errores no válidos. entradas.

La programación defensiva es una técnica que tiene varios aspectos, y es especialmente útil
si se combina con otros principios de diseño (esto quiere decir que el hecho de que siga una
filosofía diferente a DbC no significa que se trate de uno o de los dos). otro , podría
significar que podrían complementarse entre sí).

Las ideas principales sobre el tema de la programación defensiva son cómo manejar
errores para escenarios que podríamos esperar que ocurran y cómo lidiar con errores que
nunca deberían ocurrir (cuando ocurren condiciones imposibles). El primero caerá en los
procedimientos de manejo de errores, mientras que el segundo será el caso de las
aserciones, ambos temas que exploraremos en las siguientes secciones.

[ 60 ]
Rasgos generales del buen código Capítulo 3

Manejo de errores
En nuestros programas, recurrimos a procedimientos de manejo de errores para situaciones
que anticipamos como propensas a causar errores. Este suele ser el caso de la entrada de
datos.

La idea detrás del manejo de errores es responder con gracia a estos errores esperados en
un intento de continuar con la ejecución de nuestro programa o decidir fallar si el error
resulta ser insuperable.

Existen diferentes enfoques por los cuales podemos manejar los errores en nuestros
programas, pero no todos son siempre aplicables. Algunos de estos enfoques son los
siguientes:

Sustitución de valor
Registro de errores
Manejo de excepciones

Sustitución de valor
En algunos escenarios, cuando hay un error y existe el riesgo de que el software produzca
un valor incorrecto o falle por completo, es posible que podamos reemplazar el resultado
con otro valor más seguro. A esto lo llamamos sustitución de valor, ya que de hecho
estamos reemplazando el resultado erróneo real por un valor que debe considerarse no
disruptivo (podría ser un valor predeterminado, una constante conocida, un valor centinela
o simplemente algo que no lo hace). afectar el resultado en absoluto, como devolver cero en
un caso en el que se pretende que el resultado se aplique a una suma).

Sin embargo, la sustitución de valores no siempre es posible. Esta estrategia debe elegirse
cuidadosamente para los casos en los que el valor sustituido es en realidad una opción
segura. Tomar esta decisión es un compromiso entre robustez y corrección. Un programa de
software es robusto cuando no falla, incluso en presencia de un escenario erróneo. Pero esto
tampoco es correcto.

Esto podría no ser aceptable para algunos tipos de software. Si la aplicación es crítica o los
datos que se manejan son demasiado confidenciales, esta no es una opción, ya que no
podemos darnos el lujo de proporcionar a los usuarios (u otras partes de la aplicación)
resultados erróneos. En estos casos, optamos por la corrección, en lugar de dejar que el
programa explote al arrojar resultados erróneos.
[ 61 ]
Rasgos generales del buen código Capítulo 3

Una versión ligeramente diferente y más segura de esta decisión es usar valores
predeterminados para los datos que no se proporcionan. Este puede ser el caso de partes del
código que pueden funcionar con un comportamiento predeterminado, por ejemplo,
valores predeterminados para variables de entorno que no están establecidas, para entradas
faltantes en archivos de configuración o para parámetros de funciones. Podemos encontrar
ejemplos de Python soportando esto a través de diferentes métodos de su API, por ejemplo,
los diccionarios tienen un método get , cuyo segundo parámetro (opcional) te permite
indicar un valor por defecto:

>>> configuración = {"dbport": 5432}


>>> configuración.get("dbhost", "localhost") 'localhost'
>>> configuración.get("dbport")
5432

Las variables de entorno tienen una API similar:


>>> import os
>>> os.getenv("DBHOST")
'localhost'
>>> os.getenv("DPORT", 5432)
5432

En los dos ejemplos anteriores, si no se proporciona el segundo parámetro, se devolverá


None , porque es el valor predeterminado con el que se definen esas funciones. También
podemos definir valores por defecto para los parámetros de nuestras propias funciones:
>>> def connect_database(host="localhost", puerto=5432):
... logger.info("conectando al servidor de la base de datos en %s:%i", host, puerto)

En general, es aceptable reemplazar los parámetros faltantes con valores predeterminados,


pero reemplazar datos erróneos con valores de cierre legales es más peligroso y puede
enmascarar algunos errores. Tenga en cuenta este criterio al decidir sobre este enfoque.

Manejo de excepciones
En presencia de datos de entrada incorrectos o faltantes, a veces es posible corregir la
situación con algunos ejemplos como los mencionados en la sección anterior. En otros casos,
sin embargo, es mejor evitar que el programa continúe ejecutándose con datos incorrectos
que dejarlo computando bajo suposiciones erróneas. En esos casos, fallar y notificar a la
persona que llama que algo está mal es un buen enfoque, y este es el caso de una condición
previa que se violó, como vimos en DbC.
[ 62 ]
Rasgos generales del buen código Capítulo 3

No obstante, los datos de entrada erróneos no son la única forma posible en que una
función puede fallar. Después de todo, las funciones no se tratan solo de pasar datos;
también tienen efectos secundarios y se conectan a componentes externos.

Es posible que una falla en una llamada de función se deba a un problema en uno de estos
componentes externos, y no en nuestra función en sí. Si ese es el caso, nuestra función
debería comunicar esto correctamente. Esto facilitará la depuración. La función debe
notificar de forma clara e inequívoca al resto de la aplicación sobre los errores que no se
pueden ignorar para que se puedan abordar en consecuencia.

El mecanismo para lograr esto es una excepción. Es importante enfatizar que esto es para
lo que se deben usar las excepciones : anunciar claramente una situación excepcional, no
alterar el flujo del programa de acuerdo con la lógica comercial.

Si el código intenta usar excepciones para manejar escenarios esperados o lógica comercial,
el flujo del programa será más difícil de leer. Esto conducirá a una situación en la que las
excepciones se utilizan como una especie de declaración de acceso, que (para empeorar las
cosas) podría abarcar múltiples niveles en la pila de llamadas (hasta las funciones de la
persona que llama), violando la encapsulación de la lógica en su correcta nivel de
abstracción. El caso podría empeorar aún más si estos bloques de excepción mezclan la
lógica empresarial con casos realmente excepcionales contra los que el código intenta
defenderse; en ese caso, será más difícil distinguir entre la lógica central que debemos
mantener y los errores que debemos manejar.

No utilice excepciones como un mecanismo de referencia para la


lógica empresarial . Genera excepciones cuando realmente hay algún
problema con el código que las personas que llaman deben tener en
cuenta.
Este último concepto es importante; las excepciones generalmente se refieren a notificar a la
persona que llama sobre algo que no está bien. Esto significa que las excepciones deben
usarse con cuidado porque debilitan la encapsulación. Cuantas más excepciones tenga una
función, más tendrá que anticipar la función que llama y, por lo tanto, conocerá la función a
la que llama. Y si una función lanza demasiadas excepciones, significa que no está tan libre
de contexto, porque cada vez que queramos invocarla, tendremos que tener en cuenta todos
sus posibles efectos secundarios.

Esto se puede usar como una heurística para saber cuándo una función no es lo
suficientemente cohesiva y tiene demasiadas responsabilidades. Si genera demasiadas
excepciones, podría ser una señal de que debe dividirse en varias más pequeñas.
Aquí hay algunas recomendaciones relacionadas con las excepciones en Python.

[ 63 ]
Rasgos generales del buen código Capítulo 3

Manejar excepciones en el nivel correcto de abstracción


Las excepciones también forman parte de las funciones principales que hacen una cosa, y
sólo una cosa. La excepción que la función está manejando (o generando) tiene que ser
consistente con la lógica encapsulada en ella.

En este ejemplo, podemos ver lo que queremos decir con mezclar diferentes niveles de
abstracción. Imagine un objeto que actúa como transporte de algunos datos en nuestra
aplicación. Se conecta a un componente externo donde se enviarán los datos al
decodificarlos. En el siguiente listado, nos centraremos en el método deliver_event :

class DataTransport:
"""Un ejemplo de un objeto que maneja excepciones de diferentes niveles."""

retry_threshold: int = 5
retry_n_times: int = 3

def __init__(self, conector):


self._conector = conector
self.conexión = Ninguno

def deliver_event(self, event):


try:
self.connect()
data = event.decode()
self.send(data)
excepto ConnectionError as e:
logger.info("error de conexión detectado: %s", e)
aumentar
excepto ValueError como e:
logger.error("%r contiene datos incorrectos: %s", evento, e) aumento

def connect(self):
for _ in range(self.retry_n_times):
try:
self.connection = self._connector.connect() excepto
ConnectionError as e:
logger.info(
"%s: intentando una nueva conexión en %is", e,
self.retry_threshold,
)
time.sleep(self.retry_threshold)
else:
return self.connection
raise ConnectionError(
[ 64 ]
Rasgos generales del buen código Capítulo 3

f"No se pudo conectar después de {self.retry_n_times} veces" )

def enviar(auto, datos):


volver auto.conexión.enviar(datos)

Para nuestro análisis, acerquémonos y centrémonos en cómo el método deliver_event()


maneja las excepciones.

¿Qué tiene que ver ValueError con ConnectionError ? Poco. Al observar estos dos tipos de
error tan diferentes, podemos tener una idea de cómo se deben dividir las
responsabilidades. El ConnectionError debe manejarse dentro del método de conexión . Esto
permitirá una clara separación del comportamiento. Por ejemplo, si este método necesita
admitir reintentos, esa sería una forma de hacerlo. Por el contrario, ValueError pertenece al
método de decodificación del evento. Con esta nueva implementación, este método no
necesita detectar ninguna excepción : las excepciones por las que se preocupaba antes son
manejadas por métodos internos o deliberadamente se dejan generar.

Deberíamos separar estos fragmentos en diferentes métodos o funciones. Para la gestión


de conexiones, una pequeña función debería ser suficiente. Esta función se encargará de
intentar establecer la conexión, detectar las excepciones (en caso de que ocurran) y
registrarlas en consecuencia:

def connect_with_retry(connector, retry_n_times, retry_threshold=5): """Intenta establecer la


conexión de <conector> reintentando <reintentar_n_veces>.

Si puede conectarse, devuelve el objeto de conexión.


Si no es posible después de los reintentos, genera ConnectionError

:param conector: Un objeto con un método `.connect()`.


:param retry_n_times int: El número de veces para intentar llamar a
``connector.connect()``.
:param retry_threshold int: El lapso de tiempo entre reintentos de llamadas.

"""
para _ en rango (reintentar_n_veces):
intente:
devuelva conector.conectar()
excepto Error de conexión como e:
logger.info(
"%s: intentando una nueva conexión en %es", e, reintentar_umbral )
[ sesenta y cinco ]
Rasgos generales del buen código Capítulo 3

time.sleep(retry_threshold)
exc = ConnectionError(f"No se pudo conectar después de {retry_n_times} veces")
logger.exception(exc)
aumentar exc

Entonces, llamaremos a esta función en nuestro método. En cuanto a la excepción ValueError


en el evento, podríamos separarlo con un nuevo objeto y hacer la composición, pero para
este caso limitado sería excesivo, por lo que bastaría con mover la lógica a un método
separado. Con estas dos consideraciones en su lugar, la nueva versión del método parece
mucho más compacta y fácil de leer:
class DataTransport:
"""Un ejemplo de un objeto que separa el manejo de excepciones por niveles de abstracción.
"""

retry_threshold: int = 5
retry_n_times: int = 3

def __init__(self, conector):


self._conector = conector
self.conexión = Ninguno

def deliver_event(self, event):


self.connection = connect_with_retry(
self._connector, self.retry_n_times, self.retry_threshold )
self.send(evento)

def send(self, event):


try:
return self.connection.send(event.decode())
excepto ValueError as e:
logger.error("%r contiene datos incorrectos: %s", evento, e) aumentar

No exponer rastros
Esta es una consideración de seguridad. Cuando se trata de excepciones, podría ser
aceptable dejar que se propaguen si el error es demasiado importante, y tal vez incluso
dejar que el programa falle si esta es la decisión para ese escenario en particular y se
favoreció la corrección sobre la robustez.
[ 66 ]
Rasgos generales del buen código Capítulo 3

Cuando hay una excepción que denota un problema, es importante iniciar sesión con
tantos detalles como sea posible (incluida la información de seguimiento, el mensaje y
todo lo que podamos recopilar) para que el problema se pueda corregir de manera
eficiente. Al mismo tiempo, queremos incluir tantos detalles como sea posible para
nosotros ; definitivamente, no queremos que nada de esto sea visible para los usuarios.

En Python, los rastreos de excepciones contienen información de depuración muy rica y


útil. Desafortunadamente, esta información también es muy útil para atacantes o
usuarios malintencionados que quieran intentar dañar la aplicación, sin mencionar que
la filtración representaría una importante divulgación de información, poniendo en
peligro la propiedad intelectual de su organización (partes del código quedarán
expuestas). ).

Si elige dejar que se propaguen las excepciones, asegúrese de no divulgar ninguna


información confidencial. Además, si tiene que notificar a los usuarios sobre un
problema, elija mensajes genéricos (como Algo salió mal o Página no encontrada). Esta
es una técnica común utilizada en aplicaciones web que muestran mensajes informativos
genéricos cuando ocurre un error HTTP.

Evitar bloques vacíos excepto


Esto incluso se denominó el antipatrón de Python más diabólico (REAL 01). Si bien es
bueno anticipar y defender nuestros programas contra algunos errores, estar demasiado a la
defensiva podría conducir a problemas aún peores. En particular, el único problema de
estar demasiado a la defensiva es que hay un bloque de excepción vacío que pasa
silenciosamente sin hacer nada.

Python es tan flexible que nos permite escribir código que puede ser defectuoso y, sin
embargo, no generará un error, como este:

intente:
process_data ()
excepto:
pase

El problema con esto es que no fallará, nunca. Incluso cuando debería. Tampoco es
Pythonic si recuerdas del zen de Python que los errores nunca deben pasar en silencio.

Si hay una verdadera excepción, este bloque de código no fallará, lo que podría ser lo que
queríamos en primer lugar. Pero, ¿y si hay un defecto? Necesitamos saber si hay un error
en nuestra lógica para poder corregirlo. Escribir bloques como este enmascarará los
problemas, haciendo que las cosas sean más difíciles de mantener.

[ 67 ]
Rasgos generales del buen código Capítulo 3

Hay dos alternativas:

Captura una excepción más específica (no demasiado amplia, como una
excepción ). De hecho, algunas herramientas de linting e IDE le advertirán en
algunos casos cuando el código esté manejando una excepción demasiado
amplia.
Realice un manejo de errores real en el bloque de excepción .

Lo mejor que se puede hacer es aplicar ambos elementos simultáneamente.

Manejar una excepción más específica (por ejemplo, AttributeError o KeyError ) hará que
el programa sea más fácil de mantener porque el lector sabrá qué esperar y puede tener
una idea del por qué. También dejará que se presenten otras excepciones, y si eso sucede,
probablemente signifique un error, solo que esta vez se puede descubrir.

Manejar la excepción en sí puede significar varias cosas. En su forma más simple, podría
tratarse simplemente de registrar la excepción (asegúrese de usar logger.exception o
logger.error para proporcionar el contexto completo de lo que sucedió). Otras alternativas
podrían ser devolver un valor por defecto (sustitución, solo que en este caso después de
detectar un error, no antes de provocarlo), o lanzar una excepción diferente.

Si elige generar una excepción diferente, incluya la excepción


original que causó el problema, lo que nos lleva al siguiente punto.

Incluir la excepción original


Como parte de nuestra lógica de manejo de errores, podríamos decidir generar uno
diferente y tal vez incluso cambiar su mensaje. Si ese es el caso, se recomienda incluir la
excepción original que condujo a eso.

En Python 3 (PEP-3134), ahora podemos usar la sintaxis aumentar <e> desde


<excepción_original> . Al usar esta construcción, el rastreo original se incrustará en la nueva
excepción y la excepción original se establecerá en el atributo __causa__ de la resultante.
[ 68 ]
Rasgos generales del buen código Capítulo 3

Por ejemplo, si deseamos envolver excepciones predeterminadas con excepciones


personalizadas internamente en nuestro proyecto, aún podríamos hacerlo e incluir
información sobre la excepción raíz:

class InternalDataError(Exception):
"""Una excepción con los datos de nuestro problema de dominio."""

def proceso (diccionario_datos, id_registro):


intente:
devolver diccionario_datos[id_registro]
excepto KeyError como e:
generar InternalDataError("Registro no presente") de e

Utilice siempre la sintaxis aumentar <e> desde <o> al cambiar el tipo de


excepción.

Usando aserciones en Python


aserciones deben usarse para situaciones que nunca deberían suceder, por lo que la
expresión en la declaración de aserción tiene que significar una condición imposible. Si
ocurre esta condición, significa que hay un defecto en el software.

En contraste con el enfoque de manejo de errores, aquí existe (o no debería existir) la


posibilidad de continuar con el programa. Si ocurre tal error, el programa debe detenerse.
Tiene sentido detener el programa porque, como se comentó anteriormente, estamos en
presencia de un defecto, por lo que no hay forma de avanzar liberando una nueva versión
del software que corrija este defecto.

La idea de usar aserciones es evitar que el programa cause más daños si se presenta un
escenario no válido. A veces, es mejor detenerse y dejar que el programa se cuelgue, en
lugar de dejar que continúe procesando bajo suposiciones incorrectas.

Por esta razón, las aserciones no deben mezclarse con la lógica empresarial ni utilizarse
como mecanismos de flujo de control para el software. El siguiente ejemplo es una mala
idea:

intente:
afirmar condition.holds(), "Condición no satisfecha" excepto AssertionError:
Alternative_procedure()
[ 69 ]
Rasgos generales del buen código Capítulo 3

No detecte la excepción AssertionError .

Asegúrese de que el programa finalice cuando falle una aserción.

Incluya un mensaje de error descriptivo en la declaración de afirmación y registre los


errores para asegurarse de que puede depurar y corregir el problema correctamente más
adelante.

Otra razón importante por la que el código anterior es una mala idea es que, además de
detectar AssertionError , la declaración en la afirmación es una llamada de función. Las
llamadas a funciones pueden tener efectos secundarios y no siempre son repetibles (no
sabemos si llamar nuevamente a
condition.holds() arrojará el mismo resultado). Además, si detenemos el depurador en esa
línea, es posible que no podamos ver convenientemente el resultado que causa el error y,
de nuevo, incluso si volvemos a llamar a esa función, no sabemos si ese fue el valor
ofensivo.

Una alternativa mejor requiere menos líneas de código y proporciona información más útil:
result = condition.holds()
aseverar resultado > 0, "Error con {0}".format(resultado)

Separación de intereses
Este es un principio de diseño que se aplica en múltiples niveles. No se trata solo del
diseño de bajo nivel (código), sino que también es relevante en un nivel más alto de
abstracción, por lo que aparecerá más adelante cuando hablemos de arquitectura.

Deben asignarse diferentes responsabilidades a los diferentes componentes, capas o


módulos de la aplicación. Cada parte del programa solo debe ser responsable de una
parte de la funcionalidad (lo que llamamos sus preocupaciones) y no debe saber nada
sobre el resto.

El objetivo de separar las preocupaciones en el software es mejorar la mantenibilidad


minimizando los efectos dominó. Un efecto dominó significa la propagación de un cambio
en el software desde un punto de partida. Este podría ser el caso de un error o excepción
que desencadena una cadena de otras excepciones, provocando fallas que resultarán en un
defecto en una parte remota de la aplicación. También puede ser que tengamos que cambiar
mucho código disperso en múltiples partes del código base, como resultado de un simple
cambio en la definición de una función.

Claramente, no queremos que estos escenarios sucedan. El software tiene que ser fácil de
cambiar. Si tenemos que modificar o refactorizar alguna parte del código que tiene que
tener un impacto mínimo en el resto de la aplicación, la forma de lograrlo es a través de una
encapsulación adecuada.

[ 70 ]
Rasgos generales del buen código Capítulo 3

De manera similar, queremos contener cualquier error potencial para que no cause un
daño mayor.

Este concepto está relacionado con el principio DbC en el sentido de que cada
preocupación puede hacerse cumplir mediante un contrato. Cuando se viola un contrato y
se genera una excepción como resultado de dicha violación, sabemos qué parte del
programa tiene la falla y qué responsabilidades no se cumplieron.

A pesar de esta similitud, la separación de intereses va más allá. Normalmente pensamos


en contratos entre funciones, métodos o clases, y aunque esto también se aplica a las
responsabilidades que deben separarse, la idea de la separación de preocupaciones
también se aplica a los módulos, paquetes y básicamente a cualquier componente de
software de Python.

Cohesión y acoplamiento
Estos son conceptos importantes para un buen diseño de software.

Por un lado, la cohesión significa que los objetos deben tener un propósito pequeño y
bien definido, y deben hacer lo menos posible. Sigue una filosofía similar a los comandos
de Unix que hacen una sola cosa y la hacen bien. Cuanto más cohesivos son nuestros
objetos, más útiles y reutilizables se vuelven, lo que hace que nuestro diseño sea mejor.

Por otro lado, el acoplamiento se refiere a la idea de cómo dos o más objetos dependen
unos de otros. Esta dependencia plantea una limitación. Si dos partes del código (objetos o
métodos) son demasiado dependientes entre sí, traen consigo algunas consecuencias no
deseadas:

Sin reutilización de código : si una función depende demasiado de un objeto en


particular, o toma demasiados parámetros, se acopla con este objeto, lo que
significa que será realmente difícil usar esa función en un contexto diferente
(para hacerlo, tendremos que encontrar un parámetro adecuado que cumpla
con una interfaz muy restrictiva)
Efectos dominó : los cambios en una de las dos partes ciertamente afectarán a la
otra, ya que están demasiado cerca
Bajo nivel de abstracción : cuando dos funciones están tan estrechamente
relacionadas, es difícil verlas como preocupaciones diferentes que resuelven
problemas en diferentes niveles de
abstracción .
Regla general: el software bien definido logrará una alta cohesión y un
bajo acoplamiento.

[ 71 ]
Rasgos generales del buen código Capítulo 3

Siglas para vivir


En esta sección, revisaremos algunos principios que generan algunas buenas ideas de
diseño. El punto es relacionar rápidamente las buenas prácticas de software mediante siglas
que sean fáciles de recordar, funcionando como una especie de regla mnemotécnica. Si tiene
en cuenta estas palabras, podrá asociarlas con buenas prácticas más fácilmente y encontrar
la idea correcta detrás de una línea de código particular que está viendo será más rápido.

Estas no son definiciones formales o académicas, sino más bien ideas empíricas que
surgieron de años de trabajo en la industria del software. Algunos de ellos aparecen en
libros, ya que fueron acuñados por importantes autores (consulte las referencias para
investigarlos con más detalle), y otros tienen sus raíces probablemente en publicaciones de
blogs, artículos o conferencias.

SECO/OAOO
Las ideas de Don't Repeat Yourself ( DRY ) y Once and Only Once ( OAOO ) están
estrechamente relacionadas, por lo que se incluyeron juntas aquí. Se explican por sí
mismos, debe evitar la duplicación a toda costa.

Las cosas en el código, el conocimiento, tienen que definirse una sola vez y en un solo
lugar. Cuando tenga que hacer un cambio en el código, solo debe haber una ubicación
legítima para modificar. No hacerlo es señal de un sistema mal diseñado.

La duplicación de código es un problema que afecta directamente la mantenibilidad. Es


muy indeseable tener duplicación de código debido a sus muchas consecuencias
negativas:

Es propenso a errores : Cuando alguna lógica se repite varias veces a lo largo del
código, y esto necesita cambiar, significa que dependemos de corregir
eficientemente todas las instancias con esta lógica, sin olvidar ninguna de ellas,
porque en ese caso habrá un insecto.
Es caro : Ligado al punto anterior, hacer un cambio en múltiples lugares lleva
mucho más tiempo (esfuerzo de desarrollo y pruebas) que si se definiera una
sola vez. Esto ralentizará al equipo.
No es confiable : también relacionado con el primer punto, cuando se deben
cambiar varios lugares para un solo cambio en el contexto, confía en la persona
que escribió el código para recordar todas las instancias en las que se debe
realizar la modificación. No hay una única fuente de verdad.
La duplicación a menudo es causada por ignorar (u olvidar) que el código representa
conocimiento. Al dar significado a ciertas partes del código, estamos identificando y
etiquetando ese
conocimiento.

[ 72 ]
Rasgos generales del buen código Capítulo 3

Veamos qué significa esto con un ejemplo. Imagine que, en un centro de estudios, los
estudiantes se clasifican según los siguientes criterios: 11 puntos por examen aprobado,
menos cinco puntos por examen reprobado y menos dos por año en la institución. El
siguiente no es un código real, sino solo una representación de cómo podría estar disperso
en una base de código real:

def process_students_list(students):
# hacer algo de procesamiento...

= ordenado(
estudiantes, clave=lambda s: s.aprobado * 11 - s.reprobado * 5 - s.años * 2 )
# más procesamiento
para estudiante en ranking_estudiantes:
print(
"Nombre: {0}, Puntaje: {1 }".format(
estudiante.nombre,
(estudiante.aprobado * 11 - estudiante.reprobado * 5 - estudiante.años * 2),
)
)

Observe cómo la lambda que está en la clave de la función ordenada representa algún
conocimiento válido del problema de dominio, pero no lo refleja (no tiene un nombre, una
ubicación adecuada y legítima, no hay un significado asignado a ese código, nada). Esta
falta de significado en el código conduce a la duplicación que encontramos cuando se
imprime la partitura mientras se enumera la clasificación.

Deberíamos reflejar nuestro conocimiento del problema de nuestro dominio en nuestro


código, y entonces será menos probable que nuestro código sufra duplicaciones y será
más fácil de entender:

def score_for_student(student):
return estudiante.aprobado * 11 - estudiante.reprobado * 5 - estudiante.años * 2

def process_students_list(students):
# hacer algo de procesamiento...

clasificación_estudiantes = ordenado(estudiantes,
clave=puntuación_del_estudiante) # más procesamiento
para el estudiante en clasificación_estudiantes:
print(
"Nombre: {0}, Puntuación: {1}".format(
estudiante.nombre, puntuación_del_estudiante(estudiante)
)
)

[ 73 ]
Rasgos generales del buen código Capítulo 3

Un descargo de responsabilidad justo: esto es solo un análisis de uno de los rasgos de la


duplicación de código. En realidad, existen más casos, tipos y taxonomías de duplicación
de código, se podrían dedicar capítulos enteros a este tema, pero aquí nos enfocamos en
un aspecto en particular para dejar clara la idea detrás del acrónimo.

En este ejemplo, hemos tomado lo que probablemente sea el enfoque más simple para
eliminar la duplicación: crear una función. Dependiendo del caso, la mejor solución sería
diferente. En algunos casos, puede haber un objeto completamente nuevo que deba crearse
(tal vez faltaba una abstracción completa). En otros casos, podemos eliminar la duplicación
con un administrador de contexto. Los iteradores o generadores (descritos en el Capítulo 7 ,
Uso de generadores ) también podrían ayudar a evitar la repetición en el código, y los
decoradores (explicados en el Capítulo 5 , Uso de decoradores para mejorar nuestro código),
también ayudarán.

Desafortunadamente, no existe una regla o patrón general que le diga cuáles de las
características de Python son las más adecuadas para abordar la duplicación de código,
pero con suerte, después de ver los ejemplos en este libro y cómo se usan los elementos de
Python, el lector sabrá ser capaz de desarrollar su propia intuición.

YAGNI
YAGNI (abreviatura de You Ain't Gonna Need It ) es una idea que quizás desee tener
en cuenta muy a menudo al escribir una solución si no desea diseñarla en exceso.

Queremos poder modificar fácilmente nuestros programas, por lo que queremos que estén
preparados para el futuro. En línea con eso, muchos desarrolladores piensan que tienen que
anticipar todos los requisitos futuros y crear soluciones que son muy complejas y, por lo
tanto, crean abstracciones que son difíciles de leer, mantener y comprender. Algún tiempo
después, resulta que esos requisitos anticipados no aparecen, o lo hacen pero de una
manera diferente (¡sorpresa!), y el código original que se suponía que manejaría
precisamente eso no funciona. El problema es que ahora es aún más difícil refactorizar y
ampliar nuestros programas. Lo que sucedió fue que la solución original no manejó
correctamente los requisitos originales, y tampoco los actuales, simplemente porque es la
abstracción incorrecta.

Tener un software mantenible no se trata de anticipar requisitos futuros (¡no hagas


futurología!). Se trata de escribir software que solo aborde los requisitos actuales de tal
manera que sea posible (y fácil) cambiar más adelante. En otras palabras, al diseñar,
asegúrese de que sus decisiones no lo aten y que podrá seguir construyendo, pero no
construya más de lo necesario.

[ 74 ]
Rasgos generales del buen código Capítulo 3

KIS
KIS (significa Keep It Simple ) se relaciona mucho con el punto anterior. Cuando diseñe
un componente de software, evite el exceso de ingeniería; pregúntate si tu solución es la
mínima que se ajusta al problema.

Implemente una funcionalidad mínima que resuelva correctamente el problema y no


complique su solución más de lo necesario. Recuerde: cuanto más simple sea el diseño, más
fácil de
mantener será.

Este principio de diseño es una idea que queremos tener en cuenta en todos los niveles
de abstracción, ya sea que estemos pensando en un diseño de alto nivel o
dirigiéndonos a una línea de código en particular.

En un nivel alto, piense en los componentes que estamos creando. ¿Realmente los
necesitamos a todos?
¿Este módulo realmente requiere ser completamente extensible en este momento? Enfatice
la última parte : tal vez queramos hacer que ese componente sea extensible, pero ahora no
es el momento adecuado, o no es apropiado hacerlo porque todavía no tenemos suficiente
información para crear las abstracciones adecuadas, y tratando de llegar a las interfaces
genéricas en este punto solo conducirán a problemas aún peores.

En términos de código, mantenerlo simple generalmente significa usar la estructura de


datos más pequeña que se ajuste al problema. Lo más probable es que lo encuentre en la
biblioteca estándar.

A veces, podemos complicar demasiado el código, creando más funciones o métodos de los
necesarios. La siguiente clase crea un espacio de nombres a partir de un conjunto de
argumentos de palabras clave que se han proporcionado, pero tiene una interfaz de código
bastante complicada:

class ComplicatedNamespace:
"""Un ejemplo complicado de inicializar un objeto con algunas propiedades."""

ACCEPTED_VALUES = ("id_", "usuario", "ubicación")

@classmethod
def init_with_data(cls, **data):
instancia = cls()
for key, value in data.items():
if key in cls.ACCEPTED_VALUES:
setattr(instancia, clave, valor) return instancia
[ 75 ]
Rasgos generales del buen código Capítulo 3

Tener un método de clase adicional para inicializar el objeto no parece realmente necesario.
Luego, la iteración y la llamada a setattr dentro de ella hacen que las cosas sean aún más
extrañas, y la interfaz que se presenta al usuario no es muy clara:
>>> cn = ComplicatedNamespace.init_with_data(
... id_=42, usuario="raíz", ubicación="127.0.0.1", extra="excluido" ... )
>>> cn.id_, cn.user, cn.ubicación
(42, 'raíz', '127.0.0.1')

>>> hasattr(cn, "extra")


Falso

El usuario tiene que saber de la existencia de este otro método, lo cual no es


conveniente. Sería mejor mantenerlo simple y simplemente inicializar el objeto como
inicializamos cualquier otro objeto en Python (después de todo, hay un método para
eso) con el método __init__ :

class Namespace:
"""Crear un objeto a partir de argumentos de palabra clave."""

ACCEPTED_VALUES = ("id_", "usuario", "ubicación")

def __init__(self, **datos): datos_aceptados


={
k: v for k, v in data.items() if k in self.VALORES_ACEPTADOS }
self.__dict__.update(datos_aceptados)

Recuerda el zen de Python: lo simple es mejor que lo complejo.

EAFP/LBIL
EAFP (siglas de Más fácil pedir perdón que permiso ), mientras que LBYL (siglas de
Look Before You Leap ).

La idea de EAFP es que escribimos nuestro código para que realice una acción
directamente, y luego nos ocupamos de las consecuencias en caso de que no funcione. Por
lo general, esto significa intentar ejecutar algún código, esperando que funcione, pero
detectando una excepción si no lo hace, y luego manejando el código correctivo en el
bloque de excepción.
[ 76 ]
Rasgos generales del buen código Capítulo 3

Esto es lo opuesto a LBYL . Como su nombre lo dice, en el enfoque de mirar antes de


saltar, primero verificamos lo que estamos a punto de usar. Por ejemplo, podríamos
querer verificar si un archivo está disponible antes de intentar operar con él:
si os.path.exists (nombre de archivo):
con abierto (nombre de archivo)
como f:
...

Esto podría ser bueno para otros lenguajes de programación, pero no es la forma Pythonic
de escribir código. Python se creó con ideas como EAFP y lo alienta a seguirlas (recuerde,
lo explícito es mejor que lo implícito). En cambio, este código se reescribiría así:

intente:
con abrir (nombre de archivo) como
f:
...
excepto FileNotFoundError como e:
logger.error(e)

Prefiere EAFP sobre LBYL.

Composición y herencia
En el diseño de software orientado a objetos, a menudo hay discusiones sobre cómo
abordar algunos problemas utilizando las ideas principales del paradigma
(polimorfismo, herencia y encapsulación).

Probablemente, la más utilizada de estas ideas es la herencia : los desarrolladores a


menudo comienzan creando una jerarquía de clases con las clases que van a necesitar y
deciden los métodos que cada una debe implementar.

Si bien la herencia es un concepto poderoso, viene con sus peligros. La principal es que
cada vez que extendemos una clase base, estamos creando una nueva que está
estrechamente acoplada con la clase principal. Como ya hemos comentado, el
acoplamiento es una de las cosas que queremos reducir al mínimo al diseñar software.
[ 77 ]
Rasgos generales del buen código Capítulo 3

Uno de los principales usos que los desarrolladores relacionan con la herencia es la
reutilización de código. Si bien siempre debemos aceptar la reutilización de código, no es
una buena idea obligar a nuestro diseño a usar la herencia para reutilizar el código solo
porque obtenemos los métodos de la clase principal de forma gratuita. La forma correcta
de reutilizar el código es tener objetos altamente cohesivos que se puedan componer
fácilmente y que puedan funcionar en múltiples contextos.

Cuando la herencia es una buena decisión


Tenemos que tener cuidado al crear una clase derivada, porque esta es una espada de
doble filo , por un lado, tiene la ventaja de que obtenemos todo el código de los métodos
de la clase padre de forma gratuita, pero por otro lado , los llevamos a todos a una nueva
clase, lo que significa que podríamos estar colocando demasiada funcionalidad en una
nueva definición.

Al crear una nueva subclase, tenemos que pensar si realmente va a usar todos los métodos
que acaba de heredar, como una heurística para ver si la clase está definida correctamente.
Si, por el contrario, descubrimos que no necesitamos la mayoría de los métodos y tenemos
que anularlos o reemplazarlos, esto es un error de diseño que puede deberse a varias
razones:

La superclase está vagamente definida y contiene demasiada responsabilidad, en


lugar de una interfaz bien definida
La subclase no es una especialización adecuada de la superclase que está tratando
de extender

Un buen caso para usar la herencia es el tipo de situación en la que tiene una clase que
define ciertos componentes con su comportamiento definido por la interfaz de esta clase
(sus métodos y atributos públicos), y luego necesita especializar esta clase para para crear
objetos que hagan lo mismo pero con algo más agregado, o con algunas partes particulares
de su comportamiento cambiado.

Puede encontrar ejemplos de buenos usos de la herencia en la propia biblioteca estándar de


Python. Por ejemplo, en el paquete http.server (https://docs.python.org/3/library/http.
server.html#http.server.BaseHTTPRequestHandler) , podemos encontrar una clase base como
BaseHTTPRequestHandler , y subclases como SimpleHTTPRequestHandler que amplían esta
añadiendo o cambiando parte de su interfaz base.
Hablando de definición de interfaz, este es otro buen uso para la herencia. Cuando
queremos hacer cumplir la interfaz de algunos objetos, podemos crear una clase base
abstracta que no implemente el comportamiento en sí, sino que simplemente defina la
interfaz ; cada clase que amplíe esta tendrá que implementarlos para ser un subtipo
adecuado.

[ 78 ]
Rasgos generales del buen código Capítulo 3

Finalmente, otro buen caso para la herencia son las excepciones. Podemos ver que la
excepción estándar en Python se deriva de Exception . Esto es lo que le permite tener una
cláusula genérica como excepto Exception: , que detectará todos los errores posibles. El
punto importante es el conceptual, son clases derivadas de Exception porque son
excepciones más específicas. Esto también funciona en bibliotecas conocidas, como las
solicitudes , por ejemplo, en las que HTTPError es RequestException , que a su vez es IOError .

Anti-patrones por herencia


Si el apartado anterior tuviera que resumirse en una sola palabra, sería
especialización . El uso correcto de la herencia es especializar objetos y crear
abstracciones más detalladas a partir de los básicos.

La clase principal (o base) es parte de la definición pública de la nueva clase derivada. Esto
se debe a que los métodos que se heredan serán parte de la interfaz de esta nueva clase. Por
esta razón, cuando leemos los métodos públicos de una clase, tienen que ser consistentes
con lo que define la clase padre.

Por ejemplo, si vemos que una clase derivada de BaseHTTPRequestHandler implementa un


método llamado handle() , tendría sentido porque está anulando uno de los padres.
Si tuviera cualquier otro método cuyo nombre se relacione con una acción que tiene que ver
con una solicitud HTTP, entonces también podríamos pensar que está colocado
correctamente (pero no lo pensaríamos si encontráramos algo llamado process_purchase() en
esa clase).

La ilustración anterior puede parecer obvia, pero es algo que sucede muy a menudo,
especialmente cuando los desarrolladores intentan usar la herencia con el único objetivo de
reutilizar el código. En el siguiente ejemplo, veremos una situación típica que representa un
antipatrón común en Python : hay un problema de dominio que debe representarse y se
diseña una estructura de datos adecuada para ese problema, pero en lugar de crear un
objeto que utiliza una estructura de datos de este tipo, el objeto se convierte en la propia
estructura de datos.

Veamos estos problemas más concretamente a través de un ejemplo. Imagina que tenemos
un sistema de gestión de seguros, con un módulo encargado de aplicar pólizas a diferentes
clientes. Necesitamos mantener en la memoria un conjunto de clientes que se están
procesando en ese momento para aplicar esos cambios antes de continuar con el
procesamiento o la persistencia. Las operaciones básicas que necesitamos son almacenar un
nuevo cliente con sus registros como datos satelitales, aplicar un cambio en una póliza o
editar algunos de los datos, solo por nombrar algunos. También necesitamos admitir una
operación por lotes, es decir, cuando algo en la política en sí cambia (la que este módulo
está procesando actualmente), tenemos que aplicar estos cambios en general a los clientes
en la transacción actual.

[ 79 ]
Rasgos generales del buen código Capítulo 3

Pensando en términos de la estructura de datos que necesitamos, nos damos cuenta de


que acceder al registro de un cliente en particular en tiempo constante es una buena
característica. Por lo tanto, algo como
policy_transaction[customer_id] parece una buena interfaz. A partir de esto, podríamos
pensar que un objeto subíndice es una buena idea y, más adelante, podríamos dejarnos
llevar por la idea de que el objeto que necesitamos es un diccionario:

class TransactionalPolicy(collections.UserDict): """Ejemplo de un uso


incorrecto de la herencia."""

def change_in_policy(self, customer_id, **new_policy_data):


self[customer_id].update(**new_policy_data)

Con este código podemos obtener información sobre una póliza para un cliente por su
identificador:
>>> política = Política transaccional ({
... "cliente001": {
... "tarifa": 1000.0,
... "fecha_de_caducidad": fecha y hora (2020, 1, 3),
... }
... })
>>> politica["cliente001"]
{'tarifa': 1000.0, 'fecha_de_vencimiento': fechahora.fechahora(2020, 1, 3, 0, 0)} >>>
politica.cambio_en_politica("cliente001", fecha_de_vencimiento=fechahora( 2020, 1, 4))
>>> política["cliente001"]
{'tarifa': 1000.0, 'fecha_de_caducidad': datetime.datetime(2020, 1, 4, 0, 0)}

Claro, logramos la interfaz que queríamos en primer lugar, pero ¿a qué costo? Ahora,
esta clase tiene mucho comportamiento extra al llevar a cabo métodos que no eran
necesarios:
>>> dir(política)
[ # todos los métodos mágicos y especiales han sido omitidos por brevedad...
'cambiar_en_política', 'borrar', 'copiar', 'datos', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem',
'setdefault', 'update', 'values ']

Hay (al menos) dos problemas principales con este diseño. Por un lado, la jerarquía está
mal. Crear una nueva clase a partir de una base conceptualmente significa que es una
versión más específica de la clase que está extendiendo (de ahí el nombre). ¿Cómo es que
TransactionalPolicy es un diccionario? ¿Esto tiene sentido? Recuerde, esto es parte de la
interfaz pública del objeto, por lo que los usuarios verán esta clase, su jerarquía y notarán
una especialización tan extraña, así como sus métodos públicos.
[ 80 ]
Rasgos generales del buen código Capítulo 3

Esto nos lleva al segundo problema : el acoplamiento. La interfaz de la política


transaccional ahora incluye todos los métodos de un diccionario. ¿Una política
transaccional realmente necesita métodos como pop() o items() ? Sin embargo, ahí están.
También son públicos, por lo que cualquier usuario de esta interfaz tiene derecho a
llamarlos, con el efecto secundario no deseado que puedan tener. Más sobre este punto :
realmente no ganamos mucho al extender un diccionario. El único método que realmente
necesita actualizar para todos los clientes afectados por un cambio en la política actual (
change_in_policy() ) no está en la clase base, por lo que tendremos que definirlo nosotros
mismos de cualquier manera.

Este es un problema de mezclar objetos de implementación con objetos de dominio. Un


diccionario es un objeto de implementación, una estructura de datos, adecuado para ciertos
tipos de operaciones y con una compensación como todas las estructuras de datos. Una
política transaccional debería representar algo en el problema del dominio, una entidad que
sea parte del problema que estamos tratando de resolver.

Las jerarquías como esta son incorrectas, y el hecho de que obtengamos algunos métodos
mágicos de una clase base (para hacer que el objeto sea subíndice mediante la extensión de
un diccionario) no es motivo suficiente para crear dicha extensión. Las clases de
implementación deben extenderse únicamente al crear otras clases de implementación más
específicas. En otras palabras, extienda un diccionario si desea crear otro diccionario (más
específico o ligeramente modificado). La misma regla se aplica a las clases del problema del
dominio.

La solución correcta aquí es usar la composición. TransactionalPolicy no es un


diccionario , utiliza un diccionario. Debe almacenar un diccionario en un atributo privado e
implementar __getitem__() mediante el proxy de ese diccionario y luego solo implementar el
resto del método público que requiere:

class TransactionalPolicy:
"""Ejemplo refactorizado para usar composición."""

def __init__(self, policy_data, **extra_data): self._data =


{**policy_data, **extra_data}

def change_in_policy(self, customer_id, **new_policy_data):


self._data[customer_id].update(**new_policy_data)

def __getitem__(self, id_cliente):


return self._data[id_cliente]

def __len__(auto):
return len(auto._datos)
[ 81 ]
Rasgos generales del buen código Capítulo 3

Esta forma no solo es conceptualmente correcta, sino también más extensible. Si la


estructura de datos subyacente (que, por ahora, es un diccionario) se cambia en el futuro,
las personas que llamen a este objeto no se verán afectadas, siempre que se mantenga la
interfaz. Esto reduce el acoplamiento, minimiza los efectos dominó, permite una mejor
refactorización (las pruebas unitarias no deben cambiarse) y hace que el código sea más fácil
de mantener.

Herencia múltiple en Python


Python admite la herencia múltiple. Como la herencia, cuando se usa incorrectamente,
conduce a problemas de diseño, también se puede esperar que la herencia múltiple genere
problemas aún mayores cuando no se implementa correctamente.

La herencia múltiple es, por tanto, un arma de doble filo. También puede ser muy
beneficioso en algunos casos. Para que quede claro, la herencia múltiple no tiene nada de
malo ; el único problema que tiene es que, cuando no se implementa correctamente,
multiplicará los problemas.

La herencia múltiple es una solución perfectamente válida cuando se usa correctamente,


y esto abre nuevos patrones (como el patrón de adaptador que discutimos en el Capítulo 9
, Patrones de diseño comunes) y mixins.

Una de las aplicaciones más potentes de la herencia múltiple es quizás la que


permite la creación de mixins. Antes de explorar los mixins, debemos comprender
cómo funciona la herencia múltiple y cómo se resuelven los métodos en una
jerarquía compleja.

Orden de Resolución de Método (MRO)


A algunas personas no les gusta la herencia múltiple debido a las limitaciones que tiene en
otros lenguajes de programación, por ejemplo, el llamado problema del diamante. Cuando
una clase se extiende desde dos o más, y todas esas clases también se extienden desde
otras clases base, las inferiores tendrán varias formas de resolver los métodos que
provienen de las clases de nivel superior. La pregunta es, ¿cuál de estas implementaciones
se utiliza?

Considere el siguiente diagrama, que tiene una estructura con herencia múltiple. La clase de
nivel superior tiene un atributo de clase e implementa el método __str__ . Piense en
cualquiera de las clases concretas, por ejemplo, ConcreteModuleA12 : se extiende desde
BaseModule1 y BaseModule2 , y cada una de ellas tomará la implementación de __str__ de
BaseModule . ¿Cuál de estos dos métodos será el de ConcreteModuleA12 ?

[ 82 ]
Rasgos generales del buen código Capítulo 3

Con el valor del atributo de clase, esto se hará evidente:


clase BaseModule:
module_name = "arriba"

def __init__(self, nombre_módulo):


self.nombre = nombre_módulo

def __str__(self):
return f"{self.module_name}:{self.name}"

clase BaseModule1(BaseModule):
nombre_módulo = "módulo-1"

clase BaseModule2(BaseModule):
nombre_módulo = "módulo-2"

clase BaseModule3(BaseModule):
nombre_módulo = "módulo-3"

clase ConcreteModuleA12(BaseModule1, BaseModule2):


"""Extender 1 y 2"""
[ 83 ]
Rasgos generales del buen código Capítulo 3

clase ConcreteModuleB23(BaseModule2, BaseModule3): """Extend


2 & 3"""

Ahora, probemos esto para ver qué método se está llamando:


>>> str(ConcreteModuleA12("prueba"))
'módulo-1:prueba'

No hay colisión. Python resuelve esto mediante el uso de un algoritmo llamado


linealización C3 o MRO, que define una forma determinista en la que se llamarán los
métodos.

De hecho, podemos preguntar específicamente a la clase por su orden de resolución:


>>> [cls.__name__ for cls in ConcreteModuleA12.mro()]
['ConcreteModuleA12', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object']

Saber cómo se va a resolver el método en una jerarquía puede ser una ventaja para
nosotros al diseñar clases porque podemos hacer uso de mixins.

mezclas
Un mixin es una clase base que encapsula algún comportamiento común con el objetivo de
reutilizar el código. Por lo general, una clase mixin no es útil por sí sola, y la extensión de
esta clase por sí sola ciertamente no funcionará, porque la mayoría de las veces depende de
los métodos y propiedades que se definen en otras clases. La idea es usar clases de mixin
junto con otras, a través de herencia múltiple, para que los métodos o propiedades que se
usan en el mixin estén disponibles.

Imagine que tenemos un analizador simple que toma una cadena y proporciona una
iteración sobre ella por sus valores separados por guiones (-):

clase BaseTokenizer:

def __init__(self, str_token):


self.str_token = str_token

def __iter__(self):
rendimiento de self.str_token.split("-")

Esto es bastante sencillo:


>>> conocimientos básicos = BaseTokenizer("28a2320b-fd3f-4627-9792-
a2b38e3c46b0") >>> lista(conocimientos básicos)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']
[ 84 ]
Rasgos generales del buen código Capítulo 3

Pero ahora queremos que los valores se envíen en mayúsculas, sin alterar la clase base. Para
este ejemplo simple, podríamos simplemente crear una nueva clase, pero imagina que
muchas clases ya se están extendiendo desde BaseTokenizer y no queremos reemplazarlas
todas. Podemos mezclar una nueva clase en la jerarquía que maneja esta transformación:

class UpperIterableMixin:
def __iter__(self):
return map(str.upper, super().__iter__())

tokenizador de clase (UpperIterableMixin, BaseTokenizer): pasar

La nueva clase Tokenizer es realmente simple. No necesita ningún código porque aprovecha
el mixin. Este tipo de mezcla actúa como una especie de decorador. Basado en lo que
acabamos de ver, Tokenizer tomará __iter__ del mixin, y este, a su vez, delegará a la siguiente
clase en la línea (llamando a super() ), que es BaseTokenizer , pero convierte sus valores a
mayúsculas , creando el efecto deseado.

Argumentos en funciones y métodos


En Python, las funciones se pueden definir para recibir argumentos de varias maneras
diferentes, y las personas que llaman también pueden proporcionar estos argumentos
de varias maneras.

También existe un conjunto de prácticas en toda la industria para definir


interfaces en ingeniería de software que se relaciona estrechamente con la
definición de argumentos en funciones.

En esta sección, primero exploraremos la mecánica de los argumentos en las funciones


de Python y luego revisaremos los principios generales de la ingeniería de software que
se relacionan con las buenas prácticas en este tema para finalmente relacionar ambos
conceptos.

Cómo funcionan los argumentos de función en


Python
Primero, exploraremos las particularidades de cómo se pasan los argumentos a las
funciones en Python, y luego revisaremos la teoría general de las buenas prácticas de
ingeniería de software que se relacionan con estos conceptos.
[ 85 ]
Rasgos generales del buen código Capítulo 3

Entendiendo primero las posibilidades que ofrece Python para el manejo de parámetros,
seremos capaces de asimilar reglas generales más fácilmente, y la idea es que después de
haberlo hecho, podamos sacar conclusiones fácilmente sobre qué buenos patrones o
modismos son cuando manejamos
argumentos. Luego, podemos identificar en qué escenarios el enfoque Pythonic es el
correcto y en qué casos podríamos estar abusando de las características del lenguaje.

Cómo se copian los argumentos a las funciones


La primera regla en Python es que todos los argumentos se pasan por un valor. Siempre.
Esto significa que al pasar valores a las funciones, estos se asignan a las variables en la
definición de la firma de la función para luego usarse en ella. Notará que una función que
cambia los argumentos puede depender de los argumentos de tipo : si estamos pasando
objetos mutables , y el cuerpo de la función modifica esto, entonces, por supuesto, tenemos
el efecto secundario de que habrán sido cambiados en ese momento. la función devuelve.

A continuación podemos ver la diferencia:


>>> def función(argumento):
... argumento += " en función"
... print(argumento)
...
>>> inmutable = "hola"
>>> función(inmutable)
hola en función
>>> mutable = lista("hola")
>>> inmutable
'hola'
>>> función(mutable)
['h', 'e ', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i ', 'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', ' f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>>

Esto puede parecer una inconsistencia, pero no lo es. Cuando pasamos el primer
argumento, una cadena, esta se asigna al argumento de la función. Dado que los objetos de
cadena son inmutables, una declaración como "argumento += <expresión>" de hecho creará el
nuevo objeto, "argumento + <expresión>" y lo volverá a asignar al argumento. En ese
momento, un argumento es solo una variable local dentro del alcance de la función y no
tiene nada que ver con la original en la persona que llama.
[ 86 ]
Rasgos generales del buen código Capítulo 3

Por otro lado, cuando pasamos lista , que es un objeto mutable , esa declaración tiene un
significado diferente (en realidad es equivalente a llamar a .extend() en esa lista ). Este
operador actúa modificando la lista en el lugar sobre una variable que contiene una
referencia al objeto de la lista original , por lo tanto, modificándolo.

Tenemos que tener cuidado al tratar con este tipo de parámetros porque pueden
provocar efectos secundarios inesperados. A menos que esté absolutamente seguro de
que es correcto manipular argumentos mutables de esta manera, le recomendamos
evitarlo y buscar alternativas sin estos problemas.

No cambie los argumentos de la función. En general, intente evitar los


efectos secundarios en las funciones tanto como sea posible.

Los argumentos en Python se pueden pasar por posición, como en muchos otros lenguajes
de programación, pero también por palabra clave. Esto significa que podemos decirle
explícitamente a la función qué valores queremos para cuáles de sus parámetros. La única
advertencia es que después de pasar un parámetro por palabra clave, el resto que sigue
también debe pasarse de esta manera, de lo contrario, se generará SyntaxError .

Número variable de argumentos


Python, al igual que otros lenguajes, tiene funciones y construcciones integradas que
pueden tomar una cantidad variable de argumentos. Considere, por ejemplo, las
funciones de interpolación de cadenas (ya sea utilizando el operador % o el método de
formato para cadenas), que siguen una estructura similar a la función printf en C, un
primer parámetro posicional con el formato de cadena, seguido de cualquier cantidad de
argumentos que se colocará en los marcadores de esa cadena de formato.

Además de aprovechar estas funciones que están disponibles en Python, también podemos
crear las nuestras, que funcionarán de manera similar. En esta sección, cubriremos los
principios básicos de las funciones con un número variable de argumentos, junto con
algunas
recomendaciones, de modo que en la siguiente sección, podamos explorar cómo usar estas
características para nuestro beneficio al tratar con problemas comunes, cuestiones, y las
restricciones que pueden tener las funciones si tienen demasiados argumentos.
Para un número variable de argumentos de posición, se utiliza el símbolo de estrella ( * ),
que precede al nombre de la variable que contiene esos argumentos. Esto funciona a
través del mecanismo de empaquetado de Python.

[ 87 ]
Rasgos generales del buen código Capítulo 3

Digamos que hay una función que toma tres argumentos posicionales. En una parte del
código, convenientemente tenemos los argumentos que queremos pasar a la función dentro
de una lista, en el mismo orden en que la función los espera. En lugar de pasarlos uno por
uno por la posición (es decir, list[0] al primer elemento, list[1] al segundo, y así
sucesivamente), lo que sería muy poco Pythonic, podemos usar el mecanismo de
empaquetado y pasarlos todos juntos en una sola instrucción:

>>> def f(primero, segundo, tercero):


... imprimir(primero)
... imprimir(segundo)
... imprimir(tercero)
...
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3

Lo bueno del mecanismo de empaque es que también funciona al revés. Si queremos


extraer los valores de una lista a variables, por su respectiva posición, podemos asignarlos
así:

>>> a, b, c = [1, 2, 3]
>>> a
1
>>> b
2
>>> c
3

También es posible el desembalaje parcial. Digamos que solo estamos interesados en los
primeros valores de una secuencia (esto puede ser una lista, una tupla u otra cosa) y,
después de cierto punto, solo queremos que el resto se mantenga unido. Podemos asignar
las variables que necesitamos y dejar el resto en una lista empaquetada. El orden en que
desempaquetamos no está limitado. Si no hay nada que colocar en una de las subsecciones
desempaquetadas, el resultado será una lista vacía. Se anima al lector a probar ejemplos
como los presentados en la siguiente lista en una terminal de Python, y también a explorar
que el desempaquetado también funciona con generadores:
>>> def mostrar(e, resto):
... print("Elemento: {0} - Resto: {1}".formato(e, resto)) ...
>>> primero, *resto = [1, 2, 3, 4, 5]
>>> mostrar(primero, resto)
Elemento: 1 - Resto: [2, 3, 4, 5]
>>> *resto, último = rango(6)
>>> mostrar(ultimo, resto)

[ 88 ]
Rasgos generales del buen código Capítulo 3

Elemento: 5 - Resto: [0, 1, 2, 3, 4]


>>> primero, *medio, último = rango(6)
>>> primer
0
>>> medio
[1, 2, 3, 4]
> >> últimos
5
>>> primero, último, *vacío = (1, 2)
>>> primero
1
>>> últimos
2
>>> vacío
[]

Uno de los mejores usos para desempaquetar variables se puede encontrar en la


iteración. Cuando tenemos que iterar sobre una secuencia de elementos, y cada elemento
es, a su vez, una secuencia, es una buena idea desempaquetar al mismo tiempo que se
itera sobre cada elemento. Para ver un ejemplo de esto en acción, vamos a pretender que
tenemos una función que recibe una lista de filas de la base de datos y que se encarga de
crear usuarios a partir de esos datos. La primera implementación toma los valores para
construir el usuario a partir de la posición de cada columna en la fila, que no es
idiomática en absoluto. La segunda implementación utiliza el desempaquetado durante
la iteración:
USUARIOS = [(i, f"first_name_{i}", "last_name_{i}") for i in range(1_000)]

class Usuario:
def __init__(self, user_id, first_name, last_name): self.user_id =
user_id
self.first_name = first_name
self.last_name = last_name

def bad_users_from_rows(dbrows) -> list:


"""Un mal caso (no pythonic) de crear ``Usuarios``s de filas DB.""" return [Usuario(fila[0], fila[1],
fila [2]) para fila en dbrows]

def users_from_rows(dbrows) -> list:


"""Create ``User``s from DB rows."""
return [
User(user_id, first_name, last_name)
for (user_id, first_name, last_name) in dbrows ]

[ 89 ]
Rasgos generales del buen código Capítulo 3

Tenga en cuenta que la segunda versión es mucho más fácil de leer. En la primera versión
de la función ( bad_users_from_rows ), tenemos datos expresados en la forma fila[0] , fila[1] y
fila[2] , que no nos dice nada sobre qué son. Por otro lado, variables como user_id ,
first_name y last_name hablan por sí mismas.

Podemos aprovechar este tipo de funcionalidad a nuestro favor al diseñar nuestras


propias funciones.

Un ejemplo de esto que podemos encontrar en la biblioteca estándar radica en la función


max , que se define de la siguiente manera:

max(...)
max(iterable, *[, default=obj, key=func]) -> value
max(arg1, arg2, *args, *[, key=func]) -> value
Con un único argumento iterable , devolver su artículo más grande. El argumento
predeterminado de solo palabra clave especifica un objeto para devolver si el iterable
proporcionado está vacío.
Con dos o más argumentos, devuelve el argumento más grande.

Hay una notación similar, con dos estrellas ( ** ) para argumentos de palabras clave. Si
tenemos un diccionario y se lo pasamos con doble asterisco a una función, lo que hará es
elegir las claves como el nombre del parámetro, y pasar el valor de esa clave como el valor de
ese parámetro en esa función.

Por ejemplo, mira esto:


función(**{"clave": "valor"})

Es lo mismo que lo siguiente:


función(clave="valor")

Por el contrario, si definimos una función con un parámetro que comienza con símbolos
de dos estrellas, sucederá lo contrario : los parámetros proporcionados por palabras
clave se empaquetarán en un diccionario:

>>> función def(**kwargs):


... print(kwargs)
...
>>> función(clave="valor")
{'clave': 'valor'}
[ 90 ]
Rasgos generales del buen código Capítulo 3

El número de argumentos en las funciones.


En esta sección, coincidimos en la idea de que tener funciones o métodos que toman
demasiados argumentos es una señal de mal diseño (olor a código). Luego, proponemos
formas de abordar este problema.

La primera alternativa es un principio más general del diseño de software : la reificación


(crear un nuevo objeto para todos los argumentos que estamos pasando, que es
probablemente la abstracción que nos falta). Compactar múltiples argumentos en un nuevo
objeto no es una solución específica de Python, sino algo que podemos aplicar en cualquier
lenguaje de programación.

Otra opción sería usar las funciones específicas de Python que vimos en la sección anterior,
haciendo uso de argumentos posicionales variables y de palabras clave para crear funciones
que tengan una firma dinámica. Si bien esta podría ser una forma Pythonic de proceder,
debemos tener cuidado de no abusar de la función, porque podríamos estar creando algo
que es tan dinámico que es difícil de mantener. En este caso, deberíamos echar un vistazo al
cuerpo de la función. Independientemente de la firma, y si los parámetros parecen ser
correctos, si la función está haciendo demasiadas cosas diferentes en respuesta a los valores
de los parámetros, entonces es una señal de que debe dividirse en varias funciones más
pequeñas (recuerde, funciones deben hacer una cosa, ¡y sólo una cosa!).

Argumentos de función y acoplamiento


Cuantos más argumentos tenga la firma de una función, es más probable que esté
estrechamente asociada con la función que llama.

Digamos que tenemos dos funciones, f1 y f2 , y la última toma cinco parámetros. Cuantos
más parámetros tome f2 , más difícil será para cualquiera que intente llamar a esa función
para recopilar toda esa información y pasarla para que pueda funcionar correctamente.

Ahora, f1 parece tener toda esta información porque puede llamarla correctamente. De esto,
podemos derivar dos conclusiones: primero, f2 es probablemente una abstracción con fugas,
lo que significa que dado que f1 sabe todo lo que requiere f2 , puede averiguar lo que está
haciendo internamente y podrá hacerlo por sí mismo. Entonces, en general, f2 no está
abstrayendo tanto. En segundo lugar, parece que f2 solo es útil para f1 y es difícil imaginar
el uso de esta función en un contexto diferente, lo que dificulta su reutilización.

Cuando las funciones tienen una interfaz más general y pueden trabajar con
abstracciones de mayor nivel, se vuelven más reutilizables.
[ 91 ]
Rasgos generales del buen código Capítulo 3

Esto se aplica a todo tipo de funciones y métodos de objetos, incluido el método __init__ para
clases. La presencia de un método como este podría generalmente (pero no siempre)
significar que en su lugar se debe pasar una nueva abstracción de nivel superior, o que falta
un objeto.

Si una función necesita demasiados parámetros para funcionar


correctamente, considéralo un olor a código.

De hecho, este es un problema de diseño tan grande que las herramientas de análisis
estático como pylint (discutidas en el Capítulo 1 , Introducción, Formato de código y Herramientas
) generarán, de forma predeterminada, una advertencia cuando se encuentren con un caso
así. Cuando esto suceda, no elimine la advertencia ; en su lugar, refactorícela.

Firmas de funciones compactas que toman


demasiados argumentos
Supongamos que encontramos una función que requiere demasiados parámetros.
Sabemos que no podemos dejar el código base así, y es imperativo refactorizar. ¿Pero,
cuáles son las opciones?

Dependiendo del caso, se pueden aplicar algunas de las siguientes reglas. Esto no es de
ninguna manera extenso, pero proporciona una idea de cómo resolver algunos escenarios
que ocurren con bastante frecuencia.

A veces, hay una manera fácil de cambiar los parámetros si podemos ver que la
mayoría de ellos pertenecen a un objeto común. Por ejemplo, considere una llamada
de función como esta:
track_request(request.headers, request.ip_addr, request.request_id)

Ahora, la función puede o no tomar argumentos adicionales, pero algo es realmente obvio
aquí: todos los parámetros dependen de la solicitud , entonces, ¿por qué no pasar el objeto
de
solicitud en su lugar? Este es un cambio simple, pero mejora significativamente el código. La
llamada de función correcta debería ser track_request(request) , sin mencionar que,
semánticamente, también tiene mucho más sentido.

Si bien se recomienda pasar parámetros como este, en todos los casos en los que pasamos
objetos mutables a funciones, debemos tener mucho cuidado con los efectos secundarios. La
función que estamos llamando no debe hacer ninguna modificación al objeto que estamos
pasando porque eso mutará el objeto, creando un efecto secundario no deseado. A menos
que este sea realmente el efecto deseado (en cuyo caso, debe quedar explícito), se
desaconseja este tipo de comportamiento. Incluso cuando realmente queremos cambiar algo
en el objeto con el que estamos tratando, una mejor
alternativa sería copiarlo y devolver una (nueva) versión modificada del mismo.

[ 92 ]
Rasgos generales del buen código Capítulo 3

Trabaje con objetos inmutables y evite los efectos secundarios tanto como sea
posible.

Esto nos lleva a un tema similar: parámetros de agrupación. En el ejemplo anterior, los
parámetros ya estaban agrupados, pero el grupo (en este caso, el objeto de solicitud) no
estaba siendo utilizado. Pero otros casos no son tan obvios como ese, y es posible que
queramos agrupar todos los datos en los parámetros en un solo objeto que actúa como
contenedor. No hace falta decir que esta agrupación tiene que tener sentido. La idea aquí es
cosificar : crear la abstracción que faltaba en nuestro diseño.

Si las estrategias anteriores no funcionan, como último recurso podemos cambiar la firma
de la función para que acepte un número variable de argumentos. Si la cantidad de
argumentos es demasiado grande, usar *args o **kwargs hará que las cosas sean más
difíciles de seguir, por lo que debemos asegurarnos de que la interfaz esté debidamente
documentada y se use correctamente, pero en algunos casos vale la pena hacerlo.

Es cierto que una función definida con *args y **kwargs es realmente flexible y adaptable,
pero la desventaja es que pierde su firma, y con eso, parte de su significado, y casi toda su
legibilidad. Hemos visto ejemplos de cómo los nombres de las variables (incluidos los
argumentos de función) hacen que el código sea mucho más fácil de leer. Si una función
tomará cualquier número de argumentos (posicionales o de palabra clave), podríamos
descubrir que cuando queramos echar un vistazo a esa función en el futuro, probablemente
no sabremos exactamente qué se suponía que debía hacer con sus parámetros. , a menos
que tenga una muy buena cadena de documentación.

Comentarios finales sobre buenas


prácticas para el diseño de
software
Un buen diseño de software implica una combinación de seguir buenas prácticas de
ingeniería de software y aprovechar la mayoría de las características del lenguaje. Hay un
gran valor en usar todo lo que Python tiene para ofrecer, pero también existe un gran
riesgo de abusar de esto e intentar encajar características complejas en diseños simples.

Además de este principio general, sería bueno agregar algunas recomendaciones finales.
[ 93 ]
Rasgos generales del buen código Capítulo 3

Ortogonalidad en el software
Esta palabra es muy general y puede tener múltiples significados o interpretaciones. En
matemáticas, ortogonal significa que dos elementos son independientes. Si dos vectores
son ortogonales, su producto escalar es cero. También significa que no están relacionados
en absoluto: un cambio en uno de ellos no afecta en absoluto al otro. Esa es la forma en
que debemos pensar en nuestro software.

Cambiar un módulo, clase o función no debería tener ningún impacto en el mundo


exterior de ese componente que se está modificando. Por supuesto, esto es muy deseable,
pero no siempre es posible. Pero incluso para los casos en los que no es posible, un buen
diseño intentará minimizar el impacto tanto como sea posible. Hemos visto ideas como la
separación de preocupaciones, la cohesión y el aislamiento de componentes.

En términos de la estructura del tiempo de ejecución del software, la ortogonalidad puede


interpretarse como el hecho de que los cambios (o efectos secundarios) son locales. Esto
significa, por ejemplo, que llamar a un método en un objeto no debería alterar el estado
interno de otros objetos (no relacionados). Ya hemos enfatizado (y continuaremos
haciéndolo) en este libro la importancia de minimizar los efectos secundarios en nuestro
código.

En el ejemplo con la clase mixin, creamos un objeto tokenizador que devolvió un iterable. El
hecho de que el método __iter__ devuelva un nuevo generador aumenta las posibilidades de
que las tres clases (base, mezcla y clase concreta) sean ortogonales. Si esto hubiera devuelto
algo en concreto (una lista, digamos), esto habría creado una dependencia con el resto de las
clases, porque cuando cambiamos la lista a otra cosa, es posible que hayamos necesitado
actualizar otras partes del código, revelando que las clases no eran tan independientes como
deberían ser.

Vamos a mostrarte un ejemplo rápido. Python permite pasar funciones por parámetro
porque son solo objetos regulares. Podemos usar esta función para lograr algo de
ortogonalidad. Tenemos una función que calcula un precio, incluyendo impuestos y
descuentos, pero luego queremos formatear el precio final que se obtiene:

def calcular_precio(base_price: float, tax: float, discount: float) -> return (base_price * (1 +
tax)) * (1 - descuento)

def show_price(precio: float) -> str:


return "$ {0:,.2f}".formato(precio)
def str_final_price(
base_price: float, tax: float, discount: float, fmt_function=str ) -> str:
return fmt_function(calculate_price(base_price, tax, discount))

[ 94 ]
Rasgos generales del buen código Capítulo 3

Observe que la función de nivel superior está componiendo dos funciones ortogonales.
Una cosa a tener en cuenta es cómo calculamos el precio, que es cómo se representará el
otro. Cambiar uno no cambia el otro. Si no pasamos nada en particular, usará la
conversión de cadenas como la función de representación predeterminada, y si elegimos
pasar una función personalizada, la cadena resultante cambiará. Sin embargo, los cambios
en show_price no afectan a compute_price . Podemos hacer cambios en cualquiera de las
funciones, sabiendo que la otra permanecerá como estaba:
>>> str_precio_final(10, 0.2, 0.5)
'6.0'

>>> str_precio_final(1000, 0.2, 0)


'1200.0'

>>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price) '$ 1,080.00'

Hay un aspecto de calidad interesante que se relaciona con la ortogonalidad. Si dos partes
del código son ortogonales, significa que una puede cambiar sin afectar a la otra. Esto
implica que la parte que cambió tiene pruebas unitarias que también son ortogonales a las
pruebas unitarias del resto de la aplicación. Bajo esta suposición, si esas pruebas pasan,
podemos asumir (hasta cierto punto) que la aplicación es correcta sin necesidad de una
prueba de regresión completa.

En términos más generales, la ortogonalidad se puede considerar en términos de


características. Dos funcionalidades de la aplicación pueden ser totalmente
independientes para que puedan probarse y liberarse sin tener que preocuparse de que
una pueda romper la otra (o el resto del código, para el caso).
Imagine que el proyecto requiere un nuevo mecanismo de autenticación ( oauth2 , digamos,
pero solo por el ejemplo), y al mismo tiempo otro equipo también está trabajando en un
nuevo informe. A menos que haya algo fundamentalmente incorrecto en ese sistema,
ninguna de esas características debería afectar a la otra. Independientemente de cuál de
ellos se fusione primero, el otro no debería verse afectado en absoluto.

Estructurando el código
La forma en que se organiza el código también afecta el rendimiento del
equipo y su capacidad de mantenimiento.

En particular, tener archivos grandes con muchas definiciones (clases, funciones,


constantes, etc.) es una mala práctica y debe desaconsejarse. Esto no significa ir al extremo
de colocar una definición por archivo, pero una buena base de código estructurará y
organizará los componentes por similitud.

[ 95 ]
Rasgos generales del buen código Capítulo 3

Afortunadamente, la mayoría de las veces, cambiar un archivo grande a uno más pequeño
no es una tarea difícil en Python. Incluso si muchas otras partes del código dependen de las
definiciones hechas en ese archivo, esto se puede dividir en un paquete y mantendrá la
compatibilidad total. La idea sería crear un nuevo directorio con un archivo __init__.py (esto
lo convertirá en un paquete de Python). Junto a este archivo tendremos múltiples archivos
con todas las definiciones particulares que requiere cada uno (menos funciones y clases
agrupadas por un determinado criterio). Luego, el archivo __init__.py importará de todos los
demás archivos las definiciones que tenía anteriormente (que es lo que garantiza su
compatibilidad). Además, estas definiciones se pueden mencionar en la variable __all__ del
módulo para que sean exportables.

Hay muchas ventajas de esto. Además del hecho de que cada archivo será más fácil de
navegar y las cosas serán más fáciles de encontrar, podríamos argumentar que será
más eficiente por las siguientes razones:

Contiene menos objetos para analizar y cargar en la memoria cuando se


importa el módulo
El módulo en sí probablemente importará menos módulos porque necesita
menos dependencias, como antes.

También ayuda tener una convención para el proyecto. Por ejemplo, en lugar de colocar
constantes en todos los archivos, podemos crear un archivo específico para los valores
constantes que se utilizarán en el proyecto e importarlo desde allí:
de mypoject.constants import CONNECTION_TIMEOUT

Centralizar información como esta facilita la reutilización del código y ayuda a


evitar la duplicación inadvertida.

Se discutirán más detalles sobre la separación de módulos y la creación de paquetes de


Python en el Capítulo 10 , Arquitectura limpia , cuando exploremos esto en el contexto de la
arquitectura de software.

Resumen
En este capítulo, hemos explorado varios principios para lograr un diseño limpio.
Entender que el código es parte del diseño es clave para lograr un software de alta calidad.
Este capítulo y el siguiente se centran precisamente en eso.
Con estas ideas, ahora podemos construir un código más robusto. Por ejemplo, al aplicar
DbC, podemos crear componentes que garantizan que funcionarán bajo sus restricciones.
Más importante aún, si ocurren errores, esto no sucederá de la nada, sino que tendremos
una idea clara de quién es el infractor y qué parte del código rompió el contrato. Esta
compartimentación es clara para una depuración efectiva.

[ 96 ]
Rasgos generales del buen código Capítulo 3

De manera similar, cada componente se puede hacer más robusto si se defiende de


intenciones maliciosas o entradas incorrectas. Aunque esta idea va en una dirección
diferente al diseño por contrato, podría complementarla muy bien. La programación
defensiva es una buena idea, especialmente para las partes críticas de la aplicación.

Para ambos enfoques (diseño por contrato y programación defensiva), es importante


manejar correctamente las afirmaciones. Tenga en cuenta cómo deben usarse en Python
y no use aserciones como parte de la lógica de flujo de control del programa. Tampoco
capte esta excepción.

Hablando de excepciones, es importante saber cómo y cuándo usarlas, y el concepto más


importante aquí es evitar usar la excepción como un tipo de construcción de flujo de
control (ir a).

Hemos explorado un tema recurrente en el diseño orientado a objetos: decidir entre usar
herencia o composición. La lección principal aquí es no usar una sobre la otra, sino usar la
opción que sea mejor; también deberíamos evitar algunos antipatrones comunes, que a
menudo podemos ver en Python (especialmente dada su naturaleza altamente dinámica).

Finalmente, discutimos la cantidad de argumentos en las funciones, junto con las


heurísticas para un diseño limpio, siempre teniendo en cuenta las particularidades de
Python.

Estos conceptos son ideas de diseño fundamentales que sientan las bases para lo que viene
en el próximo capítulo. Primero debemos comprender estas ideas para poder pasar a temas
más avanzados, como los principios SOLID.

Referencias
Aquí hay una lista de información que puede consultar:

Construcción de software orientada a objetos, segunda edición , escrito por Bertrand


Meyer
The Pragmatic Programmer: From Journeyman to Master , por Andrew Hunt y
David Thomas, publicado por Addison-Wesley, 2000.
PEP-316 : Programación por Contrato para Python ( https:/)/www.python.org/dev/
peps/pep-0316/
REAL 01 : El antipatrón Python más diabólico :https://realpython.com/
blog/python/the-most-diabolical-python-antipattern/
PEP-3134: Encadenamiento de excepciones y rastreos integrados ( https:/)/www.python.
org/dev/peps/pep-3134/

[ 97 ]
Rasgos generales del buen código Capítulo 3

Python idiomático: EAFP versus LBYL (https://blogs.msdn.microsoft.com/


pythonengineering/)2016/06/29/idiomatic-python-eafp-versus-lbyl/
Composición vs Herencia: ¿Cómo elegir? ( https:/)/www.thoughtworks.com/
insights/blog/composition-vs-inheritance-how-choose
Pitón HTTP ( https:/)/docs.python.org/3/library/http.server.html#http.
server.BaseHTTPRequestHandler
Fuente de referencia para excepciones en la biblioteca de http:/solicitudes (
)/docs.python- requests.org/en/master/_modules/requests/exceptions/
Código completo: un manual práctico de construcción de software, segunda
edición , escrito por Steve McConnell

[ 98 ]
Los principios SOLID 
En este capítulo, continuaremos explorando conceptos de diseño limpio aplicados a Python.
En particular, revisaremos los llamados principios SOLID y cómo implementarlos de
manera Pythonic. Estos principios conllevan una serie de buenas prácticas para conseguir
un software de mejor calidad. En caso de que algunos de nosotros no sepamos qué significa
SOLID, aquí está:

S : Principio de responsabilidad única


O : Principio abierto/cerrado
L : Principio de sustitución de Liskov
I : Principio de segregación de interfaces
D : Principio de inversión de dependencia

Los objetivos de este capítulo son los siguientes:

Familiarizarse con los principios SOLID para el diseño de software


Diseñar componentes de software que sigan el principio de responsabilidad única
Para lograr un código más mantenible a través del principio abierto/cerrado
Implementar jerarquías de clases adecuadas en el diseño orientado a objetos,
cumpliendo con el principio de sustitución de Liskov
Para diseñar con segregación de interfaz e inversión de dependencia

Principio de responsabilidad única


El principio de responsabilidad única ( SRP ) establece que un componente de software
(en general, una clase) debe tener una sola responsabilidad. El hecho de que la clase tenga
una responsabilidad única significa que está encargada de hacer una sola cosa concreta, y
como consecuencia de ello, podemos concluir que debe tener una sola razón para cambiar.
Solo si una cosa en el problema del dominio cambia, la clase deberá actualizarse. Si
tenemos que hacer modificaciones a una clase, por diferentes motivos, significa que la
abstracción es incorrecta y que la clase tiene demasiadas responsabilidades.
Los principios SOLID Capítulo 4

Como se presentó en el Capítulo 2 , Código Pythonic , este principio de diseño nos ayuda a
construir
abstracciones más cohesivas; objetos que hacen una cosa, y solo una cosa, bueno, siguiendo
la filosofía Unix. Lo que queremos evitar en todos los casos es tener objetos con
responsabilidades múltiples (a menudo llamados objetos-dioses , porque saben demasiado,
o más de lo que deberían). Estos objetos agrupan diferentes comportamientos (en su
mayoría no relacionados), lo que los hace más difíciles de mantener.

Una vez más, cuanto más pequeña sea la clase, mejor.

El SRP está estrechamente relacionado con la idea de cohesión en el diseño de software,


que ya exploramos en el Capítulo 3 , Rasgos generales del buen código , cuando discutimos la
separación de preocupaciones en el software. Lo que nos esforzamos por lograr aquí es
que las clases estén diseñadas de tal manera que la mayoría de sus propiedades y sus
atributos sean utilizados por sus métodos, la mayor parte del tiempo. Cuando esto sucede,
sabemos que son conceptos relacionados y, por lo tanto, tiene sentido agruparlos bajo la
misma abstracción.

En cierto modo, esta idea es similar al concepto de normalización en el diseño de bases


de datos relacionales. Cuando detectamos que hay particiones en los atributos o métodos
de la interfaz de un objeto, bien podrían moverse a otro lugar , es una señal de que son
dos o más abstracciones diferentes mezcladas en una sola.

Hay otra manera de ver este principio. Si al mirar una clase encontramos métodos que se
excluyen mutuamente y no se relacionan entre sí, son las diferentes responsabilidades las
que hay que desglosar en clases más pequeñas.

Una clase con demasiadas responsabilidades.


En este ejemplo, vamos a crear el caso de una aplicación que se encarga de leer información
sobre eventos de una fuente (pueden ser archivos de registro, una base de datos o muchas
fuentes más) e identificar las acciones correspondientes a cada una en particular. Iniciar
sesión.

Un diseño que no cumpla con el SRP se vería así:


[ 100 ]
Los principios SOLID Capítulo 4

Sin considerar la implementación, el código de la clase podría verse en el siguiente listado:

# srp_1.py
class SystemMonitor:
def load_activity(self):
"""Obtener los eventos de una fuente, para ser procesados."""

def identificar_eventos(self):
"""Analizar los datos sin procesar de origen en eventos (objetos de dominio)."""

def stream_events(self):
"""Enviar los eventos analizados a un agente externo."""

El problema de esta clase es que define una interfaz con un conjunto de métodos que
corresponden a acciones que son ortogonales: cada uno se puede hacer
independientemente del resto.

Este defecto de diseño hace que la clase sea rígida, inflexible y propensa a errores porque
es difícil de mantener. En este ejemplo, cada método representa una responsabilidad de
la clase. Cada responsabilidad implica una razón por la cual la clase podría necesitar ser
modificada. En este caso, cada método representa una de las diversas razones por las
que se tendrá que modificar la clase.

Considere el método del cargador, que recupera la información de una fuente en


particular. Independientemente de cómo se haga esto (podemos resumir los detalles de
implementación aquí), está claro que tendrá su propia secuencia de pasos, por ejemplo,
conectarse a la fuente de datos, cargar los datos, analizarlos en el formato esperado, etc.
en. Si algo de esto cambia (por ejemplo, queremos cambiar la estructura de datos utilizada
para almacenar los datos), la clase
SystemMonitor deberá cambiar. Pregúntese si esto tiene sentido. ¿Tiene que cambiar un
objeto del monitor del sistema porque cambiamos la representación de los datos? No.

El mismo razonamiento se aplica a los otros dos métodos. Si cambiamos la forma en que
tomamos las huellas digitales de los eventos, o cómo los entregamos a otra fuente de
datos, terminaremos haciendo cambios en la misma clase.

Ya debería quedar claro que esta clase es bastante frágil y no muy mantenible. Hay
muchas razones diferentes que afectarán los cambios en esta clase. En cambio, queremos
que los factores externos afecten lo menos posible a nuestro código. La solución,
nuevamente, es crear abstracciones más pequeñas y más cohesivas.
[ 101 ]
Los principios SOLID Capítulo 4

Distribución de responsabilidades
Para que la solución sea más fácil de mantener, separamos cada método en una clase
diferente. De esta forma, cada clase tendrá una única responsabilidad:

El mismo comportamiento se logra usando un objeto que interactuará con instancias de


estas nuevas clases, usando esos objetos como colaboradores, pero se mantiene la idea de
que cada clase encapsula un conjunto específico de métodos que son independientes del
resto. La idea ahora es que los cambios en cualquiera de estas clases no impacten en el resto,
y todas ellas tengan un significado claro y específico. Si necesitamos cambiar algo sobre
cómo cargamos eventos desde las fuentes de datos, el sistema de alerta ni siquiera es
consciente de estos cambios, por lo que no tenemos que modificar nada en el monitor del
sistema (siempre que se conserve el contrato), y el objetivo de datos tampoco se modifica.

Los cambios ahora son locales, el impacto es mínimo y cada clase es más fácil de mantener.

Las nuevas clases definen interfaces que no solo son más fáciles de mantener sino también
reutilizables. Imagina que ahora, en otra parte de la aplicación, también necesitamos leer la
actividad de los registros, pero para diferentes propósitos. Con este diseño, podemos
simplemente usar objetos de tipo ActivityReader (que en realidad sería una interfaz, pero
para los propósitos de esta sección, ese detalle no es relevante y se explicará más adelante
para los siguientes principios). Esto tendría sentido, mientras que no lo habría tenido en el
diseño anterior, porque los intentos de reutilizar la única clase que habíamos definido
también habrían llevado métodos adicionales (como identificar_eventos() o
transmitir_eventos() ) que no eran necesarios en todos.

Una aclaración importante es que el principio no significa en absoluto que cada clase
deba tener un solo método. Cualquiera de las nuevas clases puede tener métodos
adicionales, siempre que correspondan a la misma lógica que esa clase está a cargo de
manejar.

[ 102 ]
Los principios SOLID Capítulo 4

El principio abierto/cerrado
El principio abierto/cerrado ( OCP ) establece que un módulo debe ser tanto abierto como
cerrado (pero con respecto a diferentes aspectos).

Al diseñar una clase, por ejemplo, debemos encapsular cuidadosamente la lógica para que
tenga un buen mantenimiento, lo que significa que querremos que esté abierta a la
extensión pero cerrada a la modificación.

Lo que esto significa en términos simples es que, por supuesto, queremos que nuestro
código sea extensible, para adaptarse a nuevos requisitos o cambios en el problema del
dominio. Esto significa que, cuando aparece algo nuevo en el problema del dominio, solo
queremos agregar cosas nuevas a nuestro modelo, no cambiar nada existente que esté
cerrado a la modificación.

Si, por alguna razón, cuando se debe agregar algo nuevo, nos encontramos modificando el
código, entonces esa lógica probablemente esté mal diseñada. Idealmente, cuando los
requisitos cambian, solo queremos tener que extender el módulo con el nuevo
comportamiento requerido para cumplir con los nuevos requisitos, pero sin tener que
modificar el código.

Este principio se aplica a varias abstracciones de software. Podría ser una clase o incluso un
módulo. En las siguientes dos subsecciones, veremos ejemplos de cada uno,
respectivamente.

Ejemplo de peligros de mantenibilidad por no


seguir el principio abierto/cerrado
Comencemos con un ejemplo de un sistema que está diseñado de tal manera que no sigue el
principio abierto/cerrado, para ver los problemas de mantenibilidad que esto conlleva y la
rigidez de dicho diseño.

La idea es que tengamos una parte del sistema que se encargue de identificar los eventos a
medida que ocurren en otro sistema, que está siendo monitoreado. En cada punto,
queremos que este componente identifique el tipo de evento, correctamente, de acuerdo con
los valores de los datos que se recopilaron previamente (para simplificar, supondremos que
está empaquetado en un diccionario y se recuperó previamente a través de otro medio
como como registros, consultas y muchos más). Tenemos una clase que, en base a estos
datos, recuperará el evento, que es de otro tipo con su propia jerarquía.
Un primer intento de resolver este problema podría verse así:
# openclosed_1.py
class Event:
def __init__(self, raw_data):

[ 103 ]
Los principios SOLID Capítulo 4

self.datos_sin procesar = datos_sin procesar

class UnknownEvent(Event):
"""Un tipo de evento que no se puede identificar a partir de sus datos."""

class LoginEvent(Event):
"""Un evento que representa a un usuario que acaba de ingresar al sistema."""

class LogoutEvent(Event):
"""Un evento que representa a un usuario que acaba de salir del sistema."""

class SystemMonitor:
"""Identificar eventos que ocurrieron en el sistema."""

def __init__(self, event_data):


self.event_data = event_data

def identificar_evento(self):
if (
self.event_data["antes"]["sesión"] == 0 and
self.event_data["después"]["sesión"] == 1 ):
return LoginEvent(self.event_data)
elif (
self.event_data["antes"]["sesión"] == 1 y
self.event_data["después"]["sesión"] == 0 ):
return LogoutEvent(self.event_data)

devolver UnknownEvent(self.event_data)

El siguiente es el comportamiento esperado del código anterior:


>>> l1 = SystemMonitor({"antes": {"sesión": 0}, "después": {"sesión": 1}}) >>>
l1.identify_event().__class__.__name__
'LoginEvent'

>>> l2 = SystemMonitor({"antes": {"sesión": 1}, "después": {"sesión": 0}}) >>>


l2.identify_event().__class__.__name__
'LogoutEvent'

>>> l3 = SystemMonitor({"antes": {"sesión": 1}, "después": {"sesión": 1}}) >>>


l3.identify_event().__class__.__name__
'UnknownEvent'
[ 104 ]
Los principios SOLID Capítulo 4

Podemos notar claramente la jerarquía de los tipos de eventos y alguna lógica comercial
para construirlos. Por ejemplo, cuando no había un indicador anterior para una sesión, pero
ahora lo hay, identificamos ese registro como un evento de inicio de sesión. Por el contrario,
cuando sucede lo contrario, significa que fue un evento de cierre de sesión. Si no fue posible
identificar un evento, se devuelve un evento de tipo desconocido. Esto es para preservar el
polimorfismo siguiendo el patrón de objeto nulo (en lugar de devolver None , recupera un
objeto del tipo correspondiente con alguna lógica predeterminada). El patrón de objeto nulo
se describe en el Capítulo 9 , Patrones de diseño comunes .

Este diseño tiene algunos problemas. El primer problema es que la lógica para determinar
los tipos de eventos está centralizada dentro de un método monolítico. A medida que crezca
la cantidad de eventos que queremos admitir, este método también lo hará, y podría
terminar siendo un método muy largo, lo cual es malo porque, como ya hemos comentado,
no estará haciendo solo una cosa y una cosa. bien.

En la misma línea, podemos ver que este método no está cerrado a modificaciones. Cada
vez que queramos agregar un nuevo tipo de evento al sistema, tendremos que cambiar algo
en este método (¡sin mencionar que la cadena de declaraciones elif será una pesadilla para
leer!).

Queremos poder agregar nuevos tipos de eventos sin tener que cambiar este método
(cerrado por modificación). También queremos poder admitir nuevos tipos de eventos
(abiertos para extensión) para que cuando se agregue un nuevo evento, solo tengamos que
agregar código, no cambiar el código que ya existe.

Refactorización del sistema de eventos para la


extensibilidad
El problema del ejemplo anterior era que la clase SystemMonitor interactuaba directamente
con las clases concretas que iba a recuperar.

Para lograr un diseño que honre el principio abierto/cerrado, tenemos que diseñar
hacia las abstracciones .

Una posible alternativa sería pensar en esta clase como colaboradora de los eventos, y
luego delegar la lógica para cada tipo particular de evento a su clase correspondiente:
[ 105 ]
Los principios SOLID Capítulo 4

Luego tenemos que agregar un nuevo método (polimórfico) a cada tipo de evento con la
única responsabilidad de determinar si corresponde a los datos que se pasan o no, y
también tenemos que cambiar la lógica para pasar por todos los eventos, encontrando el
correcto. una.

El nuevo código debería verse así:


# openclosed_2.py
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data

@staticmethod
def cumple_condición(event_data: dict):
devuelve Falso

class UnknownEvent(Event):
"""Un tipo de evento que no se puede identificar a partir de sus datos"""

clase LoginEvent(Evento):
@staticmethod
def meet_condition(event_data: dict):
return (
event_data["antes"]["sesión"] == 0 and
event_data["después"]["sesión"] == 1 )

class LogoutEvent(Evento):
@staticmethod
def meet_condition(event_data: dict):
return (
event_data["antes"]["sesión"] == 1 y
event_data["después"]["sesión"] == 0 )

class SystemMonitor:
"""Identificar eventos que ocurrieron en el sistema."""

def __init__(self, event_data):


self.event_data = event_data

def identificar_evento(self):
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(self.event_data):

[ 106 ]
Los principios SOLID Capítulo 4

devuelve event_cls(self.event_data) excepto KeyError:


continúa
devuelve UnknownEvent(self.event_data)

Observe cómo la interacción ahora está orientada hacia una abstracción (en este caso, sería
la clase base genérica Evento , que incluso podría ser una clase base abstracta o una interfaz,
pero para los propósitos de este ejemplo es suficiente tener un evento concreto). clase
básica). El método ya no funciona con tipos específicos de eventos, sino solo con eventos
genéricos que siguen una interfaz común : todos son polimórficos con respecto al método
meet_condition .

Observe cómo se descubren los eventos a través del método __subclasses__() . Admitir nuevos
tipos de eventos ahora se trata solo de crear una nueva clase para ese evento que tiene que
heredar de Event e implementar su propio método meet_condition() , de acuerdo con su
lógica comercial específica.

Extendiendo el sistema de eventos


Ahora, demostremos que este diseño es tan extensible como queríamos. Imagine que surge
un nuevo requerimiento y tenemos que soportar también eventos que corresponden a
transacciones que el usuario ejecutó en el sistema monitoreado.

El diagrama de clases para el diseño debe incluir un tipo de evento nuevo, como el
siguiente:

Solo al agregar el código a esta nueva clase, la lógica sigue funcionando como se esperaba:
# openclosed_3.py
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data

@métodoestático
[ 107 ]
Los principios SOLID Capítulo 4

def cumple_condición(event_data: dict):


devuelve Falso

class UnknownEvent(Event):
"""Un tipo de evento que no se puede identificar a partir de sus datos"""

clase LoginEvent(Evento):
@staticmethod
def meet_condition(event_data: dict):
return (
event_data["antes"]["sesión"] == 0 and
event_data["después"]["sesión"] == 1 )

class LogoutEvent(Evento):
@staticmethod
def meet_condition(event_data: dict):
return (
event_data["antes"]["sesión"] == 1 y
event_data["después"]["sesión"] == 0 )

class TransactionEvent(Event):
"""Representa una transacción que acaba de ocurrir en el sistema."""

@staticmethod
def cumple_condición(event_data: dict):
return event_data["después"].get("transacción") no es Ninguno

class SystemMonitor:
"""Identificar eventos que ocurrieron en el sistema."""

def __init__(self, event_data):


self.event_data = event_data

def identificar_evento(self):
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(self.event_data): return
event_cls(self.event_data)
excepto KeyError:
continue
return UnknownEvent(self.event_data)
[ 108 ]
Los principios SOLID Capítulo 4

Podemos comprobar que los casos anteriores funcionan como antes y que el nuevo
evento también está correctamente identificado:

>>> l1 = SystemMonitor({"antes": {"sesión": 0}, "después": {"sesión": 1}}) >>>


l1.identify_event().__class__.__name__
'LoginEvent'

>>> l2 = SystemMonitor({"antes": {"sesión": 1}, "después": {"sesión": 0}}) >>>


l2.identify_event().__class__.__name__
'LogoutEvent'

>>> l3 = SystemMonitor({"antes": {"sesión": 1}, "después": {"sesión": 1}}) >>>


l3.identify_event().__class__.__name__
'UnknownEvent'

>>> l4 = SystemMonitor({"después": {"transacción": "Tx001"}}) >>>


l4.identify_event().__class__.__name__
'TransactionEvent'

Observe que el método SystemMonitor.identify_event() no cambió en absoluto cuando


agregamos el nuevo tipo de evento. Por lo tanto, decimos que este método está cerrado
con respecto a nuevos tipos de eventos.

Por el contrario, la clase Evento nos permitió agregar un nuevo tipo de evento cuando se nos
pidió que lo hiciéramos. Entonces decimos que los eventos están abiertos para una
extensión con respecto a nuevos tipos.

Esta es la verdadera esencia de este principio : cuando aparece algo nuevo en el


problema del dominio, solo queremos agregar código nuevo, no modificar el código
existente.

Reflexiones finales sobre la OCP


Como habrás notado, este principio está estrechamente relacionado con el uso efectivo del
polimorfismo. Queremos diseñar hacia abstracciones que respeten un contrato polimórfico
que el cliente pueda usar, hacia una estructura que sea lo suficientemente genérica como
para que sea posible extender el modelo, siempre que se conserve la relación polimórfica.

Este principio aborda un problema importante en la ingeniería de software: la


mantenibilidad. Los peligros de no seguir el OCP son los efectos dominó y los problemas
en el software donde un solo cambio desencadena cambios en todo el código base o corre
el riesgo de romper otras partes del código.
[ 109 ]
Los principios SOLID Capítulo 4

Una nota final importante es que, para lograr este diseño en el que no cambiamos el código
para extender el comportamiento, debemos poder crear un cierre adecuado contra las
abstracciones que queremos proteger (en este ejemplo, nuevos tipos de eventos). ). Esto no
siempre es posible en todos los programas, ya que algunas abstracciones pueden colisionar
(por ejemplo, es posible que tengamos una abstracción adecuada que cierre un requisito,
pero que no funcione para otros tipos de requisitos). En estos casos, debemos ser selectivos
y aplicar una estrategia que proporcione el mejor cierre para los tipos de requisitos que
requieren ser los más extensibles.

Principio de sustitución de Liskov


El principio de sustitución de Liskov ( LSP ) establece que hay una serie de propiedades
que un tipo de objeto debe tener para preservar la confiabilidad en su diseño.

La idea principal detrás de LSP es que, para cualquier clase, un cliente debería poder
usar cualquiera de sus subtipos indistinguiblemente, sin siquiera darse cuenta y, por lo
tanto, sin comprometer el comportamiento esperado en tiempo de ejecución. Esto
significa que los clientes están completamente aislados y no se dan cuenta de los cambios
en la jerarquía de clases.

Más formalmente, esta es la definición original (LISKOV 01) del principio de sustitución de
Liskov: si S es un subtipo de T , entonces los objetos de tipo T pueden ser reemplazados por
objetos de tipo S , sin romper el programa.

Esto se puede entender con la ayuda de un diagrama genérico como el siguiente.


Imagine que hay alguna clase de cliente que requiere (incluye) objetos de otro tipo. En
términos generales, querremos que este cliente interactúe con objetos de algún tipo, es decir,
funcionará a través de una interfaz.

Ahora bien, este tipo también podría ser solo una definición de interfaz genérica, una clase
abstracta o una interfaz, no una clase con el comportamiento en sí. Puede haber varias
subclases que extiendan este tipo (descrito en el diagrama con el nombre Subtipo , hasta N
). La idea detrás de este principio es que, si la jerarquía se implementa correctamente, la
clase cliente debe poder trabajar con instancias de cualquiera de las subclases sin siquiera
darse cuenta. Estos objetos deben ser intercambiables, como se muestra aquí:
[ 110 ]
Los principios SOLID Capítulo 4

Esto está relacionado con otros principios de diseño que ya hemos visto, como el diseño
de interfaces. Una buena clase debe definir una interfaz clara y concisa, y mientras las
subclases respeten esa interfaz, el programa seguirá siendo correcto.

Como consecuencia de esto, el principio también se relaciona con las ideas detrás del
diseño por contrato. Hay un contrato entre un tipo dado y un cliente. Al seguir las reglas
de LSP, el diseño se asegurará de que las subclases respeten los contratos definidos por las
clases principales.

Detección de problemas de LSP con


herramientas
Hay algunos escenarios tan notoriamente erróneos con respecto al LSP que pueden
identificarse fácilmente con las herramientas que hemos aprendido a configurar en el
Capítulo 1 , Introducción, Formato de código y Herramientas (principalmente Mypy y Pylint).

Detección de tipos de datos incorrectos en firmas


de métodos con Mypy
Mediante el uso de anotaciones de tipo (como se recomendó anteriormente en el Capítulo 1 ,
Introducción, formato de código y herramientas ), en todo nuestro código y configurando
Mypy, podemos detectar rápidamente algunos errores básicos y verificar el cumplimiento
básico con LSP de forma gratuita.
[ 111 ]
Los principios SOLID Capítulo 4

Si una de las subclases de la clase Event anulara un método de manera incompatible,


Mypy lo notaría al inspeccionar las anotaciones:
evento de clase:
...
def cumple_condición(self, event_data: dict) -> bool: return False

class LoginEvent(Evento):
def cumple_condición(self, event_data: list) -> bool: return
bool(event_data)

Cuando ejecutamos Mypy en este archivo, obtendremos un mensaje de error que dice lo
siguiente:
error: Argumento 1 de "meets_condition" incompatible con el supertipo "Evento"

La violación de LSP es clara : dado que la clase derivada usa un tipo para el parámetro
event_data que es diferente del definido en la clase base, no podemos esperar que funcionen
de la misma manera. Recuerda que, según este principio, cualquier llamador de esta
jerarquía tiene que poder trabajar con Event o LoginEvent de forma transparente, sin notar
ninguna diferencia. Intercambiar objetos de estos dos tipos no debería hacer que la
aplicación falle. De lo contrario, se rompería el polimorfismo en la jerarquía.

Se habría producido el mismo error si el tipo de devolución se hubiera cambiado por algo
que no fuera un valor booleano. La razón es que los clientes de este código esperan un valor
booleano para trabajar. Si una de las clases derivadas cambia este tipo de devolución,
estaría rompiendo el contrato, y de nuevo, no podemos esperar que el programa siga
funcionando normalmente.

Una nota rápida sobre los tipos que no son iguales pero comparten una interfaz común:
aunque este es solo un ejemplo simple para demostrar el error, es cierto que tanto los
diccionarios como las listas tienen algo en común; ambos son iterables. Esto significa que,
en algunos casos, puede ser válido tener un método que espera un diccionario y otro que
espera recibir una lista, siempre que ambos traten los parámetros a través de la interfaz
iterable. En este caso, el problema no residiría en la lógica en sí (aún podría aplicarse LSP),
sino en la definición de los tipos de la firma, que no debería leer ni list ni dict , sino una
unión de ambos. Independientemente del caso, algo se tiene que modificar, ya sea el código
del método, todo el diseño o solo las anotaciones de tipo, pero en ningún caso debemos
silenciar la advertencia e ignorar el error que da Mypy.
No ignore errores como este usando # type: ignore o algo similar.
Refactorice o cambie el código para resolver el problema real. Las
herramientas informan una falla de diseño real por una razón válida.

[ 112 ]
Los principios SOLID Capítulo 4

Detección de firmas incompatibles con Pylint


Otra fuerte violación de LSP es cuando, en lugar de variar los tipos de parámetros en la
jerarquía, las firmas de los métodos difieren completamente. Esto puede parecer un gran
error, pero detectarlo no siempre sería tan fácil de recordar; Python se interpreta, por lo que
no hay un compilador que detecte este tipo de error desde el principio y, por lo tanto, no se
detectarán hasta el tiempo de ejecución. Afortunadamente, tenemos analizadores de código
estático como Mypy y Pylint para detectar errores como este desde el principio.

Si bien Mypy también detectará este tipo de error, no está mal ejecutar también Pylint
para obtener más información.

En presencia de una clase que rompe la compatibilidad definida por la jerarquía (por
ejemplo, al cambiar la firma del método, agregar un parámetro adicional, etc.) se muestra
de la siguiente manera:
# lsp_1.py
clase LogoutEvent(Evento):
def cumple_condición(self, event_data: dict, override: bool) -> bool: if override:
return True
...

Pylint lo detectará, imprimiendo un error informativo:


Los parámetros difieren del método anulado 'meets_condition' (arguments-differ)

Una vez más, como en el caso anterior, no elimine estos errores. Preste atención a las
advertencias y errores que dan las herramientas y adapte el código en consecuencia.

Casos más sutiles de violaciones de LSP


En otros casos, sin embargo, la forma en que se interrumpe el LSP no es tan clara u
obvia como para que una herramienta pueda identificarlo automáticamente, y
tenemos que confiar en una inspección cuidadosa del código cuando hacemos una
revisión del código.

Los casos en los que se modifican los contratos son particularmente más difíciles de
detectar automáticamente. Dado que la idea completa de LSP es que los clientes pueden
usar las subclases al igual que su clase principal, también debe ser cierto que los contratos
se conservan correctamente en la jerarquía.
[ 113 ]
Los principios SOLID Capítulo 4

Recuerde del Capítulo 3, Características generales de un buen código , que, cuando se diseña por
contrato, el contrato entre el cliente y el proveedor establece algunas reglas : el cliente debe
proporcionar las condiciones previas al método, que el proveedor puede validar, y
devuelve algún resultado. al cliente que comprobará en forma de postcondiciones.

La clase padre define un contrato con sus clientes. Las subclases de ésta deben respetar
dicho contrato. Esto significa que, por ejemplo:

Una subclase nunca puede hacer que las condiciones previas sean más estrictas
de lo que están definidas en la clase principal
Una subclase nunca puede hacer que las condiciones posteriores sean más
débiles de lo que están definidas en la clase principal

Considere el ejemplo de la jerarquía de eventos definida en la sección anterior, pero ahora


con un cambio para ilustrar la relación entre LSP y DbC.

Esta vez, vamos a asumir una condición previa para el método que verifica los criterios en
función de los datos, que el parámetro proporcionado debe ser un diccionario que contenga
las claves "antes" y "después" , y que sus valores también sean diccionarios anidados. . Esto
nos permite encapsular aún más, porque ahora el cliente no necesita capturar la excepción
KeyError , sino que simplemente llama al método de condición previa (asumiendo que es
aceptable que falle si el sistema está operando bajo las suposiciones incorrectas). Como nota
al margen, es bueno que podamos eliminar esto del cliente, ya que ahora, SystemMonitor no
requiere saber qué tipos de excepciones podrían generar los métodos de la clase
colaboradora (recuerde que la excepción debilita la encapsulación, ya que requieren que la
persona que llama para saber algo extra sobre el objeto que están llamando).

Dicho diseño podría representarse con los siguientes cambios en el código:


#lsp_2.py

evento de clase:
def __init__(self, raw_data):
self.raw_data = raw_data

@staticmethod
def cumple_condición(event_data: dict):
devuelve Falso

@staticmethod
def meet_condition_pre(event_data: dict):
"""Condición previa del contrato de esta interfaz.
Valide que el parámetro ``event_data`` esté correctamente formado. """

[ 114 ]
Los principios SOLID Capítulo 4

afirmar isinstance(event_data, dict), f"{event_data!r} no es un dict"


para momento en ("antes", "después"):
afirmar momento en event_data, f"{momento} no en {event_data}" afirmar
isinstance(event_data[momento], dict)

Y ahora el código que intenta detectar el tipo de evento correcto solo verifica la
condición previa una vez y procede a encontrar el tipo de evento correcto:

# lsp_2.py
class SystemMonitor:
"""Identificar eventos que ocurrieron en el sistema."""

def __init__(self, event_data):


self.event_data = event_data

def identificar_evento(self):
Event.meets_condition_pre(self.event_data)
event_cls = next(
(
event_cls
for event_cls in Event.__subclasses__()
if event_cls.meets_condition(self.event_data) ),
UnknownEvent,
)
return event_cls(self.event_data)

El contrato solo establece que las claves de nivel superior "antes" y "después" son obligatorias
y que sus valores también deben ser diccionarios. Cualquier intento en las subclases de
exigir un parámetro más restrictivo fallará.

La clase para el evento de transacción originalmente se diseñó correctamente. Mire cómo


el código no impone una restricción en la clave interna llamada "transacción" ; solo usa su
valor si está allí, pero esto no es obligatorio:
# lsp_2.py
class TransactionEvent(Evento):
"""Representa una transacción que acaba de ocurrir en el sistema."""

@staticmethod
def cumple_condición(event_data: dict):
return event_data["después"].get("transacción") no es Ninguno
[ 115 ]
Los principios SOLID Capítulo 4

Sin embargo, los dos métodos originales no son correctos, porque exigen la presencia de
una clave denominada "sesión" , que no forma parte del contrato original. Esto rompe el
contrato, y ahora el cliente no puede usar estas clases de la misma manera que usa el resto
porque generará KeyError .

Después de corregir esto (cambiando los corchetes por el método .get() ), se ha


restablecido el orden en el LSP y prevalece el polimorfismo:

>>> l1 = SystemMonitor({"antes": {"sesión": 0}, "después": {"sesión": 1}}) >>>


l1.identify_event().__class__.__name__
'LoginEvent'

>>> l2 = SystemMonitor({"antes": {"sesión": 1}, "después": {"sesión": 0}}) >>>


l2.identify_event().__class__.__name__
'LogoutEvent'

>>> l3 = SystemMonitor({"antes": {"sesión": 1}, "después": {"sesión": 1}}) >>>


l3.identify_event().__class__.__name__
'UnknownEvent'

>>> l4 = SystemMonitor({"antes": {}, "después": {"transacción": "Tx001"}}) >>>


l4.identify_event().__class__.__name__
'TransactionEvent'

No es razonable esperar que las herramientas automatizadas (independientemente de lo


buenas y útiles que sean) detecten casos como este. Tenemos que tener cuidado al diseñar
clases para no cambiar accidentalmente la entrada o salida de los métodos de una manera
que sería incompatible con lo que los clientes esperaban originalmente.

Comentarios sobre el LSP


El LSP es fundamental para un buen diseño de software orientado a objetos porque enfatiza
uno de sus rasgos centrales : el polimorfismo. Se trata de crear jerarquías correctas para que
las clases derivadas de una base sean polimórficas a lo largo de la matriz, con respecto a los
métodos de su interfaz.

También es interesante notar cómo este principio se relaciona con el anterior : si intentamos
extender una clase con una nueva que es incompatible, fallará, el contrato con el cliente se
romperá y, como resultado, dicha extensión no será posible (o, para hacerlo posible,
tendríamos que romper el otro extremo del principio y modificar código en el cliente que
debería estar cerrado para modificaciones, lo cual es completamente indeseable e
inaceptable).
[ 116 ]
Los principios SOLID Capítulo 4

Pensar cuidadosamente en las nuevas clases de la manera que sugiere LSP nos ayuda a
extender la jerarquía correctamente. Entonces podríamos decir que LSP contribuye a la
OCP.

Segregación de interfaz
El principio de segregación de interfaces ( ISP ) proporciona algunas pautas sobre una
idea que ya hemos revisado varias veces: que las interfaces deben ser pequeñas.

En términos orientados a objetos, una interfaz está representada por el conjunto de métodos
que expone un objeto. Es decir, todos los mensajes que un objeto es capaz de recibir o
interpretar constituyen su interfaz, y esto es lo que pueden solicitar otros clientes. La
interfaz separa la definición del comportamiento expuesto para una clase de su
implementación.

En Python, las interfaces están implícitamente definidas por una clase de acuerdo con
sus métodos. Esto se debe a que Python sigue el llamado principio de tipificación de
pato .

Tradicionalmente, la idea detrás de la tipificación pato era que cualquier objeto se


representa realmente por los métodos que tiene y por lo que es capaz de hacer. Esto
significa que, independientemente del tipo de clase, su nombre, su docstring, atributos de
clase o atributos de instancia, lo que finalmente define la esencia del objeto son los métodos
que tiene. Los métodos definidos en una clase (lo que sabe hacer) son los que determinan lo
que ese objeto será en realidad. Se llamó digitación de pato debido a la idea de que "si
camina como un pato y grazna como un pato, debe ser un pato".

Durante mucho tiempo, el tipo de pato fue la única forma en que se definieron las interfaces
en Python. Posteriormente, Python 3 (PEP-3119) introdujo el concepto de clases base
abstractas como una forma de definir las interfaces de una manera diferente. La idea básica
de las clases base abstractas es que definen un comportamiento básico o interfaz que
algunas clases derivadas son responsables de implementar. Esto es útil en situaciones en las
que queremos asegurarnos de que ciertos métodos críticos se invaliden, y también funciona
como un mecanismo para invalidar o ampliar la funcionalidad de métodos como
isinstance() .

Este módulo también contiene una forma de registrar algunos tipos como parte de una
jerarquía, en lo que se llama una subclase virtual . La idea es que esto amplía un poco más
el concepto de tipificación de patos al agregar un nuevo criterio : camina como un pato,
grazna como un pato o... dice que es un pato.

Estas nociones de cómo Python interpreta las interfaces son importantes para
comprender este principio y el siguiente.

[ 117 ]
Los principios SOLID Capítulo 4

En términos abstractos, esto significa que el ISP establece que, cuando definimos una
interfaz que proporciona múltiples métodos, es mejor dividirla en varios, cada uno con
menos métodos (preferiblemente solo uno), con un muy específico y alcance preciso. Al
separar las interfaces en las unidades más pequeñas posibles, para favorecer la reutilización
del código, cada clase que quiera implementar una de estas interfaces probablemente será
muy cohesiva dado que tiene un comportamiento y un conjunto de responsabilidades
bastante definido.

Una interfaz que proporciona demasiado


Ahora, queremos poder analizar un evento de varias fuentes de datos, en diferentes
formatos (XML y JSON, por ejemplo). Siguiendo una buena práctica, decidimos apuntar a
una interfaz como nuestra dependencia en lugar de una clase concreta, y se ideó algo
como lo siguiente:

Para crear esto como una interfaz en Python, usaríamos una clase base abstracta y
definiríamos los métodos ( from_xml() y from_json() ) como abstractos, para obligar a las
clases derivadas a implementarlos. Los eventos que se derivan de esta clase base abstracta e
implementan estos métodos podrían funcionar con sus tipos correspondientes.

Pero, ¿qué pasa si una clase en particular no necesita el método XML y solo se puede
construir a partir de un JSON? Todavía llevaría el método from_xml() desde la interfaz, y
dado que no lo necesita, tendrá que pasar. Esto no es muy flexible ya que crea
acoplamiento y obliga a los clientes de la interfaz a trabajar con métodos que no necesitan.

Cuanto más pequeña sea la interfaz, mejor


Sería mejor separar esto en dos interfaces diferentes, una para cada método:
[ 118 ]
Los principios SOLID Capítulo 4

Con este diseño, los objetos que se derivan de XMLEventParser e implementan el método
from_xml() sabrán cómo construirse a partir de un XML, y lo mismo para un archivo
JSON, pero lo más importante, mantenemos la ortogonalidad de dos funciones
independientes y preservamos el flexibilidad del sistema sin perder ninguna
funcionalidad que aún se puede lograr componiendo nuevos objetos más pequeños.

Hay cierto parecido con el SRP, pero la principal diferencia es que aquí estamos hablando
de interfaces, por lo que es una definición abstracta de comportamiento. No hay razón para
cambiar porque no hay nada allí hasta que la interfaz se implemente realmente. Sin
embargo, el incumplimiento de este principio creará una interfaz que se acoplará a la
funcionalidad ortogonal, y esta clase derivada tampoco cumplirá con el SRP (tendrá más de
una razón para cambiar).

¿Qué tan pequeña debe ser una interfaz?


El punto señalado en la sección anterior es válido, pero también necesita una
advertencia : evite un camino peligroso si se malinterpreta o se lleva al extremo.

Una clase base (abstracta o no) define una interfaz para que todas las demás clases la
amplíen. El hecho de que esto deba ser lo más pequeño posible debe entenderse en
términos de cohesión : debe hacer una cosa. Eso no significa que necesariamente debe
tener un método. En el ejemplo anterior, fue una coincidencia que ambos métodos
estuvieran haciendo cosas totalmente inconexas, por lo que tenía sentido separarlos en
diferentes clases.

Pero podría darse el caso de que más de un método pertenezca legítimamente a la misma
clase.
Imagine que desea proporcionar una clase mixta que abstraiga cierta lógica en un
administrador de contexto para que todas las clases derivadas de esa combinación obtengan
esa lógica del administrador de contexto de forma gratuita. Como ya sabemos, un
administrador de contexto implica dos métodos: __enter__ y __exit__ . ¡Deben ir juntos, o el
resultado no será un administrador de contexto válido en absoluto!

Si no se colocan ambos métodos en la misma clase, se producirá un componente roto que


no solo es inútil, sino también engañosamente peligroso. Con suerte, este ejemplo
exagerado funciona como un contrapeso al de la sección anterior, y juntos el lector puede
obtener una imagen más precisa sobre el diseño de interfaces.
inversión de dependencia
Esta es una idea realmente poderosa que volverá a surgir más adelante cuando
exploremos algunos patrones de diseño en el Capítulo 9 , Patrones de diseño comunes , y el
Capítulo 10 , Arquitectura limpia .

[ 119 ]
Los principios SOLID Capítulo 4

El principio de inversión de dependencia ( DIP ) propone un principio de diseño


interesante mediante el cual protegemos nuestro código haciéndolo independiente de las
cosas que son frágiles, volátiles o están fuera de nuestro control. La idea de invertir
dependencias es que nuestro código no debe adaptarse a detalles o implementaciones
concretas, sino al revés: queremos forzar cualquier implementación o detalle para que se
adapte a nuestro código a través de una especie de API.

Las abstracciones deben organizarse de tal manera que no dependan de los detalles, sino
al revés : los detalles (implementaciones concretas) deben depender de las abstracciones.

Imagina que dos objetos en nuestro diseño necesitan colaborar , A y B. A funciona con una
instancia de B , pero resulta que nuestro módulo no controla B directamente (puede ser una
biblioteca externa o un módulo mantenido por otro equipo, etc.). Si nuestro código depende
en gran medida de B , cuando esto cambie, el código se romperá. Para evitar esto, tenemos
que invertir la dependencia: hacer que B tenga que adaptarse a A. Esto se hace presentando
una interfaz y obligando a nuestro código a no depender de la implementación concreta de
B , sino de la interfaz que hemos definido. Entonces es responsabilidad de B cumplir con esa
interfaz.

De acuerdo con los conceptos explorados en las secciones anteriores, las abstracciones
también vienen en forma de interfaces (o clases base abstractas en Python).

En general, podríamos esperar que las implementaciones concretas cambien con mucha
más frecuencia que los componentes abstractos. Es por esta razón que colocamos las
abstracciones (interfaces) como puntos de flexibilidad donde esperamos que nuestro
sistema cambie, se modifique o se amplíe sin que la abstracción en sí tenga que cambiar.

Un caso de dependencias rígidas


La última parte de nuestro sistema de monitoreo de eventos es entregar los eventos
identificados a un recopilador de datos para su posterior análisis. Una implementación
ingenua de tal idea consistiría en tener una clase de transmisión de eventos que interactúe
con un destino de datos, por ejemplo, Syslog :
[ 120 ]
Los principios SOLID Capítulo 4

Sin embargo, este diseño no es muy bueno, porque tenemos una clase de alto nivel
( EventStreamer ) que depende de una de bajo nivel ( Syslog es un detalle de
implementación). Si algo cambia en la forma en que queremos enviar datos a Syslog , habrá
que modificar EventStreamer . Si queremos cambiar el destino de los datos por uno diferente
o agregar nuevos en tiempo de ejecución, también estamos en problemas porque nos
encontraremos modificando constantemente el método stream() para adaptarlo a estos
requisitos.

Invertir las dependencias


La solución a estos problemas es hacer que EventStreamer funcione con una interfaz, en
lugar de una clase concreta. De esta forma, implementar esta interfaz depende de las
clases de bajo nivel que contienen los detalles de implementación:

Ahora hay una interfaz que representa un objetivo de datos genérico al que se enviarán los
datos. Observe cómo las dependencias ahora se han invertido ya que EventStreamer no
depende de una implementación concreta de un objetivo de datos en particular, no tiene
que cambiar de acuerdo con los cambios en este y depende de cada objetivo de datos en
particular; para implementar la interfaz correctamente y adaptarse a los cambios si es
necesario.

En otras palabras, el EventStreamer original de la primera implementación solo funcionaba


con objetos de tipo Syslog , que no era muy flexible. Luego nos dimos cuenta de que podía
funcionar con cualquier objeto que pudiera responder a un mensaje .send() e identificamos
este método como la interfaz que debía cumplir. Ahora, en esta versión, Syslog en realidad
está extendiendo la clase base abstracta llamada DataTargetClient , que define el método
send() .
De ahora en adelante, depende de cada nuevo tipo de objetivo de datos (correo
electrónico, por ejemplo) extender esta clase base abstracta e implementar el método
send() .

[ 121 ]
Los principios SOLID Capítulo 4

Incluso podemos modificar esta propiedad en tiempo de ejecución para cualquier otro
objeto que implemente un método send() , y seguirá funcionando. Esta es la razón por la
que a menudo se le llama inyección de dependencia : porque la dependencia se puede
proporcionar de forma dinámica.

El ávido lector podría preguntarse por qué esto es realmente necesario. Python es lo
suficientemente flexible (a veces demasiado flexible) y nos permitirá proporcionar un objeto
como
EventStreamer con cualquier objeto de destino de datos en particular, sin que este tenga que
cumplir con ninguna interfaz porque se escribe dinámicamente. La pregunta es esta: ¿por
qué necesitamos definir la clase base abstracta (interfaz) cuando simplemente podemos
pasarle un objeto con un método send() ?

Con toda justicia, esto es cierto; en realidad no hay necesidad de hacer eso, y el
programa funcionará igual. Después de todo, el polimorfismo no significa (ni requiere)
herencia para funcionar.
Sin embargo, definir la clase base abstracta es una buena práctica que tiene algunas
ventajas, la primera de ellas es el tipo de pato. Junto con la tipificación pato, podemos
mencionar el hecho de que los modelos se vuelven más legibles ; recuerde que la herencia
sigue la regla de es un , por lo que al declarar la clase base abstracta y extenderla, decimos
que, por ejemplo, Syslog es DataTargetClient , que es algo que los usuarios de su código
pueden leer y comprender (nuevamente, esto es escribir pato).

Con todo, no es obligatorio definir la clase base abstracta, pero es deseable para lograr un
diseño más limpio. Esta es una de las cosas para las que es este libro : ayudar a los
programadores a evitar errores fáciles de cometer, solo porque Python es demasiado
flexible y podemos salirnos con la nuestra.

Resumen
Los principios SOLID son pautas clave para un buen diseño de software orientado a objetos.

La creación de software es una tarea increíblemente difícil : la lógica del código es


compleja, su comportamiento en tiempo de ejecución es difícil (si es posible, a veces) de
predecir, los requisitos cambian constantemente, así como el entorno, y hay muchas cosas
que pueden salir mal. .

Además, existen múltiples formas de construir software con diferentes técnicas, paradigmas
y muchos diseños diferentes, que pueden trabajar juntos para resolver un problema
particular de una manera específica. Sin embargo, no todos estos enfoques demostrarán ser
correctos a medida que pase el tiempo y los requisitos cambien o evolucionen. Sin embargo,
en este momento, ya será demasiado tarde para hacer algo con un diseño incorrecto, ya que
es rígido, inflexible y, por lo tanto, difícil cambiar un refactor a la solución adecuada.

[ 122 ]
Los principios SOLID Capítulo 4

Esto significa que, si nos equivocamos en el diseño, nos costará mucho en el futuro. ¿Cómo
podemos entonces lograr un buen diseño que finalmente valga la pena? La respuesta es que
no lo sabemos con certeza. Estamos lidiando con el futuro, y el futuro es incierto : no hay
forma de determinar si nuestro diseño será correcto y si nuestro software será flexible y
adaptable en los próximos años. Es precisamente por eso que tenemos que ceñirnos a los
principios.

Aquí es donde entran en juego los principios SOLID. No son una regla mágica (después
de todo, no hay balas de plata en la ingeniería de software), pero brindan buenas pautas
a seguir que se ha demostrado que funcionan en proyectos anteriores y harán que
nuestro software tenga muchas más probabilidades de éxito.

En este capítulo, hemos explorado los principios SOLID con el objetivo de comprender el
diseño limpio. En los siguientes capítulos, continuaremos explorando los detalles del
lenguaje y veremos en algunos casos cómo se pueden usar estas herramientas y
características con estos principios.

Referencias
Aquí hay una lista de información que puede consultar:

SRP 01 : El principio de responsabilidad única ( https:/)/8thlight.com/blog/


uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html
PEP-3119 : Introducción a las clases base abstractas ( https:/)/www.python.org/dev/
peps/pep-3119/
LISKOV 01 : Un artículo escrito por Barbara Liskov llamado Data Abstraction
and Hierarchy
[ 123 ]
Uso de decoradores para

mejorar 
Nuestro código
En este capítulo, exploraremos los decoradores y veremos cómo son útiles en muchas
situaciones en las que queremos mejorar nuestro diseño. Comenzaremos explorando
primero qué son los decoradores, cómo funcionan y cómo se implementan.

Con este conocimiento, revisaremos los conceptos que aprendimos en capítulos anteriores
con respecto a las buenas prácticas generales para el diseño de software y veremos cómo
los decoradores pueden ayudarnos a cumplir con cada principio.

Los objetivos de este capítulo son los siguientes:

Para entender cómo funcionan los decoradores en Python


Para aprender a implementar decoradores que se aplican a funciones y clases.
Para implementar decoradores de manera efectiva, evitando errores comunes de
implementación
Analizar cómo evitar la duplicación de código (principio DRY) con decoradores
Estudiar cómo los decoradores contribuyen a la separación de preocupaciones.
Analizar ejemplos de buenos decoradores
Para revisar situaciones comunes, modismos o patrones para cuando los
decoradores son la elección correcta

¿Qué son los decoradores en Python?


Los decoradores se introdujeron en Python hace mucho tiempo, en (PEP-318), como un
mecanismo para simplificar la forma en que se definen funciones y métodos cuando se
deben modificar después de su definición original.
Uso de decoradores para mejorar nuestro código Capítulo 5

Una de las motivaciones originales para esto fue que funciones como classmethod y
staticmethod se usaron para transformar la definición original del método, pero requerían
una línea adicional, modificando la definición original de la función.

En términos más generales, cada vez que teníamos que aplicar una transformación a una
función, teníamos que llamarla con la función modificadora y luego reasignarla con el
mismo nombre con el que se definió originalmente la función.

Por ejemplo, si tenemos una función llamada original , y luego tenemos una función
que cambia el comportamiento de original encima, llamada modifier , tenemos que
escribir algo como lo siguiente:

def original(...):
...
original = modificador(original)

Observe cómo cambiamos la función y la reasignamos al mismo nombre. Esto es


confuso, propenso a errores (imagínese que alguien se olvida de reasignar la función, o
la reasigna pero no en la línea inmediatamente después de la definición de la función,
sino mucho más lejos) y engorroso. Por esta razón, se agregó algo de soporte de sintaxis
al lenguaje.

El ejemplo anterior podría reescribirse así:


@modifier
def original(...):
...

Esto significa que los decoradores son solo azúcar de sintaxis para llamar a lo que sea
después del decorador como un primer parámetro del decorador en sí, y el resultado
sería lo que devuelva el decorador.

De acuerdo con la terminología de Python, y nuestro ejemplo, modificador es lo que


llamamos
decorador, y original es la función decorada, a menudo también llamada objeto envuelto .

Si bien la funcionalidad se pensó originalmente para métodos y funciones, la sintaxis actual


permite decorar cualquier tipo de objeto, por lo que exploraremos los decoradores aplicados
a funciones, métodos, generadores y clases.

Una nota final es que, si bien el nombre de un decorador es correcto (después de todo, el
decorador está haciendo cambios, ampliando o trabajando sobre la función envuelta ), no
debe confundirse con el patrón de diseño del decorador. .
[ 125 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Decorar funciones
Las funciones son probablemente la representación más simple de un objeto de Python que
se puede decorar.
Podemos usar decoradores en funciones para aplicarles todo tipo de lógica : podemos
validar parámetros, verificar condiciones previas, cambiar el comportamiento por
completo, modificar su firma, almacenar en caché los resultados (crear una versión
memorizada de la función original) y más.

Como ejemplo, crearemos un decorador básico que implemente un mecanismo de


reintento , controlando una excepción particular a nivel de dominio y reintentando una
cierta cantidad de veces:
# decorator_function_1.py
class ControlledException(Exception):
"""Una excepción genérica en el dominio del programa."""

def reintentar(operación):
@wraps(operación)
def envuelto(*args, **kwargs):
last_raised = Ninguno
RETRIES_LIMIT = 3
for _ in range(RETRIES_LIMIT):
try:
return operation(*args, **kwargs)
excepto ControlledException como e:
logger.info("reintentando %s", operación.__qualname__) last_raised = e
raise last_raised

devolver envuelto

El uso de @wraps puede ignorarse por ahora, ya que se tratará en la sección


denominada Decoradores efectivos: evitar errores comunes . El uso de _ en el bucle for significa
que el número se asigna a una variable que no nos interesa en este momento, porque no se
usa dentro del bucle for (es un modismo común en Python para nombrar valores _ que se
ignoran) .

El decorador de reintentos no toma ningún parámetro, por lo que se puede aplicar


fácilmente a cualquier función, de la siguiente manera:

@retry
def run_operation(task):
"""Ejecutar una tarea en particular, simulando algunas fallas en su ejecución.""" return task.run()
[ 126 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Como se explicó al principio, la definición de @retry además de run_operation es solo


azúcar sintáctica que proporciona Python para ejecutar realmente run_operation =
retry(run_operation) .

En este ejemplo limitado, podemos ver cómo los decoradores pueden usarse para crear
una operación de reintento genérica que, bajo ciertas condiciones (en este caso,
representadas como excepciones que podrían estar relacionadas con tiempos de espera,
por ejemplo), permitirá llamar al código decorado varias veces. veces.

Decorar clases
Las clases también se pueden decorar (PEP-3129) con lo mismo que se puede aplicar a las
funciones de sintaxis. La única diferencia es que al escribir el código de este decorador,
tenemos que tener en cuenta que estamos recibiendo una clase, no una función.

Algunos practicantes podrían argumentar que decorar una clase es algo bastante
complicado y que tal escenario podría poner en peligro la legibilidad porque estaríamos
declarando algunos atributos y métodos en la clase, pero entre bastidores, el decorador
podría estar aplicando cambios que harían una imagen completamente diferente. clase.

Esta evaluación es cierta, pero solo si se abusa mucho de esta técnica. Objetivamente, esto
no es diferente de las funciones de decoración; después de todo, las clases son solo otro
tipo de objeto en el ecosistema de Python, como lo son las funciones. Revisaremos los pros
y los contras de este problema con los decoradores en la sección titulada Decoradores y
separación de preocupaciones , pero por ahora exploraremos los beneficios de los decoradores
que se aplican particularmente a las clases:

Todos los beneficios de reutilizar código y el principio DRY. Un caso válido de un


decorador de clases sería hacer cumplir que varias clases se ajusten a una
determinada interfaz o criterio (haciendo que esto se verifique solo una vez en el
decorador que se aplicará a esas muchas clases).
Podríamos crear clases más pequeñas o más simples que los
decoradores mejorarán más adelante.
La lógica de transformación que necesitamos aplicar a una determinada clase
será mucho más fácil de mantener si usamos un decorador, a diferencia de
enfoques más complicados (y a menudo desaconsejados con razón) como las
metaclases.
Entre todas las aplicaciones posibles de los decoradores, exploraremos un ejemplo simple
para dar una idea del tipo de cosas para las que pueden ser útiles. Tenga en cuenta que este
no es el único tipo de aplicación para los decoradores de clases, sino que el código que le
mostramos podría tener muchas otras soluciones múltiples, todas con sus pros y sus
contras, pero elegimos los decoradores con el propósito de ilustrar su utilidad. .

[ 127 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Recordando nuestros sistemas de eventos para la plataforma de monitoreo, ahora


necesitamos transformar los datos para cada evento y enviarlos a un sistema externo. Sin
embargo, cada tipo de evento puede tener sus particularidades a la hora de seleccionar
cómo enviar sus datos.

En particular, el evento de inicio de sesión puede contener información confidencial, como


credenciales que queremos ocultar. Otros campos, como la marca de tiempo , también
pueden requerir algunas
transformaciones, ya que queremos mostrarlos en un formato particular. Un primer intento
de cumplir con estos requisitos sería tan simple como tener una clase que mapee cada
evento en particular y sepa cómo serializarlo:

clase LoginEventSerializer:
def __init__(self, evento):
self.event = evento

def serialize(self) -> dict:


return {
"username": self.event.username,
"password": "**redacted**",
"ip": self.event.ip,
"timestamp": self.event .timestamp.strftime("%Y-%m-%d %H:%M"),
}

clase LoginEvent:
SERIALIZER = LoginEventSerializer

def __init__(self, nombre de usuario, contraseña, ip, marca de tiempo):


self.nombre de usuario = nombre de usuario
self.password = contraseña
self.ip = ip
self.timestamp = marca de tiempo

def serialize(self) -> dict:


return self.SERIALIZER(self).serialize()

Aquí, declaramos una clase que se va a mapear directamente con el evento de inicio de
sesión, que contiene la lógica para ello : ocultar el campo de contraseña y formatear la
marca de tiempo según sea necesario.

Si bien esto funciona y puede parecer una buena opción para comenzar, a medida que
pasa el tiempo y queremos extender nuestro sistema, encontraremos algunos
problemas:
Demasiadas clases : a medida que crece el número de eventos, el número de
clases de serialización crecerá en el mismo orden de magnitud, porque se
asignan uno a uno.

[ 128 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

La solución no es lo suficientemente flexible : si necesitamos reutilizar partes de


loslacomponentes
(por ejemplo, necesitamos ocultar contraseña en otro tipo de evento que también
la tenga), tendremos que extraer esto en una función, pero también llamarlo
repetidamente desde varias clases, lo que significa que no estamos reutilizando
tanto código después todos.
Repetitivo : el método serialize() tendrá que estar presente en todas las clases
de eventos , llamando al mismo código. Aunque podemos extraer esto a otra
clase (creando un mixin), no parece un buen uso de la herencia.

Una solución alternativa es poder construir dinámicamente un objeto que, dado un


conjunto de filtros (funciones de transformación) y una instancia de evento , pueda
serializarlo aplicando los filtros a sus campos. Entonces solo necesitamos definir las
funciones para transformar cada tipo de campo, y el serializador se crea componiendo
muchas de estas funciones.

Una vez que tengamos este objeto, podemos decorar la clase para agregar el método
serialize() , que simplemente llamará a estos objetos de serialización consigo mismo:

def hide_field(campo) -> str:


return "**redactado**"

def format_time(field_timestamp: datetime) -> str: return


field_timestamp.strftime("%Y-%m-%d %H:%M")

def show_original(campo_evento):
devuelve campo_evento

clase EventSerializer:
def __init__(self, serialization_fields: dict) -> Ninguno: self.serialization_fields
= serialization_fields

def serialize(self, event) -> dict:


return {
campo: transformación(getattr(evento, campo)) for campo,
transformación en
self.serialization_fields.items()
}
clase Serialización:
def __init__(self, **transformaciones):
self.serializer = EventSerializer(transformaciones)

def __call__(self, clase_de_evento):


def serialize_method(instancia_de_evento):

[ 129 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

return self.serializer.serialize(event_instance) event_class.serialize =


serialize_method
return event_class

@Serialization(
nombre de
usuario=mostrar_original,
contraseña=ocultar_campo,
ip=mostrar_original,
marca de tiempo=formato_hora,
)
class LoginEvent:

def __init__(self, nombre de usuario, contraseña, ip, marca de tiempo):


self.nombre de usuario = nombre de usuario
self.password = contraseña
self.ip = ip
self.timestamp = marca de tiempo

Observe cómo el decorador hace que sea más fácil para el usuario saber cómo se tratará
cada campo sin tener que buscar en el código de otra clase. Con solo leer los argumentos
pasados al decorador de clases, sabemos que el nombre de usuario y la dirección IP no se
modificarán, la contraseña se ocultará y la marca de tiempo se formateará.

Ahora, el código de la clase no necesita el método serialize() definido, ni necesita extenderse


desde un mixin que lo implemente, ya que el decorador lo agregará. De hecho, esta es
probablemente la única parte que justifica la creación del decorador de clases, porque de lo
contrario, el objeto Serialization podría haber sido un atributo de clase de LoginEvent , pero el
hecho de que esté alterando la clase al agregarle un nuevo método hace que es imposible.

Además, podríamos tener otro decorador de clases que, simplemente definiendo los
atributos de la clase, implemente la lógica del método init , pero esto está más allá del
alcance de este ejemplo. Esto es lo que hacen bibliotecas como attrs (ATTRS 01), y se
propone una funcionalidad similar en (PEP-557) para la biblioteca estándar.

Al usar este decorador de clase de (PEP-557), en Python 3.7+, el ejemplo anterior podría
reescribirse de una manera más compacta, sin el código repetitivo de init , como se muestra
aquí:

desde clases de datos importar clase de datos


desde fecha y hora importar fecha y hora
@Serialization(
nombre de
usuario=mostrar_original,
contraseña=ocultar_campo,
ip=mostrar_original,
marca de tiempo=formato_hora,

[ 130 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

)
@dataclass
clase LoginEvent: nombre de
usuario: str
contraseña: str
ip: str
marca de tiempo: fecha y
hora

Otros tipos de decoradores


Ahora que sabemos qué significa realmente la sintaxis @ para los decoradores, podemos
concluir que no solo se pueden decorar funciones, métodos o clases; en realidad, todo lo
que se pueda definir, como generadores, rutinas e incluso objetos que ya se hayan
decorado, se puede decorar, lo que significa que los decoradores se pueden apilar.

El ejemplo anterior mostró cómo se pueden encadenar los decoradores. Primero definimos
la clase y luego le aplicamos @dataclass , que la convirtió en una clase de datos, actuando
como un contenedor para esos atributos. Después de eso, @Serialization aplicará la lógica a
esa clase, lo que dará como resultado una nueva clase con el nuevo método serialize()
agregado.

Otro buen uso de los decoradores es para generadores que se supone que deben usarse
como
corrutinas. Exploraremos los detalles de los generadores y corrutinas en el Capítulo 7 , Uso de
generadores, pero la idea principal es que, antes de enviar cualquier dato a un generador
recién creado, este último debe avanzar hasta su próxima declaración de rendimiento
llamando a next() en eso. Este es un proceso manual que cada usuario deberá recordar y,
por lo tanto, es propenso a errores. Podríamos crear fácilmente un decorador que tome un
generador como parámetro, llame a next() y luego devuelva el generador.

Pasar argumentos a los decoradores


En este punto, ya consideramos a los decoradores como una herramienta poderosa en
Python. Sin embargo, podrían ser aún más poderosos si pudiéramos simplemente pasarles
parámetros para que su lógica se abstraiga aún más.

Hay varias formas de implementar decoradores que pueden tomar argumentos, pero
repasaremos las más comunes. El primero es crear decoradores como funciones anidadas
con un nuevo nivel de direccionamiento indirecto, haciendo que todo en el decorador caiga
un nivel más profundo. El segundo enfoque es usar una clase para el decorador.

[ 131 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

En general, el segundo enfoque favorece más la legibilidad, porque es más fácil pensar en
términos de un objeto que tres o más funciones anidadas trabajando con cierres. Sin
embargo, para completar, exploraremos ambos, y el lector puede decidir cuál es mejor para
el problema en cuestión.

Decoradores con funciones anidadas


En términos generales, la idea general de un decorador es crear una función que devuelva
una función (a menudo llamada función de orden superior). La función interna definida en
el cuerpo del decorador será la que realmente se llame.

Ahora, si deseamos pasarle parámetros, entonces necesitamos otro nivel de


direccionamiento indirecto. El primero tomará los parámetros, y dentro de esa función,
definiremos una nueva función, que será el decorador, que a su vez definirá otra nueva
función, a saber, la que se devolverá como resultado del proceso de decoración. Esto
significa que tendremos al menos tres niveles de funciones anidadas.

No se preocupe si esto no parecía claro hasta ahora. Después de repasar los ejemplos que
están por venir, todo quedará claro.

Uno de los primeros ejemplos que vimos de decoradores implementaron la funcionalidad


de reintento sobre algunas funciones. Esta es una buena idea, excepto que tiene un
problema; nuestra implementación no nos permitía especificar el número de reintentos y,
en cambio, este era un número fijo dentro del decorador.

Ahora, queremos poder indicar cuántos reintentos tendrá cada instancia, y quizás incluso
podríamos agregar un valor predeterminado a este parámetro. Para hacer esto,
necesitamos otro nivel de funciones anidadas , primero para los parámetros y luego para el
propio decorador.

Esto se debe a que ahora vamos a tener algo en la forma de lo siguiente:


@reintentar(arg1, arg2,... )

Y eso tiene que devolver un decorador porque la sintaxis @ aplicará el resultado de ese
cálculo al objeto que se va a decorar. Semánticamente, se traduciría a algo como lo
siguiente:
<función_original> = reintentar(arg1, arg2, ....)(<función_original>)
[ 132 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Además del número de reintentos deseados, también podemos indicar los tipos de
excepción que deseamos controlar. La nueva versión del código que admite los nuevos
requisitos podría verse así:
RETRIES_LIMIT = 3

def with_retry(retries_limit=RETRIES_LIMIT, allow_exceptions=Ninguno): allow_exceptions =


allow_exceptions o (ControlledException,)

def reintentar (operación):

@wraps(operación)
def envuelto(*args, **kwargs):
last_raised = None
for _ in range(retries_limit):
try:
return operation(*args, **kwargs)
excepto allow_Exceptions as e:
logger.info("reintentando %s debido a %s", operación, e) last_raised = e
aumentar last_raised

devolver envuelto

volver reintentar

Aquí hay algunos ejemplos de cómo se puede aplicar este decorador a las funciones,
mostrando las diferentes opciones que acepta:

# decorador_parametrizado_1.py
@with_retry()
def ejecutar_operación(tarea):
volver tarea.ejecutar()

@with_retry(retries_limit=5)
def run_with_custom_retries_limit(task): return
task.run()

@with_retry(allowed_exceptions=(AttributeError,)) def
run_with_custom_exceptions(task):
return task.run()

@with_retry(
retries_limit=4, allow_exceptions=(ZeroDivisionError, AttributeError)
[ 133 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

)
def
ejecutar_con_parámetros_personalizados(tarea):
return tarea.ejecutar()

Objetos decoradores
El ejemplo anterior requiere tres niveles de funciones anidadas. La primera va a ser una
función que recibe los parámetros del decorador que queremos usar. Dentro de esta
función, el resto de funciones son cierres que utilizan estos parámetros junto con la lógica
del decorador.

Una implementación más limpia de esto sería usar una clase para definir el decorador. En
este caso, podemos pasar los parámetros en el método __init__ y luego implementar la lógica
del decorador en el método mágico llamado __call__ .

El código para el decorador se verá como en el siguiente ejemplo:


clase ConReintento:

def __init__(self, retries_limit=RETRIES_LIMIT,


allow_exceptions=Ninguno):
self.retries_limit = retries_limit
self.allowed_exceptions = allow_Exceptions o ( ControlledException ,)

def __call__(auto, operación):

@wraps(operación)
def envuelto(*args, **kwargs):
last_raised = Ninguno

for _ in range(self.retries_limit):
try:
return operation(*args, **kwargs)
excepto self.allowed_exceptions como e:
logger.info("reintentando %s debido a %s", operación, e) last_raised = e
aumentar last_raised

devolver envuelto

Y este decorador se puede aplicar casi como el anterior, así:


@WithRetry(retries_limit=5)
def run_with_custom_retries_limit(task): return
task.run()
[ 134 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Es importante tener en cuenta cómo la sintaxis de Python tiene efecto aquí. Primero,
creamos el objeto, por lo que antes de aplicar la operación @ , el objeto se crea con sus
parámetros pasados. Esto creará un nuevo objeto y lo inicializará con estos parámetros,
como se define en el método init . Después de esto, se invoca la operación @ , por lo que este
objeto envolverá la función llamada run_with_custom_reries_limit , lo que significa que se
pasará al método mágico de llamada .

Dentro de este método mágico de llamada , definimos la lógica del decorador como lo
hacemos normalmente : envolvemos la función original y devolvemos una nueva con la
lógica que queremos en su lugar.

Buenos usos para decoradores.


En esta sección, veremos algunos patrones comunes que hacen un buen uso de los
decoradores. Estas son situaciones comunes en las que los decoradores son una
buena opción.

De todas las innumerables aplicaciones para las que se pueden utilizar los decoradores,
enumeraremos algunas, las más comunes o relevantes:

Transformación de parámetros : cambio de la firma de una función para exponer


una API más agradable, mientras se encapsulan los detalles sobre cómo se tratan
y
transforman los parámetros debajo
Código de seguimiento : registro de la ejecución de una función con sus
parámetros
Validar parámetros
Implementar operaciones de reintento
Simplifique las clases moviendo algo de lógica (repetitiva) a los decoradores

Analicemos las dos primeras aplicaciones en detalle en la siguiente sección.

Transformación de parámetros
Hemos mencionado antes que los decoradores se pueden usar para validar parámetros
(e incluso hacer cumplir algunas condiciones previas o posteriores bajo la idea de DbC),
por lo que probablemente tenga la idea de que de alguna manera es común usar
decoradores cuando se trata o manipula parámetros. .
[ 135 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

En particular, hay algunos casos en los que nos encontramos repetidamente creando
objetos similares, o aplicando transformaciones similares que desearíamos abstraer. La
mayoría de las veces, podemos lograr esto simplemente usando un decorador.

código de rastreo
Cuando hablemos de rastreo en esta sección, nos referiremos a algo más general que tiene
que ver con tratar la ejecución de una función que deseamos monitorear. Esto podría
referirse a escenarios en los que queremos:

Realice un seguimiento de la ejecución de una función (por ejemplo,


registrando las líneas que ejecuta)
Supervise algunas métricas sobre una función (como el uso de la CPU o la huella
de la memoria)
Medir el tiempo de ejecución de una función
Registrar cuándo se llamó a una función y los parámetros que se le pasaron

En la siguiente sección, exploraremos un ejemplo simple de un decorador que registra la


ejecución de una función, incluido su nombre y el tiempo que tardó en ejecutarse.

Decoradores efectivos : evitar errores


comunes
Si bien los decoradores son una gran característica de Python, no están exentos de
problemas si se usan incorrectamente. En esta sección, veremos algunos problemas
comunes que se deben evitar para crear decoradores efectivos.

Conservación de datos sobre el objeto


envuelto original
Uno de los problemas más comunes al aplicar un decorador a una función es que algunas
de las propiedades o atributos de la función original no se mantienen, lo que genera
efectos secundarios no deseados y difíciles de rastrear.
[ 136 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Para ilustrar esto, mostramos un decorador que se encarga de registrar cuando la


función está a punto de ejecutarse:

#decorator_wraps_1.py

def trace_decorator(función):
def wrap(*args, **kwargs):
logger.info("ejecutando %s", function.__qualname__) return
function(*args, **kwargs)

devolver envuelto

Ahora, imaginemos que tenemos una función con este decorador aplicado. Podríamos
pensar inicialmente que nada de esa función se modifica con respecto a su definición
original:

@trace_decorator
def process_account(account_id):
"""Procesar una cuenta por Id."""
logger.info("procesando cuenta %s", account_id) ...

Pero tal vez haya cambios.

Se supone que el decorador no debe alterar nada de la función original, pero, dado que
contiene una falla, en realidad está modificando su nombre y cadena de documentación ,
entre otras propiedades.

Intentemos obtener ayuda para esta función:


>>> help(process_account)
Ayuda sobre la función incluida en el módulo decorator_wraps_1:

envuelto(*args, **kwargs)

Y veamos cómo se llama:


>>> cuenta_proceso.__qualname__
'trace_decorator.<locals>.wrapped'

Podemos ver que, dado que el decorador en realidad está cambiando la función original por
una nueva (llamada envuelta ), lo que en realidad vemos son las propiedades de esta función
en lugar de las de la función original.

Si aplicamos un decorador como este a múltiples funciones, todas con nombres diferentes,
todas terminarán siendo llamadas wrap , lo cual es una gran preocupación (por ejemplo, si
queremos registrar o rastrear la función, esto hará que la depuración sea incluso más
difícil).

[ 137 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Otro problema es que, en caso de que coloquemos docstrings con pruebas en estas
funciones, serán anuladas por las del decorador. Como resultado, las cadenas de
documentación con la prueba que queremos no se ejecutarán cuando llamemos a nuestro
código con el módulo doctest (como hemos visto en el Capítulo 1 , Introducción, Formato de
código y Herramientas ).

Sin embargo, la solución es simple. Solo tenemos que aplicar el decorador de


envolturas en la función interna ( wrap ), diciéndole que en realidad es una función de
envoltura :

# decorator_wraps_2.py
def trace_decorator(función):
@wraps(función)
def wrap(*args, **kwargs):
logger.info("ejecutando %s", function.__qualname__) return
function(*args, **kwargs)

devolver envuelto

Ahora, si comprobamos las propiedades, obtendremos lo que esperábamos en


primer lugar. Verifique la ayuda para la función, así:

>>> Ayuda sobre la función process_account en el módulo decorator_wraps_2:

process_account(account_id)
Procesar una cuenta por Id.

Y verifique que su nombre calificado sea correcto, así:


>>> cuenta_proceso.__qualname__
'cuenta_proceso'

¡Lo más importante es que recuperamos las pruebas unitarias que podríamos haber tenido
en las cadenas de documentación! Al usar el decorador de envolturas , también podemos
acceder a la función original sin modificar bajo el atributo __wrapped__ . Aunque no debe
usarse en producción, puede ser útil en algunas pruebas unitarias cuando queremos
verificar la versión no modificada de la función.

En general, para los decoradores simples, la forma en que usaríamos functools.wraps


normalmente seguiría la fórmula o estructura general:
def decorador(función_original):
@wraps(función_original)
def función_decorada(*args, **kwargs):
# modificaciones realizadas por el decorador ... return
función_original(*args, **kwargs)

volver función_decorada

[ 138 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Siempre use functools.wraps aplicado sobre la función envuelta, al crear un


decorador, como se muestra en la fórmula anterior.

Cómo lidiar con los efectos secundarios en los


decoradores
En este apartado aprenderemos que es recomendable para evitar efectos secundarios en el
organismo del decorador. Hay casos en los que esto puede ser aceptable, pero lo
fundamental es que, en caso de duda, decida no hacerlo, por las razones que se explican a
continuación. Todo lo que el decorador necesita hacer, además de la función que está
decorando, debe colocarse en la definición de función más interna, o habrá problemas a la
hora de importar.

No obstante, a veces se requiere (o incluso se desea) que estos efectos secundarios se


ejecuten en el momento de la importación, y se aplica lo contrario.

Veremos ejemplos de ambos, y donde aplica cada uno. En caso de duda, sea precavido y
retrase todos los efectos secundarios hasta lo último, justo después de que se llame a la
función envuelta .

A continuación, veremos cuándo no es una buena idea colocar lógica adicional fuera
de la función envuelta .

Manejo incorrecto de efectos secundarios en un


decorador
Imaginemos el caso de un decorador que se creó con el objetivo de registrar cuándo
comenzó a ejecutarse una función y luego registrar su tiempo de ejecución:

def traced_function_wrong(función):
logger.info("ejecución iniciada de %s", función) start_time =
time.time()

@functools.wraps(function)
def wrap(*args, **kwargs):
result = function(*args, **kwargs)
logger.info(
"La función %s tomó %.2fs",
function,
time.time() - start_time
)
retorno de resultado
retorno envuelto

[ 139 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Ahora aplicaremos el decorador a una función regular, pensando que funcionará bien:
@traced_function_wrong
def process_with_delay(callback, delay=0):
time.sleep(delay)
return callback()

Este decorador tiene un error sutil pero crítico.

Primero, importemos la función, llámela varias veces y veamos qué sucede:


>>> from decorator_side_effects_1 import process_with_delay INFO:ejecución
iniciada de <function process_with_delay at 0x...>

Con solo importar la función, notaremos que algo anda mal. La línea de registro no
debería estar allí, porque no se invocó la función.

Ahora, ¿qué sucede si ejecutamos la función y vemos cuánto tarda en ejecutarse? En


realidad, esperaríamos que llamar a la misma función varias veces arroje resultados
similares:

>>>
principal()
...
INFO: función <función proceso_con_retraso en 0x> tomó 8.67s

>>>
principal()
...
INFO: función <función proceso_con_retraso en 0x> tomó 13.39s

>>>
principal()
...
INFO: función <función proceso_con_retraso en 0x> tomó 17.01 s

¡Cada vez que ejecutamos la misma función, toma más tiempo! En este punto,
probablemente ya hayas notado el (ahora obvio) error.

Recuerde la sintaxis para decoradores. @traced_function_wrong en realidad significa lo


siguiente:
proceso_con_retraso = función_trazada_incorrecta(proceso_con_retraso)

Y esto se ejecutará cuando se importe el módulo. Por lo tanto, el tiempo que se establezca en
la función será el del momento en que se importó el módulo. Las llamadas sucesivas
calcularán la diferencia de tiempo desde el tiempo de ejecución hasta el tiempo de inicio
original. También se registrará en el momento equivocado, y no cuando se llame realmente
a la función.

[ 140 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Afortunadamente, la solución también es muy simple : solo tenemos que mover el


código dentro de la función envuelta para retrasar su ejecución:

def traced_function(función):
@functools.wraps(función)
def wrap(*args, **kwargs):
logger.info("ejecución iniciada de %s", function.__qualname__) start_time = time.time()
resultado = función (*args, **kwargs)
logger.info(
"la función %s tomó %.2fs",
function.__qualname__,
time.time() - start_time
)
devolver el resultado
devolver envuelto

Con esta nueva versión se resuelven los problemas anteriores.

Si las acciones del decorador hubieran sido diferentes, los resultados podrían haber sido
mucho más desastrosos. Por ejemplo, si requiere que registre eventos y los envíe a un
servicio externo, ciertamente fallará a menos que la configuración se haya ejecutado justo
antes de que se haya importado, lo cual no podemos garantizar. Incluso si pudiéramos,
sería una mala práctica. Lo mismo se aplica si el decorador tiene algún otro tipo de efecto
secundario, como leer de un archivo, analizar una configuración y muchos más.

Requerir decoradores con efectos secundarios


A veces, los efectos secundarios en los decoradores son necesarios, y no debemos
retrasar su ejecución hasta el último momento posible, porque eso es parte del
mecanismo que se requiere para que funcionen.

Un escenario común para cuando no queremos retrasar el efecto secundario de los


decoradores es cuando necesitamos registrar objetos en un registro público que estará
disponible en el módulo.

Por ejemplo, volviendo a nuestro ejemplo anterior del sistema de eventos , ahora queremos
que solo algunos eventos estén disponibles en el módulo, pero no todos. En la jerarquía de
eventos, podríamos querer tener algunas clases intermedias que no son eventos reales que
queremos procesar en el sistema, sino algunas de sus clases derivadas.

En lugar de marcar cada clase en función de si se procesará o no, podríamos registrar


explícitamente cada clase a través de un decorador.
[ 141 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

En este caso, tenemos una clase para todos los eventos que se relacionan con las actividades
de un usuario. Sin embargo, esta es solo una tabla intermedia para los tipos de eventos que
realmente queremos, a saber,
UserLoginEvent y UserLogoutEvent :

EVENTOS_REGISTRO = {}

def register_event(event_cls):
"""Coloque la clase para el evento en el registro para que sea accesible en
el módulo.
"""
EVENTS_REGISTRY[event_cls.__name__] = event_cls return event_cls

evento de clase:
"""Un objeto de evento base"""

clase UserEvent:
TIPO = "usuario"

@register_event
class UserLoginEvent(UserEvent):
"""Representa el evento de un usuario cuando acaba de acceder al sistema."""

@register_event
class UserLogoutEvent(UserEvent):
"""Evento activado justo después de que un usuario abandonó el sistema."""

Cuando miramos el código anterior, parece que EVENTS_REGISTRY está vacío, pero
después de importar algo de este módulo, se llenará con todas las clases que están bajo el
decorador register_event :

>>> from decorator_side_effects_2 import EVENTS_REGISTRY >>>


EVENTS_REGISTRY
{'UserLoginEvent': decorator_side_effects_2.UserLoginEvent, 'UserLogoutEvent':
decorator_side_effects_2.UserLogoutEvent}

Esto puede parecer difícil de leer, o incluso engañoso, porque EVENTS_REGISTRY tendrá su
valor final en tiempo de ejecución, justo después de importar el módulo, y no podemos
predecir fácilmente su valor con solo mirar el código.
[ 142 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Si bien eso es cierto, en algunos casos este patrón está justificado. De hecho, muchos
marcos web o bibliotecas conocidas usan esto para trabajar y exponer objetos o ponerlos a
disposición.

También es cierto que en este caso, el decorador no está cambiando el objeto envuelto , ni
alterando la forma en que funciona de ninguna manera. Sin embargo, la nota importante
aquí es que, si tuviéramos que hacer algunas modificaciones y definir una función interna
que modifique el objeto envuelto , probablemente todavía querríamos el código que registra
el objeto resultante fuera de él.

Note el uso de la palabra afuera . No necesariamente significa antes, simplemente no es


parte del mismo cierre; pero está en el ámbito externo, por lo que no se retrasa hasta el
tiempo de ejecución.

Creando decoradores que siempre funcionarán


Hay varios escenarios diferentes a los que los decoradores pueden aplicar. También puede
darse el caso de que necesitemos usar el mismo decorador para objetos que caen en estos
diferentes escenarios múltiples, por ejemplo, si queremos reutilizar nuestro decorador y
aplicarlo a una función, una clase, un método o un static método.

Si creamos el decorador pensando solo en soportar solo el primer tipo de objeto que
queremos decorar, podríamos notar que el mismo decorador no funciona igual de bien en
un tipo diferente de objeto. El ejemplo típico es donde creamos un decorador para usar en
una función, y luego queremos aplicarlo a un método de una clase, solo para darnos cuenta
de que no funciona. Un escenario similar podría ocurrir si diseñamos nuestro decorador
para un método y luego queremos que también se aplique a métodos estáticos o métodos de
clase.

Cuando diseñamos decoradores, normalmente pensamos en reutilizar el código, por lo


que también querremos usar ese decorador para funciones y métodos.

Definir nuestros decoradores con la firma *args y **kwargs hará que funcionen en todos los
casos, porque es el tipo de firma más genérica que podemos tener. Sin embargo, a veces es
posible que deseemos no usar esto y, en su lugar, definir la función de envoltura del
decorador de acuerdo con la firma de la función original, principalmente por dos razones:

Será más legible ya que se parece a la función original.


En realidad, necesita hacer algo con los argumentos, por lo que recibir *args y
**kwargs no sería conveniente.
Considere el caso en el que tenemos muchas funciones en nuestra base de código que
requieren que se cree un objeto particular a partir de un parámetro. Por ejemplo, pasamos
una cadena e inicializamos un objeto controlador con ella, repetidamente. Entonces
pensamos que podemos eliminar la duplicación usando un decorador que se encargará de
convertir este parámetro en consecuencia.

[ 143 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

En el siguiente ejemplo, pretendemos que DBDriver es un objeto que sabe cómo conectarse y
ejecutar operaciones en una base de datos, pero necesita una cadena de conexión. Los
métodos que tenemos en nuestro código, están diseñados para recibir una cadena con la
información de la base de datos y requieren crear una instancia de DBDriver siempre. La
idea del decorador es que esta conversión se llevará a cabo automáticamente : la función
continuará recibiendo una cadena, pero el decorador creará un DBDriver y lo pasará a la
función, por lo que internamente podemos suponer que recibimos el objeto. necesitamos
directamente.

En el siguiente listado se muestra un ejemplo del uso de esto en una función:


importar registros
desde functools importar envolturas

registrador = registro.getLogger(__nombre__)

clase DBDriver:
def __init__(self, dbstring):
self.dbstring = dbstring

def ejecutar(self, consulta):


devuelve f"consulta {consulta} en {self.dbstring}"

def inject_db_driver(función):
"""Este decorador convierte el parámetro creando una instancia ``DBDriver`` a partir de la
cadena dsn de la base de datos.
"""
@wraps(función)
def envuelto(dbstring):
return function(DBDriver(dbstring)) return
envuelto

@inject_db_driver
def run_query(driver):
return driver.execute("test_function")

Es fácil verificar que si pasamos una cadena a la función, obtenemos el resultado


mediante una instancia de DBDriver , por lo que el decorador funciona como se
esperaba:

>>> run_query("test_OK")
'query test_function at test_OK'
[ 144 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Pero ahora, queremos reutilizar este mismo decorador en un método de clase, donde
encontramos el mismo problema:

clase DataHandler:
@inject_db_driver
def run_query(self, driver):
return driver.execute(self.__class__.__name__)

Intentamos usar este decorador, solo para darnos cuenta de que no funciona:
>>> DataHandler().run_query("test_fails") Rastreo
(última llamada más reciente):
...
TypeError: wrap() toma 1 argumento posicional pero se dieron 2

¿Cuál es el problema?

El método de la clase se define con un argumento adicional : self .

Los métodos son solo un tipo particular de función que se recibe a sí mismo (el objeto sobre
el que están definidos) como primer parámetro.

Por lo tanto, en este caso, el decorador (diseñado para trabajar con un solo parámetro,
llamado dbstring ), interpretará que self es dicho parámetro y llamará al método pasando
la cadena en lugar de self, y nada en lugar del segundo parámetro, es decir, la cadena
que estamos pasando.

Para solucionar este problema, necesitamos crear un decorador que funcione


igualmente para métodos y funciones, y lo hacemos definiéndolo como un objeto
decorador, que también implementa el descriptor de protocolo.

Los descriptores se explican completamente en el Capítulo 7 , Uso de generadores , por lo que,


por ahora, podemos tomar esto como una receta que hará que el decorador funcione.

La solución es implementar el decorador como un objeto de clase y hacer de este


objeto una descripción, implementando el método __get__ .

desde functools importar envolturas


desde tipos importar MethodType

class inject_db_driver:
"""Convierta una cadena en una instancia de DBDriver y pásela a la función
envuelta."""
def __init__(self, función):
self.función = función

[ 145 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

wraps(self.function)(self)

def __call__(self, dbstring):


return self.function(DBDriver(dbstring))

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
return self.__class__(MethodType(self.function, instance))

Los detalles sobre los descriptores se explicarán en el Capítulo 6 , Obtener más de nuestros
objetos con descriptores , pero para los fines de este ejemplo, ahora podemos decir que lo
que hace es volver a vincular el invocable que está decorando con un método, lo que
significa que vincule la función al objeto y luego vuelva a crear el decorador con este
nuevo invocable.

Para las funciones, todavía funciona, porque no llamará al método __get__ en absoluto.

El principio DRY con decoradores


Hemos visto cómo los decoradores nos permiten abstraer cierta lógica en un componente
separado. La principal ventaja de esto es que podemos aplicar el decorador varias veces en
diferentes objetos para reutilizar el código. Esto sigue el principio Don't Repeat Yourself (
DRY ), ya que definimos cierto conocimiento una vez y solo una vez.

El mecanismo de reintento implementado en las secciones anteriores es un buen ejemplo de


un decorador que se puede aplicar varias veces para reutilizar el código. En lugar de hacer
que cada función en particular incluya su lógica de reintento , creamos un decorador y lo
aplicamos varias veces.
Esto tiene sentido una vez que nos hemos asegurado de que el decorador pueda trabajar
con métodos y funciones por igual.

El decorador de clases que define cómo se representarán los eventos también cumple con el
principio DRY en el sentido de que define un lugar específico para la lógica de serialización
de un evento, sin necesidad de duplicar código disperso entre diferentes clases. Dado que
esperamos reutilizar este decorador y aplicarlo a muchas clases, su desarrollo (y
complejidad) vale la pena.

Es importante tener en cuenta esta última observación cuando se trata de usar


decoradores para reutilizar el código : debemos estar absolutamente seguros de que
realmente guardaremos el código.
[ 146 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Cualquier decorador (especialmente si no está cuidadosamente diseñado) agrega otro nivel


de direccionamiento indirecto al código y, por lo tanto, más complejidad. Es posible que los
lectores del código deseen seguir el camino del decorador para comprender completamente
la lógica de la función (aunque estas consideraciones se abordan en la siguiente sección), así
que tenga en cuenta que esta complejidad tiene que pagar.
Si no va a haber demasiada reutilización, entonces no elija un decorador y opte por
una opción más simple (tal vez solo una función separada u otra clase pequeña sea
suficiente).

Pero, ¿cómo sabemos qué es demasiada reutilización? ¿Existe una regla para determinar
cuándo refactorizar el código existente en un decorador? No hay nada específico para los
decoradores en Python, pero podríamos aplicar una regla general en ingeniería de software
(GLASS 01) que establece que un componente debe probarse al menos tres veces antes de
considerar la creación de una abstracción genérica del tipo reutilizable. componente. De la
misma referencia (VIDRIO 01); (Animamos a todos los lectores a leer Hechos y falacias de la
ingeniería de software porque es una gran referencia) También surge la idea de que crear
componentes reutilizables es tres veces más difícil que crear componentes simples.

La conclusión es que la reutilización del código a través de decoradores es aceptable,


pero solo cuando se tienen en cuenta las siguientes consideraciones:

No cree el decorador en primer lugar desde cero. Espere hasta que surja el
patrón y la abstracción para el decorador se vuelva clara, y luego
refactorice.
Considere que el decorador debe aplicarse varias veces (al menos tres veces)
antes de implementarlo.
Mantenga el código en los decoradores al mínimo.

Decoradores y separación de intereses


El último punto de la lista anterior es tan importante que merece un apartado aparte. Ya
exploramos la idea de reutilizar el código y notamos que un elemento clave de la
reutilización del código es tener componentes que sean cohesivos. Esto significa que deben
tener el nivel mínimo de responsabilidad : hacer una cosa, solo una cosa, y hacerlo bien.
Cuanto más pequeños sean nuestros componentes, más reutilizables y más se pueden
aplicar en un contexto diferente sin tener un comportamiento adicional que cause
acoplamiento y dependencias, lo que hará que el software sea rígido.
Para mostrarle lo que esto significa, repitamos uno de los decoradores que usamos en un
ejemplo anterior. Creamos un decorador que rastreó la ejecución de ciertas funciones con
un código similar al siguiente:

def traced_function(función):
@functools.wraps(función)

[ 147 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

def envuelto(*args, **kwargs):


logger.info("ejecución iniciada de %s", function.__qualname__) start_time =
time.time()
result = function(*args, **kwargs)
logger.info(
" la función %s tomó %.2fs",
function.__qualname__,
time.time() - start_time
)
devolver el resultado
volver envuelto

Ahora bien, este decorador, mientras trabaja, tiene un problema : está haciendo más de una
cosa. Registra que se acaba de invocar una función en particular y también registra cuánto
tiempo tardó en ejecutarse. Cada vez que usamos este decorador, estamos cargando con
estas dos responsabilidades, incluso si solo quisiéramos una de ellas.

Esto debería dividirse en decoradores más pequeños, cada uno con una
responsabilidad más específica y limitada:
def log_execution(función):
@wraps(función)
def envuelto(*args, **kwargs):
logger.info("ejecución iniciada de %s", function.__qualname__) return function(*kwargs,
**kwargs)
return envuelto

def medida_tiempo(función):
@wraps(función)
def envuelto(*args, **kwargs):
start_time = tiempo.tiempo()
resultado = función(*args, **kwargs)

logger.info("La función %s tomó %.2f", function.__qualname__, time.time() -


start_time)
resultado
devuelto retorno envuelto

Tenga en cuenta que la misma funcionalidad que teníamos anteriormente se puede


lograr simplemente combinando ambos:
@measure_time
@log_execution
def operación():
....

[ 148 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Observe cómo el orden en que se aplican los decoradores también es importante.

No coloque más de una responsabilidad en un decorador. El SRP también


se aplica a los decoradores.

Analizando buenos decoradores


Como nota de cierre de este capítulo, revisemos algunos ejemplos de buenos decoradores
y cómo se usan tanto en Python como en bibliotecas populares. La idea es obtener pautas
sobre cómo se crean buenos decoradores.

Antes de saltar a los ejemplos, primero identifiquemos los rasgos que deben tener los
buenos decoradores:

Encapsulamiento, o separación de preocupaciones : Un buen decorador debe


separar de manera efectiva las diferentes responsabilidades entre lo que hace y lo
que está decorando.
No puede ser una abstracción con fugas, lo que significa que un cliente del
decorador solo debe invocarlo en modo de caja negra, sin saber cómo está
implementando realmente su lógica.
Ortogonalidad : lo que hace el decorador debe ser independiente y lo
más desvinculado posible del objeto que está decorando.
Reutilización : es deseable que el decorador se pueda aplicar a varios tipos, y no
que solo aparezca en una instancia de una función, porque eso significa que
podría haber sido solo una función. Tiene que ser lo suficientemente genérico.

Un buen ejemplo de decoradores se puede encontrar en el proyecto Celery, donde una tarea
se define aplicando el decorador de la tarea de la aplicación a una función:

@app.tarea
def mitarea():
....

Una de las razones por las que este es un buen decorador es porque es muy bueno en
algo : la encapsulación. El usuario de la biblioteca solo necesita definir el cuerpo de la
función y el decorador lo convertirá en una tarea automáticamente. El decorador
"@app.task" seguramente envuelve mucha lógica y código, pero nada de eso es relevante
para el cuerpo de
"mytask()" . Es una completa encapsulación y separación de preocupaciones : nadie tendrá
que mirar lo que hace ese decorador, por lo que es una abstracción correcta que no filtra
ningún detalle.

[ 149 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Otro uso común de los decoradores es en marcos web (Pyramid, Flask y Sanic, solo por
nombrar algunos), en los que los controladores de vistas se registran en las URL a través
de decoradores:

@route("/", method=["GET"])
def view_handler(solicitud):
...

Este tipo de decoradores tienen las mismas consideraciones que antes; también
proporcionan una encapsulación total porque un usuario del marco web rara vez (o nunca)
necesita saber qué está haciendo el decorador "@ruta" . En este caso, sabemos que el
decorador está haciendo
algo más, como registrar estas funciones en un mapeador de la URL, y también que está
cambiando la firma de la función original para brindarnos una interfaz más agradable que
recibe un objeto de solicitud. con toda la información ya configurada.

Los dos ejemplos anteriores son suficientes para hacernos notar algo más sobre este uso de
decoradores. Se ajustan a una API. Estas bibliotecas de marcos están exponiendo su
funcionalidad a los usuarios a través de decoradores, y resulta que los decoradores son
una excelente manera de definir una interfaz de programación limpia.

Esta es probablemente la mejor forma en que deberíamos pensar para los decoradores. Al
igual que en el ejemplo del decorador de clase que nos dice cómo se manejarán los
atributos del evento, un buen decorador debe proporcionar una interfaz limpia para que
los usuarios del código sepan qué esperar del decorador, sin necesidad de saberlo. cómo
funciona, o cualquiera de sus detalles para el caso.

Resumen
Los decoradores son herramientas poderosas en Python que se pueden aplicar a muchas
cosas, como clases, métodos, funciones, generadores y muchas más. Hemos demostrado
cómo crear decoradores de diferentes maneras y para diferentes propósitos, y sacamos
algunas conclusiones en el camino.

Al crear un decorador para funciones, intente que su firma coincida con la función
original que se está decorando. En lugar de usar los *args y **kwargs genéricos , hacer que
la firma coincida con la original hará que sea más fácil de leer y mantener, y se parecerá
más a la función original, por lo que será más familiar para los lectores de ese código.
[ 150 ]
Uso de decoradores para mejorar nuestro código Capítulo 5

Los decoradores son una herramienta muy útil para reutilizar código y seguir el principio
DRY.
Sin embargo, su utilidad tiene un costo, y si no se usan con prudencia, la complejidad
puede hacer más daño que bien. Por esa razón, enfatizamos que los decoradores deben
usarse cuando en realidad se van a aplicar varias veces (tres o más veces). De la misma
manera que el principio DRY, encontramos las ideas de separación de preocupaciones, con
el objetivo de mantener los decoradores lo más pequeños posible.

Otro buen uso de los decoradores es crear interfaces más limpias, por ejemplo,
simplificando la definición de una clase al extraer parte de su lógica en un decorador. En
este sentido, los decoradores también ayudan a la legibilidad al proporcionar a los
usuarios información sobre lo que hará ese componente en particular, sin necesidad de
saber cómo (encapsulación).

En el próximo capítulo, veremos otra característica avanzada de Python : los descriptores.


En particular, veremos cómo con la ayuda de los descriptores podemos crear decoradores
aún mejores y resolver algunos de los problemas que encontramos en este capítulo.

Referencias
Aquí hay una lista de información que puede consultar:

PEP-318 : Decoradores para funciones y métodos ( https:/)/www.python.org/dev/


peps/pep-0318/
PEP-3129 : Decoradores de clase ( https:/)/www.python.org/dev/peps/pep-3129/
ENVOLTURA 01 :https://pypi.org/project/wrapt/
ENVOLTURA 02 :https://wrapt.readthedocs.io/en/latest/decorators.
html#universal-decorators

El módulo Functools : La función wraps en el módulo functools de la biblioteca


estándar https://docs.python.org/3/library/functools.de Python (
html#functools.)wrap
ATTRS 01: La biblioteca attrs ( https:/)/pypi.org/project/attrs/
PEP-557 : Clases de datos ( https:/)/www.python.org/dev/peps/pep-0557/
GLASS 01 : El libro escrito por Robert L. Glass llamado Facts and Falacies of
Software Engineering
[ 151 ]
Sacar más provecho de

nuestros 
Objetos con
descriptores
Este capítulo presenta un nuevo concepto que es más avanzado en el desarrollo de Python,
ya que presenta descriptores. Además, los descriptores no son algo con lo que los
programadores de otros lenguajes estén familiarizados, por lo que no hay analogías o
paralelismos fáciles de hacer.

Los descriptores son otra característica distintiva de Python que lleva la programación
orientada a objetos
a otro nivel, y su potencial permite a los usuarios crear abstracciones más potentes y
reutilizables. La mayoría de las veces, todo el potencial de los descriptores se observa en
bibliotecas o marcos.

En este capítulo, lograremos los siguientes objetivos relacionados con los descriptores:

Comprender qué son los descriptores, cómo funcionan y cómo implementarlos


de manera efectiva
Analizar los dos tipos de descriptores (descriptores de datos y no datos), en
términos de sus diferencias conceptuales y detalles de implementación
Reutilice el código de manera efectiva a través de descriptores
Analizar ejemplos de buenos usos de descriptores, y cómo aprovecharlos
para nuestras propias bibliotecas de APIs
Un primer vistazo a los descriptores
Primero, exploraremos la idea principal detrás de los descriptores para comprender su
mecánica y funcionamiento interno. Una vez que esto esté claro, será más fácil asimilar
cómo funcionan los diferentes tipos de descriptores, que exploraremos en la siguiente
sección.

Una vez que tengamos una primera comprensión de la idea detrás de los
descriptores, veremos un ejemplo en el que su uso nos brinda una implementación
más limpia y pitónica.
Obtener más de nuestros objetos con descriptores Capítulo 6

La maquinaria detrás de los descriptores


La forma en que funcionan los descriptores no es tan complicada, pero el problema con
ellos es que hay muchas advertencias a tener en cuenta, por lo que los detalles de
implementación son de suma importancia aquí.

Para implementar descriptores, necesitamos al menos dos clases. Para los propósitos de este
ejemplo genérico, vamos a llamar a la clase cliente a la que va a aprovechar la funcionalidad
que queremos implementar en el descriptor (esta clase es generalmente solo un modelo de
dominio, una abstracción regular que create para nuestra solución), y vamos a llamar a la
clase descriptor a la que implementa la lógica del
descriptor.

Un descriptor es, por lo tanto, solo un objeto que es una instancia de una clase que
implementa el protocolo del descriptor. Esto significa que esta clase debe tener su interfaz
que contenga al menos uno de los siguientes métodos mágicos (parte del protocolo
descriptor a partir de Python 3.6+):

__obtener__
__establecer__
__Eliminar__
__escoger un nombre__

A los efectos de esta introducción inicial de alto nivel, se utilizará la siguiente


convención de nomenclatura:

Nombre Sentido
La abstracción a nivel de dominio que aprovechará la
funcionalidad que implementará el descriptor. Se dice que esta clase
ClaseCliente
es un cliente del descriptor.
Esta clase contiene un atributo de clase (denominado descriptor
por esta convención), que es una instancia de DescriptorClass .
La clase que implementa el propio descriptor . Esta clase debería
DescriptorClass implementar algunos de los métodos mágicos antes mencionados que
implican el protocolo descriptor.
cliente Una instancia de ClientClass .
cliente = ClaseCliente()
Una instancia de DescriptorClass .
descriptor descriptor = ClaseDescriptor() .
Este objeto es un atributo de clase que se coloca en ClientClass .
[ 153 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Esta relación se ilustra en el siguiente diagrama:

Una observación muy importante a tener en cuenta es que para que este protocolo
funcione, el objeto descriptor debe definirse como un atributo de clase. La creación de este
objeto como un atributo de instancia no funcionará, por lo que debe estar en el cuerpo de
la clase y no en el método init .

Siempre coloque el objeto descriptor como un atributo de clase!

En una nota ligeramente crítica, los lectores también pueden notar que es posible
implementar el protocolo descriptor parcialmente ; no siempre se deben definir todos
los métodos; en cambio, podemos implementar solo aquellos que necesitamos, como
veremos en breve.

Entonces, ahora tenemos la estructura en su lugar : sabemos qué elementos se establecen y


cómo interactúan. Necesitamos una clase para el descriptor , otra clase que consumirá la
lógica del descriptor , que, a su vez, tendrá un objeto descriptor (una instancia de
DescriptorClass ) como atributo de clase e instancias de ClientClass que seguirán al
descriptor. protocolo cuando solicitamos el atributo denominado descriptor . Pero ahora
que? ¿Cómo encaja todo esto en el tiempo de ejecución?

Normalmente, cuando tenemos una clase regular y accedemos a sus atributos,


simplemente obtenemos los objetos tal y como los esperamos, e incluso sus propiedades,
como en el siguiente ejemplo:

>>> clase Atributo:


... valor = 42
...
>>> clase Cliente:
... atributo = Atributo()
...
>>> Cliente().atributo
<__main__.Objeto de atributo en 0x7ff37ea90940> >>>
Cliente().atributo.valor
42

[ 154 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Pero, en el caso de los descriptores, sucede algo diferente. Cuando un objeto se define
como un atributo de clase (y este es un descriptor ), cuando un cliente solicita este atributo,
en lugar de obtener el objeto en sí (como esperaríamos del ejemplo anterior), obtenemos el
resultado de haber llamado al Método mágico __get__ .

Comencemos con un código simple que solo registra información sobre el contexto y
devuelve el mismo objeto de cliente :

class DescriptorClass:
def __get__(self, instancia, propietario):
si la instancia es Ninguno:
return self
logger.info("Call: %s.__get__(%r, %r)",
self.__class__.__name__,instance, propietario) instancia de
retorno

clase ClientClass:
descriptor = DescriptorClass()

Al ejecutar este código y solicitar el atributo descriptor de una instancia de ClientClass ,


descubriremos que, de hecho, no estamos obteniendo una instancia de
DescriptorClass , sino lo que devuelva su método __get__() :

>>> cliente = ClientClass()


>>> client.descriptor
INFO:Call: DescriptorClass.__get__(<Objeto ClientClass en 0x...>, <clase 'ClientClass'>)
<Objeto ClientClass en 0x...>
>> > client.descriptor es cliente
INFO: Llamada: DescriptorClass.__get__(ClientClass object at 0x...>, <class 'ClientClass'>)
True

Observe cómo se invocó la línea de registro, ubicada bajo el método __get__ , en lugar de
simplemente devolver el objeto que creamos. En este caso, hicimos que el método
devolviera al cliente mismo, por lo tanto, haciendo verdadera una comparación de la última
declaración. Los parámetros de este método se explican con más detalle en las siguientes
subsecciones cuando exploramos cada método con más detalle.
[ 155 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

A partir de este ejemplo simple pero demostrativo, podemos comenzar a crear


abstracciones más complejas y mejores decoradores, porque la nota importante aquí es que
tenemos una nueva (poderosa) herramienta con la que trabajar. Observe cómo esto cambia
el flujo de control del programa de una manera completamente diferente. Con esta
herramienta, podemos abstraer todo tipo de lógica detrás del método __get__ y hacer que el
descriptor ejecute de forma transparente todo tipo de transformaciones sin que los clientes
se den cuenta. Esto lleva la encapsulación a un nuevo nivel.

Explorando cada método del protocolo descriptor


Hasta ahora, hemos visto bastantes ejemplos de descriptores en acción y tenemos una idea
de cómo funcionan. Estos ejemplos nos dieron un primer vistazo del poder de los
descriptores, pero es posible que se pregunte acerca de algunos detalles de implementación
y modismos cuya explicación no abordamos.

Dado que los descriptores son solo objetos, estos métodos toman self como primer
parámetro. Para todos ellos, esto solo significa el objeto descriptor en sí.

En esta sección, exploraremos cada método del protocolo descriptor, en detalle,


explicando qué significa cada parámetro y cómo se pretende que se use.

__get__(yo, instancia, dueño)


El primer parámetro, instancia , se refiere al objeto desde el cual se llama al descriptor . En
nuestro primer ejemplo, esto significaría el objeto del cliente .

El parámetro propietario es una referencia a la clase de ese objeto, que siguiendo nuestro
ejemplo (del diagrama de clase anterior en la sección La maquinaria detrás de los descriptores )
sería ClientClass .

Del párrafo anterior concluimos que el parámetro llamado instancia en la firma de __get__ es
el objeto sobre el cual el descriptor actúa, y el propietario es la clase de instancia . El ávido
lector podría preguntarse por qué la firma se define de esta manera, después de que toda la
clase se puede tomar directamente de la instancia ( propietario =
instancia.__clase__ ). Hay un caso límite : cuando el descriptor se llama desde la clase (
ClientClass ), no desde la instancia ( cliente ), entonces el valor de la instancia es Ninguno , pero
es posible que aún queramos hacer algún procesamiento en ese caso.
[ 156 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Con el siguiente código simple, podemos demostrar la diferencia de cuándo se llama a un


descriptor desde la clase o desde una instancia. En este caso, el método __get__ está
haciendo dos cosas distintas para cada caso.
# descriptores_métodos_1.py

class DescriptorClass:
def __get__(self, instancia, propietario):
si la instancia es Ninguno:
return f"{self.__class__.__name__}.{owner.__name__}" return f"valor para
{instancia}"

claseClienteClase:

descriptor = ClaseDescriptor()

Cuando lo llamamos directamente desde ClientClass , hará una cosa, que es componer
un espacio de nombres con los nombres de las clases:

>>> ClaseCliente.descriptor
'ClaseDescriptor.ClaseCliente'

Y luego, si lo llamamos desde un objeto que hemos creado, devolverá el otro mensaje
en su lugar:

>>> ClientClass().descriptor
'valor para <descriptors_methods_1.ClientClass objeto en 0x...>'

En general, a menos que realmente necesitemos hacer algo con el parámetro propietario
, el idioma más común es simplemente devolver el descriptor en sí, cuando la instancia
es Ninguno .

__set__(yo, instancia, valor)


Este método se llama cuando intentamos asignar algo a un descriptor . Se activa con
sentencias como las siguientes, en las que un descriptor es un objeto que implementa __set__()
. El parámetro de la instancia , en este caso, sería client y
el valor sería la cadena "value" :

cliente.descriptor = "valor"

Si client.descriptor no implementa __set__() , entonces "valor" anulará el descriptor por


completo.
[ 157 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Tenga cuidado al asignar un valor a un atributo que es un descriptor.


Asegúrese de que implemente el método __set__ y que no estemos
causando un efecto secundario no deseado.

De forma predeterminada, el uso más común de este método es simplemente almacenar


datos en un objeto.
Sin embargo, hasta ahora hemos visto lo poderosos que son los descriptores y que
podemos aprovecharlos, por ejemplo, si tuviéramos que crear objetos de validación
genéricos que se pueden aplicar varias veces (nuevamente, esto es algo que si no hacemos
resumen, podríamos terminar repitiendo varias veces en los métodos setter de
propiedades).

La siguiente lista ilustra cómo podemos aprovechar este método para crear objetos de
validación genéricos para atributos, que se pueden crear dinámicamente con funciones
para validar los valores antes de asignarlos al objeto:
Validación de clase:

def __init__(self, función_validación, mensaje_error: str): self.función_validación =


función_validación self.mensaje_error = mensaje_error

def __call__(self, value):


if not self.validation_function(value):
raise ValueError(f"{value!r} {self.error_msg}")

campo de clase:

def __init__(self, *validations):


self._name = Ninguno
self.validations = validaciones

def __set_name__(self, propietario, nombre):


self._name = nombre

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
return instancia.__dict__[self._name]

def validar(auto, valor):


para validación en auto.validaciones:
validación(valor)
def __set__(self, instancia, valor):
self.validate(valor)

[ 158 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

instancia.__dict__[self._name] = valor

class ClientClass:
descriptor = Field(
Validation(lambda x: isinstance(x, (int, float)), "no es un número"),
Validation(lambda x: x >= 0, "is not >= 0"),
)

Podemos ver este objeto en acción en el siguiente listado:


>>> cliente = ClientClass()
>>> cliente.descriptor = 42
>>> cliente.descriptor
42
>>> cliente.descriptor = -42
Rastreo (última llamada más reciente):
...
ValueError: -42 no es >= 0
>>> client.descriptor = "valor no válido"
...
ValueError: 'valor no válido' no es un número

La idea es que algo que normalmente colocaríamos en una propiedad se pueda abstraer en
un descriptor y reutilizarlo varias veces. En este caso, el método __set__() estaría haciendo lo
que habría estado haciendo @property.setter .

__delete__(yo, instancia)
Se llama a este método con la siguiente instrucción, en la que self sería el atributo del
descriptor y la instancia sería el objeto del cliente en este ejemplo:

>>> del cliente.descriptor

En el siguiente ejemplo, usamos este método para crear un descriptor con el objetivo de
evitar que elimine atributos de un objeto sin los
privilegios administrativos necesarios. Observe cómo, en este caso, el descriptor tiene una
lógica que se usa para predicar con los valores del objeto que lo está usando, en lugar de
diferentes objetos relacionados:
# descriptores_métodos_3.py

class ProtectedAttribute:
def __init__(self, require_role=Ninguno) -> Ninguno:
self.permission_required = require_role
[ 159 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

self._name = Ninguno

def __set_name__(self, propietario, nombre):


self._name = nombre

def __set__(self, usuario, valor):


si el valor es Ninguno:
aumentar ValueError(f"{self._name} no se puede establecer en Ninguno")
user.__dict__[self._name] = valor

def __delete__(self, usuario):


si self.permission_required in user.permissions:
user.__dict__[self._name] = Ninguno
más:
aumentar ValueError(
f"User {user!s} no tiene {self.permission_required} " "permiso"
)

class User:
"""Solo los usuarios con privilegios de "administrador" pueden eliminar su dirección de
correo electrónico."""

correo electrónico = atributo protegido (requires_role = "administrador")

def __init__(self, nombre de usuario: str, email: str, lista_de_permisos: lista = Ninguno) -> Ninguno:
self.username = nombre de usuario
self.email = email
self.permissions = lista_de_permisos o []

def __str__(self):
return self.nombre de usuario

Antes de ver ejemplos de cómo funciona este objeto, es importante remarcar algunos de los
criterios de este descriptor. Observe que la clase Usuario requiere el nombre de usuario y el
correo electrónico como parámetros obligatorios. Según su método __init__ , no puede ser un
usuario si no tiene un atributo de correo electrónico . Si tuviéramos que eliminar ese atributo
y extraerlo del objeto por completo, estaríamos creando un objeto inconsistente, con algún
estado intermedio no válido que no corresponde a la interfaz definida por la clase Usuario .
Detalles como este son realmente importantes para evitar problemas. Algún otro objeto
espera trabajar con este Usuario y también espera que tenga un atributo de correo electrónico .
[ 160 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Por esta razón, se decidió que la "eliminación" de un correo electrónico simplemente lo


establecerá en Ninguno , y esa es la parte de la lista de códigos que está en negrita. Por la
misma razón, debemos prohibir que alguien intente establecer un valor Ninguno , porque
eso evitaría el mecanismo que colocamos en el método __delete__ .

Aquí podemos verlo en acción, suponiendo un caso en el que solo los usuarios con
privilegios de "administrador" pueden eliminar su dirección de correo electrónico:

>>> admin = Usuario("raíz", "raí[email protected]", ["admin"])


>>> usuario = Usuario("usuario", "[email protected]", ["correo electrónico", "
helpdesk"]) >>> admin.email
'[email protected]'
>>> del admin.email
>>> admin.email is None
True
>>> user.email
'[email protected]'
>>> usuario .email = Ninguno
...
ValueError: el correo electrónico no se puede
establecer en Ninguno
>>> del usuario.email
...
ValueError: usuario usuario no tiene permiso de administrador

Aquí, en este simple descriptor , vemos que podemos eliminar el correo electrónico
de los usuarios que contienen el permiso "admin" únicamente. En cuanto al resto,
cuando intentemos llamar a del en ese atributo, obtendremos una excepción
ValueError .

En general, este método del descriptor no se usa con tanta frecuencia como los dos
anteriores, pero vale la pena mostrarlo para completarlo.

__set_name__(yo, propietario, nombre)


Cuando creamos el objeto descriptor en la clase que lo va a usar, generalmente
necesitamos que el descriptor sepa el nombre del atributo que va a manejar.

Este nombre de atributo es el que usamos para leer y escribir en __dict__ en los métodos
__get__ y __set__ , respectivamente.
Antes de Python 3.6, el descriptor no podía tomar este nombre automáticamente, por lo que
el enfoque más general era pasarlo explícitamente al inicializar el objeto. Esto funciona bien,
pero tiene el problema de que requiere que dupliquemos el nombre cada vez que queramos
usar el descriptor para un nuevo atributo.

[ 161 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Así es como se vería un descriptor típico si no tuviéramos este método:


class DescriptorWithName:
def __init__(self, nombre):
self.name = nombre

def __get__(self, instancia, valor):


si la instancia es Ninguno:
return self
logger.info("obteniendo %r atributo de %r", self.name, instancia) return
instance.__dict__[self.name]

def __set__(self, instancia, valor):


instancia.__dict__[self.name] = valor

clase ClientClass:
descriptor = DescriptorWithName("descriptor")

Podemos ver cómo el descriptor usa este valor:


>>> cliente = ClientClass()
>>> cliente.descriptor = "valor"
>>> cliente.descriptor
INFO: obteniendo el atributo 'descriptor' del <objeto ClientClass en 0x...> 'valor'

Ahora bien, si quisiéramos evitar escribir el nombre del atributo dos veces (una para la
variable asignada dentro de la clase, y otra vez como el nombre del primer parámetro
del descriptor), tenemos que recurrir a algunos trucos, como usar un decorador de clase,
o (aún peor) usando una metaclase.

En Python 3.6, se agregó el nuevo método __set_name__ , y recibe la clase donde se está
creando ese descriptor y el nombre que se le está dando a ese descriptor. El modismo más
común es usar este método para el descriptor para que pueda almacenar el nombre
requerido en este método.

Por compatibilidad, generalmente es una buena idea mantener un valor


predeterminado en el método __init__ pero aún así aprovechar __set_name__ .
[ 162 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Con este método, podemos reescribir los descriptores anteriores de la siguiente manera:
class DescriptorWithName:
def __init__(self, nombre=Ninguno):
self.name = nombre

def __set_name__(self, propietario, nombre):


self.name = nombre
...

Tipos de descriptores
Con base en los métodos que acabamos de explorar, podemos hacer una distinción
importante entre los descriptores en términos de cómo funcionan. Comprender esta
distinción juega un papel importante en el trabajo efectivo con los descriptores y también
ayudará a evitar advertencias o errores comunes en el tiempo de ejecución.

Si un descriptor implementa los métodos __set__ o __delete__ , se denomina descriptor


de datos . De lo contrario, un descriptor que solo implementa __get__ es un
descriptor que no es de datos . Tenga en cuenta que __set_name__ no afecta en
absoluto a esta clasificación.

Al intentar resolver un atributo de un objeto, un descriptor de datos siempre tendrá


prioridad sobre el diccionario del objeto, mientras que un descriptor que no sea de datos
no lo hará . Esto significa que en un descriptor que no es de datos, si el objeto tiene una
clave en su diccionario con el mismo nombre que el descriptor, siempre se llamará a esta y
el descriptor nunca se ejecutará. Por el contrario, en un descriptor de datos, aunque exista
una clave en el diccionario con el mismo nombre que el descriptor, ésta nunca se utilizará
ya que siempre se acabará llamando al propio descriptor.

Las siguientes dos secciones explican esto con más detalle, con ejemplos, para tener
una idea más profunda de qué esperar de cada tipo de descriptor.

Descriptores que no son datos


Comenzaremos con un descriptor que solo implementa el método __get__ y veremos cómo
se usa:

class NonDataDescriptor:
def __get__(self, instancia, propietario):
si la instancia es None:
return self
return 42

[ 163 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

clase ClientClass:
descriptor = NonDataDescriptor()

Como siempre, si preguntamos por el descriptor , obtenemos el resultado de su método


__get__ :

>>> cliente = ClientClass()


>>> cliente.descriptor
42

Pero si cambiamos el atributo del descriptor a otra cosa, perdemos el acceso a este valor y,
en su lugar, obtenemos lo que se le asignó:

>>> cliente.descriptor = 43
>>> cliente.descriptor
43

Ahora, si borramos el descriptor , y lo volvemos a pedir, veamos qué obtenemos:


>>> del cliente.descriptor
>>> cliente.descriptor
42

Rebobinemos lo que acaba de pasar. Cuando creamos por primera vez el objeto del
cliente , el atributo del descriptor estaba en la clase, no en la instancia, por lo que si solicitamos
el diccionario del objeto del cliente , estará vacío:

>>> vars(cliente)
{}

Y luego, cuando solicitamos el atributo .descriptor , no encuentra ninguna clave en


client.__dict__ llamada "descriptor" , por lo que va a la clase, donde la encontrará... pero solo
como descriptor, por eso devuelve el resultado del método __get__ .

Pero luego, cambiamos el valor del atributo .descriptor a otra cosa, y lo que esto hace es
establecer esto en el diccionario de la instancia , lo que significa que esta vez no estará
vacío:
>>> cliente.descriptor = 99
>>> vars(cliente)
{'descriptor': 99}

Entonces, cuando solicitamos el atributo .descriptor aquí, lo buscará en el objeto (y esta vez
lo encontrará, porque hay una clave llamada descriptor en el atributo __dict__ del objeto,
como muestra el resultado de vars nosotros), y devolverlo sin tener que buscarlo en la clase.
Por esta razón, el protocolo descriptor nunca se invoca, y la próxima vez que solicitemos
este atributo, devolverá el valor con el que lo hemos anulado ( 99 ).

[ 164 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Luego, eliminamos este atributo llamando a del , y lo que esto hace es eliminar la clave
"descriptor" del diccionario del objeto, dejándonos de vuelta en el primer escenario, donde
se establecerá de manera predeterminada en la clase donde estará el protocolo del
descriptor. activado:
>>> del cliente.descriptor
>>> vars(cliente)
{}
>>> cliente.descriptor
42

Esto significa que si establecemos el atributo del descriptor en otra cosa, es posible que lo
rompamos accidentalmente. ¿Por qué? Porque el descriptor no maneja la acción de
eliminar (algunos de ellos no necesitan hacerlo).

Esto se denomina descriptor sin datos porque no implementa el método mágico


__set__ , como veremos en el siguiente ejemplo.

Descriptores de datos
Ahora, veamos la diferencia de usar un descriptor de datos. Para ello, vamos a crear otro
descriptor sencillo que implemente el método __set__ :

descriptor de datos de clase:

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
return 42

def __set__(self, instancia, valor):


logger.debug("configurando %s.descriptor a %s", instancia, valor)
instancia.__dict__["descriptor"] = valor

clase ClientClass:
descriptor = DataDescriptor()

Veamos qué devuelve el valor del descriptor :


>>> cliente = ClientClass()
>>> cliente.descriptor
42
[ 165 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Ahora, intentemos cambiar este valor a otro y veamos qué devuelve en su lugar:
>>> cliente.descriptor = 99
>>> cliente.descriptor
42

El valor devuelto por el descriptor no cambió. Pero cuando le asignamos un valor diferente,
debe establecerse en el diccionario del objeto (como estaba antes):
>>> vars(cliente)
{'descriptor': 99}

>>> cliente.__dict__["descriptor"]
99

Entonces, se llamó al método __set__() y, de hecho, estableció el valor en el diccionario del


objeto, solo que esta vez, cuando solicitamos este atributo, en lugar de usar el atributo
__dict__ del diccionario, el descriptor tiene prioridad (porque es un descriptor primordial ).

Una cosa más : eliminar el atributo ya no funcionará:

>>> del client.descriptor


Traceback (última llamada más reciente):
...
Error de atributo: __eliminar__

La razón es la siguiente : dado que ahora, el descriptor siempre tiene lugar, llamar a del
en un objeto no intenta eliminar el atributo del diccionario ( __dict__ ) del objeto, sino que
intenta llamar al método __delete__() del descriptor (que no está implementado en este
ejemplo, de ahí el error de atributo).

Esta es la diferencia entre descriptores de datos y no datos. Si el descriptor implementa


__set__() , entonces siempre tendrá prioridad, sin importar qué atributos estén presentes en
el diccionario del objeto. Si no se implementa este método, primero se buscará el
diccionario y luego se ejecutará el descriptor.

Una observación interesante que quizás haya notado es esta línea en el método set :
instancia.__dict__["descriptor"] = valor

Hay muchas cosas que cuestionar sobre esa línea, pero dividámosla en partes.
[ 166 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Primero, ¿por qué está alterando solo el nombre de un atributo "descriptor" ? Esto es solo
una
simplificación para este ejemplo, pero, como sucede cuando se trabaja con descriptores, en
este momento no sabe el nombre del parámetro al que se asignó, por lo que solo usamos el
del ejemplo, sabiendo que iba a ser "descriptor" .

En un ejemplo real, haría una de dos cosas : recibir el nombre como un parámetro y
almacenarlo internamente en el método init , de modo que este solo use el atributo interno
o, mejor aún, use el método __set_name__ .

¿Por qué accede directamente al atributo __dict__ de la instancia? Otra buena pregunta, que
también tiene al menos dos explicaciones. Primero, podría estar pensando por qué no
simplemente hacer lo siguiente:
setattr(instancia, "descriptor", valor)

Recuerde que este método ( __set__ ) se llama cuando intentamos asignar algo al atributo
que es un descriptor . Entonces, usar setattr() llamará a este descriptor nuevamente, el
cual, a su vez, lo llamará nuevamente, y así sucesivamente. Esto terminará en una
recursión infinita.

No use setattr() o la expresión de asignación directamente en el descriptor


dentro del método __set__ porque eso desencadenará una recursividad
infinita.

¿Por qué, entonces, el descriptor no puede llevar en libros los valores de las propiedades
de todos sus objetos?

La clase de cliente ya tiene una referencia al descriptor. Si agregamos una referencia del
descriptor al objeto del cliente , estamos creando dependencias circulares, y estos objetos
nunca se recolectarán como basura. Dado que se apuntan entre sí, sus recuentos de
referencia nunca caerán por debajo del umbral de eliminación.

Una posible alternativa aquí es usar referencias débiles, con el módulo de referencia débil, y
crear un diccionario de clave de referencia débil si queremos hacer eso. Esta
implementación se explica más adelante en este capítulo, pero para las implementaciones
dentro de este libro, preferimos usar este modismo, ya que es bastante común y aceptado
cuando se escriben descriptores.
[ 167 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Descriptores en acción
Ahora que hemos visto qué son los descriptores, cómo funcionan y cuáles son las ideas
principales detrás de ellos, podemos verlos en acción. En esta sección, exploraremos
algunas situaciones que se pueden abordar con elegancia a través de descriptores.

Aquí, veremos algunos ejemplos de trabajo con descriptores, y también cubriremos las
consideraciones de implementación para ellos (diferentes formas de crearlos, con sus
ventajas y desventajas), y finalmente discutiremos cuáles son los escenarios más
adecuados para los descriptores.

Una aplicación de descriptores.


Comenzaremos con un ejemplo simple que funciona, pero que conducirá a cierta
duplicación de código.
No está muy claro cómo se abordará este problema. Más adelante, idearemos una forma
de abstraer la lógica repetida en un descriptor, que abordará el problema de la
duplicación, y notaremos que el código de nuestras clases cliente se reducirá
drásticamente.

Un primer intento sin utilizar descriptores


El problema que queremos resolver ahora es que tenemos una clase regular con algunos
atributos, pero deseamos rastrear todos los diferentes valores que tiene un atributo en
particular a lo largo del tiempo, por ejemplo, en una lista. La primera solución que se nos
ocurre es utilizar una propiedad, y cada vez que se cambia un valor para ese atributo en el
método setter de la propiedad, lo añadimos a una lista interna que mantendrá este rastro tal
como lo queremos.

Imagine que nuestra clase representa un viajero en nuestra aplicación que tiene una
ciudad actual, y queremos realizar un seguimiento de todas las ciudades que el usuario
ha visitado durante la ejecución del programa. El siguiente código es una posible
implementación que aborda estos
requisitos:
Clase Viajero:

def __init__(self, nombre, ciudad_actual): self.nombre =


nombre
self._ciudad_actual = ciudad_actual
self._ciudades_visitadas = [ciudad_actual]
@property
def current_city(self):
return self._current_city

@actual_ciudad.setter

[ 168 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

def ciudad_actual(self, nueva_ciudad):


if ciudad_nueva != self._ciudad_actual:
self._ciudades_visitadas.append(nueva_ciudad)
self._ciudad_actual = nueva_ciudad

@property
def cities_visited(self):
return self._cities_visited

Podemos comprobar fácilmente que este código funciona según nuestros requisitos:
>>> alicia = Viajero("Alicia", "Barcelona") >>>
alicia.ciudad_actual = "Paris"
>>> alicia.ciudad_actual = "Bruselas"
>>> alicia.ciudad_actual = "Amsterdam"

>>> alice.cities_visited
['Barcelona', 'Paris', 'Bruselas', 'Amsterdam']

Hasta ahora, esto es todo lo que necesitamos y nada más tiene que implementarse. Para los
propósitos de este problema, la propiedad sería más que suficiente. ¿Qué sucede si
necesitamos exactamente la misma lógica en varios lugares de la aplicación? Esto
significaría que en realidad se trata de una instancia de un problema más genérico : rastrear
todos los valores de un atributo en otro. ¿Qué pasaría si quisiéramos hacer lo mismo con
otros atributos, como hacer un seguimiento de todos los boletos que compró Alice o todos
los países en los que ha estado? Tendríamos que repetir la lógica en todos estos lugares.

Además, ¿qué pasaría si necesitamos este mismo comportamiento en diferentes clases?


Tendríamos que repetir el código o encontrar una solución genérica (tal vez un
decorador, un constructor de propiedades o un descriptor). Dado que los generadores de
propiedades son un caso particular (y más complicado) de descriptores, están más allá
del alcance de este libro y, en cambio, se sugieren descriptores como una forma más
limpia de proceder.

La implementación idiomática
Ahora veremos cómo abordar las preguntas de la sección anterior utilizando un
descriptor que sea lo suficientemente genérico como para ser aplicado en cualquier clase.
De nuevo, este ejemplo no es realmente necesario porque los requisitos no especifican
dicho comportamiento genérico (ni siquiera hemos seguido la regla de las tres instancias
del patrón similar creando previamente la abstracción), pero se muestra con el objetivo
de representar los descriptores en acción.
[ 169 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

No implemente un descriptor a menos que haya evidencia real de la


repetición que estamos tratando de resolver, y se demuestre que la
complejidad ha valido la pena.

Ahora, crearemos un descriptor genérico que, dado un nombre para el atributo que
contenga las huellas de otro, almacenará los diferentes valores del atributo en una lista.

Como mencionamos anteriormente, el código es más de lo que necesitamos para el


problema, pero su intención es solo mostrar cómo nos ayudaría un descriptor en este caso.
Dada la naturaleza genérica de los descriptores, el lector notará que la lógica en ellos (el
nombre de su método y atributos) no se relaciona con el problema de dominio en cuestión
(un objeto viajero). Esto se debe a que la idea del descriptor es poder usarlo en cualquier
tipo de clase, probablemente en diferentes proyectos, con los mismos resultados.

Para abordar esta brecha, se anotan algunas partes del código y la explicación
respectiva de cada sección (qué hace y cómo se relaciona con el problema original) se
describe en el siguiente código:

class HistoryTracedAttribute:
def __init__(self, trace_attribute_name) -> Ninguno:
self.trace_attribute_name = trace_attribute_name # [1] self._name = Ninguno

def __set_name__(self, propietario, nombre):


self._name = nombre

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
return instancia.__dict__[self._name]

def __set__(self, instancia, valor):


self._track_change_in_value_for_instance(instancia, valor)
instancia.__dict__[self._name] = valor

def _seguimiento_de_cambio_en_valor_por_instancia(self, instancia, valor):


self._set_default(instancia) # [2]
if self._necesita_para_seguir_cambio(instancia, valor):
instancia.__dict__[self.trace_attribute_name].append(value)

def _necesita_seguir_el_cambio(self, instancia, valor) -> bool: try:


current_value = instance.__dict__[self._name] excepto KeyError: # [3]
return True
[ 170 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

valor devuelto! = valor_actual # [4]

def _set_default(self, instancia):


instancia.__dict__.setdefault(self.trace_attribute_name, []) # [6]

Clase Viajero:

ciudad_actual = HistoryTracedAttribute("ciudades_visitadas") # [1]

def __init__(self, nombre, ciudad_actual):


self.nombre = nombre
self.ciudad_actual = ciudad_actual # [5]

Algunas anotaciones y comentarios sobre el código son los siguientes (los números en la
lista corresponden
a las anotaciones de números en el listado anterior):

1. El nombre del atributo es una de las variables asignadas al descriptor , en


este caso, ciudad_actual . Pasamos al descriptor el nombre de la variable en
que almacenará la traza de la variable del descriptor . En este ejemplo,
le estamos diciendo a nuestro objeto que realice un seguimiento de todos los
valores que tiene current_city
tenía en el atributo llamado cities_visited .
2. La primera vez que llamamos al descriptor , en el init , el atributo para rastrear
los valores no existirán, en cuyo caso lo inicializaremos en una lista vacía para
luego agregar
valores a ello.
3. En el método init , el nombre del atributo current_city no existirá
tampoco, por lo que también queremos realizar un seguimiento de este cambio.
Este es el equivalente de
inicializando la lista con el primer valor en el ejemplo anterior.
4. Solo realice un seguimiento de los cambios cuando el nuevo valor sea diferente
del actual
establecer.
5. En el método init , el descriptor ya existe y esta asignación
La instrucción desencadena las acciones del paso 2 (cree la lista vacía para
comenzar a rastrear
valores para él), y el paso 3 (añadir el valor a esta lista, y establecerlo en la clave
en el
objeto para recuperarlo más tarde).
6. El método setdefault en un diccionario se usa para evitar un KeyError . En esto
en caso de que se devuelva una lista vacía para aquellos atributos que aún no
están disponibles
( verhttps://docs.python.org/3.6/library/stdtypes.html#dict.setdefault
para referencia).
Es cierto que el código del descriptor es bastante complejo. Por otra parte, el código de
la clase de cliente es considerablemente más simple. Por supuesto, este equilibrio solo se
amortiza si estamos
vamos a usar este descriptor varias veces, lo cual es una preocupación que ya hemos
cubierto.

[ 171 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Lo que podría no estar tan claro en este punto es que el descriptor es completamente
independiente de la clase de cliente . Nada en él sugiere nada sobre la lógica
empresarial. Esto lo hace perfectamente apto para aplicarlo en cualquier otra clase;
incluso si hace algo completamente diferente, el descriptor tendrá el mismo efecto.

Esta es la verdadera naturaleza pitónica de los descriptores. Son más apropiados para
definir bibliotecas, marcos o API internas, y no tanto para la lógica comercial.

Diferentes formas de implementar descriptores


Primero debemos comprender un problema común que es específico de la naturaleza de
los descriptores antes de pensar en formas de implementarlos. Primero, discutiremos el
problema de un estado global compartido, y luego continuaremos y veremos diferentes
formas en que se pueden implementar los descriptores teniendo esto en cuenta.

La cuestión del estado global compartido


Como ya hemos mencionado, los descriptores deben configurarse como atributos de
clase para que funcionen. Esto no debería ser un problema la mayor parte del tiempo,
pero viene con algunas advertencias que deben tenerse en cuenta.

El problema con los atributos de clase es que se comparten entre todas las instancias de
esa clase. Los descriptores no son una excepción aquí, por lo que si intentamos mantener
datos en un objeto descriptor , tenga en cuenta que todos tendrán acceso al mismo valor.

Veamos qué sucede cuando definimos incorrectamente un descriptor que guarda los datos
en sí, en lugar de almacenarlos en cada objeto:

clase SharedDataDescriptor:
def __init__(self, initial_value):
self.value = initial_value

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
return self.value

def __set__(self, instancia, valor):


self.value = valor

clase ClientClass:
descriptor = SharedDataDescriptor("primer valor")
[ 172 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

En este ejemplo, el objeto descriptor almacena los datos en sí. Esto trae consigo el
inconveniente de que cuando modificamos el valor de una instancia todas las demás
instancias de las mismas clases también se modifican con este valor. La siguiente lista de
código pone esa teoría en acción:
>>> cliente1 = ClientClass()
>>> cliente1.descriptor
'primer valor'

>>> cliente2 = ClientClass()


>>> cliente2.descriptor
'primer valor'

>>> cliente2.descriptor = "valor para cliente 2" >>>


cliente2.descriptor
'valor para cliente 2'

>>> cliente1.descriptor
'valor para cliente 2'

Observe cómo cambiamos un objeto, y de repente todos son de la misma clase, y


podemos ver que se refleja este valor. Esto se debe a que ClientClass.descriptor es único; es
el mismo objeto para todos ellos.

En algunos casos, esto podría ser lo que realmente queremos (por ejemplo, si tuviéramos
que crear una especie de implementación de patrón Borg, en la que queremos compartir el
estado entre todos los objetos de una clase), pero en general, ese no es el caso, y necesitamos
diferenciar entre objetos. Dicho patrón se analiza con más detalle en el Capítulo 9 , Patrones de
diseño comunes .

Para lograr esto, el descriptor necesita conocer el valor de cada instancia y devolverlo en
consecuencia. Esa es la razón por la que hemos estado operando con el diccionario (
__dict__ ) de cada instancia y configurando y recuperando los valores desde allí.

Este es el enfoque más común. Ya hemos cubierto por qué no podemos usar
getattr() y setattr() en esos métodos, por lo que modificar el atributo __dict__ es la última
opción y, en este caso, es aceptable.

Acceso al diccionario del objeto


La forma en que implementamos los descriptores a lo largo de este libro es hacer que el
objeto descriptor almacene los valores en el diccionario del objeto, __dict__ , y también
recupere los parámetros desde allí.

[ 173 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Almacene y devuelva siempre los datos del atributo __dict__ de la


instancia.

Uso de referencias débiles


Otra alternativa (si no queremos usar __dict__ ) es hacer que el objeto descriptor realice un
seguimiento de los valores de cada instancia, en un mapeo interno, y también devuelva
valores de este mapeo.

Sin embargo, hay una advertencia. Este mapeo no puede ser cualquier diccionario. Dado
que la clase de cliente tiene una referencia al descriptor, y ahora el descriptor mantendrá
referencias a los objetos que lo usan, esto creará dependencias circulares y, como
resultado, estos objetos nunca serán recolectados como basura porque están apuntando a
El uno al otro.

Para abordar esto, el diccionario tiene que ser de clave débil, tal como se define en
el módulo débilref (WEAKREF 01).

En este caso, el código para el descriptor podría tener el siguiente aspecto:


de débilref importar WeakKeyDictionary

class DescriptorClass:
def __init__(self, initial_value): self.value =
initial_value
self.mapping = WeakKeyDictionary()

def __get__(self, instancia, propietario):


si la instancia es None:
return self
return self.mapping.get(instancia, self.value)

def __set__(self, instancia, valor):


self.mapping[instancia] = valor

Esto aborda los problemas, pero viene con algunas consideraciones:

Los objetos ya no tienen sus atributos , sino el descriptor. Esto es algo


controvertido y puede que no sea del todo exacto desde un punto de vista
conceptual. Si olvidamos este detalle, podríamos estar preguntando al objeto
inspeccionando su diccionario, tratando de encontrar cosas que simplemente no
están allí (llamar a
vars(cliente) no devolverá los datos completos, por ejemplo).

[ 174 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Plantea el requisito sobre los objetos que necesitan ser hashable. Si ellos
no lo son, no pueden ser parte del mapeo. Esto podría ser un requisito
demasiado exigente para algunas aplicaciones.

Por estas razones, preferimos la implementación que se ha mostrado hasta ahora en este
libro, que utiliza el diccionario de cada instancia. Sin embargo, para completar, también
hemos mostrado esta alternativa.

Más consideraciones sobre los descriptores


Aquí, discutiremos consideraciones generales sobre los descriptores en términos de lo que
podemos hacer con ellos cuando es una buena idea usarlos, y también cómo se pueden
mejorar las cosas que inicialmente podríamos haber concebido como resueltas mediante
otro enfoque. a través de descriptores. A continuación, analizaremos los pros y los contras
de la implementación original frente a la implementación posterior al uso de los
descriptores.

Reutilizando código
Los descriptores son una herramienta genérica y una poderosa abstracción que podemos
usar para evitar la duplicación de código. La mejor manera de decidir cuándo usar
descriptores es identificar los casos en los que estaríamos usando una propiedad (ya sea
para su lógica de obtención , lógica de establecimiento o ambas), pero repitiendo su
estructura muchas veces.

Las propiedades son solo un caso particular de descriptores (el decorador @property es un
descriptor que implementa el protocolo de descriptor completo para definir sus acciones get
, set y delete ), lo que significa que podemos usar descriptores para tareas mucho más
complejas.

Otro tipo poderoso que hemos visto para reutilizar código fueron los decoradores, como
se explica en el Capítulo 5 , Uso de decoradores para mejorar nuestro código . Los descriptores
pueden ayudarnos a crear mejores decoradores asegurándonos de que también puedan
funcionar correctamente para los métodos de clase.

Cuando se trata de decoradores, podríamos decir que es seguro implementar siempre el


método __get__() en ellos, y también convertirlo en un descriptor. Cuando intente decidir si
vale la pena crear el decorador, considere la regla de los tres problemas que establecimos en
el Capítulo 5 , Uso de decoradores para mejorar nuestro código , pero tenga en cuenta que no hay
consideraciones adicionales para los descriptores.

[ 175 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

En cuanto a los descriptores genéricos, además de la mencionada regla de las tres instancias
que se aplica a los decoradores (y, en general, a cualquier componente reutilizable),
conviene tener en cuenta también que se deben utilizar descriptores para los casos en los
que queramos definir una API interna, que es un código que hará que los clientes lo
consuman. Esta es una característica más orientada hacia el diseño de bibliotecas y marcos,
en lugar de soluciones únicas.

A menos que haya una muy buena razón para hacerlo, o que el código se verá
significativamente mejor, debemos evitar poner la lógica empresarial en un descriptor. En
cambio, el código de un descriptor contendrá más código de implementación en lugar de
código comercial. Es más similar a definir una nueva estructura de datos u objeto que otra
parte de nuestra lógica empresarial utilizará como herramienta.

En general, los descriptores contendrán lógica de implementación y no


tanta lógica comercial.

Evitar los decoradores de clase


Si recordamos el decorador de clases que usamos en el Capítulo 5, Uso de decoradores para
mejorar nuestro código , para determinar cómo se serializará un objeto de evento, terminamos
con una
implementación que (para Python 3.7+) se basó en dos decoradores de clases:

@Serialization(
nombre de
usuario=mostrar_original,
contraseña=ocultar_campo,
ip=mostrar_original,
marca de tiempo=formato_hora,
)
@dataclass
class LoginEvent: nombre de
usuario: str
contraseña: str
ip: str
marca de tiempo: fecha y hora

El primero toma los atributos de las anotaciones para declarar las variables, mientras que
el segundo define cómo tratar cada archivo. Veamos si podemos cambiar estos dos
decoradores por descriptores.
[ 176 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

La idea es crear un descriptor que aplicará la transformación sobre los valores de cada
atributo, devolviendo la versión modificada de acuerdo con nuestros requisitos (por
ejemplo, ocultando información confidencial y formateando fechas correctamente):

de functools importar parcial


de tipear import Callable

clase BaseFieldTransformation:

def __init__(self, transformación: Callable[[], str]) -> Ninguno: self._name = Ninguno


self.transformation = transformación

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
raw_value = instancia.__dict__[self._name] return
self.transformation(raw_value)

def __set_name__(self, propietario, nombre):


self._name = nombre

def __set__(self, instancia, valor):


instancia.__dict__[self._name] = valor

ShowOriginal = parcial(BaseFieldTransformation, transformación=lambda x: x) HideField = parcial(


BaseFieldTransformation, transform=lambda x: "**redacted**" )
FormatTime = parcial(
BaseFieldTransformation,
transform=lambda ft: ft.strftime("%Y -%m-%d %H:%M"),
)

Este descriptor es interesante. Fue creado con una función que toma un argumento y
devuelve un valor. Esta función será la transformación que queremos aplicar al campo. A
partir de la definición base que define de forma genérica cómo va a funcionar, se definen el
resto de clases de descriptores , simplemente cambiando la función particular que necesita
cada una.

El ejemplo usa functools.partial ( https://docs.python.org/3.6/library/ functools.html#functools.partial


) como una forma de simular subclases, aplicando una aplicación parcial de la función de
transformación para esa clase, dejando un nuevo invocable que se puede instanciar
directamente.
[ 177 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Para simplificar el ejemplo, implementaremos los métodos __init__() y serialize() ,


aunque también podrían abstraerse. Bajo estas consideraciones, la clase para el evento
ahora se definirá de la siguiente manera:

class LoginEvent: nombre de


usuario = ShowOriginal()
contraseña = HideField()
ip = ShowOriginal()
timestamp = FormatTime()

def __init__(self, nombre de usuario, contraseña, ip, marca de tiempo):


self.nombre de usuario = nombre de usuario
self.password = contraseña
self.ip = ip
self.timestamp = marca de tiempo

def serialize(self):
return {
"username": self.username,
"password": self.password,
"ip": self.ip,
"timestamp": self.timestamp,
}

Podemos ver cómo se comporta el objeto en tiempo de ejecución:


>>> le = LoginEvent("john", "contraseña secreta", "1.1.1.1",
datetime.utcnow())
>>> vars(le)
{'username': 'john', 'password': 'secret contraseña', 'ip': '1.1.1.1', 'timestamp': ...}
>>> le.serialize()
{'username': 'john', 'password': '**redactado**', 'ip': '1.1.1.1', 'timestamp': '...'}
>>> le.contraseña
'**redactado**'

Hay algunas diferencias con respecto a la implementación anterior que utilizaba un


decorador. Este ejemplo agregó el método serialize() y ocultó los campos antes de
presentarlos en su diccionario resultante, pero si solicitamos cualquiera de estos atributos
a una instancia del evento en la memoria en cualquier momento, aún nos daría el valor
original, sin ninguna transformación aplicada (podríamos haber optado por aplicar la
transformación al establecer el valor y devolverlo directamente en __get__() , también).
[ 178 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Dependiendo de la sensibilidad de la aplicación, esto puede ser aceptable o no, pero en este
caso, cuando le preguntamos al objeto por sus atributos públicos , el descriptor aplicará la
transformación antes de presentar los resultados. Todavía es posible acceder a los valores
originales preguntando por el diccionario del objeto (accediendo a __dict__ ), pero cuando le
pedimos el valor, por defecto, lo devolverá convertido.

En este ejemplo, todos los descriptores siguen una lógica común, que se define en la clase
base.
El descriptor debe almacenar el valor en el objeto y luego solicitarlo, aplicando la
transformación que define. Podríamos crear una jerarquía de clases, cada una definiendo su
propia función de conversión, de manera que funcione el patrón de diseño del método de
plantilla. En este caso, dado que los cambios en las clases derivadas son relativamente
pequeños (solo una función), optamos por crear las clases derivadas como aplicaciones
parciales de la clase base. Crear cualquier nuevo campo de transformación debería ser tan
simple como definir una nueva clase que será la clase base, que se aplica parcialmente con
la función que necesitamos. Esto incluso se puede hacer ad hoc, por lo que es posible que no
haya necesidad de establecer un nombre para ello.

Independientemente de esta implementación, el punto es que dado que los descriptores son
objetos, podemos crear modelos y aplicarles todas las reglas de la programación orientada a
objetos. Los patrones de diseño también se aplican a los descriptores. Podríamos definir
nuestra jerarquía, establecer el comportamiento personalizado, etc. Este ejemplo sigue el
OCP, que presentamos en el Capítulo 4 , Los principios de SOLID
, porque agregar un nuevo tipo de método de conversión solo se trataría de crear una nueva
clase, derivada de la base con la función que necesita, sin tener que modificar el la clase base
en sí misma (para ser justos, la implementación anterior con decoradores también era
compatible con OCP, pero no había clases involucradas para cada mecanismo de
transformación).

Tomemos un ejemplo en el que creamos una clase base que implementa los métodos
__init__() y serialize() para que podamos definir la clase LoginEvent simplemente
derivándola, de la siguiente manera:

class LoginEvent(BaseEvent):
nombre de usuario = ShowOriginal()
contraseña = HideField()
ip = ShowOriginal()
timestamp = FormatTime()

Una vez que logramos este código, la clase se ve más limpia. Solo define los atributos que
necesita, y su lógica se puede analizar rápidamente observando la clase de cada atributo.
La clase base abstraerá solo los métodos comunes, y la clase de cada evento se verá más
simple y compacta.

[ 179 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

No solo las clases para cada evento parecen simples, sino que el descriptor mismo es muy
compacto y mucho más simple que los decoradores de clases. La implementación original
con decoradores de clase era buena, pero los descriptores la hicieron aún mejor.

Análisis de descriptores
Hemos visto cómo funcionan los descriptores hasta ahora y exploramos algunas
situaciones interesantes en las que contribuyen a un diseño limpio al simplificar su
lógica y aprovechar clases más compactas.

Hasta este punto, sabemos que mediante el uso de descriptores, podemos lograr un código
más limpio,
abstrayendo la lógica repetida y los detalles de implementación. Pero, ¿cómo sabemos que
nuestra implementación de los descriptores es limpia y correcta? ¿Qué hace a un buen
descriptor? ¿Estamos usando esta herramienta correctamente o estamos haciendo un exceso
de ingeniería con ella?

En esta sección, analizaremos los descriptores para responder a estas preguntas.

Cómo usa Python los descriptores internamente


En referencia a la pregunta de qué hace un buen descriptor, una respuesta simple sería que
un buen descriptor es muy parecido a cualquier otro buen objeto de Python. Es consistente
con Python mismo. La idea que sigue esta premisa es que analizar cómo Python usa
los descriptores nos dará una buena idea de las buenas implementaciones para que
sepamos qué esperar de los descriptores que escribimos.

Veremos los escenarios más comunes donde el propio Python utiliza descriptores para
resolver partes de su lógica interna, y también descubriremos descriptores elegantes y que
han estado ahí a simple vista todo el tiempo.

Funciones y métodos
El caso más resonante de un objeto que es un descriptor es probablemente una función. Las
funciones implementan el método __get__ , por lo que pueden funcionar como métodos
cuando se definen dentro de una clase.
Los métodos son solo funciones que toman un argumento adicional. Por convención, el
primer argumento de un método se llama "self", y representa una instancia de la clase en
la que se define el método. Entonces, cualquier cosa que haga el método con "self", sería lo
mismo que cualquier otra función. recibir el objeto y aplicarle modificaciones.

[ 180 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

En otras palabras, cuando definimos algo como esto:


clase MiClase:
método def(self, ...):
self.x = 1

En realidad es lo mismo que si definimos esto:


clase Mi Clase: pasar

método def(mi_instancia_de_clase, ...):


mi_instancia_de_clase.x = 1

método(MiClase())

Entonces, es solo otra función, modificando el objeto, solo que está definido dentro de la
clase, y se dice que está vinculado al objeto.

Cuando llamamos a algo en la forma de esto:


instancia = MiClase()
instancia.método(...)

Python está, de hecho, haciendo algo equivalente a esto:


instancia = MiClase()
MiClase.método(instancia, ...)

Tenga en cuenta que esto es solo una conversión de sintaxis que Python maneja
internamente. La forma en que esto funciona es por medio de descriptores.

Dado que las funciones implementan el protocolo del descriptor (consulte la siguiente
lista) antes de llamar al método, primero se invoca el método __get__() y se producen
algunas transformaciones antes de ejecutar el código en el invocable interno:
>>> función def(): pasar
...
>>> function.__get__
<método-envoltorio '__get__' del objeto de función en 0x...>

En la instrucción instancia.método(...) , antes de procesar todos los argumentos del


invocable dentro del paréntesis, se evalúa la parte "instancia.método" .

Dado que el método es un objeto definido como un atributo de clase y tiene un método
__get__ , se llama a este. Lo que esto hace es convertir la función en un método, lo que
significa vincular el invocable a la instancia del objeto con el que va a trabajar.
[ 181 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Veamos esto con un ejemplo para que podamos tener una idea de lo que Python podría
estar haciendo internamente.

Definiremos un objeto invocable dentro de una clase que actuará como una especie de
función o método que queremos definir para ser invocado externamente. Se supone que
una instancia de la clase Method es una función o un método que se utilizará dentro de una clase
diferente. Esta función solo imprimirá sus tres parámetros : la instancia que recibió (que
sería el parámetro propio en la clase en la que se define) y dos argumentos más. Tenga en
cuenta que en el método __call__() , el parámetro self no representa la instancia de MyClass ,
sino una instancia de Method . El parámetro llamado instancia está destinado a ser un tipo
de objeto MyClass :

Método de clase:
def __init__(self, nombre):
self.name = nombre

def __call__(self, instancia, arg1, arg2):


print(f"{self.name}: {instancia} llamada con {arg1} y {arg2}")

clase MiClase:
método = Método("Llamada interna")

Bajo estas consideraciones y, después de crear el objeto, las siguientes dos llamadas
deberían ser equivalentes, en base a la definición anterior:
instancia = MyClass()
Method("Llamada externa"))(instancia, "primera", "segunda")
instancia.método("primera", "segunda")

Sin embargo, solo el primero funciona como se esperaba, ya que el segundo da un error:
Rastreo (última llamada más reciente):
Archivo "archivo", línea, en <módulo>
instancia.método("primero", "segundo")
Error de tipo: __call__() falta 1 argumento posicional requerido: 'arg2'

Estamos viendo el mismo error que enfrentamos con un decorador en el Capítulo 5 , Uso de
decoradores para mejorar nuestro código . Los argumentos se desplazan a la izquierda por
uno, instancia está tomando el lugar de self , arg1 será instancia y no hay nada que
proporcionar para arg2 .

Para arreglar esto, necesitamos hacer de Method un descriptor.


[ 182 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

De esta forma, cuando llamemos a instance.method primero, vamos a llamar a su


__get__() , en el que vincularemos esta llamada al objeto en consecuencia (sin pasar por
alto el objeto como primer parámetro), y luego procederemos:

de tipos importar MethodType

Método de clase:
def __init__(self, nombre):
self.name = nombre

def __call__(self, instancia, arg1, arg2):


print(f"{self.name}: {instancia} llamada con {arg1} y {arg2}")

def __get__(self, instancia, propietario):


si la instancia es Ninguno:
return self
return MethodType(self, instancia)

Ahora, ambas llamadas funcionan como se esperaba:


Llamada externa: <Objeto MyClass en 0x...> llamado con primero y segundo Llamada
interna: <Objeto MyClass en 0x...> llamado con primero y segundo

Lo que hicimos fue convertir la función (en realidad, el objeto invocable que definimos en
su lugar) en un método utilizando MethodType del módulo de tipos . El primer parámetro
de esta clase debe ser invocable ( self , en este caso, es uno por definición porque
implementa __call__ ), y el segundo es el objeto al que vincular esta función.

Algo similar a esto es lo que usan los objetos de función en Python para que puedan
funcionar como métodos cuando se definen dentro de una clase.

Dado que esta es una solución muy elegante, vale la pena explorarla para tenerla en
cuenta como un enfoque Pythonic al definir nuestros propios objetos. Por ejemplo, si
tuviéramos que definir nuestro propio invocable, sería una buena idea convertirlo también
en un descriptor para que también podamos usarlo en las clases como atributos de clase.

Decoradores incorporados para métodos


Como ya sabrá al consultar la documentación oficial (PYDESCR-02), todos los
decoradores @property , @classmethod y @staticmethod son descriptores.
[ 183 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Hemos mencionado varias veces que el idioma hace que el descriptor se devuelva a sí
mismo cuando se llama directamente desde una clase. Dado que las propiedades son en
realidad descriptores, esa es la razón por la cual, cuando le preguntamos a la clase, no
obtenemos el resultado de calcular la propiedad, sino el objeto de propiedad completo :

>>> class MyClass:


... @property
... def prop(self): pass
...
>>> MyClass.prop
<objeto de propiedad en 0x...>

Para los métodos de clase, la función __get__ en el descriptor se asegurará de que la clase
sea el primer parámetro que se pasará a la función que se está decorando,
independientemente de si se llama directamente desde la clase o desde una instancia. Para
los métodos estáticos, se asegurará de que no se vinculen más parámetros que los
definidos por la función, es decir, deshaciendo el enlace realizado por __get__() en las
funciones que hacen de sí mismo el primer parámetro de esa función.

Tomemos un ejemplo; creamos un decorador @classproperty que funciona como el


decorador normal @property , pero para las clases en su lugar. Con un decorador como
este, el siguiente código debería poder funcionar:

clase TableEvent:
esquema = tabla
"pública"
= "usuario"

@classproperty
def topic(cls):
prefix = read_prefix_from_config()
return f"{prefix}{cls.schema}.{cls.table}"

>>> TableEvent.topic
'público.usuario'

>>> TableEvent().tema
'público.usuario'
[ 184 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Tragamonedas
Cuando una clase define el atributo __slots__ , puede contener todos los atributos que la
clase espera y no más.

Intentar agregar atributos adicionales dinámicamente a una clase que define __slots__ dará
como resultado un AttributeError . Al definir este atributo, la clase se vuelve estática, por lo
que no tendrá un atributo __dict__ donde puede agregar más objetos dinámicamente.

Entonces, ¿cómo se recuperan sus atributos si no es del diccionario del objeto?


Mediante el uso de descriptores. Cada nombre definido en un espacio tendrá su
propio descriptor que almacenará el valor para recuperarlo más tarde:

clase Coordinate2D:
__slots__ = ("lat", "long")

def __init__(self, lat, long):


self.lat = lat
self.long = long

def __repr__(self):
return f"{self.__class__.__name__}({self.lat}, {self.long})"

Si bien esta es una característica interesante, debe usarse con precaución porque está
eliminando la naturaleza dinámica de Python. En general, esto debe reservarse solo para
objetos que sabemos que son estáticos, y si estamos absolutamente seguros de que no les
agregamos ningún atributo dinámicamente en otras partes del código.

Como ventaja de esto, los objetos definidos con ranuras usan menos memoria, ya que
solo necesitan un conjunto fijo de campos para contener valores y no un diccionario
completo.

Implementando descriptores en decoradores


Ahora entendemos cómo Python usa descriptores en funciones para que funcionen como
métodos cuando se definen dentro de una clase. También hemos visto ejemplos de casos en
los que podemos hacer que los decoradores funcionen haciéndolos cumplir con el protocolo
del descriptor usando el método __get__() de la interfaz para adaptar el decorador al objeto
con el que se está llamando. Esto resuelve el problema para nuestros decoradores de la
misma manera que Python resuelve el problema de las funciones como métodos en los
objetos.
[ 185 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

La receta general para adaptar un decorador de tal manera es implementar el método


__get__() en él y usar tipos.MethodType para convertir el invocable (el propio decorador) en un
método vinculado al objeto que está recibiendo (el parámetro de instancia recibido por __get__
).

Para que esto funcione, tendremos que implementar el decorador como un objeto, porque
de lo contrario, si estamos usando una función, ya tendrá un método __get__() , que estará
haciendo algo diferente que no funcionará a menos que lo adaptemos. . La forma más
limpia de proceder es definir una clase para el decorador.

Use una clase de decorador cuando defina un decorador que queremos


aplicar a los métodos de clase e implemente el método __get__() en él.

Resumen
Los descriptores son una característica más avanzada en Python que empuja los límites,
más cerca de la metaprogramación. Uno de sus aspectos más interesantes es cómo dejan
muy claro que las clases en Python son solo objetos normales y, como tales, tienen
propiedades y podemos interactuar con ellas. Los descriptores son, en este sentido, el tipo
de atributo más interesante que puede tener una clase porque su protocolo facilita
posibilidades más avanzadas y orientadas a objetos.

Hemos visto la mecánica de los descriptores, sus métodos y cómo todo esto encaja, creando
una imagen más interesante del diseño de software orientado a objetos. Al comprender los
descriptores, pudimos crear poderosas abstracciones que producen clases limpias y
compactas. Hemos visto cómo corregir los decoradores que queremos aplicar a funciones y
métodos, hemos entendido mucho más sobre cómo funciona internamente Python y cómo
los descriptores juegan un papel central y crítico en la implementación del lenguaje.

Este estudio de cómo se usan internamente los descriptores en Python debería servir como
referencia para identificar buenos usos de los descriptores en nuestro propio código, con
el fin de lograr soluciones idiomáticas.

A pesar de todas las poderosas opciones que representan los descriptores para nuestra
ventaja, debemos tener en cuenta cuándo utilizarlos correctamente sin sobrediseñar. En esta
línea, hemos sugerido que deberíamos reservar la funcionalidad de los descriptores para
casos verdaderamente genéricos, como el diseño de APIs de desarrollo interno, librerías o
frameworks. Otra consideración importante en esta línea es que, en general, no deberíamos
colocar la lógica de negocios en los descriptores, sino la lógica que implementa la
funcionalidad técnica para ser utilizada por otros componentes que sí contienen lógica de
negocios.

[ 186 ]
Obtener más de nuestros objetos con descriptores Capítulo 6

Hablando de funcionalidad avanzada, el próximo capítulo también cubre un tema


interesante y profundo: los generadores. A primera vista, los generadores son bastante
simples (y la mayoría de los lectores probablemente ya estén familiarizados con ellos), pero
lo que tienen en común con los descriptores es que también pueden ser complejos, generar
un diseño más avanzado y elegante, y hacer de Python una herramienta única. lenguaje con
el que trabajar.

Referencias
Aquí hay una lista de algunas cosas que puede consultar para obtener más información:

Documentación oficial de Python sobre descriptores ( https:/)/docs.python.org/3/


reference/datamodel.html#implementing-descriptors
WEAKREF 01 : módulo de referencia débil de Python ( https:/
)/docs.python.org/3/library/ weakref.html
PYDESCR-02 : Decoradores integrados como descriptores ( https:/
)/docs.python.org/3/ howto/descriptor.html#static-methods-and-class-methods
[ 187 ]
Uso de generadores 
Los generadores son otra de esas características que hacen de Python un lenguaje peculiar
frente a los más tradicionales. En este capítulo, exploraremos su razón de ser, por qué se
introdujeron en el lenguaje y los problemas que resuelven. También cubriremos cómo
abordar problemas idiomáticamente mediante el uso de generadores y cómo hacer que
nuestros generadores (o cualquier iterable, para el caso) Pythonic.

Comprenderemos por qué la iteración (en forma de patrón de iterador) se admite


automáticamente en el lenguaje. A partir de ahí, emprenderemos otro viaje y
exploraremos cómo los generadores se convirtieron en una característica tan
fundamental de Python para admitir otras funcionalidades, como rutinas y
programación asincrónica.

Los objetivos de este capítulo son los siguientes:

Crear generadores que mejoren el rendimiento de nuestros programas.


Para estudiar cómo los iteradores (y el patrón de iterador, en particular)
están profundamente integrados en Python
Para resolver problemas que impliquen iteración idiomáticamente
Entender cómo funcionan los generadores como base para
corrutinas y programación asíncrona
Para explorar la compatibilidad sintáctica de coroutines : yield from , await y async
def

Requerimientos técnicos
Los ejemplos de este capítulo funcionarán con cualquier versión de Python 3.6 en cualquier
plataforma.
El código utilizado en este capítulo se puede encontrar
enhttps://github.com/PacktPublishing/ Clean-Code-in-Python.

Las instrucciones están disponibles en el archivo README .


Uso de generadores Capítulo 7

Creando generadores
Los generadores se introdujeron en Python hace mucho tiempo (PEP-255), con la idea de
introducir la iteración en Python y al mismo tiempo mejorar el rendimiento del programa
(usando menos memoria).

La idea de un generador es crear un objeto que sea iterable y, mientras se itera, producirá
los elementos que contiene, uno a la vez. El uso principal de los generadores es ahorrar
memoria : en lugar de tener una lista muy grande de elementos en la memoria, que
contiene todo a la vez, tenemos un objeto que sabe cómo producir cada elemento en
particular, uno a la vez, según sea necesario.

Esta característica permite cálculos perezosos u objetos pesados en la memoria, de manera


similar a lo que proporcionan otros lenguajes de programación funcional (Haskell, por
ejemplo). Incluso sería posible trabajar con secuencias infinitas porque la naturaleza
perezosa de los
generadores permite esa opción.

Un primer vistazo a los generadores


Comencemos con un ejemplo. El problema que tenemos ahora es que queremos procesar
una gran lista de registros y obtener algunas métricas e indicadores sobre ellos. Dado un
gran conjunto de datos con información sobre compras, queremos procesarlo para obtener
la venta más baja, la venta más alta y el precio promedio de una venta.

Para simplificar este ejemplo, supondremos un CSV con solo dos campos, en el
siguiente formato:

<fecha_de_compra>, <precio>
...

Vamos a crear un objeto que reciba todas las compras, y esto nos dará las métricas
necesarias. Podríamos obtener algunos de estos valores listos para usar simplemente
usando las funciones integradas min() y max() , pero eso requeriría iterar todas las compras
más de una vez, así que en su lugar, estamos usando nuestro objeto personalizado, que
obtendrá estos valores en una sola iteración.
[ 189 ]
Uso de generadores Capítulo 7

El código que obtendrá los números para nosotros parece bastante simple. Es solo un objeto
con un método que procesará todos los precios de una sola vez y, en cada paso, actualizará
el valor de cada métrica en particular que nos interese. Primero, mostraremos la primera
implementación en la siguiente lista y, Más adelante en este capítulo (una vez que hayamos
visto más acerca de la iteración), revisaremos esta implementación y obtendremos una
versión mucho mejor (y compacta). De momento nos decantamos por lo siguiente:

clase ComprasEstadísticas:

def __init__(self, compras):


self.compras = iter(compras)
self.min_price: float = Ninguno
self.max_price: float = Ninguno
self._total_purchases_price: float = 0.0 self._total_purchases
=0
self._initialize()

def _initialize(self):
try:
first_value = next(self.purchases) excepto
StopIteration:
raise ValueError("no se proporcionan valores")

self.min_price = self.max_price = primer_valor


self._update_avg(primer_valor)

def proceso(auto):
para valor_de_compra en auto.compras:
auto._actualizar_min(valor_de_compra)
self._actualizar_max(valor_de_compra)
self._update_avg(valor_de_compra) return self

def _update_min(self, new_value: float): if


new_value < self.min_price:
self.min_price = new_value

def _update_max(self, new_value: float): if


new_value > self.max_price:
self.max_price = new_value

@property
def avg_price(self):
return self._total_purchases_price / self._total_purchases
def _update_avg(self, new_value: float):
self._total_purchases_price += new_value

[ 190 ]
Uso de generadores Capítulo 7

self._total_compras += 1

def __str__(self):
return (
f"{self.__class__.__name__}({self.min_price}, " f"{self.max_price},
{self.avg_price})"
)

Este objeto recibirá todos los totales de las compras y procesará los valores requeridos.
Ahora, necesitamos una función que cargue estos números en algo que este objeto pueda
procesar. Aquí está la primera versión:
def _load_purchases(nombre de archivo):
compras = []
with open(nombre de archivo) como f:
para línea en f:
*_, precio_sin procesar = línea.partición(",")
compras.append(float(precio_sin procesar))

devolver compras

Este código funciona; carga todos los números del archivo en una lista que, cuando se pasa
a nuestro objeto personalizado, producirá los números que queremos. Sin embargo, tiene
un problema de rendimiento. Si lo ejecuta con un conjunto de datos bastante grande,
tardará un tiempo en completarse e incluso podría fallar si el conjunto de datos es lo
suficientemente grande como para no caber en la memoria principal.

Si echamos un vistazo a nuestro código que consume estos datos, está procesando las
compras , una a la vez, por lo que podríamos preguntarnos por qué nuestro productor cabe
todo en la memoria a la vez. Está creando una lista donde pone todo el contenido del
archivo, pero sabemos que podemos hacerlo mejor.

La solución es crear un generador. En lugar de cargar todo el contenido del archivo en una
lista, produciremos los resultados de uno en uno. El código ahora se verá así:
def load_purchases(filename):
with open(filename) as f:
for line in f:
*_, price_raw = line.partition(",") yield float(price_raw)

Si mide el proceso esta vez, notará que el uso de la memoria se ha reducido


significativamente. También podemos ver cómo el código se ve más simple : no es necesario
definir la lista (por lo tanto, no es necesario agregarla) y que la declaración de devolución
también desapareció.
En este caso, la función load_purchases es una función generadora, o simplemente un
generador.

[ 191 ]
Uso de generadores Capítulo 7

En Python, la mera presencia de la palabra clave yield en cualquier función lo convierte en


un generador y, como resultado, al llamarlo, no sucederá nada más que crear una
instancia del generador:

>>> load_purchases("archivo")
<objeto generador load_purchases en 0x...>

Un objeto generador es iterable (revisaremos los iterables con más detalle más adelante), lo
que significa que puede funcionar con bucles for . Observe cómo no tuvimos que cambiar
nada en el código del consumidor : nuestro procesador de estadísticas permaneció igual,
con el bucle for sin modificar, después de la nueva implementación.

Trabajar con iterables nos permite crear este tipo de abstracciones poderosas que son
polimórficas con respecto a los bucles for . Mientras mantengamos la interfaz iterable,
podemos iterar sobre ese objeto de forma transparente.

Generador de expresiones
Los generadores ahorran mucha memoria y, dado que son iteradores, son una alternativa
conveniente a otros iterables o contenedores que requieren más espacio en la memoria,
como listas, tuplas o conjuntos.

Al igual que estas estructuras de datos, también se pueden definir por comprensión, solo
que se denomina expresión generadora (existe un debate en curso sobre si deberían
llamarse comprensiones generadoras. En este libro, solo nos referiremos a ellas por su
expresión canónica). nombre, pero siéntete libre de usar el que prefieras).

Del mismo modo, definiríamos una lista por comprensión. Si reemplazamos los corchetes
con paréntesis, obtenemos un generador que resulta de la expresión. Las expresiones del
generador
también se pueden pasar directamente a funciones que funcionan con iterables, como sum()
y max() :

>>> [x**2 para x en el rango (10)]


[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

>>> (x**2 for x in range(10))


<objeto generador <genexpr> en 0x...>

>>> suma(x**2 para x en rango(10))


285
[ 192 ]
Uso de generadores Capítulo 7

Pase siempre una expresión de generador, en lugar de una lista de


comprensión, a funciones que esperan iterables, como min() , max() y
sum() . Esto es más eficiente y pitónico.

Iterando idiomáticamente
En esta sección, primero exploraremos algunos modismos que son útiles cuando tenemos
que lidiar con la iteración en Python. Estas recetas de código nos ayudarán a tener una
mejor idea de los tipos de cosas que podemos hacer con los generadores (especialmente
después de que ya hayamos visto las expresiones del generador) y cómo resolver
problemas típicos en relación con ellos.

Una vez que hayamos visto algunos modismos, pasaremos a explorar la iteración en
Python con más profundidad, analizando los métodos que hacen posible la iteración y
cómo funcionan los objetos iterables.

Modismos para iteración


Ya estamos familiarizados con la función enumerate() incorporada que, dado un iterable,
devolverá otro en el que el elemento es una tupla, cuyo primer elemento es la enumeración
del segundo (correspondiente al elemento en el iterable original). ):

>>> lista(enumerar("abcdef"))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e '), (5, 'f')]

Deseamos crear un objeto similar, pero en un nivel más bajo; uno que simplemente
puede crear una secuencia infinita. Queremos un objeto que pueda producir una
secuencia de números, a partir de uno inicial, sin límites.

Un objeto tan simple como el siguiente puede hacer el truco. Cada vez que llamamos a
este objeto, obtenemos el siguiente número de la secuencia hasta el infinito :
secuencia numérica de clase:

def __init__(self, inicio=0):


self.actual = inicio

def next(self):
actual = self.current
self.current += 1
return actual
[ 193 ]
Uso de generadores Capítulo 7

Según esta interfaz, tendríamos que usar este objeto invocando explícitamente su método
next() :

>>> seq = NumberSequence()


>>> seq.siguiente()
0
>>> seq.siguiente()
1

>>> seq2 = NumberSequence(10)


>>> seq2.siguiente()
10
>>> seq2.siguiente()
11

Pero con este código, no podemos reconstruir la función enumerar () como nos gustaría,
porque su interfaz no admite iteraciones sobre un bucle for regular de Python , lo que
también significa que no podemos pasarlo como parámetro a funciones que esperan algo.
para iterar. Observe cómo falla el siguiente código:

>>> list(zip(NumberSequence(), "abcdef"))


Rastreo (última llamada más reciente):
Archivo "...", línea 1, en <módulo>
TypeError: el argumento zip n.º 1 debe ser compatible con la
iteración

El problema radica en el hecho de que NumberSequence no admite la iteración. Para


arreglar esto, tenemos que hacer que el objeto sea iterable implementando el
método mágico __iter__() . También hemos cambiado el método next() anterior , usando el
método mágico __next__ , que convierte al objeto en un iterador:

clase secuencia de números:

def __init__(self, inicio=0):


self.actual = inicio

def __next__(self):
actual = self.current
self.current += 1
return actual

def __iter__(auto):
retornar auto
[ 194 ]
Uso de generadores Capítulo 7

Esto tiene una ventaja : no solo podemos iterar sobre el elemento, sino que ni siquiera
necesitamos más el método .next() porque tener __next__() nos permite usar la función
incorporada
next() :

>>> lista(zip(SequenceOfNumbers(), "abcdef"))


[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), ( 4, 'e'), (5, 'f')] >>> seq =
SequenceOfNumbers(100)
>>> next(seq)
100
>>> next(seq)
101

La siguiente función ()
La función incorporada next() avanzará el iterable a su siguiente elemento y lo devolverá:
>>> palabra = iter("hola")
>>> siguiente(palabra)
'h'
>>> siguiente(palabra)
'e' # ...

Si el iterador no tiene más elementos para producir, se genera la excepción StopIteration :

>>> ...
>>> siguiente(palabra)
'o'
>>> siguiente(palabra)
Rastreo (última llamada más reciente):
Archivo "<stdin>", línea 1, en <módulo>
StopIteration
>>>

Esta excepción indica que la iteración ha terminado y que no hay más elementos para
consumir.

Si deseamos manejar este caso, además de capturar la excepción StopIteration , podríamos


proporcionar a esta función un valor predeterminado en su segundo parámetro. Si se
proporciona, será el valor de retorno en lugar de lanzar StopIteration :

>>> siguiente(palabra, "valor


predeterminado")
'valor predeterminado'
[ 195 ]
Uso de generadores Capítulo 7

usando un generador
El código anterior se puede simplificar significativamente simplemente usando un
generador. Los objetos generadores son iteradores. De esta forma, en lugar de crear una
clase, podemos definir una función que produzca los valores necesarios:

def secuencia(comienzo=0):
while True:
yield start
start += 1

Recuerda que desde nuestra primera definición, la palabra clave yield en el cuerpo de la
función la convierte en un generador. Debido a que es un generador, está perfectamente
bien crear un ciclo infinito como este, porque, cuando se llama a esta función
generadora, ejecutará todo el código hasta que se alcance la siguiente declaración de
rendimiento . Producirá su valor y suspenderá allí:

>>> seq = secuencia(10)


>>> siguiente(siguiente)
10
>>> siguiente(siguiente)
11

>>> lista(zip(secuencia(), "abcdef"))


[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), ( 4, 'e'), (5, 'f')]

itertools
Trabajar con iterables tiene la ventaja de que el código se combina mejor con Python
porque la iteración es un componente clave del lenguaje. Además de eso, podemos
aprovechar al máximo el módulo de itertools (ITER-01). En realidad, el generador de
secuencia() que acabamos de crear es bastante similar a itertools.count() . Sin embargo,
hay más que podemos hacer.

Una de las mejores cosas de los iteradores, generadores e iterherramientas es que


son objetos componibles que se pueden encadenar.

Por ejemplo, volviendo a nuestro primer ejemplo que procesó compras para obtener
algunas métricas, ¿qué sucede si queremos hacer lo mismo, pero solo para aquellos
valores por encima de cierto umbral? El enfoque ingenuo para resolver este problema
sería colocar la condición mientras se itera:

#...
def proceso(auto):
para compra en autocompras:
si compra > 1000.0:
...

[ 196 ]
Uso de generadores Capítulo 7

Esto no solo no es Pythonic, sino que también es rígido (y la rigidez es un rasgo que denota
un código incorrecto). No maneja muy bien los cambios. ¿Qué pasa si el número cambia
ahora? ¿Lo pasamos por parámetro? ¿Qué pasa si necesitamos más de uno? ¿Qué pasa si la
condición es diferente (menor que, por ejemplo)? ¿Pasamos una lambda?

Estas preguntas no deben ser respondidas por este objeto, cuya única responsabilidad es
calcular un conjunto de métricas bien definidas sobre un flujo de compras representado
como números. Y, por supuesto, la respuesta es no. Sería un gran error hacer tal cambio
(una vez más, el código limpio es flexible y no queremos volverlo rígido acoplando este
objeto a factores externos). Estos requisitos tendrán que abordarse en otro lugar.

Es mejor mantener este objeto independiente de sus clientes. Cuanta menos responsabilidad
tenga esta clase, más útil será para más clientes, aumentando así sus posibilidades de ser
reutilizada.

En lugar de cambiar este código, lo mantendremos como está y supondremos que los
nuevos datos se filtran de acuerdo con los requisitos que tenga cada cliente de la clase.

Por ejemplo, si quisiéramos procesar solo las primeras 10 compras que suman más de 1,000 ,
haríamos lo siguiente:

>>> from itertools import islice


>>> compras = islice(filtro(lambda p: p > 1000.0, compras), 10) >>> stats =
ComprasEstadísticas(compras).proceso() # ...

No hay penalización de memoria por filtrar de esta manera porque como todos son
generadores, la evaluación siempre es perezosa. Esto nos da el poder de pensar como si
hubiéramos filtrado todo el conjunto a la vez y luego lo hemos pasado al objeto, pero sin
llegar a encajar todo en la memoria.

Simplificando el código a través de iteradores


Ahora, discutiremos brevemente algunas situaciones que se pueden mejorar con la
ayuda de iteradores y, ocasionalmente, el módulo itertools . Después de discutir cada
caso y su optimización propuesta, cerraremos cada punto con un corolario.
[ 197 ]
Uso de generadores Capítulo 7

Iteraciones repetidas
Ahora que hemos visto más acerca de los iteradores y presentamos el módulo itertools ,
podemos mostrarle cómo uno de los primeros ejemplos de este capítulo (el de calcular
estadísticas sobre algunas compras) puede simplificarse drásticamente:
def process_purchases(compras):
min_, max_, avg = itertools.tee(compras, 3) return min(min_),
max(max_), mediana(avg)

En este ejemplo, itertools.tee dividirá el iterable original en tres nuevos. Usaremos cada
uno de estos para los diferentes tipos de iteraciones que necesitemos, sin necesidad de
repetir tres ciclos diferentes sobre las compras .

El lector puede simplemente comprobar que si pasamos un objeto iterable como parámetro
de compras , este se recorre una sola vez (gracias a la función itertools.tee [ver referencias]),
que era nuestro requisito principal. También es posible comprobar cómo esta versión es
equivalente a nuestra implementación original. En este caso, no hay necesidad de generar
ValueError manualmente porque pasar una secuencia vacía a la función min() hará lo mismo.

Si está pensando en ejecutar un bucle sobre el mismo objeto más de una


vez, deténgase y piense si itertools.tee puede ser de alguna ayuda.

Bucles anidados
En algunas situaciones, necesitamos iterar sobre más de una dimensión, buscando un valor,
y los bucles anidados son la primera idea. Cuando se encuentra el valor, debemos detener la
iteración, pero la palabra clave break no funciona del todo porque tenemos que escapar de
dos (o más) bucles for , no solo de uno.

¿Cuál sería la solución para esto? ¿Una bandera que indica escape? No. ¿Generar una
excepción? No, esto sería lo mismo que la bandera, pero aún peor porque sabemos que las
excepciones no deben usarse para la lógica de flujo de control. ¿Mover el código a una
función más pequeña y devolverlo? Cerca, pero no del todo.

La respuesta es, siempre que sea posible, aplanar la iteración a un solo bucle for .

Este es el tipo de código que nos gustaría evitar:


def search_nested_bad(matriz, valor_deseado): coords =
Ninguno
para i, fila en enumerate(matriz):
[ 198 ]
Uso de generadores Capítulo 7

para j, celda en enumerar (fila):


if celda == valor_deseado:
coords = (i, j)
romper

si coords no es Ninguno:
romper

si las coordenadas son Ninguna:


aumentar ValueError(f"{valor_deseado} no encontrado")

logger.info("valor %r encontrado en [%i, %i]", valor_deseado, *coords) devolver coords

Y aquí hay una versión simplificada que no se basa en banderas para señalar la
terminación, y tiene una estructura de iteración más simple y compacta:

def _iterate_array2d(array2d):
para i, fila en enumerar(array2d):
para j, celda en enumerar(fila):
rendimiento (i, j), celda

def buscar_anidado(matriz, valor_deseado):


intente:
coord = siguiente(
coord
para (coord, celda) en _iterate_array2d(matriz) if celda ==
valor_deseado
)
excepto StopIteration:
aumentar ValueError("{valor_deseado} no encontrado")

logger.info("valor %r encontrado en [%i, %i]", valor_deseado, *coord) return coord

Vale la pena mencionar cómo el generador auxiliar que se creó funciona como una
abstracción para la iteración que se requiere. En este caso, solo necesitamos iterar sobre dos
dimensiones, pero si necesitáramos más, un objeto diferente podría manejar esto sin que el
cliente necesite saberlo. Esta es la esencia del patrón de diseño del iterador, que, en Python,
es transparente, ya que admite automáticamente los objetos del iterador, que es el tema que
se trata en la siguiente sección.

Intente simplificar la iteración tanto como sea posible con tantas


abstracciones como sea necesario, aplanando los bucles siempre
que sea posible.
[ 199 ]
Uso de generadores Capítulo 7

El patrón iterador en Python


Aquí, nos desviaremos un poco de los generadores para comprender más profundamente
la iteración en Python. Los generadores son un caso particular de objetos iterables, pero la
iteración en Python va más allá de los generadores, y poder crear buenos objetos iterables
nos dará la oportunidad de crear código más eficiente, compacto y legible.

En las listas de código anteriores, hemos estado viendo ejemplos de objetos iterables
que también son iteradores, porque implementan los métodos mágicos __iter__() y
__next__() . Si bien esto está bien en general, no se requiere estrictamente que siempre
tengan que implementar ambos métodos, y aquí mostraremos las sutiles diferencias
entre
un objeto iterable (uno que implementa __iter__ ) y un iterador (que
implementa __next__ ).

También exploramos otros temas relacionados con las iteraciones, como secuencias y
objetos contenedores.

La interfaz para la iteración.


Un iterable es un objeto que admite la iteración, lo que, en un nivel muy alto, significa
que podemos ejecutar un ciclo for .. in ... sobre él, y funcionará sin problemas.
Sin embargo, iterable no significa lo mismo que iterador .

En términos generales, un iterable es simplemente algo que podemos iterar y utiliza un


iterador para hacerlo. Esto significa que en el método mágico __iter__ , nos gustaría devolver
un iterador, es decir, un objeto con un método __next__() implementado.

Un iterador es un objeto que solo sabe cómo producir una serie de valores, uno a la vez,
cuando lo llama la función next() integrada ya explorada . Si bien no se llama al iterador,
simplemente se congela y permanece inactivo hasta que se lo vuelve a llamar para que
produzca el siguiente valor. En este sentido, los generadores son iteradores.

concepto de método Consideraciones


pitón mágico
Iterable __iter__ Trabajan con un iterador para construir la lógica de
iteración. Estos objetos se pueden iterar en un bucle for...
Defina la lógica para producirin...:valores uno a la vez.
La excepción StopIteration indica que la iteración ha
iterador __Siguiente__ terminado.
Los valores se pueden obtener uno por uno a través de next()
incorporado
función.
[ 200 ]
Uso de generadores Capítulo 7

En el siguiente código, veremos un ejemplo de un objeto iterador que no es iterable : solo


admite la invocación de sus valores, uno a la vez. Aquí, el nombre secuencia se refiere solo
a una serie de números consecutivos, no al concepto de secuencia en Python, que
exploraremos más adelante:

class SequenceIterator:
def __init__(self, start=0, step=1): self.current =
start
self.step = step

def __next__(self):
valor = self.current
self.current += self.step
valor de retorno

Tenga en cuenta que podemos obtener los valores de la secuencia de uno en uno, pero no
podemos iterar sobre este objeto (esto es una suerte porque, de lo contrario, daría como
resultado un ciclo sin fin):

>>> si = SequenceIterator(1, 2)
>>> siguiente(si)
1
>>> siguiente(si)
3
>>> siguiente(si)
5
>>> for _ en SequenceIterator(): pasar
...
Rastreo (última llamada más reciente):
...
TypeError: el objeto 'SequenceIterator' no es iterable

El mensaje de error es claro, ya que el objeto no implementa __iter__() .

Solo con fines explicativos, podemos separar la iteración en otro objeto (nuevamente, sería
suficiente hacer que el objeto implemente tanto __iter__ como __next__ , pero hacerlo por
separado ayudará a aclarar el punto distintivo que estamos tratando de hacer en esta
explicación) .

Secuenciar objetos como iterables


Como acabamos de ver, si un objeto implementa el método mágico __iter__() , significa que
puede usarse en un bucle for . Si bien esta es una gran característica, no es la única forma
posible de iteración que podemos lograr. Cuando escribimos un ciclo for , Python
intentará ver si el objeto que estamos usando implementa __iter__ y, si lo hace, lo usará
para construir la iteración, pero si no lo hace, hay opciones alternativas.

[ 201 ]
Uso de generadores Capítulo 7

Si el objeto resulta ser una secuencia (lo que significa que implementa los métodos
mágicos __getitem__() y __len__() ), también se puede iterar. Si ese es el caso, el intérprete
proporcionará valores en secuencia, hasta que se genere la excepción IndexError , que, de
forma análoga a la StopIteration antes mencionada , también señala la detención de la
iteración.

Con el único propósito de ilustrar tal comportamiento, ejecutamos el siguiente experimento


que muestra un objeto de secuencia que implementa map() en un rango de números:

# generadores_iteración_2.py

class MappedRange:
"""Aplica una transformación a un rango de números."""

def __init__(self, transformación, inicio, final): self._transformation =


transformación
self._wrapped = rango(inicio, final)

def __getitem__(self, index):


valor = self._wrapped.__getitem__(index) result =
self._transformation(value)
logger.info("Índice %d: %s", índice, resultado) return result

def __len__(auto):
return len(auto._envuelto)

Tenga en cuenta que este ejemplo solo está diseñado para ilustrar que un objeto como este
se puede iterar con un bucle for normal. Hay una línea de registro colocada en el método
__getitem__ para explorar qué valores se pasan mientras se itera el objeto, como podemos
ver en la siguiente prueba:
>>> señor = RangoMapeado(abs, -10, 5)
>>> señor[0]
Índice 0: 10
10
>>> señor[-1]
Índice -1: 4
4
>>> lista(señor)
Índice 0: 10
Índice 1: 9
Índice 2: 8
Índice 3: 7
Índice 4: 6
Índice 5: 5
Índice 6: 4
Índice 7: 3

[ 202 ]
Uso de generadores Capítulo 7

Índice 8: 2
Índice 9: 1
Índice 10: 0
Índice 11: 1
Índice 12: 2
Índice 13: 3
Índice 14: 4
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 , 1, 2, 3, 4]

Como advertencia, es importante resaltar que si bien es útil saber esto, también es un
mecanismo alternativo para cuando el objeto no implementa __iter__ , por lo que la mayoría
de las veces querremos recurrir a estos métodos pensando en la creación de secuencias
adecuadas, y no solo objetos sobre los que queremos iterar.

Cuando piense en diseñar un objeto para la iteración, prefiera un


objeto iterable adecuado (con __iter__ ), en lugar de una secuencia que,
por coincidencia, también se pueda iterar.

corrutinas
Como ya sabemos, los objetos generadores son iterables. Implementan __iter__() y __next__() .
Esto lo proporciona Python automáticamente para que cuando creamos una función de
objeto generador, obtengamos un objeto que se puede iterar o avanzar a través de la función
next() .

Además de esta funcionalidad básica, tienen más métodos para que puedan funcionar
como rutinas (PEP-342). Aquí, exploraremos cómo los generadores se convirtieron en
corrutinas para respaldar la base de la programación asincrónica antes de entrar en más
detalles en la siguiente sección, donde exploramos las nuevas características de Python y
la sintaxis que cubre la
programación asincrónica. Los métodos básicos agregados en (PEP-342) para admitir
corrutinas son los siguientes:

.cerca()
.throw(ex_tipo[, ex_valor[, ex_traceback]])
.enviar(valor)
[ 203 ]
Uso de generadores Capítulo 7

Los métodos de la interfaz del generador.


En esta sección, exploraremos qué hace cada uno de los métodos antes mencionados,
cómo funciona y cómo se espera que se use. Al comprender cómo usar estos métodos,
podremos utilizar corrutinas simples.

Más adelante, exploraremos usos más avanzados de corrutinas y cómo delegar en


subgeneradores (corrutinas) para refactorizar el código y cómo orquestar diferentes
corrutinas.

cerca()
Al llamar a este método, el generador recibirá la excepción GeneratorExit . Si no se maneja,
el generador terminará sin producir más valores y su iteración se detendrá.

Esta excepción se puede utilizar para gestionar un estado de finalización. En general, si


nuestra corrutina realiza algún tipo de administración de recursos, queremos capturar esta
excepción y usar ese bloque de control para liberar todos los recursos retenidos por la
corrutina. En general, es similar a usar un administrador de contexto o colocar el código
en el bloque finalmente de un control de excepción, pero manejar esta excepción
específicamente lo hace más explícito.

En el siguiente ejemplo, tenemos una corrutina que utiliza un objeto controlador de base de
datos que mantiene una conexión a una base de datos y ejecuta consultas sobre ella,
transmitiendo datos por páginas de una longitud fija (en lugar de leer todo lo que está
disponible a la vez) :

def stream_db_records(db_handler):
try:
while True:
yield db_handler.read_n_records(10) excepto GeneratorExit:
db_handler.close()

En cada llamada al generador, devolverá 10 filas obtenidas del controlador de la base de


datos, pero cuando decidimos terminar explícitamente la iteración y llamar a close() ,
también queremos cerrar la conexión a la base de datos:
>>> streamer = stream_db_records(DBHandler("testdb"))
>>> next(streamer)
[(0, 'fila 0'), (1, 'fila 1'), (2, 'fila 2'), ( 3, 'fila 3'), ...] >>> siguiente(transmisor)
[(0, 'fila 0'), (1, 'fila 1'), (2, 'fila 2'), (3, 'fila 3'), ...] >>> streamer.close()
INFO:...:cerrando la conexión a la base de datos 'testdb'
[ 204 ]
Uso de generadores Capítulo 7

Utilice el método close() en los generadores para realizar tareas de


finalización cuando sea necesario.

throw(ex_tipo[, ex_valor[, ex_traceback]])


Este método lanzará la excepción en la línea donde el generador está actualmente
suspendido. Si el generador maneja la excepción que se envió, se llamará al código en
esa cláusula particular de excepción ; de lo contrario, la excepción se propagará a la
persona que llama.

Aquí, estamos modificando ligeramente el ejemplo anterior para mostrar la diferencia


cuando usamos este método para una excepción que es manejada por la corrutina, y
cuando no lo es:

clase CustomException (Excepción):


pasar

def stream_data(db_handler):
while True:
try:
yield db_handler.read_n_records(10)
excepto CustomException como e:
logger.info("error controlado %r, continuando", e) excepto Exception como
e:
logger.info("error no controlado %r, parando", e) db_handler.close()
romper

Ahora, es parte del flujo de control recibir una CustomException y, en tal caso, el generador
registrará un mensaje informativo (por supuesto, podemos adaptar esto de acuerdo con
nuestra lógica comercial en cada caso) y seguir adelante. a la siguiente declaración de
rendimiento , que es la línea donde la corrutina lee de la base de datos y devuelve esos datos.

Este ejemplo en particular maneja todas las excepciones, pero si el último bloque ( excepto
Excepción:) no estuviera allí, el resultado sería que el generador se eleva en la línea donde el
generador está en pausa (nuevamente, el rendimiento ), y se propagará de allí a la persona
que llama:

>>> streamer = stream_data(DBHandler("testdb"))


>>> next(streamer)
[(0, 'fila 0'), (1, 'fila 1'), (2, 'fila 2'), ( 3, 'fila 3'), (4, 'fila 4'), ...] >>> siguiente(transmisor)
[(0, 'fila 0'), (1, 'fila 1'), (2, 'fila 2'), (3, 'fila 3'), (4, 'fila 4'), ...] >>> streamer.throw(CustomException)
[ 205 ]
Uso de generadores Capítulo 7

ADVERTENCIA: error controlado CustomException(), continuando


[(0, 'fila 0'), (1, 'fila 1'), (2, 'fila 2'), (3, 'fila 3'), (4, ' fila 4'), ...] >>> streamer.throw(RuntimeError)
ERROR: error no manejado RuntimeError(), deteniendo
INFO: cerrando la conexión a la base de datos 'testdb'
Rastreo (última llamada más reciente):
...
Detener iteración

Cuando se recibió nuestra excepción del dominio, el generador continuó. Sin embargo,
cuando recibió otra excepción que no se esperaba, el bloque predeterminado atrapó donde
cerramos la conexión a la base de datos y finalizamos la iteración, lo que resultó en la
detención del generador. Como podemos ver en la StopIteration que se generó, este
generador no se puede iterar más.

enviar (valor)
En el ejemplo anterior, creamos un generador simple que lee filas de una base de datos, y
cuando deseamos terminar su iteración, este generador libera los recursos vinculados a la
base de datos. Este es un buen ejemplo del uso de uno de los métodos que proporcionan
los generadores (cerrar), pero podemos hacer más.

Un obvio de tal generador es que estaba leyendo un número fijo de filas de la base de
datos.

Nos gustaría parametrizar ese número ( 10 ) para poder cambiarlo a lo largo de


diferentes llamadas. Desafortunadamente, la función next() no nos brinda opciones para
eso. Pero afortunadamente, tenemos send() :

def stream_db_records(db_handler): datos_recuperados


= Ninguno tamaño_página_anterior
= 10
try:
while True:
tamaño_página = rendimiento datos_recuperados si
tamaño_página es Ninguno:
tamaño_página = tamaño_página_anterior

tamaño_página_anterior = tamaño_página

datos_recuperados = db_handler.read_n_records(page_size) excepto


GeneratorExit:
db_handler.close()
[ 206 ]
Uso de generadores Capítulo 7

La idea es que ahora hemos hecho que la corrutina pueda recibir valores de la persona que
llama por medio del método send() . Este método es el que realmente distingue a un
generador de una corrutina porque cuando se usa, significa que la palabra clave yield
aparecerá en el lado derecho de la instrucción y su valor de retorno se asignará a otra cosa.

En coroutines, generalmente encontramos que la palabra clave yield se usa de la siguiente


forma:
recibir = rendimiento producido

El yield , en este caso, hará dos cosas. Enviará el producido de vuelta a la persona que llama,
que lo recogerá en la siguiente ronda de iteración (después de llamar a next() , por ejemplo),
y se suspenderá allí. En un momento posterior, la persona que llama querrá enviar un valor
a la corrutina utilizando el método send() . Este valor se convertirá en el resultado de la
declaración de rendimiento , asignada en este caso a la variable denominada recibir .

Enviar valores a la corrutina solo funciona cuando esta está suspendida en una declaración
de rendimiento , esperando que se produzca algo. Para que esto suceda, la rutina deberá
avanzar a ese estado. La única forma de hacer esto es llamando a next() en él. Esto significa
que antes de enviar algo a la corrutina, esto debe avanzarse al menos una vez a través del
método next() . De lo contrario, se producirá una excepción:

>>> c = coro()
>>> c.send(1) Rastreo (
última llamada más reciente):
...
TypeError: no se puede enviar un valor que no sea Ninguno a un generador recién iniciado

Recuerde siempre avanzar una corrutina llamando a next() antes de


enviarle cualquier valor.

Volvamos a nuestro ejemplo. Estamos cambiando la forma en que se producen o transmiten


los elementos para que pueda recibir la longitud de los registros que espera leer de la base
de datos.

La primera vez que llamamos a next() , el generador avanzará hasta la línea que contiene
yield ; proporcionará un valor a la persona que llama ( Ninguno , como se establece en la
variable), y se suspenderá allí). A partir de aquí, tenemos dos opciones. Si elegimos avanzar
el generador llamando a next() , se usará el valor predeterminado de 10 , y continuará con
esto como de costumbre. Esto se debe a que next() es técnicamente lo mismo que send(None)
, pero esto está cubierto en la declaración if que manejará el valor que establecimos
previamente.

[ 207 ]
Uso de generadores Capítulo 7

Si, por el contrario, decidimos proporcionar un valor explícito a través de send(<value>) ,


este se convertirá en el resultado de la instrucción yield , que se asignará a la variable que
contiene la longitud de la página a utilizar, que , a su vez, se utilizará para leer de la base
de datos.

Las llamadas sucesivas tendrán esta lógica, pero lo importante es que ahora podemos
cambiar dinámicamente la longitud de los datos para leer en medio de la iteración, en
cualquier momento.

Ahora que entendemos cómo funciona el código anterior, la mayoría de los Pythonistas
esperarían una versión simplificada (después de todo, Python también se trata de brevedad
y código limpio y compacto):
def stream_db_records(db_handler): datos_recuperados
= Ninguno
tamaño_página = 10
try:
while True:
tamaño_página = (rendimiento datos_recuperados) o tamaño_página
datos_recuperados = db_handler.read_n_records(tamaño_página) excepto
GeneratorExit:
db_handler.close()

Esta versión no solo es más compacta, sino que también ilustra mejor la idea. El paréntesis
alrededor del rendimiento deja más claro que es una declaración (piense en ello como si
fuera una llamada de función) y que estamos usando el resultado para compararlo con el
valor anterior.

Esto funciona como esperamos que lo haga, pero siempre debemos recordar avanzar la
rutina antes de enviarle datos. Si olvidamos llamar al primer next() , obtendremos un
TypeError . Esta llamada podría ignorarse para nuestros propósitos porque no devuelve
nada que usaremos.

Sería bueno si pudiéramos usar la rutina directamente, justo después de crearla sin tener
que recordar llamar a next() la primera vez, cada vez que la vayamos a usar. Algunos
autores (PYCOOK) idearon un decorador interesante para conseguirlo. La idea de este
decorador es avanzar la corrutina, por lo que la siguiente definición funciona
automáticamente:
@prepare_coroutine
def stream_db_records(db_handler): datos_recuperados
= Ninguno
tamaño_página = 10
try:
while True:
tamaño_página = (rendimiento datos_recuperados) o tamaño_página
datos_recuperados = db_handler.read_n_records(tamaño_página) excepto
GeneratorExit:
db_handler.close()

[ 208 ]
Uso de generadores Capítulo 7

>>> streamer = stream_db_records(DBHandler("testdb")) >>>


len(streamer.send(5))
5

Tomemos un ejemplo, creamos el decorador prepare_coroutine() .

Corrutinas más avanzadas


Hasta ahora, tenemos una mejor comprensión de las corrutinas y podemos crear otras
simples para manejar tareas pequeñas. Podemos decir que estas corrutinas son, de hecho,
solo generadores más avanzados (y eso sería correcto, las corrutinas son solo generadores
sofisticados), pero, si realmente queremos comenzar a admitir escenarios más complejos,
generalmente tenemos que buscar un diseño. que maneja muchas corrutinas al mismo
tiempo y que requiere más funciones.

Al manejar muchas corrutinas, encontramos nuevos problemas. A medida que el flujo de


control de nuestra aplicación se vuelve más complejo, queremos pasar valores arriba y abajo
de la pila (así como excepciones), ser capaces de capturar valores de subrutinas a las que
podríamos llamar en cualquier nivel y, finalmente, programar múltiples corrutinas para
correr hacia un objetivo común.

Para simplificar las cosas, los generadores tuvieron que ampliarse una vez más. Esto es lo
que abordó PEP-380 : cambiando la semántica de los generadores para que puedan
devolver valores e introduciendo el nuevo rendimiento de la construcción.

Devolviendo valores en coroutines


Como se presentó al comienzo de este capítulo, la iteración es un mecanismo que
llama a next() en un objeto iterable muchas veces hasta que se genera una excepción
StopIteration .

Hasta ahora, hemos estado explorando la naturaleza iterativa de los generadores :


producimos valores de uno en uno y, en general, solo nos preocupamos por cada valor a
medida que se produce en cada paso del ciclo for . Esta es una forma muy lógica de pensar
en los generadores, pero las corrutinas tienen una idea diferente; aunque técnicamente son
generadores, no fueron concebidos con la idea de la iteración en mente, sino con el objetivo
de suspender la ejecución de un código hasta que se reanude más tarde.
Este es un desafío interesante; cuando diseñamos una rutina, generalmente nos
preocupamos más por suspender el estado que por iterar (y iterar una rutina sería un caso
extraño).
El desafío radica en que es fácil mezclarlos a ambos. Esto se debe a un detalle de
implementación técnica; el soporte para corrutinas en Python se basó en generadores.

[ 209 ]
Uso de generadores Capítulo 7

Si queremos utilizar corrutinas para procesar alguna información y suspender su


ejecución, tendría sentido pensar en ellas como hilos ligeros (o hilos verdes, como se les
llama en otras plataformas). En tal caso, tendría sentido si pudieran devolver valores,
como llamar a cualquier otra función regular.

Pero recordemos que los generadores no son funciones regulares, por lo que en un
generador, el valor de
construcción = generador() no hará nada más que crear un objeto generador . ¿Cuál sería la
semántica para hacer que un generador devuelva un valor? Tendrá que ser después de que
se complete la iteración.

Cuando un generador devuelve un valor, la iteración se detiene inmediatamente (no se


puede repetir más). Para preservar la semántica, la excepción StopIteration aún se genera y
el valor que se devolverá se almacena dentro del objeto de excepción . Es responsabilidad
de la persona que llama captarlo.

En el siguiente ejemplo, estamos creando un generador simple que produce dos valores y
luego devuelve un tercero. Observe cómo tenemos que capturar la excepción para obtener
este valor , y cómo se almacena precisamente dentro de la excepción bajo el atributo llamado
valor :

>>> def generador():


... rinde 1
... rinde 2
... regresa 3
...
>>> valor = generador()
>>> siguiente(valor)
1
>>> siguiente(valor)
2
>>> probar:
... siguiente(valor)
... excepto StopIteration as e:
... print(" >>>>>> valor devuelto ", e.value) ...
>>>>>> valor devuelto 3

Delegar en corrutinas más pequeñas : el


rendimiento de la sintaxis
La función anterior es interesante en el sentido de que abre muchas posibilidades nuevas
con coroutines (generadores), ahora que pueden devolver valores. Pero esta función, por sí
sola, no sería tan útil sin el soporte de sintaxis adecuado, porque capturar el valor devuelto
de esta manera es un poco engorroso.

[ 210 ]
Uso de generadores Capítulo 7

Esta es una de las principales características del rendimiento de la sintaxis. Entre otras cosas
(que revisaremos en detalle), puede recolectar el valor devuelto por un subgenerador.
¿Recuerda que dijimos que devolver datos en un generador estaba bien, pero que,
desafortunadamente, escribir declaraciones como valor = generador () no funcionaría? Bueno,
escribirlo como value = yield from generator() lo haría.

El uso más simple del rendimiento de


En su forma más básica, el nuevo rendimiento de la sintaxis se puede usar para encadenar
generadores de bucles for anidados en uno solo, que terminará con una sola cadena de todos
los valores en una secuencia continua.

El ejemplo canónico se trata de crear una función similar a itertools.chain() de la biblioteca


estándar . Esta es una función muy buena porque le permite pasar cualquier número de
iterables y los devolverá todos juntos en una sola secuencia.

La implementación ingenua podría verse así:


def cadena(*iterables):
para ello en iterables:
para valor en ello:
valor de rendimiento

Recibe un número variable de iterables , los recorre todos, y dado que cada valor es iterable
, admite una construcción for... in... , por lo que tenemos otro bucle for para obtener cada
valor dentro de cada iterable en particular, que es producido por la función llamador.
Esto podría ser útil en varios casos, como encadenar generadores o intentar iterar cosas
que normalmente no serían posibles de comparar de una sola vez (como listas con tuplas,
etc.).

Sin embargo, el rendimiento de la sintaxis nos permite ir más allá y evitar el bucle anidado
porque es capaz de producir los valores directamente desde un subgenerador. En este
caso, podríamos simplificar el código así:
def cadena(*iterables):
para ello en iterables:
rendimiento de ello

Note que para ambas implementaciones, el comportamiento del generador es exactamente


el mismo:
>>> lista(cadena("hola", ["mundo"], ("tupla", " de ", "valores"))) ['h', 'e', 'l', 'l', 'o',
'mundo', 'tupla', 'de', 'valores'].
[ 211 ]
Uso de generadores Capítulo 7

Esto significa que podemos usar el rendimiento de sobre cualquier otro iterable, y
funcionará como si el generador de nivel superior (el que está usando el rendimiento )
estuviera generando esos valores por sí mismo.

Esto funciona con cualquier iterable, e incluso las expresiones generadoras no son la
excepción. Ahora que estamos familiarizados con su sintaxis, veamos cómo podemos
escribir una función generadora simple que produzca todas las potencias de un número
(por ejemplo, si se le proporciona
all_powers(2, 3) , tendrá que producir 2^ 0, 2^1,... 2^3 ):

def all_powers(n, pow):


rendimiento de (n ** i for i in range(pow + 1))

Si bien esto simplifica un poco la sintaxis, guardar una línea de una declaración for
no es una gran ventaja y no justificaría agregar tal cambio al lenguaje.

De hecho, esto es solo un efecto secundario y la verdadera razón de ser del


rendimiento de la construcción es lo que vamos a explorar en las siguientes dos
secciones.

Capturando el valor devuelto por un subgenerador


En el siguiente ejemplo, tenemos un generador que llama a otros dos generadores
anidados, produciendo valores en una secuencia. Cada uno de estos generadores anidados
devuelve un valor, y veremos cómo el generador de nivel superior puede capturar de
manera efectiva el valor de retorno, ya que está llamando a los generadores internos a
través de yield from :

def secuencia(nombre, inicio, final):


logger.info("%s comenzó en %i", nombre, inicio) yield from
range(inicio, final)
logger.info("%s terminó en %i", nombre, final) retorno final

def main():
paso1 = rendimiento de secuencia("primero", 0, 5)
paso2 = rendimiento de secuencia("segundo", paso1, 10) return paso1
+ paso2

Esta es una posible ejecución del código en main mientras se itera:


>>> g = main()
>>> next(g)
INFO:generators_yieldfrom_2:empezó primero en 0 0
>>> next(g)
1
>>> next(g)
2
>>> next(g)

[ 212 ]
Uso de generadores Capítulo 7

3
>>> siguiente(g)
4
>>> siguiente(g)
INFO:generators_yieldfrom_2:primero terminó en 5
INFO:generators_yieldfrom_2:segundo comenzó en 5 5
>>> siguiente(g)
6
>>> siguiente(g)
7
> >> next(g)
8
>>> next(g)
9
>>> next(g)
INFO:generators_yieldfrom_2:segundo terminado en 10 Rastreo
(última llamada más reciente):
Archivo "<stdin>", línea 1, en < módulo>
StopIteration: 15

La primera línea de delegados principales en el generador interno, y produce los valores,


extrayéndolos directamente de él. Esto no es nada nuevo, como ya hemos visto. Observe,
sin embargo, cómo la función generadora de secuencia() devuelve el valor final, que se
asigna en la primera línea a la variable denominada paso1 , y cómo este valor se usa
correctamente al comienzo de la siguiente instancia de ese generador.

Al final, este otro generador también devuelve el segundo valor final ( 10 ), y el generador
principal, a su vez, devuelve la suma de ellos ( 5+10=15 ), que es el valor que vemos una
vez parada la iteración.

Podemos usar yield from para capturar el último valor de una corrutina
después de que haya terminado su procesamiento.

Envío y recepción de datos hacia y desde un subgenerador


Ahora, veremos la otra característica agradable del rendimiento de la sintaxis, que es
probablemente lo que le da todo su poder. Como ya presentamos cuando exploramos los
generadores que actúan como corrutinas, sabemos que podemos enviarles valores y
lanzarles excepciones y, en tales casos, la corrutina recibirá el valor para su procesamiento
interno o tendrá que manejar la excepción en consecuencia.
[ 213 ]
Uso de generadores Capítulo 7

Si ahora tenemos una rutina que delega en otras (como en el ejemplo anterior), también nos
gustaría conservar esta lógica. Tener que hacerlo manualmente sería bastante complejo
(puedes echarle un vistazo al código descrito en PEP-380 si no tuviéramos esto manejado
por el rendimiento de forma automática.

Para ilustrar esto, mantengamos el mismo generador de nivel superior (principal) sin
modificar con respecto al ejemplo anterior (llamando a otros generadores internos), pero
modifiquemos los generadores internos para que puedan recibir valores y manejar
excepciones. El código probablemente no sea idiomático, solo para mostrar cómo funciona
este mecanismo:

def secuencia(nombre, inicio, final):


valor = inicio
logger.info("%s comenzó en %i", nombre, valor)
while valor < final:
prueba:
recibido = rendimiento valor
logger.info("%s recibido % r", nombre, recibido) valor += 1
excepto CustomException como e:
logger.info("%s está manejando %s", nombre, e) recibido =
rendimiento "OK"
return end

Ahora, llamaremos a la corrutina principal , no solo iterándola, sino también pasándole


valores y lanzando excepciones para ver cómo se manejan dentro de la secuencia :

>>> g = main()
>>> next(g)
INFO: primero comenzó en 0
0
>>> next(g)
INFO: primero recibido Ninguno
1
>>> g.send("valor para 1")
INFO: primero recibió 'valor para 1'
2
>>> g.throw(CustomException("error controlado"))
INFO: primero está manejando el error controlado
'OK'
... # avanzar más veces
INFO:segundo comenzó en 5
5
>>> g.throw(CustomException("excepción en el segundo generador"))
INFORMACIÓN: el segundo está manejando la excepción en el segundo
generador
'OK'

[ 214 ]
Uso de generadores Capítulo 7

Este ejemplo nos muestra muchas cosas diferentes. Observe cómo nunca enviamos
valores a la secuencia , sino solo a la principal , y aun así, el código que recibe esos valores
son los generadores anidados. A pesar de que nunca enviamos nada explícitamente a la
secuencia , está recibiendo los datos a medida que se transmiten mediante el rendimiento
de .

La corrutina principal llama internamente a otras dos corrutinas, produciendo sus valores, y
se suspenderá en un momento determinado en cualquiera de ellas. Cuando se detiene en el
primero, podemos ver los registros que nos dicen que es esa instancia de la rutina la que
recibió el valor que enviamos. Lo mismo sucede cuando le lanzamos una excepción.
Cuando finaliza la primera corrutina, devuelve el valor que se asignó en la variable
denominada step1 y se pasó como entrada para la segunda corrutina, que hará lo mismo
(manejará las llamadas send()
y throw() , según corresponda).

Lo mismo sucede con los valores que produce cada rutina. Cuando estamos en cualquier
paso dado, el retorno de llamar a send() corresponde al valor que ha producido la subrutina
(aquella en la que main está actualmente suspendida). Cuando lanzamos una excepción que
se está manejando, la corrutina de secuencia produce el valor OK , que se propaga a la
llamada ( principal ), y que a su vez terminará en la persona que llama a la principal.

Programación asíncrona
Con las construcciones que hemos visto hasta ahora, somos capaces de crear programas
asincrónicos en Python. Esto significa que podemos crear programas que tengan muchas
corrutinas, programarlos para que funcionen en un orden particular y cambiar entre ellos
cuando estén suspendidos después de que se haya llamado a un rendimiento de cada uno de
ellos.

La principal ventaja que podemos sacar de esto es la posibilidad de paralelizar


operaciones de E/S de forma no bloqueante. Lo que necesitaríamos es un generador de
bajo nivel (generalmente implementado por una biblioteca de terceros) que sepa cómo
manejar la E/S real mientras la rutina está suspendida. La idea es que la corrutina efectúe
la suspensión para que nuestro programa pueda manejar otra tarea mientras tanto. La
forma en que la aplicación recuperaría el control es por medio de la declaración yield from ,
que suspenderá y producirá un valor para la persona que llama (como en los ejemplos que
vimos anteriormente cuando usamos esta sintaxis para alterar el flujo de control del
programa) .
Esta es más o menos la forma en que la programación asincrónica había estado funcionando
en Python durante bastantes años, hasta que se decidió que se necesitaba un mejor soporte
sintáctico.

[ 215 ]
Uso de generadores Capítulo 7

El hecho de que las corrutinas y los generadores sean técnicamente lo mismo genera cierta
confusión. Sintácticamente (y técnicamente) son lo mismo, pero semánticamente son
diferentes. Creamos generadores cuando queremos lograr una iteración eficiente. Por lo
general, creamos rutinas con el objetivo de ejecutar operaciones de E/S sin bloqueo.

Si bien esta diferencia es clara, la naturaleza dinámica de Python aún permitiría a los
desarrolladores mezclar estos diferentes tipos de objetos, lo que terminaría con un error de
tiempo de ejecución en una etapa muy avanzada del programa. Recuerde que en la forma
más simple y básica de la sintaxis yield from , usamos esta construcción sobre iterables
(creamos una especie de función de cadena aplicada sobre cadenas, listas, etc.). Ninguno de
estos objetos eran rutinas y aún funcionaba. Luego, vimos que podemos tener varias
corrutinas, usar yield from para enviar el valor (o excepciones) y obtener algunos resultados.
Sin embargo, estos son claramente dos casos de uso muy diferentes, si escribimos algo en la
línea de la siguiente declaración:
resultado = rendimiento de iterable_or_awaitable()

No está claro qué devuelve iterable_or_awaitable . Puede ser un iterable simple, como una
cadena, y aún puede ser sintácticamente correcto. O bien, podría ser una rutina real. El
costo de este error se pagará mucho más tarde.

Por esta razón, el sistema de tipeo en Python tuvo que ser ampliado. Antes de Python 3.5,
las corrutinas eran solo generadores con un decorador @coroutine aplicado, y debían
llamarse con el rendimiento de la sintaxis. Ahora, hay un tipo específico de objeto, es decir,
una rutina.

Este cambio anunció cambios de sintaxis también. Se introdujeron las sintaxis await y async
def . El primero está destinado a ser utilizado en lugar de rendimiento, y solo funciona con
objetos disponibles (que resulta conveniente que sean corrutinas). Intentar
llamar a la espera con algo que no respeta la interfaz de una espera generará una
excepción. La definición asíncrona es la nueva forma de definir corrutinas, reemplazando el
decorador mencionado anteriormente, y esto en realidad crea un objeto que, cuando se
llama, devolverá una instancia de una corrutina.

Sin entrar en todos los detalles y posibilidades de la programación asíncrona en


Python, podemos decir que a pesar de la nueva sintaxis y los nuevos tipos, esto no
está haciendo nada fundamentalmente diferente de los conceptos que hemos
cubierto en este capítulo.
[ 216 ]
Uso de generadores Capítulo 7

La idea de programar asincrónicamente en Python es que hay un bucle de


eventos (típicamente asyncio porque es el que está incluido en la biblioteca estándar , pero
hay muchos otros que funcionarán igual) que administra una serie de rutinas. Estas rutinas
pertenecen al bucle de eventos, que las llamará de acuerdo con su mecanismo de
programación. Cuando cada uno de estos se ejecute, llamará a nuestro código (según la
lógica que hayamos definido dentro de la corrutina que programamos), y cuando queramos
recuperar el control del bucle de eventos, llamaremos await <coroutine> , que procesará una
tarea de forma asíncrona. El ciclo de eventos se reanudará y se llevará a cabo otra rutina
mientras esa operación se deja en ejecución.

En la práctica, hay más particularidades y casos límite que están más allá del alcance de
este libro. Sin embargo, vale la pena mencionar que estos conceptos están relacionados con
las ideas presentadas en este capítulo y que esta arena es otro lugar donde los generadores
demuestran ser un concepto central del lenguaje, ya que hay muchas cosas construidas
sobre ellos.

Resumen
Los generadores están en todas partes en Python. Desde su creación en Python hace mucho
tiempo, demostraron ser una gran adición que hace que los programas sean más eficientes y
que la iteración sea mucho más sencilla.

A medida que pasaba el tiempo y era necesario agregar tareas más complejas a Python,
los generadores ayudaron nuevamente a admitir rutinas.

Y, aunque en Python, las corrutinas son generadores, no debemos olvidar que son
semánticamente diferentes. Los generadores se crean con la idea de la iteración, mientras
que las corrutinas tienen como objetivo la programación asíncrona (suspender y reanudar
la ejecución de una parte de nuestro programa en un momento dado). Esta distinción se
volvió tan importante que hizo que la sintaxis (y el sistema de tipos) de Python
evolucionara.

La iteración y la programación asíncrona constituyen el último de los principales pilares


de la programación en Python. Ahora es el momento de ver cómo encaja todo y poner en
práctica todos estos conceptos que hemos estado explorando en los últimos capítulos.

Los siguientes capítulos describirán otros aspectos fundamentales de los proyectos de


Python, como las pruebas, los patrones de diseño y la arquitectura.
[ 217 ]
Uso de generadores Capítulo 7

Referencias
Aquí hay una lista de información que puede consultar:

PEP-234 : Iteradores ( https:/)/www.python.org/dev/peps/pep-0234/


PEP-255 : Generadores Simples ( https:/)/www.python.org/dev/peps/pep-0255/
ITER-01 : módulo itertools de Python ( https:/)/docs.python.org/3/library/
itertools.html
GoF : El libro escrito por Erich Gamma, Richard Helm, Ralph Johnson, John
Vlissides llamado Design Patterns: Elements of Reusable Object-Oriented
Software
PEP-342 : rutinas a través de generadores mejorados ( https:/)/www.python.org/dev/
peps/pep-0342/
PYCOOK : El libro escrito por Brian Jones, David Beazley llamado Python
Cookbook: Recipes for Mastering Python 3, Third Edition
PY99 : subprocesos falsos (generadores, rutinas y continuaciones) ( https:/)/mail.
python.org/pipermail/python-dev/1999-July/000467.html
CORO-01 : Rutina Co ( http://wiki.c2.com/?CoRoutine)
CORO-02 : Los generadores no son corrutinas ( http:/)/wiki.c2.com/?
GeneratorsAreNotCoroutines
TEE : La función itertools.tee ( https:/)/docs.python.org/3/library/
itertools.html#itertools.tee
[ 218 ]
Pruebas unitarias y

refactorización 
Las ideas exploradas en este capítulo son pilares fundamentales en el contexto global del
libro, debido a su importancia para nuestro objetivo final: escribir
software mejor y más mantenible.

Las pruebas unitarias (y cualquier forma de prueba automática, para el caso) son
fundamentales para la mantenibilidad del software y, por lo tanto, son algo que no
puede faltar en ningún proyecto de calidad. Es por esa razón que este capítulo está
dedicado exclusivamente a los aspectos de las pruebas automatizadas como una
estrategia clave para modificar el código de manera segura e iterarlo en versiones
cada vez mejores.

Después de este capítulo, habremos obtenido más información sobre lo siguiente:

Por qué las pruebas automatizadas son fundamentales para los proyectos que
se ejecutan bajo una metodología ágil de desarrollo de software
Cómo funcionan las pruebas unitarias como heurística de la calidad del código
Qué marcos y herramientas están disponibles para desarrollar pruebas
automatizadas y configurar puertas de calidad
Aprovechar las pruebas unitarias para comprender mejor el problema del
dominio y documentar el código
Conceptos relacionados con las pruebas unitarias, como el desarrollo basado en
pruebas

Principios de diseño y pruebas unitarias


En esta sección, primero vamos a echar un vistazo a las pruebas unitarias desde un punto
de vista conceptual. Revisaremos algunos de los principios de ingeniería de software que
discutimos anteriormente para tener una idea de cómo se relaciona esto con el código
limpio.
Pruebas unitarias y refactorización Capítulo 8

Después de eso, discutiremos con más detalle cómo poner en práctica estos conceptos
(a nivel de código) y qué marcos y herramientas podemos utilizar.

Primero definimos rápidamente de qué se trata la prueba unitaria. Las pruebas unitarias
son partes del código encargadas de validar otras partes del código. Normalmente,
cualquiera estaría tentado a decir que las pruebas unitarias validan el "núcleo" de la
aplicación, pero tal definición considera las pruebas unitarias en un lugar secundario, que
no es la forma en que se las considera en este libro. Las pruebas unitarias son
fundamentales y un componente crítico del software y deben tratarse con las mismas
consideraciones que la lógica empresarial.

Una prueba unitaria es una pieza de código que importa partes del código con la lógica de
negocios y ejerce su lógica, afirmando varios escenarios con la idea de garantizar ciertas
condiciones.
Hay algunos rasgos que deben tener las pruebas unitarias, tales como:

Aislamiento: las pruebas unitarias deben ser completamente independientes de


cualquier otro agente externo, y deben centrarse únicamente en la lógica de
negocio. Por eso, no se conectan a una base de datos, no realizan peticiones
HTTP, etc. El aislamiento también significa que las pruebas son independientes
entre sí: deben poder ejecutarse en cualquier orden, sin depender de ningún
estado previo.
Rendimiento: las pruebas unitarias deben ejecutarse rápidamente. Están
destinados a ejecutarse varias veces, repetidamente.
Autovalidación: La ejecución de una prueba unitaria determina su resultado. No
debería haber ningún paso adicional requerido para interpretar la prueba
unitaria (mucho menos manual).

Más concretamente, en Python esto significa que tendremos nuevos archivos *.py donde
colocaremos nuestras pruebas unitarias, y alguna herramienta los llamará. Estos archivos
tendrán declaraciones de importación , para tomar lo que necesitamos de nuestra lógica
comercial (lo que pretendemos probar), y dentro de este archivo programamos las pruebas
en sí. Posteriormente, una herramienta recogerá nuestras pruebas unitarias y las ejecutará,
dando un resultado.

Esta última parte es lo que realmente significa la autovalidación. Cuando la herramienta


llame a nuestros archivos, se iniciará un proceso de Python y nuestras pruebas se ejecutarán
en él. Si las pruebas fallan, el proceso habrá finalizado con un código de error (en un
entorno Unix, puede ser cualquier número diferente de 0 ). El estándar es que la
herramienta ejecuta la prueba e imprime un punto ( . ) por cada prueba exitosa, una F si la
prueba falló (la condición de la prueba no se cumplió) y una E si hubo una excepción.

[ 220 ]
Pruebas unitarias y refactorización Capítulo 8

Una nota sobre otras formas de pruebas


automatizadas
Las pruebas unitarias están destinadas a verificar unidades muy pequeñas, por ejemplo,
una función o un método. Queremos que nuestras pruebas unitarias alcancen un nivel de
granularidad muy detallado, probando la mayor cantidad de código posible. Para probar
una clase, no querríamos usar pruebas unitarias, sino un conjunto de pruebas, que es una
colección de pruebas unitarias. Cada uno de ellos estará probando algo más específico,
como un método de esa clase.

Esta no es la única forma de pruebas unitarias y no puede detectar todos los errores
posibles. También hay pruebas de aceptación e integración, ambas fuera del alcance de
este libro.

En una prueba de integración, querremos probar varios componentes a la vez. En este


caso, queremos validar si, en conjunto, funcionan como se esperaba. En este caso es
aceptable (más que eso deseable) que tenga efectos secundarios, y olvidarse del
aislamiento, es decir que querremos emitir peticiones HTTP, conectarnos a bases de datos,
etcétera.

Una prueba de aceptación es una forma automatizada de prueba que intenta validar el
sistema desde la perspectiva de un usuario, normalmente ejecutando casos de uso.

Estas dos últimas formas de prueba pierden otro buen rasgo con respecto a las pruebas
unitarias: la velocidad. Como puede imaginar, tardarán más en ejecutarse, por lo que se
ejecutarán con menos frecuencia.

En un buen entorno de desarrollo, el programador tendrá todo el conjunto de pruebas y


ejecutará pruebas unitarias todo el tiempo, repetidamente, mientras realiza cambios en el
código, iteraciones, refactorizaciones, etc. Una vez que los cambios estén listos y la solicitud
de extracción esté abierta, el servicio de integración continua ejecutará la compilación para
esa sucursal, donde se ejecutarán las pruebas unitarias, siempre que las pruebas de
integración o aceptación que puedan existir. No hace falta decir que el estado de la
compilación debe ser correcto (verde) antes de la fusión, pero la parte importante es la
diferencia entre los tipos de pruebas: queremos ejecutar pruebas unitarias todo el tiempo y
con menos frecuencia las pruebas que toman más tiempo. Por esta razón, queremos tener
muchas pruebas unitarias pequeñas y algunas pruebas automatizadas, diseñadas
estratégicamente para cubrir tanto como sea posible donde las pruebas unitarias no pueden
llegar (la base de datos, por ejemplo).
Finalmente, una palabra para los sabios. Recuerde que este libro fomenta el pragmatismo.
Además de estas definiciones y los puntos señalados sobre las pruebas unitarias al
comienzo de la sección, el lector debe tener en cuenta que debe predominar la mejor
solución de acuerdo con su criterio y contexto. Nadie conoce su sistema mejor que usted. Lo
que significa que, si por alguna razón tiene que escribir pruebas unitarias que necesitan
lanzar un contenedor Docker para probar contra una base de datos, hágalo. Como hemos
recordado repetidamente a lo largo del libro, la practicidad supera a la pureza .

[ 221 ]
Pruebas unitarias y refactorización Capítulo 8

Pruebas unitarias y desarrollo ágil de software


En el desarrollo de software moderno, queremos ofrecer valor constantemente y lo más
rápido posible. La razón detrás de estos objetivos es que cuanto antes recibamos
comentarios, menor será el impacto y más fácil será cambiar. Estas no son ideas nuevas en
absoluto; algunos de ellos se asemejan a los principios de fabricación de hace décadas, y
otros (como la idea de obtener comentarios de las partes interesadas lo antes posible e
iterarlo) se pueden encontrar en ensayos como The Cathedral and the Bazaar (abreviado
como CatB ).

Por lo tanto, queremos poder responder de manera efectiva a los cambios, y para eso, el
software que escribimos tendrá que cambiar. Como mencionamos en los capítulos
anteriores, queremos que nuestro software sea adaptable, flexible y extensible.

El código por sí solo (independientemente de lo bien escrito y diseñado que esté) no puede
garantizarnos que sea lo suficientemente flexible como para cambiarlo. Digamos que
diseñamos una pieza de software siguiendo los principios SOLID, y en una parte tenemos
un conjunto de componentes que cumplen con el principio abierto/cerrado, lo que significa
que podemos extenderlos fácilmente sin afectar demasiado el código existente. Suponga
además que el código está escrito de una manera que favorece la
refactorización, por lo que podríamos cambiarlo según sea necesario. ¿Qué quiere decir que
cuando hacemos estos cambios, no estamos introduciendo ningún error? ¿Cómo sabemos
que se conserva la funcionalidad existente? ¿Se sentiría lo suficientemente seguro como
para lanzarlo a sus usuarios? ¿Creerán que la nueva versión funciona tal y como esperaban?

La respuesta a todas estas preguntas es que no podemos estar seguros a menos que
tengamos una prueba formal de ello. Y las pruebas unitarias son solo eso, una prueba
formal de que el programa funciona de acuerdo con la especificación.

Las pruebas unitarias (o automatizadas), por lo tanto, funcionan como una red de
seguridad que nos da la confianza para trabajar en nuestro código. Armados con estas
herramientas, podemos trabajar eficientemente en nuestro código y, por lo tanto, esto es lo
que determina en última instancia la velocidad (o capacidad) del equipo que trabaja en el
producto de software. Cuanto mejores sean las pruebas, más probable es que podamos
ofrecer valor rápidamente sin que los errores nos detengan de vez en cuando.

Pruebas unitarias y diseño de software.


Esta es la otra cara de la moneda cuando se trata de la relación entre el código principal y
las pruebas unitarias. Además de las razones pragmáticas exploradas en la sección anterior,
todo se reduce al hecho de que un buen software es un software comprobable. La capacidad
de prueba (el atributo de calidad que determina qué tan fácil es probar el software) no es
solo algo bueno, sino un impulsor para un código limpio.

[ 222 ]
Pruebas unitarias y refactorización Capítulo 8

Las pruebas unitarias no son solo algo complementario a la base del código principal, sino
algo que tiene un impacto directo y una influencia real en cómo se escribe el código. Hay
muchos niveles de esto, desde el principio cuando nos damos cuenta de que en el momento
en que queremos agregar pruebas unitarias para algunas partes de nuestro código, tenemos
que cambiarlo (dando como resultado una mejor versión), hasta su última expresión (
explorado cerca del final de este capítulo) cuando todo el código (el diseño) se basa en la
forma en que se probará a través del diseño basado en pruebas .

Comenzando con un ejemplo simple, le mostraremos un pequeño caso de uso en el que las
pruebas (y la necesidad de probar nuestro código) conducen a mejoras en la forma en que
nuestro código termina siendo escrito.

En el siguiente ejemplo, simularemos un proceso que requiere enviar métricas a un


sistema externo sobre los resultados obtenidos en cada tarea en particular (como siempre,
los detalles no harán ninguna diferencia mientras nos concentremos en el código).
Tenemos un objeto Process que representa alguna tarea en el problema del dominio, y
utiliza un cliente de métricas (una dependencia externa y, por lo tanto, algo que no
controlamos) para enviar las métricas reales a la entidad externa (que podría estar
enviando datos a syslog , o statsd , por ejemplo):

clase MetricsClient:
"""cliente de métricas de terceros"""

def enviar(self, metric_name, metric_value):


si no es instancia(metric_name, str):
aumentar TypeError("tipo esperado str para metric_name")

si no es instancia (metric_value, str):


aumentar TypeError ("se esperaba tipo str para metric_value")

logger.info("enviando %s = %s", metric_name, metric_value)

Proceso de clase:

def __init__(self):
self.client = MetricsClient() # Un cliente de métricas de terceros

def process_iterations(self, n_iterations):


for i in range(n_iterations):
result = self.run_process()
self.client.send("iteration.{}".format(i), result)
[ 223 ]
Pruebas unitarias y refactorización Capítulo 8

En la versión simulada del cliente de terceros, ponemos el requisito de que los parámetros
proporcionados deben ser de tipo cadena. Por lo tanto, si el resultado del método
run_process no es una cadena, podemos esperar que falle, y de hecho lo hace:

Rastreo (última llamada más reciente):


...
aumentar TypeError("tipo esperado str para metric_value") TypeError: tipo
esperado str para metric_value

Recuerda que esta validación está fuera de nuestras manos y no podemos cambiar el
código, por lo que debemos proporcionar al método los parámetros del tipo correcto antes
de continuar. Pero dado que este es un error que detectamos, primero queremos escribir
una prueba unitaria para asegurarnos de que no vuelva a suceder. Hacemos esto para
demostrar que solucionamos el problema y para protegernos contra este error en el futuro,
independientemente de cuántas veces se refactorice el código.

Sería posible probar el código tal como está burlándose del cliente del objeto Proceso
(veremos cómo hacerlo en la sección sobre objetos simulados, cuando exploremos las
herramientas para pruebas unitarias), pero hacerlo ejecuta más código que es necesario
(observe cómo la parte que queremos probar está anidada en el código). Además, es bueno
que el método sea relativamente pequeño, porque si no lo fuera, la prueba tendría que
ejecutar aún más partes no deseadas que también podríamos necesitar simular. Este es otro
ejemplo de buen diseño (funciones o métodos pequeños y cohesivos), que se relaciona con
la capacidad de prueba.

Finalmente, decidimos no tomarnos muchas molestias y probar solo la parte que


necesitamos, así que en lugar de interactuar con el cliente directamente en el método
principal , delegamos a un método contenedor y la nueva clase se ve así:

clase WrappedClient:

def __init__(self):
self.cliente = MetricsClient()

def send(self, metric_name, metric_value):


return self.client.send(str(metric_name), str(metric_value))

clase Proceso:
def __init__(self):
self.client = WrappedClient()

... # resto del código permanece sin cambios


En este caso, optamos por crear nuestra propia versión del cliente para métricas, es decir,
un contenedor de la biblioteca de terceros que teníamos. Para ello colocamos una clase
que (con la misma interfaz) hará la conversión de los tipos correspondiente.

[ 224 ]
Pruebas unitarias y refactorización Capítulo 8

Esta forma de usar la composición se asemeja al patrón de diseño del adaptador


(exploraremos los patrones de diseño en el próximo capítulo, así que, por ahora, es solo un
mensaje informativo), y dado que este es un objeto nuevo en nuestro dominio, puede tener
su respectivo pruebas unitarias. Tener este objeto hará que las cosas sean más sencillas de
probar, pero lo que es más importante, ahora que lo miramos, nos damos cuenta de que esta
es probablemente la forma en que el código debería haber sido escrito en primer lugar.
¡Intentar escribir una prueba unitaria para nuestro código nos hizo darnos cuenta de que
nos faltaba una abstracción importante por completo!

Ahora que hemos separado el método como debería ser, escribamos la prueba unitaria real
para él. Los detalles sobre el módulo unittest utilizado en este ejemplo se explorarán con
más detalle en la parte del capítulo donde exploramos las herramientas y bibliotecas de
prueba, pero por ahora leer el código nos dará una primera impresión sobre cómo probarlo
y hará que los conceptos anteriores sean un poco menos abstractos:
importar unittest
desde unittest.mock importar Mock

class TestWrappedClient(unittest.TestCase): def


test_send_converts_types(self): envuelto_cliente =
WrappedClient() envuelto_cliente.cliente = Mock()
envuelto_cliente.send("valor", 1)

envuelto_cliente.cliente.enviar.afirmar_llamado_con("valor", "1")

Mock es un tipo que está disponible en el módulo unittest.mock , que es un objeto bastante
conveniente para preguntar sobre todo tipo de cosas. Por ejemplo, en este caso, lo estamos
usando en lugar de la biblioteca de terceros (simulada en los límites del sistema, como se
comenta en la siguiente sección) para verificar que se llame como se esperaba (y una vez
más, no estoy probando la biblioteca en sí, solo que se llama correctamente). Observe
cómo ejecutamos una llamada como la de nuestro objeto Process , pero esperamos que los
parámetros se conviertan en cadenas.

Definición de los límites de lo que se debe probar


La prueba requiere esfuerzo. Y si no tenemos cuidado al decidir qué probar, nunca
terminaremos de probar, por lo que desperdiciaremos mucho esfuerzo sin lograr mucho.

Deberíamos abarcar las pruebas hasta los límites de nuestro código. Si no lo hacemos,
tendríamos que probar también las dependencias (bibliotecas o módulos externos/de
terceros) o nuestro código, y luego sus respectivas dependencias, y así sucesivamente en un
viaje interminable. No es nuestra responsabilidad probar las dependencias, por lo que
podemos suponer que estos proyectos tienen sus propias pruebas. Sería suficiente probar
que las llamadas correctas a las dependencias externas se realizan con los parámetros
correctos (y eso podría incluso ser un uso aceptable de la aplicación de parches), pero no
deberíamos esforzarnos más que eso.

[ 225 ]
Pruebas unitarias y refactorización Capítulo 8

Este es otro caso en el que un buen diseño de software vale la pena. Si hemos sido
cuidadosos en nuestro diseño y hemos definido claramente los límites de nuestro sistema
(es decir, diseñamos hacia las interfaces, en lugar de implementaciones concretas que
cambiarán, por lo tanto, invirtiendo las dependencias sobre los componentes externos para
reducir el acoplamiento temporal), entonces será Será mucho más fácil burlarse de estas
interfaces al escribir pruebas unitarias.

En una buena prueba unitaria, queremos parchear los límites de nuestro sistema y
centrarnos en la funcionalidad central que se va a ejercitar. No probamos bibliotecas
externas (herramientas de terceros instaladas a través de pip , por ejemplo), sino que
verificamos que se llamen correctamente. Cuando exploremos objetos simulados más
adelante en este capítulo, revisaremos técnicas y herramientas para realizar este tipo de
afirmación.

Marcos y herramientas para pruebas


Hay muchas herramientas que podemos usar para escribir pruebas unitarias, todas ellas
con ventajas y desventajas y con diferentes propósitos. Pero entre todos ellos, hay dos que
probablemente cubrirán casi todos los escenarios y, por lo tanto, limitamos esta sección
solo a ellos.

Junto con los marcos de trabajo de prueba y las bibliotecas en ejecución de prueba, a
menudo es común encontrar proyectos que configuran la cobertura de código, que utilizan
como una métrica de calidad. Dado que la cobertura (cuando se usa como métrica) es
engañosa, después de ver cómo crear pruebas unitarias, discutiremos por qué no debe
tomarse a la ligera.

Frameworks y bibliotecas para pruebas unitarias


En esta sección, discutiremos dos marcos para escribir y ejecutar pruebas unitarias. El
primero, unittest , está disponible en la biblioteca estándar de Python, mientras que el
segundo
, pytest , debe instalarse externamente a través de pip .

prueba de unidad :https://docs.python.org/3/library/unittest.html


Pytest :https://docs.pytest.org/en/latest/
Cuando se trata de cubrir escenarios de prueba para nuestro código, la prueba unitaria por
sí sola probablemente sea suficiente, ya que tiene muchos ayudantes. Sin embargo, para
sistemas más complejos en los que tenemos múltiples dependencias, conexiones a
sistemas externos y, probablemente, la necesidad de parchear objetos y definir
dispositivos parametrizar casos de prueba, entonces pytest parece una opción más
completa.

[ 226 ]
Pruebas unitarias y refactorización Capítulo 8

Usaremos un pequeño programa como ejemplo para mostrarle cómo se puede probar
usando ambas opciones, lo que al final nos ayudará a tener una mejor idea de cómo se
comparan los dos.

El ejemplo que muestra las herramientas de prueba es una versión simplificada de una
herramienta de control de versiones que admite revisiones de código en solicitudes de
combinación. Comenzaremos con los siguientes criterios:

Se rechaza una solicitud de fusión si al menos una persona no está de acuerdo con los
cambios
Si nadie está en desacuerdo y la solicitud de fusión es buena para al menos
otros dos desarrolladores, se aprueba
En cualquier otro caso, su estado está pendiente

Y así es como se vería el código:


de enum importar Enum

class MergeRequestStatus(Enum):
APROBADO = "aprobado"
RECHAZADO = "rechazado"
PENDIENTE = "pendiente"

class MergeRequest:
def __init__(self):
self._context = {
"votos positivos": set(),
"votos negativos": set(),
}

@property
def status(self):
if self._context["downvotes"]:
return MergeRequestStatus.REJECTED elif
len(self._context["upvotes"]) >= 2: return
MergeRequestStatus.APPROVED return
MergeRequestStatus.PENDING

def upvote(self, by_user):


self._context["downvotes"].discard(by_user)
self._context["upvotes"].add(by_user)
def downvote(self, by_user):
self._context["upvotes"].discard(by_user)
self._context["downvotes"].add(by_user)

[ 227 ]
Pruebas unitarias y refactorización Capítulo 8

prueba de unidad
El módulo unittest es una excelente opción para comenzar a escribir pruebas unitarias
porque proporciona una API enriquecida para escribir todo tipo de condiciones de
prueba y, dado que está disponible en la biblioteca estándar, es bastante versátil y
conveniente.

El módulo unittest se basa en los conceptos de JUnit (de Java), que a su vez también se basa
en las ideas originales de pruebas unitarias que provienen de Smalltalk, por lo que su
naturaleza es orientada a objetos. Por esta razón, las pruebas se escriben a través de objetos,
donde las comprobaciones se verifican por métodos, y es común agrupar pruebas por
escenarios en clases.

Para comenzar a escribir pruebas unitarias, debemos crear una clase de prueba que
herede de
unittest.TestCase y definir las condiciones que queremos enfatizar en sus métodos. Estos
métodos deben comenzar con test_* y pueden usar internamente cualquiera de los
métodos heredados de unittest.TestCase para verificar las condiciones que deben
cumplirse.

Algunos ejemplos de condiciones que podríamos querer verificar para nuestro caso son los
siguientes:
clase TestMergeRequestStatus(unittest.TestCase):

def test_simple_rejected(self):
merge_request = MergeRequest()
merge_request.downvote("mantainer")
self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)

def test_just_created_is_pending(self):
self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING)

def test_pending_awaiting_review(self):
merge_request = MergeRequest()
merge_request.upvote("core-dev")
self.assertEqual(merge_request.status, MergeRequestStatus.PENDING)

def test_approved(self):
merge_request = MergeRequest()
merge_request.upvote("dev1")
merge_request.upvote("dev2")

self.assertEqual(merge_request.status, MergeRequestStatus.APROBADO)
La API para pruebas unitarias proporciona muchos métodos útiles para la comparación, el
más común es assertEquals(<real>, <expected>[, message]) , que se puede usar para comparar
el resultado de la operación con el valor que esperábamos, opcionalmente mediante un
mensaje que se mostrará en caso de error.

[ 228 ]
Pruebas unitarias y refactorización Capítulo 8

Otro método de prueba útil nos permite verificar si se generó una determinada excepción o
no. Cuando sucede algo excepcional, generamos una excepción en nuestro código para
evitar el procesamiento continuo bajo suposiciones incorrectas y también para informar a la
persona que llama que algo anda mal con la llamada tal como se realizó. Esta es la parte de
la lógica que debe probarse, y para eso es este método.

Imagine que ahora estamos ampliando nuestra lógica un poco más para permitir que los
usuarios cierren sus solicitudes de combinación y, una vez que esto suceda, no queremos
que se realicen más votaciones (no tendría sentido evaluar una solicitud de combinación
una vez esto ya estaba cerrado). Para evitar que esto suceda, ampliamos nuestro código y
generamos una excepción en el caso desafortunado cuando alguien intenta emitir un voto
en una solicitud de fusión cerrada.

Después de agregar dos nuevos estados ( ABIERTO y CERRADO ) y un nuevo método


close() , modificamos los métodos anteriores para que la votación maneje esta
verificación primero:
class MergeRequest:
def __init__(self):
self._context = {
"votos positivos": set(),
"votos negativos": set(),
}
self._status = MergeRequestStatus.OPEN

def close(self):
self._status = MergeRequestStatus.CERRADO

...
def _cannot_vote_if_closed(self):
if self._status == MergeRequestStatus.CERRADO:
aumentar MergeRequestException("no se puede votar en una solicitud de
fusión cerrada")

def voto a favor(self, by_user):


self._cannot_vote_if_closed()

self._context["votos negativos"].descartar(por_usuario)
self._context["votos positivos"].add(por_usuario)

def downvote(self, by_user):


self._cannot_vote_if_closed()
self._context["votos a favor"].descartar(por_usuario)
self._context["votos a favor"].add(por_usuario)

[ 229 ]
Pruebas unitarias y refactorización Capítulo 8

Ahora, queremos comprobar que esta validación realmente funciona. Para esto,
vamos a usar los métodos asssertRaises y assertRaisesRegex :

def test_cannot_upvote_on_closed_merge_request(self):
self.merge_request.close()
self.assertRaises(
MergeRequestException, self.merge_request.upvote, "dev1" )

def test_cannot_downvote_on_closed_merge_request(self):
self.merge_request.close()
self.assertRaisesRegex(
MergeRequestException,
"no puedo votar en una solicitud de fusión cerrada",
self.merge_request.downvote,
"dev1",
)

El primero esperará que la excepción proporcionada se genere al llamar al invocable en el


segundo argumento, con los argumentos ( *args y **kwargs ) en el resto de la función, y si
ese no es el caso, fallará, diciendo que el excepción que se esperaba que se generara, no lo
fue. Este último hace lo mismo pero también verifica que la excepción que se generó
contenga el mensaje que coincide con la expresión regular que se proporcionó como
parámetro.
Incluso si se genera la excepción, pero con un mensaje diferente (que no coincide con
la expresión regular), la prueba fallará.

Intente verificar el mensaje de error, ya que no solo la excepción, como


una verificación adicional, será más precisa y se asegurará de que sea
realmente la excepción que queremos que se active, sino que también
verificará si llegó otra del mismo tipo. por casualidad.

Pruebas parametrizadas
Ahora, nos gustaría probar cómo funciona el umbral de aceptación para la solicitud de
fusión, simplemente brindando muestras de datos de cómo se ve el contexto sin necesitar el
objeto MergeRequest completo . Queremos probar la parte de la propiedad de estado que está
después de la línea que verifica si está cerrada, pero de forma independiente.

La mejor manera de lograr esto es separar ese componente en otra clase, usar la
composición y luego pasar a probar esta nueva abstracción con su propio conjunto
de pruebas:
clase Umbral de aceptación:
def __init__(self, merge_request_context: dict) -> Ninguno: self._context =
merge_request_context

[ 230 ]
Pruebas unitarias y refactorización Capítulo 8

def status(self):
if self._context["downvotes"]:
return MergeRequestStatus.REJECTED elif
len(self._context["upvotes"]) >= 2: return
MergeRequestStatus.APPROVED return
MergeRequestStatus.PENDING

solicitud de combinación de
clase:
...
@property
def status(self):
if self._status == MergeRequestStatus.CERRADO: return
self._status

devolver Umbral de aceptación(self._context).status()

Con estos cambios, podemos ejecutar las pruebas nuevamente y verificar que pasen, lo
que significa que este pequeño refactor no rompió nada de la funcionalidad actual (las
pruebas unitarias aseguran la
regresión). Con esto, podemos continuar con nuestro objetivo de escribir pruebas que sean
específicas para la nueva clase:

class TestAcceptanceThreshold(unittest.TestCase):
def setUp(self):
self.fixture_data = (
(
{"votos negativos": conjunto(), "votos positivos": conjunto()},
MergeRequestStatus.PENDING
),
(
{"votos negativos": conjunto (), "votos a favor": {"dev1"}},
MergeRequestStatus.PENDING,
),
(
{"votos a favor": "dev1", "votos a favor": set()},
MergeRequestStatus.REJECTED
),
(
{"votos a favor": set (), "votos a favor": {"dev1", "dev2"}},
MergeRequestStatus.APROBADO
),
)
def test_status_solution(self):
para contexto, esperado en self.fixture_data:
with self.subTest(context=context):
status = AcceptanceThreshold(context).status() self.assertEqual(status,
esperado)

[ 231 ]
Pruebas unitarias y refactorización Capítulo 8

Aquí, en el método setUp() , definimos el accesorio de datos que se usará durante las
pruebas. En este caso, en realidad no es necesario, porque podríamos haberlo puesto
directamente en el método, pero si esperamos ejecutar algún código antes de ejecutar
cualquier prueba, este es el lugar para escribirlo, porque este método se llama una vez antes
de cada se ejecuta la prueba.

Al escribir esta nueva versión del código, los parámetros bajo el código que se está
probando son más claros y compactos, y en cada caso, informará los resultados.

Para simular que estamos ejecutando todos los parámetros, la prueba itera sobre todos los
datos y ejercita el código con cada instancia. Una ayuda interesante aquí es el uso de
subTest , que en este caso usamos para marcar la condición de prueba que se llama. Si una
de estas iteraciones fallaba, unittest lo informaría con el valor correspondiente de las
variables que se pasaron a la subprueba (en este caso, se denominó context , pero cualquier
serie de argumentos de palabras clave funcionaría igual). Por ejemplo, una ocurrencia de
error podría verse así:

FALLO: (contexto={'votos negativos': set(), 'votos positivos': {'dev1', 'dev2'}})--------------------- ------


-------------------------------------------Rastrear (última llamada más reciente):
Archivo "" test_status_solution
self.assertEqual(estado, esperado)
AssertionError: <MergeRequestStatus.APPROVED: 'aprobado'> !=
<MergeRequestStatus.REJECTED: 'rejected'>

Si elige parametrizar las pruebas, intente proporcionar el contexto de


cada instancia de los parámetros con la mayor cantidad de información
posible para facilitar la depuración.

pytest
Pytest es un excelente marco de prueba y se puede instalar a través de pip install pytest . Una
diferencia con respecto a unittest es que, si bien todavía es posible clasificar escenarios de
prueba en clases y crear modelos orientados a objetos de nuestras pruebas, en realidad esto
no es obligatorio, y es posible escribir pruebas unitarias con menos repeticiones con solo
verificar las condiciones queremos verificar con la declaración de afirmación.

De forma predeterminada, hacer comparaciones con una declaración de afirmación será


suficiente para que pytest identifique una prueba unitaria e informe su resultado en
consecuencia. También son posibles usos más avanzados como los vistos en la sección
anterior, pero requieren el uso de funciones específicas del paquete.
[ 232 ]
Pruebas unitarias y refactorización Capítulo 8

Una buena característica es que el comando pytests ejecutará todas las pruebas que pueda
descubrir, incluso si se escribieron con unittest . Esta compatibilidad facilita la transición
gradual de unittest a pytest .

Casos de prueba básicos con pytest


Las condiciones que probamos en la sección anterior se pueden reescribir en funciones
simples con pytest .

Algunos ejemplos con afirmaciones simples son los siguientes:


def test_simple_rejected():
merge_request = MergeRequest()
merge_request.downvote("mantainer")
afirmar merge_request.status == MergeRequestStatus.REJECTED

def test_just_created_is_pending():
afirmar MergeRequest().status == MergeRequestStatus.PENDING

def test_pending_awaiting_review():
merge_request = MergeRequest()
merge_request.upvote("core-dev")
afirmar merge_request.status == MergeRequestStatus.PENDING

Las comparaciones de igualdad booleana no requieren más que una simple declaración de
afirmación, mientras que otros tipos de comprobaciones como las de las excepciones
requieren que usemos algunas funciones:
def test_invalid_types():
merge_request = MergeRequest()
pytest.raises(TypeError, merge_request.upvote, {"invalid-object"})

def test_cannot_vote_on_closed_merge_request():
merge_request = MergeRequest()
merge_request.close()
pytest.raises(MergeRequestException, merge_request.upvote, "dev1") with pytest.raises(
MergeRequestException,
match="no se puede votar en una solicitud de fusión cerrada",
):
merge_request.downvote("dev1")
[ 233 ]
Pruebas unitarias y refactorización Capítulo 8

En este caso, pytest.raises es el equivalente de unittest.TestCase.assertRaises , y también


acepta que se le llame como método y como administrador de contexto. Si queremos
verificar el mensaje de la excepción, en lugar de un método diferente
(como assertRaisesRegex ), se debe usar la misma función, pero como un administrador de
contexto, y proporcionando el parámetro de coincidencia con la expresión que nos gustaría
identificar.

pytest también envolverá la excepción original en una personalizada que se puede


esperar (verificando algunos de sus atributos como .value , por ejemplo) en caso de que
queramos verificar más condiciones, pero este uso de la función cubre la gran mayoría
de los casos.

Pruebas parametrizadas
Ejecutar pruebas parametrizadas con pytest es mejor, no solo porque proporciona una API
más limpia, sino también porque cada combinación de la prueba con sus parámetros
genera un nuevo caso de prueba.

Para trabajar con esto, tenemos que usar el decorador pytest.mark.parametrize en nuestra
prueba. El primer parámetro del decorador es una cadena que indica los nombres de los
parámetros para pasar a la función de prueba , y el segundo tiene que ser iterable con los
valores respectivos para esos parámetros.

Observe cómo el cuerpo de la función de prueba se reduce a una línea (después de


eliminar el bucle for interno y su administrador de contexto anidado), y los datos para
cada caso de prueba se aíslan correctamente del cuerpo de la función, lo que facilita la
extensión. y mantener:

@pytest.mark.parametrize("contexto, estado_esperado", (
(
{"votos negativos": conjunto(), "votos positivos": conjunto()},
MergeRequestStatus.PENDING
),
(
{"votos negativos": conjunto(), "votos positivos" ": {"dev1"}},
MergeRequestStatus.PENDING,
),
(
{"downvotes": "dev1", "upvotes": set()},
MergeRequestStatus.REJECTED
),
(
{"downvotes": set(), " votos a favor": {"dev1", "dev2"}},
MergeRequestStatus.APPROVED
),
))
def test_acceptance_threshold_status_solution(contexto, esperado_estado): afirmar Aceptación de
umbral(contexto).estado() == estado_esperado

[ 234 ]
Pruebas unitarias y refactorización Capítulo 8

Utilice @pytest.mark.parametrize para eliminar la repetición, mantener el


cuerpo de la prueba lo más cohesivo posible y crear los parámetros
(entradas de prueba o escenarios) que el código debe admitir de forma
explícita.

Accesorios
Una de las mejores cosas de pytest es cómo facilita la creación de funciones reutilizables
para que podamos alimentar nuestras pruebas con datos u objetos para probar de manera
más efectiva y sin repeticiones.

Por ejemplo, podríamos querer crear un objeto MergeRequest en un estado particular y usar
ese objeto en múltiples pruebas. Definimos nuestro objeto como un accesorio creando una
función y aplicando el decorador @pytest.fixture . Las pruebas que quieran usar ese
accesorio deberán tener un parámetro con el mismo nombre que la función definida, y
pytest se asegurará de que se proporcione:

@pytest.fixture
def added_mr():
merge_request = MergeRequest()

fusionar_solicitud.downvote("dev1")
fusionar_solicitud.upvote("dev2")
fusionar_solicitud.upvote("dev3")
fusionar_solicitud.downvote("dev4")

devolver merge_request

def prueba_simple_rechazado(rechazado_señor):
afirmar rechazado_señor.status == MergeRequestStatus.REJECTED

def prueba_rechazada_con_aprobaciones(señor_rechazado):
señor_rechazado.voto a favor("dev2") señor_rechazado.voto a favor
("dev3")
afirmar señor_rechazado.status == MergeRequestStatus.REJECTED

def prueba_rechazado_a_pendiente(rechazado_señor):
rechazado_señor.voto a favor("dev1")
afirmar rechazo_señor.status == MergeRequestStatus.PENDING

def prueba_rechazado_a_aprobado(señor_rechazado): señor_rechazado.voto a favor


("dev1")
señor_rechazado.voto a favor("dev2")
afirmar señor_rechazado.status == MergeRequestStatus.APROBADO
[ 235 ]
Pruebas unitarias y refactorización Capítulo 8

Recuerde que las pruebas también afectan el código principal, por lo que los principios del
código limpio también se aplican a ellas. En este caso, el principio Don't Repeat Yourself (
DRY ) que exploramos en capítulos anteriores aparece una vez más, y podemos lograrlo
con la ayuda de los accesorios de pytest .

Además de crear múltiples objetos o exponer datos que se usarán en todo el conjunto de
pruebas, también es posible usarlos para configurar algunas condiciones, por ejemplo,
parchear globalmente algunas funciones que no queremos que se llamen, o cuando
queramos. objetos de parche que se utilizarán en su lugar.

Cobertura de código
ejecutores de pruebas admiten complementos de cobertura (que se instalarán a través de
pip ) que proporcionarán información útil sobre qué líneas del código se han ejecutado
mientras se ejecutaban las pruebas.
Esta información es de gran ayuda para saber qué partes del código necesitan ser cubiertas
por pruebas, así como identificar las mejoras a realizar (tanto en el código de producción
como en las pruebas). Una de las bibliotecas más utilizadas para esto es la cobertura (
https://pypi. org/project/coverage/) .

Si bien son de gran ayuda (y le recomendamos que los use y configure su proyecto para
ejecutar la cobertura en el CI cuando se ejecutan las pruebas), también pueden ser
engañosos; particularmente en Python, podemos tener una impresión falsa si no
prestamos mucha atención al informe de cobertura.

Configuración de la cobertura de descanso


En el caso de pytest , tenemos que instalar el paquete pytest-cov (al momento de escribir este
artículo, se usa la versión 2.5.1 en este libro). Una vez instalado, cuando se ejecutan las
pruebas, debemos decirle al corredor de pytest que pytest-cov también se ejecutará y qué
paquete (o paquetes) deben cubrirse (entre otros parámetros y configuraciones).

Este paquete admite múltiples configuraciones, como diferentes tipos de formatos de salida,
y es fácil de integrar con cualquier herramienta de CI, pero entre todas estas características,
una opción muy recomendable es configurar la bandera que nos dirá qué líneas no han sido
cubiertas por pruebas todavía, porque esto es lo que nos ayudará a diagnosticar nuestro
código y nos permitirá comenzar a escribir más pruebas.
[ 236 ]
Pruebas unitarias y refactorización Capítulo 8

Para mostrarle un ejemplo de cómo se vería esto, use el siguiente comando:


pytest \
--cov-report term-missing \
--cov=coverage_1 \
test_coverage_1.py

Esto producirá una salida similar a la siguiente:


prueba_cobertura_1.py ............. [100%]

----------- cobertura: plataforma linux, python 3.6.5-final-0 -----------Nombre Stmts Miss Cover
Missing
---------- -----------------------------------
cobertura_1.py 38 1 97% 53

Aquí, nos dice que hay una línea que no tiene pruebas unitarias para que podamos echarle
un vistazo y ver cómo escribir una prueba unitaria para ella. Este es un escenario común
en el que nos damos cuenta de que para cubrir esas líneas que faltan, necesitamos
refactorizar el código creando métodos más pequeños. Como resultado, nuestro código se
verá mucho mejor, como en el ejemplo que vimos al comienzo de este capítulo.

El problema radica en la situación inversa : ¿podemos confiar en la alta cobertura? ¿Significa


que nuestro código es correcto? Desafortunadamente, tener una buena cobertura de prueba
es necesario pero en condiciones suficientes para un código limpio. No tener pruebas para
partes del código es claramente algo malo.
Tener pruebas es realmente muy bueno (y podemos decir esto de las pruebas que existen), y
en realidad afirma condiciones reales de que son una garantía de calidad para esa parte del
código. Sin embargo, no podemos decir que eso es todo lo que se requiere; a pesar de tener
un alto nivel de cobertura, se requieren aún más pruebas.

Estas son las advertencias de la cobertura de prueba, que mencionaremos en la siguiente


sección.

Advertencias de cobertura de prueba


Python se interpreta y, en un nivel muy alto, las herramientas de cobertura aprovechan
esto para identificar las líneas que se interpretaron (ejecutaron) mientras se ejecutaban
las pruebas. Luego informará esto al final. El hecho de que una línea haya sido
interpretada no significa que haya sido debidamente probada, y por eso debemos tener
cuidado de leer el informe final de cobertura y confiar en lo que dice.
[ 237 ]
Pruebas unitarias y refactorización Capítulo 8

Esto es realmente cierto para cualquier idioma. El hecho de que se haya ejercitado una línea
no significa en absoluto que se haya acentuado con todas sus combinaciones posibles. El
hecho de que todas las ramas se ejecuten correctamente con los datos proporcionados solo
significa que el código admite esa combinación, pero no nos dice nada sobre otras posibles
combinaciones de parámetros que harían que el programa fallara.

Utilice la cobertura como una herramienta para encontrar puntos ciegos


en el código, pero no como una métrica o un objetivo.

Objetos simulados
Hay casos en los que nuestro código no es lo único que estará presente en el contexto de
nuestras pruebas. Después de todo, los sistemas que diseñamos y construimos tienen que
hacer algo real, y eso generalmente significa conectarse a servicios externos (bases de
datos, servicios de almacenamiento, API externas, servicios en la nube, etc.). Debido a que
necesitan tener esos efectos secundarios, son inevitables.
Por mucho que abstraigamos nuestro código, programemos hacia las interfaces y aislemos
el código de factores externos para minimizar los efectos secundarios, estarán presentes en
nuestras pruebas y necesitamos una forma efectiva de manejar eso.

Los objetos simulados son una de las mejores tácticas para defenderse de los efectos
secundarios no deseados. Es posible que nuestro código deba realizar una solicitud HTTP o
enviar un correo electrónico de notificación, pero seguramente no queremos que eso suceda
en nuestras pruebas unitarias. Además, las pruebas unitarias deben ejecutarse rápidamente,
ya que queremos ejecutarlas con bastante frecuencia (en realidad, todo el tiempo), y esto
significa que no podemos permitirnos la latencia. Por lo tanto, las pruebas de unidades
reales no utilizan ningún servicio real : no se conectan a ninguna base de datos, no emiten
solicitudes HTTP y, básicamente, no hacen nada más que ejercer la lógica del código de
producción.

Necesitamos pruebas que hagan esas cosas, pero no son unidades. Se supone que las
pruebas de integración prueban la funcionalidad con una perspectiva más amplia, casi
imitando el comportamiento de un usuario. Pero no son rápidos. Debido a que se conectan
a sistemas y servicios externos, tardan más en ejecutarse y son más costosos. En general, nos
gustaría tener muchas pruebas unitarias que se ejecuten muy rápido para ejecutarlas todo el
tiempo y que las pruebas de integración se ejecuten con menos frecuencia (por ejemplo, en
cualquier nueva solicitud de fusión).
Si bien los objetos simulados son útiles, abusar de su uso oscila entre un olor de código o
un antipatrón es la primera advertencia que nos gustaría mencionar antes de entrar en
detalles.

[ 238 ]
Pruebas unitarias y refactorización Capítulo 8

Una advertencia justa sobre parches y simulacros.


Dijimos antes que las pruebas unitarias nos ayudan a escribir mejor código, porque en el
momento en que queremos comenzar a probar partes del código, generalmente tenemos
que escribirlas para que sean comprobables, lo que a menudo significa que también son
cohesivas, granulares y pequeñas. Todos estos son buenos rasgos para tener en un
componente de software.

Otra ventaja interesante es que las pruebas nos ayudarán a notar los olores del código en
partes donde pensamos que nuestro código era correcto. Una de las principales
advertencias de que nuestro código huele a código es si nos encontramos tratando de
parchear (o simular) muchas cosas diferentes solo para cubrir un caso de prueba simple.

El módulo unittest proporciona una herramienta para parchear nuestros objetos en


unittest.mock.patch .
La aplicación de parches significa que el código original (dado por una cadena que indica
su ubicación en el momento de la importación) será reemplazado por algo diferente a su
código original, siendo el objeto simulado por defecto. Esto reemplaza el código en tiempo
de ejecución y tiene la desventaja de que estamos perdiendo contacto con el código original
que estaba allí en primer lugar, lo que hace que nuestras pruebas sean un poco más
superficiales. También conlleva consideraciones de rendimiento, debido a la sobrecarga que
impone la modificación de objetos en el intérprete en tiempo de ejecución, y es algo que
podría terminar actualizándose si refactorizamos nuestro código y movemos las cosas.

El uso de parches de mono o simulacros en nuestras pruebas podría ser aceptable y, por sí
solo, no representa un problema. Por otro lado, el abuso en el parcheo de monos es de
hecho una señal de que algo debe mejorarse en nuestro código.

Usando objetos ficticios


En la terminología de las pruebas unitarias, hay varios tipos de objetos que se incluyen en la
categoría llamada dobles de prueba . Un doble de prueba es un tipo de objeto que ocupará
el lugar de uno real en nuestro conjunto de pruebas por diferentes tipos de razones (tal vez
no necesitemos el código de producción real, pero solo funcionaría un objeto ficticio, o tal
vez podamos no lo usamos porque requiere acceso a servicios o tiene efectos secundarios
que no queremos en nuestras pruebas unitarias, etc.).

Existen diferentes tipos de dobles de prueba, como objetos ficticios, stubs, espías o
simulacros. Los simulacros son el tipo de objeto más general, y dado que son bastante
flexibles y versátiles, son apropiados para todos los casos sin necesidad de entrar en
muchos detalles sobre el resto de ellos.
Es por esta razón que la biblioteca estándar también incluye un objeto de este tipo, y es
común en la mayoría de los programas de Python. Ese es el que vamos a usar
aquí: unittest.mock.Mock .

[ 239 ]
Pruebas unitarias y refactorización Capítulo 8

Un simulacro es un tipo de objeto creado según una especificación (generalmente


similar al objeto de una clase de producción) y algunas respuestas configuradas (es
decir, podemos decirle al simulacro qué debe devolver en ciertas llamadas y cuál debe
ser su comportamiento). El objeto Mock luego registrará, como parte de su estado
interno, cómo fue llamado (con qué parámetros, cuántas veces, etc.), y podemos usar
esa información para verificar el comportamiento de nuestra aplicación en una etapa
posterior.

En el caso de Python, el objeto Mock que está disponible en la biblioteca estándar


proporciona una buena API para realizar todo tipo de afirmaciones de comportamiento,
como verificar cuántas veces se llamó al simulacro, con qué parámetros, etc.

tipos de simulacros
La biblioteca estándar proporciona objetos Mock y MagicMock en el módulo unittest.mock . El
primero es un doble de prueba que se puede configurar para devolver cualquier valor y
hará un seguimiento de las llamadas que se le hicieron. Este último hace lo mismo, pero
también admite métodos mágicos. Esto significa que, si hemos escrito un código idiomático
que usa métodos mágicos (y partes del código que estamos probando se basarán en eso), es
probable que tengamos que usar una instancia de MagicMock en lugar de solo un Mock .

Intentar usar Mock cuando nuestro código necesita llamar a métodos mágicos resultará en
un error. Consulte el siguiente código para ver un ejemplo de esto:

class GitBranch:
def __init__(self, commits: List[Dict]):
self._commits = {c["id"]: c for c in commits}

def __getitem__(self, commit_id):


return self._commits[commit_id]

def __len__(self):
return len(self._commits)

def autor_por_id(commit_id, branch):


return branch[commit_id]["autor"]
[ 240 ]
Pruebas unitarias y refactorización Capítulo 8

Queremos probar esta función; sin embargo, otra prueba necesita llamar a
la función author_by_id . Por alguna razón, dado que no estamos probando esa función,
cualquier valor proporcionado a esa función (y devuelto) será bueno:
def test_find_commit():
branch = GitBranch([{"id": "123", "author": "dev1"}]) afirmar
author_by_id("123", branch) == "dev1"

def test_find_any():
author = author_by_id("123", Mock()) no es Ninguno # ... resto de las
pruebas..

Como se anticipó, esto no funcionará:


def author_by_id(commit_id, branch):
> return branch[commit_id]["author"]
E TypeError: el objeto 'Mock' no se puede suscribir

Usar MagicMock en su lugar funcionará. Incluso podemos configurar el método mágico de


este tipo de simulacro para que devuelva algo que necesitamos para controlar la ejecución
de nuestra prueba:
def test_find_any():
mbranch = MagicMock()
mbranch.__getitem__.return_value = {"autor": "prueba"} afirmar
autor_por_id("123", mbranch) == "prueba"

Un caso de uso para dobles de prueba


Para ver un posible uso de los simulacros, necesitamos agregar un nuevo componente a
nuestra aplicación que se encargará de notificar a la solicitud de fusión del estado de la
compilación . Cuando finaliza una compilación , se llamará a este objeto con el ID de la
solicitud de fusión y el estado de la compilación , y actualizará el estado de la solicitud de
fusión con esta información mediante el envío de una solicitud HTTP POST a un punto final
fijo en particular:

# mock_2.py

desde fechahora fechahora de importación

solicitudes de importación
de constantes import STATUS_ENDPOINT
class BuildStatus:
"""El estado de CI de una solicitud de extracción."""

[ 241 ]
Pruebas unitarias y refactorización Capítulo 8

@staticmethod
def build_date() -> str:
return datetime.utcnow().isoformat()

@classmethod
def notificar (cls, merge_request_id, estado):
build_status = {
"id": merge_request_id,
"status": status,
"built_at": cls.build_date(),
}
respuesta = solicitudes.post(STATUS_ENDPOINT, json=build_status)
respuesta.raise_for_status()
devolver respuesta

Esta clase tiene muchos efectos secundarios, pero uno de ellos es una dependencia
externa importante que es difícil de superar. Si intentamos escribir una prueba sobre él
sin modificar nada, fallará con un error de conexión tan pronto como intente realizar la
conexión HTTP.

Como objetivo de prueba, solo queremos asegurarnos de que la información esté compuesta
correctamente y que las solicitudes de la biblioteca se llamen con los parámetros adecuados.
Dado que se trata de una dependencia externa, no probamos las solicitudes; bastará con
comprobar que se llama correctamente.

Otro problema al que nos enfrentaremos al tratar de comparar los datos que se envían a la
biblioteca es que la clase está calculando la marca de tiempo actual, que es imposible de
predecir en una prueba unitaria. Parchear datetime directamente no es posible, porque el
módulo está escrito en C. Hay algunas bibliotecas externas que pueden hacer eso ( freegun
, por ejemplo), pero vienen con una penalización de rendimiento, y para este ejemplo sería
excesivo. Por lo tanto, optamos por envolver la funcionalidad que queremos en un método
estático que podremos parchear.

Ahora que hemos establecido los puntos que deben reemplazarse en el código, escribamos
la prueba unitaria:
# prueba_simulacro_2.py

del simulacro de importación unittest

desde constantes importar STATUS_ENDPOINT


desde mock_2 importar BuildStatus
@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests): build_date = "2018-01-
01T00:00:01"
with mock.patch("mock_2.BuildStatus.build_date",

[ 242 ]
Pruebas unitarias y refactorización Capítulo 8

return_value=build_date):
BuildStatus.notify(123, "OK")

útil esperada = {"id": 123, "estado": "OK", "construido en


" : fecha_de_compilación
}

Primero, usamos mock.patch como decorador para reemplazar el módulo de solicitudes . El


resultado de esta función creará un objeto simulado que se pasará como parámetro a la
prueba
(llamado mock_requests en este ejemplo). Luego, usamos esta función nuevamente, pero esta
vez como administrador de contexto para cambiar el valor de retorno del método de la clase
que calcula la fecha de la compilación , reemplazando el valor con uno que controlamos, que
usaremos en la aserción.

Una vez que tengamos todo esto en su lugar, podemos llamar al método de clase con
algunos parámetros, y luego podemos usar el objeto simulado para comprobar cómo se
llamó. En este caso, estamos usando el método para ver si solicitudes.post realmente se
llamó con los parámetros como queríamos que estuvieran compuestos.

Esta es una buena característica de los simulacros : no solo ponen algunos límites
alrededor de todos los componentes externos (en este caso, para evitar enviar algunas
notificaciones o emitir solicitudes HTTP), sino que también proporcionan una API útil
para verificar las llamadas y sus parámetros.

Si bien, en este caso, pudimos probar el código configurando los respectivos objetos
simulados en su lugar, también es cierto que tuvimos que parchear bastante en proporción al
total de líneas de código para la funcionalidad principal. No existe una regla sobre la
proporción de código productivo puro que se prueba en comparación con cuántas partes de
ese código tenemos que simular, pero ciertamente, usando el sentido común, podemos ver
que, si tuviéramos que parchear muchas cosas en el mismas partes, algo no está claramente
abstracto y parece un olor a código.

En la siguiente sección, exploraremos cómo refactorizar el código para solucionar este


problema.

refactorización
La refactorización es una actividad crítica en el mantenimiento del software, pero es algo
que no se puede hacer (al menos correctamente) sin tener pruebas unitarias. De vez en
cuando, necesitamos admitir una nueva función o usar nuestro software de formas no
deseadas. Necesitamos darnos cuenta de que la única forma de acomodar tales requisitos
es primero refactorizando nuestro código, haciéndolo más genérico. Solo así podremos
avanzar.

[ 243 ]
Pruebas unitarias y refactorización Capítulo 8

Por lo general, cuando refactorizamos nuestro código, queremos mejorar su estructura y


hacerlo mejor, a veces más genérico, más legible o más flexible. El desafío es lograr estos
objetivos y, al mismo tiempo, conservar exactamente la misma funcionalidad que tenía
antes de las modificaciones que se realizaron. Esto significa que, a los ojos de los clientes de
esos
componentes que estamos refactorizando, bien podría ser que no hubiera pasado nada en
absoluto.

Esta restricción de tener que admitir las mismas funcionalidades que antes pero con una
versión diferente del código implica que debemos ejecutar pruebas de regresión en el
código que se modificó. La única forma rentable de ejecutar pruebas de regresión es si esas
pruebas son automáticas. La versión más rentable de las pruebas automáticas son las
pruebas unitarias.

Evolucionando nuestro código


En el ejemplo anterior, pudimos separar los efectos secundarios de nuestro código para
hacerlo comprobable parcheando aquellas partes del código que dependían de cosas que no
podíamos controlar en la prueba unitaria. Este es un buen enfoque ya que, después de todo,
la función mock.patch es útil para este tipo de tareas y reemplaza los objetos que le
indicamos, devolviéndonos un objeto Mock .

La desventaja de eso es que tenemos que proporcionar la ruta del objeto que vamos a
simular, incluido el módulo, como una cadena. Esto es un poco frágil, porque si
refactorizamos nuestro código (digamos que cambiamos el nombre del archivo o lo
movemos a otra ubicación), todos los lugares con el parche tendrán que actualizarse o la
prueba fallará.

En el ejemplo, el hecho de que el método notificar() dependa directamente de un detalle de


implementación (el módulo de solicitudes ) es un problema de diseño, es decir, está pasando
factura también a las pruebas unitarias con la fragilidad mencionada que ello implica.

Todavía necesitamos reemplazar esos métodos con dobles (simulacros), pero si


refactorizamos el código, podemos hacerlo de una mejor manera. Separemos estos métodos
en otros más pequeños y, lo que es más importante, inyectemos la dependencia en lugar de
mantenerla fija. El código ahora aplica el principio de inversión de dependencia y espera
trabajar con algo que admita una interfaz (en este ejemplo, una implícita) como la que
proporciona el módulo de solicitudes :

desde fechahora fechahora de importación


desde la importación de constantes STATUS_ENDPOINT

clase BuildStatus:

punto final = STATUS_ENDPOINT

[ 244 ]
Pruebas unitarias y refactorización Capítulo 8

def __init__(auto, transporte):


auto.transporte = transporte

@staticmethod
def build_date() -> str:
return datetime.utcnow().isoformat()

def compose_payload(self, merge_request_id, status) -> dict: return {


"id": merge_request_id,
"status": status,
"built_at": self.build_date(),
}

def deliver(self, payload):


respuesta = self.transport.post(self.endpoint, json=payload)
respuesta.raise_for_status()
devolver respuesta

def notificar(self, merge_request_id, status):


return self.deliver(self.compose_payload(merge_request_id, status))

los métodos (no notificar ahora es componer + entregar),


hacemos que compose_payload() sea un nuevo método (para que podamos reemplazarlo, sin
necesidad de parchear la clase), y requerimos que se inyecte la dependencia de transporte .
Ahora que el
transporte es una dependencia, es mucho más fácil cambiar ese objeto por cualquier doble
que queramos.

Incluso es posible exponer un accesorio de este objeto con los dobles reemplazados según
sea necesario:
@pytest.fixture
def build_status():
bstatus = BuildStatus(Mock())
bstatus.build_date = Mock(return_value="2018-01-01T00:00:01") return
bstatus

def test_build_notification_sent(build_status):

build_status.notify(1234, "OK")

={
"id": 1234,
"estado": "OK",
"construido_en":
estado_construcción.fecha_construcción(), }

[ 245 ]
Pruebas unitarias y refactorización Capítulo 8

build_status.transport.post.assert_called_with( build_status.endpoint,
json=expected_payload )

El código de producción no es lo único que


evoluciona
Seguimos diciendo que las pruebas unitarias son tan importantes como el código de
producción. Y si somos lo suficientemente cuidadosos con el código de producción para
crear la mejor abstracción posible, ¿por qué no haríamos lo mismo con las pruebas
unitarias?

Si el código para las pruebas unitarias es tan importante como el código principal,
definitivamente es aconsejable diseñarlo teniendo en cuenta la extensibilidad y hacerlo lo
más mantenible posible. Después de todo, este es el código que deberá mantener un
ingeniero que no sea su autor original, por lo que debe ser legible.

La razón por la que prestamos tanta atención a la flexibilidad del código es que sabemos
que los requisitos cambian y evolucionan con el tiempo y, finalmente, a medida que
cambian las reglas comerciales del dominio, nuestro código también tendrá que cambiar
para admitir estos nuevos requisitos. Dado que el código de producción cambió para
admitir nuevos requisitos, a su vez, el código de prueba también tendrá que cambiar para
admitir la versión más nueva del código de producción.

En uno de los primeros ejemplos que usamos, creamos una serie de pruebas para el
objeto de solicitud de combinación, probando diferentes combinaciones y comprobando
el estado en el que se encontraba la solicitud de combinación. Este es un buen primer
enfoque, pero podemos hacerlo mejor que eso.

Una vez que comprendemos mejor el problema, podemos comenzar a crear mejores
abstracciones. Con esto, la primera idea que nos viene a la mente es que podemos crear una
abstracción de mayor nivel que verifique condiciones particulares. Por ejemplo, si tenemos
un objeto que es un conjunto de pruebas que
apunta específicamente a la clase MergeRequest , sabemos que su funcionalidad se limitará al
comportamiento de esta clase (porque debe cumplir con el SRP) y, por lo tanto, podríamos
crear pruebas específicas. métodos en esta clase de prueba. Estos solo tendrán sentido para
esta clase, pero serán útiles para reducir una gran cantidad de código repetitivo.
[ 246 ]
Pruebas unitarias y refactorización Capítulo 8

En lugar de repetir afirmaciones que siguen exactamente la misma estructura, podemos


crear un método que encapsule esto y reutilizarlo en todas las pruebas:

class TestMergeRequestStatus(unittest.TestCase): def setUp(self):


self.merge_request = MergeRequest()

def afirmar_rechazado(self):
self.assertEqual(
self.merge_request.status, MergeRequestStatus.REJECTED )

def assert_pending(self):
self.assertEqual(
self.merge_request.status, MergeRequestStatus.PENDING )

def afirmar_aprobado(self):
self.assertEqual(
self.merge_request.status, MergeRequestStatus.APPROVED )

def test_simple_rejected(self):
self.merge_request.downvote("mantenedor")
self.assert_rejected()

def test_just_created_is_pending(self):
self.assert_pending()

Si algo cambia con la forma en que verificamos el estado de una solicitud de fusión (o
digamos que queremos agregar verificaciones adicionales), solo hay un lugar (el método
assert_approved() ) que deberá modificarse. Más importante aún, al crear estas abstracciones
de alto nivel, el código que comenzó como pruebas unitarias comienza a evolucionar hacia
lo que podría terminar siendo un marco de prueba con su propia API o lenguaje de
dominio, lo que hace que las pruebas sean más declarativas.

Más sobre pruebas unitarias


Con los conceptos que hemos revisado hasta ahora, sabemos cómo probar nuestro código,
pensar en nuestro diseño en términos de cómo se va a probar y configurar las
herramientas en nuestro proyecto para ejecutar las pruebas automatizadas que nos darán
algún grado. de confianza sobre la calidad del software que hemos escrito.
[ 247 ]
Pruebas unitarias y refactorización Capítulo 8

Si nuestra confianza en el código está determinada por las pruebas unitarias escritas en él,
¿cómo sabemos que son suficientes? ¿Cómo podemos estar seguros de que hemos pasado lo
suficiente en los escenarios de prueba y que no nos faltan algunas pruebas? ¿Quién dice que
estas pruebas son correctas? Es decir, ¿quién prueba las pruebas?

La primera parte de la pregunta, sobre ser minuciosos en las pruebas que escribimos, se
responde yendo más allá en nuestros esfuerzos de prueba a través de pruebas basadas en
propiedades.

La segunda parte de la pregunta puede tener múltiples respuestas desde diferentes puntos
de vista, pero vamos a mencionar brevemente las pruebas de mutación como un medio para
determinar que nuestras pruebas son correctas. En este sentido, estamos pensando que las
pruebas unitarias verifican nuestro código productivo principal, y esto también funciona
como un control para las pruebas unitarias.

Pruebas basadas en propiedades


Las pruebas basadas en propiedades consisten en generar datos para casos de prueba
con el objetivo de encontrar escenarios que hagan que el código falle, que no estaban
cubiertos por nuestras pruebas unitarias anteriores.

La biblioteca principal para esto es la hipótesis que, configurada junto con nuestras
pruebas unitarias, nos ayudará a encontrar datos problemáticos que harán que nuestro
código falle.

Podemos imaginar que lo que hace esta biblioteca es encontrar contraejemplos para nuestro
código. Escribimos nuestro código de producción (¡y pruebas unitarias para él!) y
afirmamos que es correcto. Ahora, con esta biblioteca, definimos algunas hipótesis que
deben cumplirse para nuestro código, y si hay algunos casos en los que nuestras
afirmaciones no se cumplen, la hipótesis proporcionará un conjunto de datos que causa el
error.

Lo mejor de las pruebas unitarias es que nos hacen pensar más en nuestro código de
producción. Lo mejor de la hipótesis es que nos hace pensar más sobre nuestras pruebas
unitarias.

Pruebas de mutación
Sabemos que las pruebas son el método de verificación formal que tenemos para
asegurarnos de que nuestro código es correcto. ¿Y qué asegura que la prueba sea correcta?
El código de producción, podrías pensar, y sí, de alguna manera esto es correcto, podemos
pensar en el código principal como un contrapeso para nuestras pruebas.

[ 248 ]
Pruebas unitarias y refactorización Capítulo 8

El objetivo de escribir pruebas unitarias es que nos estamos protegiendo contra errores y
probando escenarios de falla que realmente no queremos que sucedan en producción. Es
bueno que pasen las pruebas, pero sería malo si pasan por las razones equivocadas. Es
decir, podemos usar pruebas unitarias como una herramienta de regresión automática : si
alguien introduce un error en el código, más adelante, esperamos que al menos una de
nuestras pruebas lo detecte y falle. Si esto no sucede, o falta una prueba o las que teníamos
no están haciendo las comprobaciones correctas.

Esta es la idea detrás de las pruebas de mutación. Con una herramienta de prueba de
mutaciones, el código se modificará a nuevas versiones (llamadas mutantes), que son
variaciones del código original pero con parte de su lógica alterada (por ejemplo, los
operadores se intercambian, las condiciones se invierten, etc.). Un buen conjunto de pruebas
debería atrapar a estos mutantes y matarlos, en cuyo caso significa que podemos confiar en
las pruebas. Si algunos mutantes sobreviven al experimento, suele ser una mala señal. Por
supuesto, esto no es del todo preciso, por lo que hay estados intermedios que podríamos
querer ignorar.

Para mostrarle rápidamente cómo funciona esto y permitirle tener una idea práctica de esto,
vamos a utilizar una versión diferente del código que calcula el estado de una solicitud de
fusión en función de la cantidad de aprobaciones y rechazos. En esta ocasión, hemos
cambiado el código por una versión sencilla que, en base a estos números, devuelve el
resultado. Hemos movido la enumeración con las constantes para los estados a un módulo
separado para que ahora se vea más compacto:
# Archivo de mutación_prueba_1.py
de mrstatus importar MergeRequestStatus como Estado

def Evaluation_merge_request(upvote_count, downvotes_count): si


downvotes_count > 0:
devuelve Estado.RECHAZADO
si upvote_count >= 2:
devuelve Estado.APROBADO
devuelve Estado.PENDIENTE

Y ahora agregaremos una prueba unitaria simple, verificando una de las condiciones y
su resultado esperado :

# archivo: test_mutation_testing_1.py
class TestMergeRequestEvaluation(unittest.TestCase): def
test_approved(self):
resultado = evaluar_merge_request(3, 0)
self.assertEqual(resultado, Estado.APROBADO)
Ahora, instalaremos mutpy , una herramienta de prueba de mutación para Python, con pip
install mutpy , y le indicaremos que ejecute la prueba de mutación para este módulo con
estas pruebas:

$ mut.py \
--target mutación_testing_$N \

[ 249 ]
Pruebas unitarias y refactorización Capítulo 8

--unit-test test_mutation_testing_$N \
--operator AOD `# eliminar operador aritmético` \ --operator AOR `#
reemplazar operador aritmético` \ --operator COD `# eliminar
operador condicional` \ --operator COI `# insertar condicional
operator` \ --operator CRP `# reemplazar constante` \
--operator ROR `# reemplazar operador relacional` \ --show-mutants

El resultado va a ser algo similar a esto:


[*] Puntuación de mutación [0,04649 s]: 100,0 %
- todos: 4
- muertos: 4 (100,0 %)
- sobrevivientes: 0 (0,0 %)
- incompetentes: 0 (0,0 %)
- tiempo de espera: 0 (0,0 %)

Esta es una buena señal. Tomemos un caso particular para analizar lo sucedido. Una de
las líneas en la salida muestra el siguiente mutante:

- [# 1] ROR mutación_prueba_1:11:
---------------------------------------- --------------
7: from mrstatus import MergeRequestStatus as Status
8:
9:
10: def evaluar_merge_request(upvote_count, downvotes_count): ~11: si
downvotes_count < 0:
12: return Status.REJECTED
13: si upvote_count >= 2:
14: estado de retorno.APROBADO
15: estado de retorno.PENDIENTE
------------------------------ ------------------------
[0.00401 s] eliminado por test_approved
(test_mutation_testing_1.TestMergeRequestE Evaluation)

Observe que este mutante consiste en la versión original con el operador cambiado en la
línea 11 ( > por < ), y el resultado nos dice que las pruebas mataron a este mutante. Esto
quiere decir que con esta versión del código (imaginemos que alguien por error hace este
cambio), entonces el resultado de la función hubiera sido APROBADO , y como la prueba
espera que sea RECHAZADO , falla, lo cual es buena señal (la prueba detectó el error que se
introdujo).
[ 250 ]
Pruebas unitarias y refactorización Capítulo 8

Las pruebas de mutación son una buena manera de asegurar la calidad de las pruebas
unitarias, pero requieren cierto esfuerzo y un análisis cuidadoso. Al usar esta herramienta
en entornos complejos, tendremos que tomarnos un tiempo analizando cada escenario.
También es cierto que es costoso ejecutar estas pruebas porque requiere múltiples
ejecuciones de diferentes versiones del código, lo que puede consumir demasiados recursos
y puede demorar más en completarse. Sin embargo, sería aún más costoso tener que
realizar estas comprobaciones manualmente y requerirá mucho más esfuerzo. No hacer
estas comprobaciones en absoluto podría ser incluso más arriesgado, porque estaríamos
poniendo en peligro la calidad de las pruebas.

Una breve introducción al


desarrollo basado en pruebas
Hay libros completos dedicados únicamente a TDD, por lo que no sería realista tratar de
cubrir este tema de manera integral en este libro. Sin embargo, es un tema tan importante
que tiene que ser mencionado.

La idea detrás de TDD es que las pruebas deben escribirse antes que el código de
producción de manera que el código de producción solo se escriba para responder a las
pruebas que fallan debido a la falta de implementación de la funcionalidad.

Hay múltiples razones por las que nos gustaría escribir las pruebas primero y luego el
código.
Desde un punto de vista pragmático, estaríamos cubriendo nuestro código de producción
con bastante
precisión. Dado que todo el código de producción se escribió para responder a una prueba
unitaria, sería muy poco probable que faltaran pruebas de funcionalidad (eso no significa
que haya una cobertura del 100%, por supuesto, pero al menos todas las funciones
principales, métodos, o componentes tendrán sus respectivas pruebas, aunque no estén
completamente cubiertos).

El flujo de trabajo es simple y en un alto nivel consta de tres pasos. Primero, escribimos
una prueba unitaria que describe algo que necesitamos implementar. Cuando ejecutemos
esta prueba, fallará, porque esa funcionalidad aún no se ha implementado. Luego,
pasamos a
implementar el código mínimo requerido que satisfaga esa condición y ejecutamos la
prueba nuevamente. Esta vez, la prueba debería pasar. Ahora, podemos mejorar
(refactorizar) el código.
Este ciclo se ha popularizado como el famoso red-green-refactor , lo que significa que
al principio, las pruebas fallan (rojo), luego las hacemos pasar (verde), y luego
procedemos a refactorizar el código e iterarlo.

[ 251 ]
Pruebas unitarias y refactorización Capítulo 8

Resumen
Las pruebas unitarias son un tema realmente interesante y profundo, pero lo que es más
importante, es una parte crítica del código limpio. En última instancia, las pruebas unitarias
son las que determinan la calidad del código. Las pruebas unitarias a menudo actúan como
un espejo del código : cuando el código es fácil de probar, es claro y está diseñado
correctamente, y esto se reflejará en las pruebas unitarias.

El código para las pruebas unitarias es tan importante como el código de producción. Todos
los principios que se aplican al código de producción también se aplican a las pruebas
unitarias. Esto significa que deben diseñarse y mantenerse con el mismo esfuerzo y
consideración. Si no nos preocupamos por nuestras pruebas unitarias, comenzarán a tener
problemas y se volverán defectuosas (o problemáticas), y como resultado, inútiles. Si esto
sucede, y son difíciles de mantener, se convierten en un lastre que empeora aún más las
cosas, porque la gente tenderá a ignorarlos o desactivarlos por completo.
Este es el peor escenario porque una vez que esto sucede, todo el código de
producción está en peligro. Avanzar a ciegas (sin pruebas unitarias) es una receta
para el desastre.

Afortunadamente, Python proporciona muchas herramientas para pruebas unitarias,


tanto en la biblioteca estándar como disponible a través de pip . Son de gran ayuda, e
invertir tiempo en configurarlos realmente vale la pena a largo plazo.

Hemos visto cómo las pruebas unitarias funcionan como la especificación formal del
programa y la prueba de que una pieza de software funciona de acuerdo con la
especificación, y también aprendimos que cuando se trata de descubrir nuevos escenarios
de prueba, siempre hay margen de mejora. y que siempre podemos crear más pruebas. En
este sentido, expandir nuestras pruebas unitarias con diferentes enfoques (como pruebas
basadas en propiedades o pruebas de mutaciones) es una buena inversión.

Referencias
Aquí hay una lista de información que puede consultar:

El módulo unittest de la biblioteca estándar de Python contiene documentación


completa sobre cómo comenzar a crear un conjunto de pruebas ( https:/
)/docs.python.org/ 3/library/unittest.html
Documentación oficial de hipótesis (https:/)/hypothesis.readthedocs.io/en/
latest/
documentación oficial https:/de pytest ( )/docs.pytest.org/en/latest/
The Cathedral and the Bazaar: Musings on Linux and Open Source by an Accidental
Revolutionary ( CatB ), escrito por Eric S. Raymond (editor O'Reilly Media, 1999)

[ 252 ]
Patrones de diseño

comunes 
Los patrones de diseño han sido un tema generalizado en la ingeniería de software desde su
creación original en el famoso libro Gang of Four ( GoF ), Design Patterns: Elements of
Reusable Object-Oriented Software . Los patrones de diseño ayudan a resolver problemas
comunes con abstracciones que funcionan para ciertos escenarios. Cuando se implementan
correctamente, el diseño general de la solución puede beneficiarse de ellos.

En este capítulo echamos un vistazo a algunos de los patrones de diseño más comunes,
pero no desde la perspectiva de herramientas para aplicar bajo ciertas condiciones (una
vez que se han ideado los patrones), sino que analizamos cómo los patrones de diseño
contribuyen a un código limpio. Después de presentar una solución que implementa un
patrón de diseño, analizamos cómo la implementación final es comparativamente mejor
que si hubiéramos elegido un camino diferente.

Como parte de este análisis, veremos cómo implementar patrones de diseño en Python de
manera concreta. Como resultado de ello, veremos que la naturaleza dinámica de Python
implica algunas diferencias de implementación, con respecto a otros lenguajes tipificados
estáticos, para los cuales se pensaron originalmente muchos de los patrones de diseño. Esto
significa que hay algunas particularidades sobre los patrones de diseño que debe tener en
cuenta cuando se trata de Python y, en algunos casos, tratar de aplicar un patrón de diseño
donde realmente no encaja no es Pythonic.

En este capítulo, cubriremos los siguientes temas:

Patrones de diseño comunes.


Patrones de diseño que no se aplican en Python y la alternativa idiomática
que se debe seguir.
La forma Pythonic de implementar los patrones de diseño más comunes.
Comprender cómo las buenas abstracciones evolucionan naturalmente en
patrones.
Patrones de diseño comunes Capítulo 9

Consideraciones para patrones de


diseño en Python
Los patrones de diseño orientado a objetos son ideas de construcción de software que
aparecen en diferentes escenarios cuando tratamos con modelos del problema que estamos
resolviendo. Debido a que son ideas de alto nivel, es difícil pensar en ellas como vinculadas
a lenguajes de programación particulares. En cambio, son conceptos más generales sobre
cómo los objetos interactuarán en la aplicación. Por supuesto, tendrán sus detalles de
implementación, que variarán de un idioma a otro, pero eso no forma la esencia de un
patrón de diseño.

Ese es el aspecto teórico de un patrón de diseño, el hecho de que es una idea abstracta que
expresa conceptos sobre el diseño de los objetos en la solución. Hay muchos otros libros y
varios otros recursos sobre diseño orientado a objetos y patrones de diseño en particular,
por lo que en este libro nos centraremos en los detalles de implementación para Python.

Dada la naturaleza de Python, algunos de los patrones de diseño clásicos no son realmente
necesarios.
Eso significa que Python ya admite funciones que hacen que esos patrones sean
invisibles. Algunos argumentan que no existen en Python, pero tenga en cuenta que
invisible no significa que no existe. Están allí, simplemente incrustados en Python, por lo
que es probable que ni siquiera los notemos.

Otros tienen una implementación mucho más sencilla, de nuevo gracias a la naturaleza
dinámica del lenguaje, y el resto son prácticamente iguales que en otras plataformas, con
pequeñas diferencias.

En cualquier caso, el objetivo importante para lograr un código limpio en Python es saber
qué patrones implementar y cómo. Eso significa reconocer algunos de los patrones que
Python ya abstrae y cómo podemos aprovecharlos. Por ejemplo, sería completamente
diferente a Python tratar de implementar la definición estándar del patrón iterador (como lo
haríamos en diferentes lenguajes), porque (como ya hemos cubierto) la iteración está
profundamente integrada en Python, y el hecho de que podemos crear objetos que
funcionarán directamente en un bucle for , lo que hace que esta sea la forma correcta de
proceder.
[ 254 ]
Patrones de diseño comunes Capítulo 9

Algo similar sucede con algunos de los patrones creacionales. Las clases son objetos
regulares en Python, al igual que las funciones. Como hemos visto en varios ejemplos hasta
ahora, se pueden pasar, decorar, reasignar, etc. Eso significa que cualquier tipo de
personalización que nos gustaría hacer a nuestros objetos, lo más probable es que podamos
hacerlo sin necesidad de ninguna configuración particular de clases de fábrica. Además, no
existe una sintaxis especial para crear objetos en Python (por ejemplo, ninguna palabra
clave nueva). Esta es otra razón por la que, la mayoría de las veces, una simple llamada de
función funcionará como una fábrica.

Todavía se necesitan otros patrones, y veremos cómo, con algunas pequeñas adaptaciones,
podemos hacerlos más pitónicos, aprovechando al máximo las características que
proporciona el lenguaje (métodos mágicos o la biblioteca estándar).

De todos los patrones disponibles, no todos son igualmente frecuentes, ni útiles, por lo que
nos centraremos en los principales, aquellos que más esperaríamos ver en nuestras
aplicaciones, y lo haremos siguiendo un enfoque pragmático. .

Patrones de diseño en acción


La referencia canónica en este tema, según lo escrito por el GoF, presenta 23 patrones de
diseño, cada uno de los cuales se incluye en una de las categorías de creación, estructura y
comportamiento. Hay incluso más patrones o variaciones de los existentes, pero en lugar de
aprender todos estos patrones de memoria, debemos centrarnos en tener dos cosas en
mente. Algunos de los patrones son invisibles en Python, y probablemente los usamos sin
darnos cuenta. En segundo lugar, no todos los patrones son igualmente comunes; algunos
de ellos son tremendamente útiles, por lo que se encuentran con mucha frecuencia, mientras
que otros son para casos más específicos.

En esta sección, revisaremos los patrones más comunes, aquellos que tienen más
probabilidades de surgir de nuestro diseño. Nótese el uso de la palabra emerger aquí. Es
importante. No debemos forzar la aplicación de un patrón de diseño a la solución que
estamos construyendo, sino evolucionar, refactorizar y mejorar nuestra solución hasta que
surja un patrón.

Por lo tanto, los patrones de diseño no se inventan sino que se descubren. Cuando se revela
una situación que ocurre repetidamente en nuestro código, el diseño general y más
abstracto de clases, objetos y componentes relacionados aparece bajo un nombre con el que
identificamos un patrón.
[ 255 ]
Patrones de diseño comunes Capítulo 9

Pensando lo mismo, pero ahora al revés, nos damos cuenta de que el nombre de un patrón
de diseño envuelve muchos conceptos. Esto es probablemente lo mejor de los patrones de
diseño; proporcionan un lenguaje. A través de patrones de diseño, es más fácil comunicar
ideas de diseño de manera efectiva. Cuando dos o más ingenieros de software comparten
el mismo vocabulario, y uno de ellos menciona builder, el resto puede pensar
inmediatamente en todas las clases, y cómo se relacionarían, cuál sería su mecánica, etc.,
sin tener que repita esta explicación de nuevo.

El lector notará que el código que se muestra en este capítulo es diferente de la visión
canónica u original del patrón de diseño en cuestión. Hay más de una razón para esto. La
primera razón es que los ejemplos adoptan un enfoque más pragmático, dirigido a
soluciones para escenarios particulares en lugar de explorar la teoría general del diseño. La
segunda razón es que los patrones están implementados con las particularidades de Python,
que en algunos casos son muy sutiles, pero en otros las diferencias son notorias,
generalmente simplificando el código.

Patrones creacionales
En ingeniería de software, los patrones de creación son aquellos que se ocupan de la
creación de instancias de objetos, tratando de abstraer gran parte de la complejidad (como
determinar los parámetros para inicializar un objeto, todos los objetos relacionados que
podrían ser necesarios, etc.), para dejar el usuario con una interfaz más simple, que
debería ser más segura de usar. La forma básica de creación de objetos podría dar lugar a
problemas de diseño o complejidad añadida al diseño. Los patrones de diseño creacional
resuelven este problema al controlar de alguna manera la creación de este objeto.

De los cinco patrones para crear objetos, discutiremos principalmente las variantes que se
usan para evitar el patrón singleton y reemplazarlo con el patrón Borg (más comúnmente
usado en aplicaciones de Python), discutiendo sus diferencias y ventajas.

Fábricas
Como se mencionó en la introducción, una de las características principales de Python es
que todo es un objeto y, como tal, todos pueden tratarse por igual. Esto significa que no hay
distinciones especiales de cosas que podemos o no podemos hacer con clases, funciones u
objetos personalizados. Todos pueden pasarse por parámetro, asignarse, etc.
Es por esta razón que muchos de los patrones de fábrica no son realmente necesarios.
Podríamos simplemente definir una función que construirá un conjunto de objetos, e
incluso podemos pasar la clase que queremos crear por un parámetro.

[ 256 ]
Patrones de diseño comunes Capítulo 9

Singleton y estado compartido (monoestado)


El patrón singleton, por otro lado, es algo que Python no abstrae por completo. La verdad es
que la mayoría de las veces, este patrón no es realmente necesario o es una mala elección.
Hay muchos problemas con los singletons (después de todo, son, de hecho, una forma de
variables globales para el software orientado a objetos y, como tales, son una mala práctica).
Son difíciles de probar unitariamente, el hecho de que puedan ser modificados en cualquier
momento por cualquier objeto los hace difíciles de predecir, y sus efectos secundarios
pueden ser realmente problemáticos.

Como principio general, debemos evitar el uso de singletons tanto como sea posible. Si en
algún caso extremo son necesarios, la forma más fácil de lograrlo en Python es mediante el
uso de un módulo. Podemos crear un objeto en un módulo y, una vez que esté allí, estará
disponible desde todas las partes del módulo que se importen. Python mismo se asegura de
que los módulos ya sean singletons, en el sentido de que no importa cuántas veces se
importen y desde cuántos lugares, el mismo módulo es siempre el que se va a cargar
en sys.modules .

estado compartido
En lugar de obligar a nuestro diseño a tener un singleton en el que solo se cree una
instancia, sin importar cómo se invoque, construya o inicialice el objeto, es mejor replicar los
datos en varias instancias.

La idea del patrón monoestado (SNGMONO) es que podemos tener muchas instancias que
son solo objetos regulares, sin que nos importe si son singletons o no (ya que son solo
objetos). Lo bueno de este patrón es que estos objetos tendrán su información sincronizada,
de forma totalmente transparente, sin que tengamos que preocuparnos de cómo funciona
esto internamente.

Esto hace que este patrón sea una opción mucho mejor, no solo por su conveniencia, sino
también porque es menos propenso a errores y tiene menos desventajas de los singletons
(con respecto a su comprobabilidad, creación de clases derivadas, etc.).

Podemos usar este patrón en muchos niveles, dependiendo de cuánta información


necesitemos sincronizar.

En su forma más simple, podemos suponer que solo necesitamos tener un atributo para
que se refleje en todas las instancias. Si ese es el caso, la implementación es tan trivial
como usar una variable de clase, y solo debemos tener cuidado de proporcionar una
interfaz correcta para actualizar y recuperar el valor del atributo.
[ 257 ]
Patrones de diseño comunes Capítulo 9

Digamos que tenemos un objeto que tiene que extraer una versión de un código en un
repositorio de Git por la etiqueta más reciente . Puede haber varias instancias de este objeto, y
cuando cada cliente llama al método para obtener el código, este objeto utilizará la versión
de la etiqueta de su atributo. En cualquier momento, esta etiqueta se puede actualizar para
una versión más nueva, y queremos que cualquier otra instancia (nueva o ya creada) use
esta nueva rama cuando se llama a la operación de búsqueda , como se muestra en el siguiente
código:

clase GitFetcher:
_current_tag = Ninguno

def __init__(self, etiqueta):


self.current_tag = etiqueta

@property
def current_tag(self):
si self._current_tag es Ninguno:
aumentar AttributeError("la etiqueta nunca se estableció")
return self._current_tag

@current_tag.setter
def current_tag(self, new_tag):
self.__class__._current_tag = new_tag

def pull(self):
logger.info("extrayendo de %s", self.etiqueta_actual) return
self.etiqueta_actual

El lector puede simplemente verificar que la creación de múltiples objetos del tipo
GitFetcher con diferentes versiones dará como resultado que todos los objetos se
configuren con la última versión en cualquier momento, como se muestra en el
siguiente código:

>>> f1 = GitFetcher(0.1)
>>> f2 = GitFetcher(0.2)
>>> f1.etiqueta_actual = 0.3
>>> f2.pull()
0.3
>>> f1.pull()
0.3

En el caso de que necesitemos más atributos, o que deseemos encapsular un poco más el
atributo compartido, para que el diseño sea más limpio, podemos usar un descriptor.
[ 258 ]
Patrones de diseño comunes Capítulo 9

Un descriptor, como el que se muestra en el siguiente código, resuelve el problema y, si bien


es cierto que requiere más código, también encapsula una responsabilidad más concreta, y
parte del código se aleja de nuestra clase original, lo que hace que uno de ellos más cohesivo
y compatible con el principio de responsabilidad única:

class SharedAttribute:
def __init__(self, initial_value=Ninguno): self.value
= initial_value
self._name = Ninguno

def __get__(self, instancia, propietario):


si la instancia es None:
devuelve self
si self.value es None: aumenta
AttributeError(f"{self._name} nunca se estableció") return self.value

def __set__(self, instancia, new_value): self.value


= new_value

def __set_name__(self, propietario, nombre):


self._name = nombre

Aparte de estas consideraciones, también es cierto que el patrón ahora es más reutilizable.
Si queremos repetir esta lógica, solo tenemos que crear un nuevo objeto descriptor que
funcione (cumpliendo con el principio DRY).

Si ahora queremos hacer lo mismo, pero para la rama actual, creamos este nuevo atributo de
clase, y el resto de la clase se mantiene intacto, sin dejar de tener la lógica deseada, como se
muestra en el siguiente código:

clase GitFetcher:
etiqueta_actual = AtributoCompartido()
rama_actual = AtributoCompartido()

def __init__(self, etiqueta, rama=Ninguno):


self.etiqueta_actual = etiqueta
self.rama_actual = rama

def pull(self):
logger.info("extrayendo de %s", self.etiqueta_actual) return
self.etiqueta_actual
[ 259 ]
Patrones de diseño comunes Capítulo 9

El equilibrio y la compensación de este nuevo enfoque deberían estar claros ahora. Esta
nueva implementación usa un poco más de código, pero es reutilizable, por lo que
ahorra líneas de código (y lógica duplicada) a largo plazo. Una vez más, consulte la regla
de tres o más instancias para decidir si debe crear tal abstracción.

Otro beneficio importante de esta solución es que también reduce la repetición de pruebas
unitarias.
Reutilizar el código aquí nos dará más confianza en la calidad general de la solución,
porque ahora solo tenemos que escribir pruebas unitarias para el objeto descriptor, no
para todas las clases que lo usan (podemos asumir con seguridad que son correctas
siempre que ya que las pruebas unitarias prueban que el descriptor es correcto).

el patrón de Borgoña
Las soluciones anteriores deberían funcionar para la mayoría de los casos, pero si
realmente tenemos que optar por un singleton (y esta tiene que ser una muy buena
excepción), entonces hay una última alternativa mejor, solo que esta es más
arriesgada.

Este es el patrón monoestado real, denominado patrón borg en Python. La idea es crear un
objeto que sea capaz de replicar todos sus atributos entre todas las instancias de la misma
clase. El hecho de que se repliquen absolutamente todos los atributos tiene que ser una
advertencia para tener en cuenta los efectos secundarios no deseados. Aún así, este patrón
tiene muchas ventajas sobre el singleton.

En este caso, vamos a dividir el objeto anterior en dos : uno que funciona con las
etiquetas de Git y el otro con las ramas. Y estamos usando el código que hará que el
patrón borg funcione:

class BaseFetcher:
def __init__(self, fuente):
self.fuente = fuente

clase TagFetcher(BaseFetcher):
_atributos = {}

def __init__(self, fuente):


self.__dict__ = self.__class__._attributes super().__init__(fuente)

def pull(self):
logger.info("extrayendo de la etiqueta %s", self.source) return f"Tag
= {self.source}"
clase BranchFetcher(BaseFetcher):

[ 260 ]
Patrones de diseño comunes Capítulo 9

_atributos = {}

def __init__(self, fuente):


self.__dict__ = self.__class__._attributes super().__init__(fuente)

def pull(self):
logger.info("extrayendo de la rama %s", self.source) return f"Branch =
{self.source}"

Ambos objetos tienen una clase base, compartiendo su método de inicialización. Pero luego
tienen que implementarlo nuevamente para que la lógica borg funcione. La idea es que
usamos un atributo de clase que es un diccionario para almacenar los atributos, y luego
hacemos que el diccionario de cada objeto (en el momento en que se inicializa) use este
mismo diccionario. Esto quiere decir que cualquier actualización en el diccionario de un
objeto se reflejará en la clase, que será la misma para el resto de objetos porque su clase es la
misma, y los diccionarios son objetos mutables que se pasan como referencia. En otras
palabras, cuando creamos nuevos objetos de este tipo, todos utilizarán el mismo
diccionario, y este diccionario se actualiza constantemente.

Tenga en cuenta que no podemos poner la lógica del diccionario en la clase base, porque
esto mezclará los valores entre los objetos de diferentes clases, que no es lo que queremos.
Esta solución repetitiva es lo que haría que muchos pensaran que en realidad es una
expresión idiomática en lugar de un patrón.

Una forma posible de abstraer esto de una manera que logre el principio DRY sería
crear una clase mixin, como se muestra en el siguiente código:

class SharedAllMixin:
def __init__(self, *args, **kwargs): try:
self.__class__._attributes
excepto AttributeError:
self.__class__._attributes = {}

self.__dict__ = self.__class__._atributos super().__init__(*args,


**kwargs)

class BaseFetcher:
def __init__(self, fuente):
self.fuente = fuente
class TagFetcher(SharedAllMixin, BaseFetcher):
def pull(self):
logger.info("extrayendo de la etiqueta %s", self.source)

[ 261 ]
Patrones de diseño comunes Capítulo 9

return f"Etiqueta = {self.fuente}"

class BranchFetcher(SharedAllMixin, BaseFetcher):


def pull(self):
logger.info("obteniendo de la rama %s", self.source) return f"Branch =
{self.source}"

Esta vez, estamos usando la clase mixin para crear el diccionario con los atributos de
cada clase en caso de que aún no exista, y luego continuamos con la misma lógica.

Esta implementación no debería tener mayores problemas con la herencia, por lo que es
una alternativa más viable.

Constructor
El patrón constructor es un patrón interesante que abstrae toda la compleja
inicialización de un objeto. Este patrón no se basa en ninguna particularidad del lenguaje,
por lo que es igualmente aplicable en Python como lo sería en cualquier otro lenguaje.

Si bien resuelve un caso válido, también suele ser un caso complicado que es más probable
que aparezca en el diseño de un marco, una biblioteca o una API. De manera similar a las
recomendaciones dadas para los descriptores, debemos reservar esta implementación para
los casos en los que esperamos exponer una API que va a ser consumida por varios
usuarios.

La idea de alto nivel de este patrón es que necesitamos crear un objeto complejo, que es un
objeto que también requiere que muchos otros trabajen con él. En lugar de permitir que el
usuario cree todos esos objetos auxiliares y luego los asigne al principal, nos gustaría crear
una
abstracción que permita hacer todo eso en un solo paso. Para lograr esto, tendremos un
objeto constructor que sepa crear todas las partes y vincularlas entre sí, dándole al usuario
una interfaz (que podría ser un método de clase), para parametrizar toda la información
sobre lo que debe ser el objeto resultante. parece.

Patrones estructurales
Los patrones estructurales son útiles para situaciones en las que necesitamos crear interfaces
más simples u objetos que sean más potentes al extender su funcionalidad sin agregar
complejidad a sus interfaces.
[ 262 ]
Patrones de diseño comunes Capítulo 9

Lo mejor de estos patrones es que podemos crear objetos más interesantes, con una
funcionalidad mejorada, y podemos lograr esto de una manera limpia; es decir,
componiendo múltiples objetos únicos (el ejemplo más claro de esto es el patrón
compuesto), o reuniendo muchas interfaces simples y cohesivas.

Adaptador
El patrón adaptador es probablemente uno de los patrones de diseño más simples que
existen y, al mismo tiempo, uno de los más útiles. También conocido como envoltorio, este
patrón resuelve el problema de adaptar interfaces de dos o más objetos que no son
compatibles.

Por lo general, nos encontramos con la situación en la que parte de nuestro código
funciona con un modelo o conjunto de clases que eran polimórficas con respecto a un
método. Por ejemplo, si hubiera varios objetos para recuperar datos con un método fetch()
, entonces queremos mantener esta interfaz para no tener que hacer cambios importantes
en nuestro código.

Pero luego llegamos a un punto en el que es necesario agregar una nueva fuente de datos y,
lamentablemente, esta no tendrá un método fetch() . Para empeorar las cosas, no solo este
tipo de objeto no es compatible, sino que tampoco es algo que controlemos (quizás un
equipo diferente se decidió por la API y no podemos modificar el código).

En lugar de usar este objeto directamente, adaptamos su interfaz a la que necesitamos.


Hay dos maneras de hacer esto.

La primera forma sería crear una clase que herede de la que queremos usar, y que cree un
alias para el método (si es necesario, también tendrá que adaptar los parámetros y la firma).

Mediante herencia, importamos la clase externa y creamos una nueva que definirá el
nuevo método, llamando al que tiene otro nombre. En este ejemplo, digamos que la
dependencia externa tiene un método llamado search() , que toma solo un parámetro para
la búsqueda porque consulta de una manera diferente, por lo que nuestro método de
adaptador no solo llama al externo, sino que también traduce los parámetros. en
consecuencia, como se muestra en el siguiente código:

de _adapter_base importar UsernameLookup

class UserSource(UsernameLookup):
def fetch(self, user_id, username):
user_namespace = self._adapt_arguments(user_id, username) return
self.search(user_namespace)

[ 263 ]
Patrones de diseño comunes Capítulo 9

@staticmethod
def _adapt_arguments(user_id, nombre de usuario):
return f"{user_id}:{username}"

Puede darse el caso de que nuestra clase ya derive de otra, en cuyo caso, esto terminará
como un caso de herencias múltiples, que es compatible con Python, por lo que no debería
ser un problema. Sin embargo, como hemos visto muchas veces antes, la herencia viene con
más acoplamiento (¿quién sabe cuántos otros métodos se llevan desde la biblioteca
externa?) y es inflexible. Conceptualmente, tampoco sería la elección correcta porque
reservamos la herencia para situaciones de especificación (an es un tipo de relación), y en
este caso, no está nada claro que nuestro objeto tenga que ser uno de los tipos que son
proporcionado por una biblioteca de terceros (especialmente porque no comprendemos
completamente ese objeto).

Por lo tanto, un mejor enfoque sería utilizar la composición en su lugar. Suponiendo que
podamos proporcionar a nuestro objeto una instancia de UsernameLookup , el código
sería tan simple como redirigir la petición antes de adoptar los parámetros, como se
muestra en el siguiente código:

clase fuente de usuario:


...
def buscar(self, user_id, nombre de usuario):
user_namespace = self._adapt_arguments(user_id, username) return
self.username_lookup.search(user_namespace)

Si necesitamos adoptar varios métodos, y también podemos idear una forma genérica de
adaptar su firma, podría valer la pena usar el método mágico __getattr__() para redirigir
las solicitudes hacia el objeto envuelto, pero como siempre con las implementaciones
genéricas, deberíamos tenga cuidado de no agregar más complejidad a la solución.

Compuesto
Habrá partes de nuestros programas que requieren que trabajemos con objetos que están
hechos de otros objetos. Tenemos objetos base que tienen una lógica bien definida, y luego
tendremos otros objetos contenedores que agruparán un montón de objetos base, y el
desafío es que queremos tratarlos a ambos (los objetos base y contenedor) sin notando
alguna diferencia.

Los objetos se estructuran en una jerarquía de árbol, donde los objetos básicos serían las
hojas del árbol, y los objetos compuestos nodos intermedios. Un cliente podría querer
llamar a cualquiera de ellos para obtener el resultado de un método llamado. El objeto
compuesto, sin embargo, actuará como cliente; esto también pasará esta solicitud junto con
todos los objetos que contiene, ya sean hojas u otras notas intermedias hasta que se
procesen todos.

[ 264 ]
Patrones de diseño comunes Capítulo 9

Imagina una versión simplificada de una tienda online en la que tenemos productos. Decir
que ofrecemos la posibilidad de agrupar esos productos, y le damos a los clientes un
descuento por grupo de productos. Un producto tiene un precio, y este valor se pedirá
cuando los clientes vengan a pagar. Pero un conjunto de productos agrupados también
tiene un precio que debe calcularse. Tendremos un objeto que represente este grupo que
contiene los productos, y que delegue la responsabilidad de pedir el precio a cada producto
en particular (que puede ser otro grupo de productos también), y así sucesivamente, hasta
que no haya nada más que calcular . La implementación de esto se muestra en el siguiente
código:

clase Producto:
def __init__(self, nombre, precio):
self._name = nombre
self._price = precio

@property
def price(self):
return self._price

class ProductBundle:
def __init__(
self,
nombre,
perc_discount,
*products: Iterable[ Union[Product, "ProductBundle"] ] ) -> Ninguno:
self._name = nombre
self._perc_discount = perc_discount
self._products = productos

@property
def price(self):
total = sum(p.price for p in self._products) return total *
(1 - self._perc_discount)

Exponemos la interfaz pública a través de una propiedad y dejamos el precio como un


atributo privado. La clase ProductBundle utiliza esta propiedad para calcular el valor
con el descuento aplicado sumando primero todos los precios de todos los productos
que contiene.

La única discrepancia entre estos objetos es que se crean con diferentes parámetros. Para
ser totalmente compatibles, deberíamos haber intentado imitar la misma interfaz y luego
agregar métodos adicionales para agregar productos al paquete pero usando una interfaz
que permitiera la creación de objetos completos. No necesitar estos pasos extra es una
ventaja que justifica esta pequeña diferencia.

[ 265 ]
Patrones de diseño comunes Capítulo 9

Decorador
No confunda el patrón decorador con el concepto de un decorador de Python que hemos
visto en el Capítulo 5 , Uso de decoradores para mejorar nuestro código . Hay cierta
semejanza, pero la idea del patrón de diseño es bastante diferente.

Este patrón nos permite extender dinámicamente la funcionalidad de algunos objetos, sin
necesidad de herencia. Es una buena alternativa a la herencia múltiple para crear objetos
más flexibles.

Vamos a crear una estructura que permita al usuario definir un conjunto de operaciones
(decoraciones) para aplicar sobre un objeto, y veremos cómo se lleva a cabo cada paso en el
orden especificado.

El siguiente ejemplo de código es una versión simplificada de un objeto que construye una
consulta en forma de diccionario a partir de parámetros que se le pasan (podría ser un
objeto que usaríamos para ejecutar consultas a elasticsearch, por ejemplo, pero el código
deja de lado los detalles de implementación que distraen para centrarse en los conceptos
del patrón).

En su forma más básica, la consulta solo devuelve el diccionario con los datos que se
proporcionaron cuando se creó. Los clientes esperan usar el método render() de este
objeto:

clase DictQuery:
def __init__(self, **kwargs):
self._raw_query = kwargs

def render(self) -> dict:


return self._raw_query

Ahora queremos representar la consulta de diferentes maneras aplicando


transformaciones a los datos (filtrando valores, normalizándolos, etc.). Podríamos crear
decoradores y aplicarlos al método de renderizado , pero eso no sería lo suficientemente
flexible, ¿y si queremos cambiarlos en tiempo de ejecución? ¿O si queremos seleccionar
algunos de ellos, pero no otros?

El diseño es crear otro objeto, con la misma interfaz y la capacidad de mejorar (decorar) el
resultado original a través de muchos pasos, pero que se pueden combinar.
Estos objetos están encadenados, y cada uno de ellos hace lo que originalmente se
suponía que debía hacer, además de algo más. Ese algo más es el paso particular de la
decoración.
Dado que Python tiene escritura pato, no necesitamos crear una nueva clase base y hacer
que estos nuevos objetos formen parte de esa jerarquía, junto con DictQuery . Simplemente
crear una nueva clase que tenga un método render() será suficiente (nuevamente, el
polimorfismo no debería requerir herencia).
Este proceso se muestra en el siguiente código:
clase QueryEnhancer:
def __init__(self, consulta: DictQuery):

[ 266 ]
Patrones de diseño comunes Capítulo 9

self.decorated = consulta

def render(self):
return self.decorated.render()

class RemoveEmpty(QueryEnhancer):
def render(self):
original = super().render()
return {k: v for k, v in original.items() if v}

clase CaseInsensible(QueryEnhancer):
def render(self):
original = super().render()
return {k: v.lower() for k, v in original.items()}

La frase QueryEnhancer tiene una interfaz compatible con lo que esperan los clientes de
DictQuery , por lo que son intercambiables. Este objeto está diseñado para recibir un
decorado. Tomará los valores de esto y los convertirá, devolviendo la versión modificada
del código.

Si queremos eliminar todos los valores que se evalúan como False y normalizarlos para
formar nuestra consulta original, tendríamos que usar el siguiente esquema:
>>> original = DictQuery(clave="valor", vacío="", ninguno=Ninguno,
superior="MAYÚSCULAS", título="Título")
>>> nueva_consulta = Insensible a mayúsculas y minúsculas(EliminarVacío(original))
>>> original .render()
{'clave': 'valor', 'vacío': '', 'ninguno': Ninguno, 'superior': 'MAYÚSCULAS', 'título': 'Título'}
>>> nueva_consulta.render()
{'clave': 'valor', 'superior': 'mayúsculas', 'título': 'título'}

Este es un patrón que también podemos implementar de diferentes maneras, aprovechando


la naturaleza dinámica de Python y el hecho de que las funciones son objetos. Podríamos
implementar este patrón con funciones que se proporcionan al objeto decorador base (
QueryEnhancer ) y definir cada paso de decoración como una función, como se muestra en el
siguiente código:
class QueryEnhancer:
def __init__(
self,
consulta: DictQuery,
*decorators: Iterable[Callable[[Dict[str, str]], Dict[str, str]]] ) -> Ninguno:
self._decorated = consulta
self._decorators = decoradores

[ 267 ]
Patrones de diseño comunes Capítulo 9

def render(self):
resultado_actual = self._decorated.render() for deco
in self._decorators:
current_result = deco(current_result) return
current_result

Con respecto al cliente, nada ha cambiado porque esta clase mantiene la


compatibilidad a través de su método render() . Sin embargo, internamente, este objeto se
usa de una manera ligeramente diferente, como se muestra en el siguiente código:

>>> consulta = DictQuery(foo="barra", vacío="", ninguno=Ninguno, superior="MAYÚSCULAS",


título="Título")
>>> QueryEnhancer(consulta, eliminar_vacío, mayúsculas y minúsculas).render() { 'foo':
'barra', 'superior': 'mayúsculas', 'título': 'título'}

En el código anterior, remove_empty y case_insensitive son solo funciones regulares que


transforman un diccionario.

En este ejemplo, el enfoque basado en funciones parece más fácil de entender. Puede
haber casos con reglas más complejas que se basan en datos del objeto que se está
decorando (no solo su resultado), y en esos casos, podría valer la pena optar por el
enfoque orientado a objetos, especialmente si realmente queremos crear una jerarquía. de
objetos donde cada clase en realidad representa algún conocimiento que queremos hacer
explícito en nuestro diseño.

Fachada
La fachada es un patrón excelente. Es útil en muchas situaciones en las que queremos
simplificar la interacción entre objetos. El patrón se aplica donde existe una relación de
muchos a muchos entre varios objetos, y queremos que interactúen. En lugar de crear
todas estas conexiones, colocamos un objeto intermedio frente a muchas de ellas que
actúan como fachada.

La fachada funciona como un eje o un único punto de referencia en este diseño. Cada vez
que un nuevo objeto quiere conectarse a otro, en lugar de tener que tener N interfaces para
todos los N posibles objetos a los que necesita conectarse, simplemente hablará con la
fachada, y esto redirigirá la solicitud en consecuencia. Todo lo que hay detrás de la
fachada es completamente opaco al resto de los objetos externos.

Además del beneficio principal y obvio (el desacoplamiento de objetos), este patrón
también fomenta un diseño más simple con menos interfaces y una mejor
encapsulación.
[ 268 ]
Patrones de diseño comunes Capítulo 9

Este es un patrón que podemos usar no solo para mejorar el código de nuestro problema de
dominio, sino también para crear mejores API. Si usamos este patrón y proporcionamos una
sola interfaz, que actúa como un único punto de verdad o punto de entrada para nuestro
código, será mucho más fácil para nuestros usuarios interactuar con la funcionalidad
expuesta. No solo eso, sino que al exponer una funcionalidad y ocultar todo detrás de una
interfaz, somos libres de cambiar o refactorizar ese
código subyacente tantas veces como queramos, porque mientras esté detrás de la fachada,
no romperá la compatibilidad con versiones anteriores, y nuestros usuarios no se verán
afectados.

Tenga en cuenta que esta idea de usar fachadas ni siquiera se limita a objetos y clases, sino
que también se aplica a paquetes (técnicamente, los paquetes son objetos en Python, pero
aun así). Podemos usar esta idea de la fachada para decidir el diseño de un paquete; es
decir, lo que es visible para el usuario e importable, y lo que es interno y no debe
importarse directamente.

Cuando creamos un directorio para construir un paquete, colocamos el archivo __init__.py


junto con el resto de los archivos. Esta es la raíz del módulo, una especie de fachada. El
resto de los archivos definen los objetos a exportar, pero no deben ser importados
directamente por los clientes. El archivo init debería importarlos y luego los clientes
deberían obtenerlos desde allí. Esto crea una mejor interfaz porque los usuarios solo
necesitan conocer un único punto de entrada desde el cual obtener los objetos y, lo que es
más importante, el paquete (el resto de los archivos) se puede refactorizar o reorganizar
tantas veces como sea necesario, y esto no afectan a los clientes siempre que se mantenga la
API principal en el archivo init . Es de suma importancia tener en cuenta principios como
este para crear software que se pueda mantener.

Hay un ejemplo de esto en Python mismo, con el módulo os . Este módulo agrupa la
funcionalidad de un sistema operativo, pero debajo de él, utiliza el módulo posix para
sistemas operativos de interfaz de sistema operativo portátil ( POSIX ) (esto se llama nt
en plataformas Windows). La idea es que, por razones de portabilidad, nunca deberíamos
importar el módulo posix directamente, sino siempre el módulo os . Depende de este
módulo determinar desde qué plataforma se está llamando y exponer la funcionalidad
correspondiente.

Patrones de comportamiento
Los patrones de comportamiento tienen como objetivo resolver el problema de cómo
deben cooperar los objetos, cómo deben comunicarse y cuáles deben ser sus interfaces en
tiempo de ejecución.

Discutimos principalmente los siguientes patrones de comportamiento:

Cadena de responsabilidad
Método de plantilla
Dominio
Estado

[ 269 ]
Patrones de diseño comunes Capítulo 9

Esto se puede lograr estáticamente por medio de herencia o dinámicamente usando


composición. Independientemente de lo que use el patrón, lo que veremos a lo largo de los
siguientes ejemplos es que estos patrones tienen en común el hecho de que el código
resultante es mejor de alguna manera significativa, ya sea porque evita la duplicación o crea
buenas abstracciones que encapsulan comportamiento en consecuencia y desacoplar
nuestros modelos.

Cadena de responsabilidad
Ahora vamos a echar otro vistazo a nuestros sistemas de eventos. Queremos analizar
información sobre los eventos que ocurrieron en el sistema desde las líneas de registro
(archivos de texto, volcados de nuestro servidor de aplicaciones HTTP, por ejemplo) y
queremos extraer esta información de una manera conveniente.

En nuestra implementación anterior, logramos una solución interesante que cumplía con
el principio abierto/cerrado y dependía del uso del método mágico __subclasses__() para
descubrir todos los tipos de eventos posibles y procesar los datos con el evento correcto,
resolviendo la responsabilidad a través de un método encapsulado en cada clase.

Esta solución funcionó para nuestros propósitos y era bastante extensible, pero como
veremos, este patrón de diseño traerá beneficios adicionales.

La idea aquí es que vamos a crear los eventos de una manera ligeramente diferente. Cada
evento todavía tiene la lógica para determinar si puede o no procesar una línea de registro
en particular, pero también tendrá un sucesor. Este sucesor es un nuevo evento, el siguiente
en la línea, que seguirá procesando la línea de texto en caso de que el primero no pudiera
hacerlo. La lógica es simple : encadenamos los eventos y cada uno de ellos trata de procesar
los datos. Si puede, simplemente devuelve el resultado. Si no puede, lo pasará a su sucesor
y lo repetirá, como se muestra en el siguiente código:
importar re

Evento de clase:
patrón = Ninguno

def __init__(self, next_event=Ninguno):


self.successor = next_event

def proceso(self, logline: str):


if self.can_process(logline):
return self._process(logline)
si self.successor no es None:
return self.successor.process(logline)

[ 270 ]
Patrones de diseño comunes Capítulo 9

def _process(self, logline: str) -> dict: parsed_data =


self._parse_data(logline) return {
"type": self.__class__.__name__, "id": parsed_data["id"],
"value": parsed_data[ "valor"],
}

@classmethod
def can_process(cls, logline: str) -> bool:
return cls.pattern.match(logline) no es Ninguno

@classmethod
def _parse_data(cls, logline: str) -> dict:
return cls.pattern.match(logline).groupdict()

clase LoginEvent(Evento):
patrón = re.compile(r"(?P<id>\d+):\s+login\s+(?P<valor>\S+)")

clase LogoutEvent(Evento):
patrón = re.compile(r"(?P<id>\d+):\s+logout\s+(?P<valor>\S+)")

Con esta implementación, creamos los objetos de evento y los organizamos en el orden
particular en el que se van a procesar. Dado que todos tienen un método de proceso () , son
polimórficos para este mensaje, por lo que el orden en que se alinean es completamente
transparente para el cliente, y cualquiera de ellos también sería transparente. No solo eso,
sino que el método process() tiene la misma lógica; intenta extraer la información si los
datos proporcionados son correctos para el tipo de objeto que lo maneja, y si no, pasa al
siguiente de la línea.

De esta forma, podríamos procesar un evento de inicio de sesión de la siguiente manera:


>>> cadena = LogoutEvent(LoginEvent())
>>> chain.process("567: login User")
{'type': 'LoginEvent', 'id': '567', 'value': 'User'}

Observe cómo LogoutEvent recibió LoginEvent como su sucesor, y cuando se le pidió que
procesara algo que no podía manejar, lo redirigió al objeto correcto. Como podemos ver
en la clave de tipo del diccionario, LoginEvent fue el que realmente creó ese diccionario.

Esta solución es lo suficientemente flexible y comparte un rasgo interesante con la anterior


: todas las condiciones son mutuamente excluyentes. Siempre que no haya colisiones y
ningún dato tenga más de un controlador, procesar los eventos en cualquier orden no será
un problema.
[ 271 ]
Patrones de diseño comunes Capítulo 9

Pero, ¿y si no podemos hacer tal suposición? Con la implementación anterior, aún


podíamos cambiar la llamada __subclasses__() por una lista que hicimos de acuerdo con
nuestro criterio, y eso habría funcionado bien. ¿Y si quisiéramos que ese orden de
precedencia se determinara en tiempo de ejecución (por el usuario o cliente, por ejemplo)?
Eso sería un defecto.

Con la nueva solución, es posible cumplir con tales requisitos, porque ensamblamos la
cadena en tiempo de ejecución, por lo que podemos manipularla dinámicamente según sea
necesario.

Por ejemplo, ahora agregamos un tipo genérico que agrupa tanto el inicio como el cierre
de sesión de un evento de sesión, como se muestra en el siguiente código:
class SessionEvent(Evento):
patrón = re.compile(r"(?P<id>\d+):\s+log(in|out)\s+(?P<value>\S+)")

Si por alguna razón, y en alguna parte de la aplicación, queremos capturar esto antes del
evento de inicio de sesión, esto se puede hacer mediante la siguiente cadena :

cadena = SessionEvent(LoginEvent(LogoutEvent()))

Al cambiar el orden, podemos, por ejemplo, decir que un evento de sesión genérico tiene
una prioridad más alta que el inicio de sesión, pero no el cierre de sesión, y así
sucesivamente.

El hecho de que este patrón funcione con objetos lo hace más flexible con respecto a nuestra
implementación anterior, que se basaba en clases (y aunque todavía son objetos en Python,
no están excluidos de cierto grado de rigidez).

El método de la plantilla
El método de plantilla es un patrón que produce importantes beneficios cuando se
implementa correctamente. Principalmente, nos permite reutilizar el código y también hace
que nuestros objetos sean más flexibles y fáciles de cambiar, conservando el polimorfismo.

La idea es que haya una jerarquía de clases que defina algún comportamiento, digamos un
método importante de su interfaz pública. Todas las clases de la jerarquía comparten una
plantilla común y es posible que necesiten cambiar solo ciertos elementos de la misma. La
idea entonces es colocar esta lógica genérica en el método público de la clase padre que
internamente llamará a todos los demás métodos (privados), y estos métodos son los que
van a modificar las clases derivadas; por lo tanto, se reutiliza toda la lógica de la plantilla.
[ 272 ]
Patrones de diseño comunes Capítulo 9

Los lectores ávidos pueden haber notado que ya implementamos este patrón en la sección
anterior (como parte del ejemplo de la cadena de responsabilidad). Tenga en cuenta que las
clases derivadas de Event implementan solo una cosa, su patrón particular. Para el resto de
la lógica, la plantilla está en la clase Evento . El evento de proceso es genérico y se basa en
dos métodos auxiliares can_process() y process() (que a su vez llama a _parse_data() ).

Estos métodos adicionales se basan en un patrón de atributo de clase. Por lo tanto, para
extender esto con un nuevo tipo de objeto, solo tenemos que crear una nueva clase derivada
y colocar la expresión regular. Después de eso, el resto de la lógica se heredará con este
nuevo atributo cambiado.
Esto reutiliza una gran cantidad de código porque la lógica para procesar las líneas de
registro se define una vez y solo una vez en la clase principal.

Esto hace que el diseño sea flexible porque la preservación del polimorfismo también es
fácil de
lograr. Si necesitamos un nuevo tipo de evento que por alguna razón necesita una forma
diferente de analizar los datos, solo anulamos este método privado en esa subclase, y se
mantendrá la compatibilidad, siempre que devuelva algo del mismo tipo que el original.
(cumpliendo con los principios de sustitución de Liskov y abierto/cerrado). Esto se debe a
que es la clase principal la que llama al método desde las clases derivadas.

Este patrón también es útil si estamos diseñando nuestra propia biblioteca o marco. Al
organizar la lógica de esta manera, brindamos a los usuarios la capacidad de cambiar el
comportamiento de una de las clases con bastante facilidad. Tendrían que crear una
subclase y anular el método privado particular, y el resultado será un nuevo objeto con el
nuevo comportamiento que se garantiza que sea compatible con las llamadas anteriores
del objeto original.

Dominio
El patrón de comando nos brinda la capacidad de separar una acción que debe realizarse
desde el momento en que se solicita hasta su ejecución real. Más que eso, también puede
separar la solicitud original emitida por un cliente de su destinatario, que podría ser un
objeto diferente. En esta sección, nos vamos a centrar principalmente en el primer aspecto
de los patrones; el hecho de que podemos separar cómo debe ejecutarse una orden de
cuándo se ejecuta realmente.

Sabemos que podemos crear objetos invocables implementando el método mágico


__call__() , por lo que podríamos simplemente inicializar el objeto y luego llamarlo más
tarde. De hecho, si este es el único requisito, incluso podríamos lograrlo a través de una
función anidada que, mediante un cierre, crea otra función para lograr el efecto de una
ejecución retrasada. Pero este patrón se puede extender a fines que no son tan fáciles de
lograr.

[ 273 ]
Patrones de diseño comunes Capítulo 9

La idea es que el comando también pueda modificarse después de su definición. Esto


significa que el cliente especifica un comando para ejecutar, y luego se pueden cambiar
algunos de sus parámetros, agregar más opciones, etc., hasta que alguien finalmente
decida realizar la acción.

Se pueden encontrar ejemplos de esto en bibliotecas que interactúan con bases de datos. Por
ejemplo, en psycopg2 (una biblioteca cliente de PostgreSQL), establecemos una conexión. A
partir de esto, obtenemos un cursor, y a ese cursor podemos pasarle una instrucción SQL
para que se ejecute. Cuando llamamos al método de ejecución , la representación interna del
objeto cambia, pero en realidad no se ejecuta nada en la base de datos. Es cuando llamamos
a fetchall() (o un método similar) que los datos se consultan y están disponibles en el cursor.

Lo mismo sucede en el popular Object Relational Mapper SQLAlchemy


( ORM SQLAlchemy ). Una consulta se define a través de varios pasos, y una vez que
tenemos el objeto de consulta , aún podemos interactuar con él (agregar o quitar filtros,
cambiar las condiciones, solicitar un pedido, etc.), hasta que decidamos que queremos
los resultados de la consulta. Después de llamar a cada método, el objeto de consulta
cambia sus propiedades internas y devuelve self (él mismo).

Estos son ejemplos que se asemejan al comportamiento que nos gustaría lograr. Una forma
muy sencilla de crear esta estructura sería tener un objeto que almacene los parámetros de
los comandos que se van a ejecutar. Después de eso, también debe proporcionar métodos
para
interactuar con esos parámetros (agregar o eliminar filtros, etc.). Opcionalmente, podemos
agregar capacidades de seguimiento o registro a ese objeto para auditar las operaciones
que se han llevado a cabo. Finalmente, necesitamos proporcionar un método que
realmente realice la acción. Este puede ser solo __call__() o uno personalizado. Llamémoslo
hacer() .

Estado
El patrón de estado es un claro ejemplo de cosificación en el diseño de software, haciendo
que el concepto de nuestro problema de dominio sea un objeto explícito en lugar de solo
un valor secundario.

En el Capítulo 8 , Pruebas unitarias y refactorización , teníamos un objeto que representaba una


solicitud de fusión y tenía un estado asociado (abierto, cerrado, etc.). Usamos una
enumeración para representar esos estados porque, en ese momento, solo eran datos que
contenían un valor, la representación de cadena de ese estado en particular. Si tuvieran
que tener algún comportamiento, o toda la solicitud de fusión tuviera que realizar algunas
acciones dependiendo de su estado y transiciones, esto no hubiera sido suficiente.

[ 274 ]
Patrones de diseño comunes Capítulo 9

El hecho de que estemos agregando comportamiento, una estructura de tiempo de


ejecución, a una parte del código tiene que hacernos pensar en términos de objetos, porque
eso es lo que se supone que deben hacer los objetos, después de todo. Y aquí viene la
reificación : ahora el estado no puede ser simplemente una enumeración con una cadena;
tiene que ser un objeto.

Imagine que tenemos que agregar algunas reglas a la solicitud de combinación, por
ejemplo, que cuando pasa de abierto a cerrado, se eliminan todas las aprobaciones (tendrán
que revisar el código nuevamente) , y que cuando se abre una solicitud de combinación, el
número de aprobaciones se establece en cero (independientemente de si se trata de una
solicitud de combinación reabierta o nueva). Otra regla podría ser que cuando se fusiona
una solicitud de fusión, queremos eliminar la rama de origen y, por supuesto, queremos
prohibir a los usuarios realizar transiciones no válidas (por ejemplo, una solicitud de fusión
cerrada no se puede fusionar, etc.).

Si pusiéramos toda esa lógica en un solo lugar, es decir, en la clase MergeRequest ,


terminaríamos con una clase que tiene muchas responsabilidades (un diseño deficiente),
probablemente muchos métodos y una gran cantidad de declaraciones if . Sería difícil
seguir el código y comprender qué parte se supone que representa qué regla comercial.

Es mejor distribuir esto en objetos más pequeños, cada uno con menos responsabilidades, y
los objetos de estado son un buen lugar para esto. Creamos un objeto para cada tipo de
estado que queramos representar, y en sus métodos colocamos la lógica de las transiciones
con las
reglas antes mencionadas. El objeto MergeRequest tendrá un colaborador de estado y este, a
su vez, también conocerá MergeRequest (se necesita el mecanismo de envío doble para
ejecutar las acciones apropiadas en MergeRequest y manejar las transiciones).

Definimos una clase abstracta base con el conjunto de métodos a implementar, y luego
una subclase para cada estado particular que queremos representar. Luego, el objeto
MergeRequest delega todas las acciones al estado , como se muestra en el siguiente código:

class InvalidTransitionError(Exception):
"""Generado al intentar pasar a un estado de destino desde un estado de origen inalcanzable
.
"""

class MergeRequestState(abc.ABC):
def __init__(self, merge_request):
self._merge_request = merge_request
@abc.abstractmethod
def open(self):
...

[ 275 ]
Patrones de diseño comunes Capítulo 9

@abc.abstractmethod
def close(self):
...

@abc.abstractmethod
def merge(self):
...

def __str__(self):
return self.__class__.__name__

class Open(MergeRequestState):
def open(self):
self._merge_request.approvals = 0

def close(self):
self._merge_request.approvals = 0
self._merge_request.state = Cerrado

def merge(self):
logger.info("fusionando %s", self._merge_request)
logger.info("eliminando rama %s",
self._merge_request.source_branch)
self._merge_request.state = Fusionado

class Closed(MergeRequestState):
def open(self):
logger.info("reapertura de solicitud de fusión cerrada %s",
self._merge_request)
self._merge_request.state = Abrir

def cerrar(auto):
pasar

def merge(self):
raise InvalidTransitionError("no se puede fusionar una solicitud cerrada")

class Merged(MergeRequestState):
def open(self):
raise InvalidTransitionError("solicitud ya fusionada")

def close(self):
raise InvalidTransitionError("Solicitud ya fusionada")
def fusionar(auto):

[ 276 ]
Patrones de diseño comunes Capítulo 9

pasar

class MergeRequest:
def __init__(self, source_branch: str, target_branch: str) -> Ninguno: self.source_branch =
source_branch
self.target_branch = target_branch
self._state = Ninguno
self.approvals = 0
self.state = Open

@property
def state(self):
return self._state

@state.setter
def state(self, new_state_cls):
self._state = new_state_cls(self)

def abierto(auto):
devuelve auto.estado.abierto()

def close(self):
return self.state.close()

def merge(self):
return self.state.merge()

def __str__(self):
return f"{self.target_branch}:{self.source_branch}"

La siguiente lista describe algunas aclaraciones sobre los detalles de implementación y las
decisiones de diseño que se deben tomar:

El estado es una propiedad, por lo que no solo es público, sino que hay un solo
lugar con las definiciones de cómo se crean los estados para una solicitud de
fusión, pasándose self como parámetro.
La clase base abstracta no es estrictamente necesaria, pero tenerla tiene ventajas.
En primer lugar, hace más explícito el tipo de objeto con el que estamos tratando.
En segundo lugar, obliga a cada subestado a implementar todos los métodos de
la interfaz. Hay dos alternativas a esto:
Podríamos no haber puesto los métodos y dejar que AttributeError
surgiera al intentar realizar una acción no válida, pero esto no es
correcto y no expresa lo que sucedió.
[ 277 ]
Patrones de diseño comunes Capítulo 9

Relacionado con este punto está el hecho de que podríamos haber usado una
base
class simple
y dejó esos métodos vacíos, pero luego el comportamiento
predeterminado de no hacer nada no deja más claro lo que debería suceder.
Si uno de los métodos en la subclase no debe hacer nada (como en el caso de
la combinación), entonces es mejor dejar que el método vacío se quede allí y
hacer explícito que para ese caso particular, no se debe hacer nada, en lugar
de forzar esa lógica a todos los objetos.

MergeRequest y MergeRequestState tienen enlaces entre sí. En el momento en que se


realiza una transición, el objeto anterior no tendrá referencias adicionales y debe
recolectarse como basura, por lo que esta relación debe ser siempre 1:1. Con
algunas consideraciones menores y más detalladas, podría usarse una referencia
débil.

El siguiente código muestra algunos ejemplos de cómo se usa el objeto:


>>> señor = MergeRequest("desarrollar", "maestro")
>>> señor.abrir()
>>> señor.aprobaciones
0
>>> señor.aprobaciones = 3
>>> señor.cerrar()
>>> señor .aprobaciones
0
>>> mr.open()
INFO:log:reapertura de solicitud de fusión cerrada maestro:desarrollar
>>> mr.merge()
INFO:log:maestro de fusión:desarrollar
INFO:log:borrar rama desarrollar
>>> mr. close()
Rastreo (última llamada más reciente):
...
InvalidTransitionError: solicitud ya fusionada

Las acciones para los estados de transición se delegan al objeto de estado , que MergeRequest
mantiene en todo momento (puede ser cualquiera de las subclases de ABC ). Todos saben
cómo responder a los mismos mensajes (de diferentes maneras), por lo que estos objetos
tomarán las acciones apropiadas correspondientes a cada transición (eliminar ramas,
generar excepciones, etc.) y luego moverán MergeRequest al siguiente estado.

Dado que MergeRequest delega todas las acciones a su objeto de estado ,


encontraremos que esto suele suceder cada vez que las acciones que debe realizar
tienen el formato
self.state.open() , y así sucesivamente. ¿Podemos eliminar algo de ese repetitivo?

[ 278 ]
Patrones de diseño comunes Capítulo 9

Podríamos, por medio de __getattr__() , como se muestra en el siguiente código:


class MergeRequest:
def __init__(self, source_branch: str, target_branch: str) -> Ninguno: self.source_branch =
source_branch
self.target_branch = target_branch
self._state: MergeRequestState
self.approvals = 0
self.state = Open

@property
def state(self):
return self._state

@state.setter
def state(self, new_state_cls):
self._state = new_state_cls(self)

@property
def status(self):
return str(self.state)

def __getattr__(self, método):


return getattr(self.state, method)

def __str__(self):
return f"{self.target_branch}:{self.source_branch}"

Por un lado, es bueno que reutilicemos algo de código y eliminemos líneas repetitivas. Esto
le da a la clase base abstracta aún más sentido. En algún lugar, queremos tener todas las
acciones posibles documentadas, enumeradas en un solo lugar. Ese lugar solía ser la clase
MergeRequest , pero ahora esos métodos desaparecieron, por lo que la única fuente restante
de esa verdad está
en MergeRequestState . Afortunadamente, la anotación de tipo en el atributo de estado es
realmente útil para que los usuarios sepan dónde buscar la definición de la interfaz.

Un usuario puede simplemente echar un vistazo y ver que todo lo que MergeRequest no
tiene se le preguntará sobre su atributo de estado . A partir de la definición de inicio , la
anotación nos dirá que se trata de un objeto del tipo MergeRequestState y, al observar esta
interfaz, veremos que podemos solicitar con seguridad los métodos open() , close() y merge()
en eso.
[ 279 ]
Patrones de diseño comunes Capítulo 9

El patrón de objeto nulo


El patrón de objeto nulo es una idea que se relaciona con las buenas prácticas que se
mencionaron en capítulos anteriores de este libro. Aquí, los estamos formalizando y
dando más contexto y análisis a esta idea.

El principio es bastante simple : las funciones o métodos deben devolver objetos de un tipo
consistente. Si esto está garantizado, los clientes de nuestro código pueden usar los objetos
que se devuelven con polimorfismo, sin tener que ejecutar controles adicionales sobre ellos.

En los ejemplos anteriores, exploramos cómo la naturaleza dinámica de Python facilitó las
cosas para la mayoría de los patrones de diseño. En algunos casos desaparecen por
completo y en otros son mucho más fáciles de implementar. El objetivo principal de los
patrones de diseño tal como se pensaron originalmente es que los métodos o funciones no
deben nombrar explícitamente la clase del objeto que necesitan para funcionar. Por ello,
proponen la creación de interfaces y una forma de reacomodar los objetos para que encajen
en estas interfaces con el fin de modificar el diseño. Pero la mayoría de las veces, esto no es
necesario en Python, y podemos simplemente pasar diferentes objetos, y siempre que
respeten los métodos que deben tener, la solución funcionará.

Por otro lado, el hecho de que los objetos no necesariamente tengan que cumplir con una
interfaz nos obliga a tener más cuidado con las cosas que regresan de dichos métodos y
funciones. De la misma manera que nuestras funciones no hicieron ninguna suposición
sobre lo que estaban recibiendo, es justo asumir que los clientes de nuestro código
tampoco harán ninguna
suposición (es nuestra responsabilidad proporcionar objetos que sean compatibles). Esto
se puede hacer cumplir o validar con el diseño por contrato. Aquí, exploraremos un
patrón simple que nos ayudará a evitar este tipo de problemas.

Considere el patrón de diseño de cadena o responsabilidad explorado en la sección


anterior. Vimos lo flexible que es y sus muchas ventajas, como desacoplar
responsabilidades en objetos más pequeños. Uno de los problemas que tiene es que en
realidad nunca sabemos qué objeto acabará procesando el mensaje, si lo hay. En
particular, en nuestro ejemplo, si no hubiera un objeto adecuado para procesar la línea de
registro, el método simplemente devolvería None .

No sabemos cómo usarán los usuarios los datos que pasamos, pero sabemos que
esperan un diccionario. Por lo tanto, podría ocurrir el siguiente error:
AttributeError: el objeto 'NoneType' no tiene atributo 'keys'
En este caso, la solución es bastante simple : el valor predeterminado del método process()
debe ser un diccionario vacío en lugar de Ninguno .

Asegúrese de devolver objetos de un tipo coherente.

[ 280 ]
Patrones de diseño comunes Capítulo 9

Pero, ¿y si el método no devolviera un diccionario, sino un objeto personalizado de nuestro


dominio?

Para resolver este problema, deberíamos tener una clase que represente el estado vacío de
ese objeto y devolverlo. Si tenemos una clase que representa a los usuarios en nuestro
sistema y una función que consulta a los usuarios por su ID, en caso de que no se encuentre
un usuario, debería hacer una de las dos cosas siguientes:

Levantar una excepción


Devuelve un objeto del tipo UserUnknown

Pero en ningún caso debería devolver None . La frase Ninguno no representa lo que acaba
de suceder, y la persona que llama puede legítimamente intentar preguntarle métodos, y
fallará con AttributeError .

Hemos discutido las excepciones y sus ventajas y desventajas anteriormente, por lo que
debemos mencionar que este objeto nulo debe tener los mismos métodos que el usuario
original y no hacer nada para cada uno de ellos.

La ventaja de usar esta estructura es que no solo evitamos un error en tiempo de ejecución
sino que este objeto puede ser útil. Podría hacer que el código sea más fácil de probar e
incluso puede, por ejemplo, ayudar en la depuración (tal vez podríamos iniciar sesión en
los métodos para comprender por qué se alcanzó ese estado, qué datos se le
proporcionaron, etc.).

Al explotar casi todos los métodos mágicos de Python, sería posible crear un objeto nulo
genérico que no hace absolutamente nada, sin importar cómo se llame, pero que se puede
llamar desde casi cualquier cliente. Tal objeto se parecería ligeramente a un objeto
simulado . No es recomendable ir por ese camino por las siguientes razones:

Pierde significado con el problema del dominio. Volviendo a nuestro ejemplo,


tener un objeto del tipo UnknownUser tiene sentido y le da a la persona que llama
una idea clara de que algo salió mal con la consulta.
No respeta la interfaz original. Esto es problemático. Recuerde que el punto es que
un UnknownUser es un usuario y, por lo tanto, debe tener los mismos métodos. Si
la persona que llama accidentalmente solicita un método que no está allí,
entonces, en ese caso, debería generar una excepción AttributeError , y eso sería
bueno.
nulo genérico que puede hacer cualquier cosa y responder a cualquier cosa,
estaríamos perdiendo esta información y podrían aparecer errores. Si optamos
por crear un objeto simulado con spec=User , esta anomalía se detectaría, pero de
nuevo, usar un objeto Mock para representar lo que en realidad es un estado vacío
daña
la intención que revela el grado del código.

Este patrón es una buena práctica que nos permite mantener el polimorfismo en nuestros
objetos.

[ 281 ]
Patrones de diseño comunes Capítulo 9

Reflexiones finales sobre los patrones de


diseño
Hemos visto el mundo de los patrones de diseño en Python y, al hacerlo, hemos encontrado
soluciones a problemas comunes, así como más técnicas que nos ayudarán a lograr un
diseño limpio.

Todo esto suena bien, pero plantea la pregunta, ¿qué tan buenos son los patrones de
diseño? Algunas personas argumentan que hacen más daño que bien, que fueron creados
para lenguajes cuyo sistema de tipo limitado (y falta de funciones de primera clase) hace
imposible lograr cosas que normalmente haríamos en Python. Otros afirman que los
patrones de diseño fuerzan una solución de diseño, creando un sesgo que limita un diseño
que de otro modo habría surgido y que habría sido mejor. Veamos cada uno de estos
puntos a su vez.

La influencia de los estampados sobre el diseño.


Un patrón de diseño, como cualquier otro tema de la ingeniería de software, no puede ser
bueno o malo en sí mismo, sino en cómo se implementa. En algunos casos, en realidad no
hay necesidad de un patrón de diseño y una solución más simple sería suficiente. Intentar
forzar un patrón donde no encaja es un caso de ingeniería excesiva, y eso es claramente
malo, pero no significa que haya un problema con los patrones de diseño, y muy
probablemente en estos escenarios, el problema es ni siquiera relacionado con los patrones
en absoluto. Algunas personas intentan sobrediseñar todo porque no entienden lo que
realmente significa un software flexible y adaptable. Como mencionamos anteriormente en
este libro, hacer un buen software no se trata de anticipar los requisitos futuros (no tiene
sentido hacer futurología), sino simplemente resolver el problema que tenemos entre manos
en este momento, de una manera que no nos impida realizar cambios en él en el futuro. No
tiene que manejar esos cambios ahora; solo necesita ser lo suficientemente flexible para que
pueda modificarse en el futuro. Y cuando llegue ese futuro, aún tendremos que recordar la
regla de tres o más instancias del mismo problema antes de encontrar una solución genérica
o una abstracción adecuada.

Este suele ser el punto en el que deberían surgir los patrones de diseño, una vez
que hayamos identificado el problema correctamente y podamos reconocer el
patrón y abstraerlo en consecuencia.
[ 282 ]
Patrones de diseño comunes Capítulo 9

Volvamos al tema de la adecuación de los patrones al lenguaje. Como dijimos en la


introducción del capítulo, los patrones de diseño son ideas de alto nivel. Por lo general, se
refieren a la relación de los objetos y sus interacciones. Es difícil pensar que tales cosas
puedan desaparecer de un idioma a otro. Es cierto que algunos patrones en realidad se
implementan manualmente en Python, como es el caso del patrón de iterador (que, como se
discutió mucho antes en el libro, está construido en Python), o una estrategia (porque, en
cambio, simplemente pasar funciones como cualquier otro objeto regular; no necesitamos
encapsular el método de estrategia en un objeto, la función en sí misma sería un objeto).

Pero en realidad se necesitan otros patrones, y de hecho resuelven problemas, como en el


caso del decorador y los patrones compuestos. En otros casos, hay patrones de diseño que el
mismo Python implementa, y simplemente no siempre los vemos, como en el caso del
patrón de fachada que discutimos en la sección sobre os .

En cuanto a nuestros patrones de diseño que conducen nuestra solución en una dirección
equivocada, debemos tener cuidado aquí. Una vez más, es mejor si comenzamos a diseñar
nuestra solución pensando en términos del problema del dominio y creando las
abstracciones correctas, y luego vemos si hay un patrón de diseño que emerge de ese
diseño. Digamos que lo hace. ¿Es eso algo malo? El hecho de que ya haya una solución para
el problema que estamos tratando de resolver no puede ser algo malo. Sería malo reinventar
la rueda, como sucede muchas veces en nuestro campo. Además, el hecho de que estemos
aplicando un patrón, algo ya probado y validado, debería darnos mayor confianza en la
calidad de lo que estamos construyendo.

Nombres en nuestros modelos


¿Deberíamos mencionar que estamos usando un patrón de diseño en nuestro código?

Si el diseño es bueno y el código está limpio, debería hablar por sí mismo. No se


recomienda que nombre las cosas según los patrones de diseño que está utilizando por un
par de razones:

Los usuarios de nuestro código y otros desarrolladores no necesitan conocer el


patrón de diseño detrás del código, siempre que funcione según lo
previsto.
Establecer el patrón de diseño arruina el principio de revelación de la intención.
Agregar el nombre del patrón de diseño a una clase hace que pierda parte de su
significado original. Si una clase representa una consulta, debe llamarse Query o
EnhancedQuery , algo que revele la intención de lo que se supone que debe
hacer ese objeto. EnhancedQueryDecorator no significa nada significativo, y el
sufijo Decorator crea más confusión que claridad.

[ 283 ]
Patrones de diseño comunes Capítulo 9

Mencionar los patrones de diseño en docstrings podría ser aceptable porque funcionan
como documentación, y expresar las ideas de diseño (nuevamente, comunicar) en nuestro
diseño es algo bueno. Sin embargo, esto no debería ser necesario. Sin embargo, la mayoría
de las veces no necesitamos saber que existe un patrón de diseño.

Los mejores diseños son aquellos en los que los patrones de diseño son completamente
transparentes para los usuarios.
Un ejemplo de esto es cómo aparece el patrón de fachada en la biblioteca estándar,
haciéndolo completamente transparente para los usuarios sobre cómo acceder al módulo os
. Un ejemplo aún más elegante es cómo el patrón de diseño del iterador está tan
completamente abstraído por el lenguaje que ni siquiera tenemos que pensar en ello.

Resumen
Los patrones de diseño siempre se han visto como soluciones probadas a problemas
comunes. Esta es una evaluación correcta, pero en este capítulo las exploramos desde el
punto de vista de las buenas técnicas de diseño, patrones que aprovechan el código limpio.
En la mayoría de los casos, observamos cómo brindan una buena solución para preservar el
polimorfismo, reducir el acoplamiento y crear las abstracciones correctas que encapsulan los
detalles según sea necesario. Todos los rasgos que se relacionan con los conceptos
explorados en el Capítulo 8 , Pruebas unitarias y refactorización .

Aún así, lo mejor de los patrones de diseño no es el diseño limpio que podemos obtener al
aplicarlos, sino el vocabulario extendido. Utilizado como herramienta de comunicación,
podemos utilizar sus nombres para expresar la intención de nuestro diseño. Y a veces, no
es el patrón completo lo que necesitamos aplicar, pero es posible que necesitemos tomar
una idea particular (una subestructura, por ejemplo) de un patrón de nuestra solución, y
aquí, también, demuestran ser una forma de comunicación. más eficazmente.

Cuando creamos soluciones pensando en términos de patrones, estamos resolviendo


problemas a un nivel más general. Pensar en términos de patrones de diseño nos acerca al
diseño de alto nivel. Podemos "alejarnos" lentamente y pensar más en términos de una
arquitectura. Y ahora que estamos resolviendo problemas más generales, es hora de
comenzar a pensar en cómo evolucionará y se mantendrá el sistema a largo plazo (cómo se
ampliará, cambiará, adaptará, etc.).

Para que un proyecto de software tenga éxito en estos objetivos, requiere un código
limpio en su núcleo, pero la arquitectura también debe ser limpia, que es lo que veremos
en el próximo capítulo.
[ 284 ]
Patrones de diseño comunes Capítulo 9

Referencias
Aquí hay una lista de información que puede
consultar:

GoF : El libro escrito por Erich Gamma, Richard Helm, Ralph Johnson y John
Vlissides llamado Design Patterns: Elements of Reusable Object-Oriented Software
SNGMONO : un artículo escrito por Robert C. Martin, 2002 llamado
SINGLETON y MONOSTATE
El patrón de objeto nulo , escrito por Bobby Woolf
[ 285 ]
Arquitectura limpia 
En este capítulo final, nos enfocamos en cómo encaja todo en el diseño de un sistema
completo. Este es un capítulo más teórico. Dada la naturaleza del tema, sería demasiado
complejo profundizar en los detalles de más bajo nivel. Además, el punto es precisamente
escapar de esos detalles, asumir que todos los principios explorados en los capítulos
anteriores están asimilados y enfocarse en el diseño de un sistema a escala.

Las principales preocupaciones y objetivos de este capítulo son los siguientes:

Diseñar sistemas de software que se puedan mantener a largo plazo


Trabajar eficazmente en un proyecto de software manteniendo los atributos de
calidad.
Estudiar cómo todos los conceptos aplicados al código se relacionan con los
sistemas en general

Del código limpio a la arquitectura limpia


Esta sección es una discusión de cómo los conceptos que se enfatizaron en capítulos
anteriores reaparecen en una forma ligeramente diferente cuando consideramos aspectos
de sistemas grandes. Existe una similitud interesante en cómo los conceptos que se aplican
a un diseño más detallado, así como el código, también se aplican a grandes sistemas y
arquitecturas.

Los conceptos explorados en capítulos anteriores estaban relacionados con aplicaciones


individuales, generalmente un proyecto, que podría ser un repositorio único (o varios), para
un sistema de versión de control de código fuente (git).
Esto no quiere decir que esas ideas de diseño solo sean aplicables al código, o que no
sirvan para pensar en una arquitectura, por dos razones: el código es la base de la
arquitectura y, si no está escrito con cuidado, el el sistema fallará independientemente de
lo bien pensada que esté la arquitectura.
En segundo lugar, algunos principios que se revisaron en capítulos anteriores no se
aplican al código, sino que son ideas de diseño. El ejemplo más claro proviene de los
patrones de diseño. Son ideas de alto nivel. Con esto, podemos obtener una imagen rápida
de cómo podría aparecer un componente en nuestra arquitectura, sin entrar en los detalles
del código.
Arquitectura limpia Capítulo 10

Pero los sistemas de grandes empresas generalmente consisten en muchas de estas


aplicaciones, y ahora es el momento de comenzar a pensar en términos de un diseño más
grande, en forma de un sistema distribuido.

En las siguientes secciones, analizamos los temas principales que se han tratado a lo largo
del libro, pero ahora desde la perspectiva de un sistema.

Separación de intereses
Dentro de una aplicación, hay múltiples componentes. Su código se divide en otros
subcomponentes, como módulos o paquetes, y los módulos en clases o funciones, y las
clases en métodos. A lo largo del libro, el énfasis ha estado en mantener estos
componentes lo más pequeños posible, particularmente en el caso de las funciones : las
funciones deben hacer una cosa y ser pequeñas.

Se presentaron varias razones para justificar este razonamiento. Las funciones pequeñas
son más fáciles de entender, seguir y depurar. También son más fáciles de probar.
Cuanto más pequeñas sean las piezas de nuestro código, más fácil será escribir pruebas
unitarias para él.

Para los componentes de cada aplicación, queríamos características diferentes,


principalmente alta cohesión y bajo acoplamiento. Al dividir los componentes en unidades
más pequeñas, cada una con una responsabilidad única y bien definida, logramos una
mejor estructura donde los cambios son más fáciles de administrar. Ante los nuevos
requisitos, habrá un único lugar adecuado para realizar los cambios, y el resto del código
probablemente no debería verse afectado.

Cuando hablamos de código, decimos componente para referirnos a una de estas unidades
cohesivas (podría ser una clase, por ejemplo). Cuando se habla en términos de arquitectura,
un componente significa cualquier cosa en el sistema que pueda tratarse como una unidad
de trabajo. El término componente en sí mismo es bastante vago, por lo que no existe una
definición universalmente aceptada en la arquitectura de software de lo que esto significa
más concretamente. El concepto de unidad de trabajo es algo que puede variar de un
proyecto a otro. Un componente debe poder liberarse o desplegarse con sus propios ciclos,
independientemente del resto de las partes del sistema. Y es precisamente eso, una de las
partes de un sistema, es decir, la aplicación entera.

Para los proyectos de Python, un componente podría ser un paquete, pero un servicio
también puede ser un
componente. Observe cómo dos conceptos diferentes, con diferentes niveles de
granularidad, pueden considerarse bajo la misma categoría. Para dar un ejemplo, los
sistemas de eventos que usamos en capítulos anteriores podrían considerarse un
componente. Es una unidad de trabajo con un propósito claramente definido (enriquecer
los eventos identificados a partir de los registros), puede desplegarse independientemente
del resto (ya sea como un paquete de Python, o, si exponemos su funcionalidad, como un
servicio), y es parte de todo el sistema, pero no de toda la aplicación en sí.

[ 287 ]
Arquitectura limpia Capítulo 10

En los ejemplos de capítulos anteriores hemos visto un código idiomático, y también


hemos resaltado la importancia de un buen diseño para nuestro código, con objetos que
tienen responsabilidades únicas bien definidas, siendo aislados, ortogonales y más fáciles
de mantener. Este mismo criterio, que se aplica al diseño detallado (funciones, clases,
métodos), también se aplica a los componentes de una arquitectura de software.

Probablemente no sea deseable que un sistema grande sea solo un componente. Una
aplicación monolítica actuará como la única fuente de verdad, responsable de todo en el
sistema, y eso traerá muchas consecuencias no deseadas (más difícil de aislar e identificar
cambios, de probar de manera efectiva, etc.). De la misma manera, nuestro código será
más difícil de mantener, si no tenemos cuidado y colocamos todo en un solo lugar, la
aplicación sufrirá problemas similares si sus componentes no son tratados con el mismo
nivel de atención.

La idea de crear componentes cohesivos en un sistema puede tener más de


una implementación, dependiendo del nivel de abstracción que necesitemos.

Una opción sería identificar la lógica común que es probable que se reutilice varias veces
y colocarla en un paquete de Python (discutiremos los detalles más adelante en este
capítulo).

Otra alternativa sería dividir la aplicación en múltiples servicios más pequeños, en una
arquitectura de microservicio. La idea es tener componentes con una responsabilidad
única y bien definida, y lograr la misma funcionalidad que una aplicación monolítica
haciendo que esos servicios cooperen e intercambien información.

abstracciones
Aquí es donde aparece de nuevo la encapsulación. Desde nuestros sistemas (como
hacemos en relación al código), queremos hablar en términos del problema del dominio,
y dejar los detalles de implementación lo más escondidos posible.

De la misma manera que el código tiene que ser expresivo (casi hasta el punto de ser
autodocumentado), y tener las abstracciones adecuadas que revelen la solución al
problema esencial (minimizando la complejidad accidental), la arquitectura debe
decirnos qué es el sistema. es sobre. Detalles como la solución utilizada para almacenar
datos en el disco, el marco web elegido, las bibliotecas utilizadas para conectarse a
agentes externos y la interacción entre sistemas no son relevantes. Lo relevante es lo
que hace el sistema. Un concepto como arquitectura de gritos (SCREAM) refleja esta
idea.

[ 288 ]
Arquitectura limpia Capítulo 10

El principio de inversión de dependencia ( DIP ), explicado en el Capítulo 4 , Los


Principios SOLID
, es de gran ayuda en este sentido; no queremos depender de
implementaciones concretas sino de abstracciones. En el código, colocamos abstracciones
(o interfaces) en los límites, las dependencias, esas partes de la aplicación que no
controlamos y que podrían cambiar en el futuro. Hacemos esto porque queremos
invertir las dependencias. Que tengan que adaptarse a nuestro código (al tener que
ajustarse a una interfaz), y no al revés.

Crear abstracciones e invertir dependencias son buenas prácticas, pero no son suficientes.
Queremos que toda nuestra aplicación sea independiente y esté aislada de cosas que están
fuera de nuestro control. Y esto es incluso más que abstraer con objetos : necesitamos capas
de abstracción.

Esta es una diferencia sutil, pero importante, con respecto al diseño detallado. En el DIP,
se recomendó crear una interfaz, que podría implementarse con el módulo abc de la
biblioteca estándar, por ejemplo. Debido a que Python funciona con el tipo de pato, aunque
puede ser útil usar una clase abstracta, no es obligatorio, ya que podemos lograr
fácilmente el mismo efecto con objetos regulares siempre que cumplan con la interfaz
requerida. La naturaleza de escritura dinámica de Python nos permitió tener estas
alternativas. Cuando se piensa en términos de arquitectura, no existe tal cosa. Como
quedará más claro con el ejemplo, necesitamos abstraer las dependencias por completo, y
no hay ninguna característica de Python que pueda hacer eso por nosotros.

Algunos podrían argumentar: "Bueno, el ORM es una buena abstracción para una base de
datos, ¿no es así?" No es suficiente. El propio ORM es una dependencia y, como tal, está
fuera de nuestro control. Sería incluso mejor crear una capa intermedia, un adaptador,
entre la API del ORM y nuestra aplicación.

Esto significa que no abstraemos la base de datos solo con un ORM; usamos la capa de
abstracción que creamos encima, para definir objetos propios que pertenecen a nuestro
dominio.

Luego, la aplicación importa este componente y utiliza las entidades proporcionadas por
esta capa, pero no al revés. La capa de abstracción no debe conocer la lógica de nuestra
aplicación; es aún más cierto que la base de datos no debería saber nada sobre la aplicación
en sí. Si ese fuera el caso, la base de datos estaría acoplada a nuestra aplicación. El objetivo
es invertir la dependencia : esta capa proporciona una API y cada componente de
almacenamiento que quiera conectarse debe cumplir con esta API. Este es el concepto de
una arquitectura hexagonal (HEX) .
[ 289 ]
Arquitectura limpia Capítulo 10

Componentes de software
Ahora tenemos un sistema grande y necesitamos escalarlo. También tiene que ser
mantenible. En este punto, las preocupaciones no son solo técnicas sino también
organizativas. Esto significa que no se trata solo de administrar repositorios de software;
lo más probable es que cada repositorio pertenezca a una aplicación, y será mantenido por
un equipo propietario de esa parte del sistema.

Esto exige que tengamos en cuenta cómo se divide un gran sistema en diferentes
componentes. Esto puede tener muchas fases, desde un enfoque muy simple sobre, por
ejemplo, la creación de paquetes de Python, hasta escenarios más complejos en una
arquitectura de microservicio.

La situación podría ser aún más compleja cuando se trata de diferentes lenguajes, pero
en este capítulo asumiremos que todos son proyectos de Python.

Estos componentes necesitan interactuar, al igual que los equipos. La única forma en que
esto puede funcionar a escala es si todas las partes acuerdan una interfaz, un contrato.

Paquetes
Un paquete de Python es una forma conveniente de distribuir software y reutilizar código
de una manera más general. Los paquetes construidos pueden publicarse en un
repositorio de artefactos (como un servidor PyPi interno de la empresa), desde donde será
descargado por el resto de aplicaciones que lo requieran.

La motivación detrás de este enfoque tiene muchos elementos : se trata de reutilizar el


código en general y también de lograr la integridad conceptual.

Aquí, discutimos los conceptos básicos de empaquetar un proyecto de Python que se puede
publicar en un repositorio. El repositorio predeterminado puede ser PyPi ( https://pypi.org/) ,
pero también interno; o las configuraciones personalizadas funcionarán con los mismos
conceptos básicos.

Vamos a simular que hemos creado una pequeña biblioteca y la usaremos como ejemplo
para revisar los puntos principales a tener en cuenta.

Además de todas las bibliotecas de código abierto disponibles, a veces es posible que
necesitemos alguna funcionalidad adicional ; tal vez nuestra aplicación usa un lenguaje
particular repetidamente o depende bastante de una función o mecanismo y el equipo ha
ideado una mejor función para estas necesidades particulares. Para trabajar de manera más
efectiva, podemos colocar esta abstracción en una biblioteca y animar a todos los miembros
del equipo a usar los modismos proporcionados por ella, porque hacerlo ayudará a evitar
errores y reducir errores.

[ 290 ]
Arquitectura limpia Capítulo 10

Potencialmente, hay infinitos ejemplos que podrían adaptarse a este escenario. Tal vez la
aplicación necesite extraer una gran cantidad de archivos .tag.gz (en un formato particular) y
haya enfrentado problemas de seguridad en el pasado con archivos maliciosos que
terminaron con ataques de cruce de ruta. Como medida de mitigación, la funcionalidad
para abstraer formatos de archivo personalizados de forma segura se colocó en una
biblioteca que envuelve la predeterminada y agrega algunas comprobaciones adicionales.
Esto suena como una buena idea.

O tal vez hay un archivo de configuración que debe escribirse o analizarse en un formato
particular, y esto requiere seguir muchos pasos en orden; De nuevo, crear una función
auxiliar para envolver esto y usarla en todos los proyectos que la necesiten constituye
una buena inversión, no solo porque ahorra muchas repeticiones de código, sino
también porque hace que sea más difícil cometer errores.

La ventaja no es solo cumplir con el principio DRY (evitando la duplicación de código,


fomentando la reutilización), sino también que la funcionalidad abstraída representa un
único punto de referencia de cómo se deben hacer las cosas, contribuyendo así al logro de la
integridad conceptual.

En general, el diseño mínimo para una biblioteca se vería así:


.
├── Makefile
├── README.rst
├── setup.py
├── src
│└── apptool
│├── common.py
│├── __init__.py
│└── parse.py
└ pruebas
├── integración
└── unidad

La parte importante es el archivo setup.py , que contiene la definición del paquete. En este
archivo se especifican todas las definiciones importantes del proyecto (sus requisitos,
dependencias, nombre, descripción, etc.).

El directorio apptool bajo src es el nombre de la biblioteca en la que estamos trabajando.


Este es un proyecto típico de Python, por lo que colocamos aquí todos los archivos que
necesitamos.
[ 291 ]
Arquitectura limpia Capítulo 10

Un ejemplo del archivo setup.py podría ser:


desde setuptools import find_packages, setup

con open("README.rst", "r") como longdesc:


long_description = longdesc.read()

setup(
name="apptool",
description="Descripción de la intención del paquete",
long_description=long_description,
author="Dev team",
version="0.1.0",
packages=find_packages(where="src/") ,
paquete_dir={"": "fuente"},
)

Este ejemplo mínimo contiene los elementos clave del proyecto. El argumento name en la
función setup se usa para dar el nombre que tendrá el paquete en el repositorio (bajo este
nombre ejecutamos el comando para instalarlo, en este caso su pip install apptool ). No es
estrictamente necesario que coincida con el nombre del directorio del proyecto ( src/apptool
), pero es muy recomendable, por lo que es más fácil para los usuarios.

En este caso, dado que ambos nombres coinciden, es más fácil ver la relación entre what pip
install apptool y luego, en nuestro código, run from apptool import myutil . Pero el último
corresponde al nombre bajo el directorio src/ y el primero al especificado en el archivo
setup.py .

La versión es importante para mantener diferentes lanzamientos y luego se especifican los


paquetes. Al usar la función find_packages() , descubrimos automáticamente todo lo que es
un paquete, en este caso en el directorio src/ . Buscar en este directorio ayuda a evitar
mezclar archivos más allá del alcance del proyecto y, por ejemplo, liberar pruebas
accidentalmente o una estructura rota del proyecto.

Un paquete se crea ejecutando los siguientes comandos, asumiendo que se ejecuta


dentro de un entorno virtual con las dependencias instaladas:
$VIRTUAL_ENV/bin/pip install -U setuptools rueda
$VIRTUAL_ENV/bin/python setup.py sdist bdist_wheel

Esto colocará los artefactos en el directorio dist/ , desde donde se pueden publicar más
tarde en PyPi o en el repositorio de paquetes interno de la empresa.
[ 292 ]
Arquitectura limpia Capítulo 10

Los puntos clave para empaquetar un proyecto de Python son:

Pruebe y verifique que la instalación sea independiente de la plataforma y que


no dependa de ninguna configuración local (esto se puede lograr colocando los
archivos de origen en un directorio src/ )
Asegúrese de que las pruebas unitarias no se envíen como parte del paquete que se está
construyendo.
Dependencias separadas : lo que el proyecto necesita estrictamente para
ejecutarse no es lo mismo que requieren los desarrolladores
Es una buena idea crear puntos de entrada para los comandos que más se
van a necesitar.

El archivo setup.py admite muchos otros parámetros y configuraciones y se puede realizar


de una manera mucho más complicada. Si nuestro paquete requiere que se instalen varias
bibliotecas del sistema operativo, es una buena idea escribir algo de lógica en el archivo
setup.py para compilar y construir las extensiones que se requieren. De esta manera, si algo
anda mal, fallará al principio del proceso de instalación, y si el paquete proporciona un
mensaje de error útil, el usuario podrá corregir las dependencias más rápidamente y
continuar.

La instalación de tales dependencias representa otro paso difícil para hacer que la
aplicación sea ubicua y fácil de ejecutar para cualquier desarrollador, independientemente
de la plataforma que elija. La mejor manera de superar este obstáculo es abstraer la
plataforma creando una imagen de Docker, como veremos en la siguiente sección.

Contenedores
Este capítulo está dedicado a la arquitectura, por lo que el término contenedor se refiere a
algo completamente diferente de un contenedor de Python (un objeto con un método
__contains__ ), explorado en el Capítulo 2 , Código Pythonic . Un contenedor es un proceso que
se ejecuta en el sistema operativo bajo un grupo con ciertas restricciones y consideraciones
de aislamiento. Concretamente nos referimos a los contenedores Docker, que permiten
gestionar aplicaciones (servicios o procesos) como componentes independientes.

Los contenedores representan otra forma de entregar software. La creación de paquetes de


Python teniendo en cuenta las consideraciones de la sección anterior es más adecuada para
bibliotecas o marcos, donde el objetivo es reutilizar el código y aprovechar el uso de un solo
lugar donde se reúne la lógica específica.
[ 293 ]
Arquitectura limpia Capítulo 10

En el caso de los contenedores, el objetivo no será crear bibliotecas sino aplicaciones (la
mayoría de las veces). Sin embargo, una aplicación o plataforma no significa
necesariamente un servicio completo. La idea de construir contenedores es crear pequeños
componentes que representen un servicio con un propósito pequeño y claro.

En esta sección, mencionaremos a Docker cuando hablemos de contenedores y


exploraremos los conceptos básicos de cómo crear imágenes y contenedores de Docker para
proyectos de Python. Tenga en cuenta que esta no es la única tecnología para lanzar
aplicaciones en contenedores, y también que es completamente independiente de Python.

Un contenedor de Docker necesita una imagen para ejecutarse, y esta imagen se crea a
partir de otras imágenes base. Pero las imágenes que creamos pueden servir como imágenes
base para otros contenedores. Querremos hacer eso en los casos en que haya una base
común en nuestra aplicación que se pueda compartir entre muchos contenedores. Un uso
potencial sería crear una imagen base que instale un paquete (o varios) de la manera que
describimos en la sección anterior, y también todas sus dependencias, incluidas aquellas a
nivel de sistema operativo. Como se discutió en el Capítulo 9 , Patrones de diseño comunes , un
paquete que creamos puede depender no solo de otras bibliotecas de Python, sino también
de una plataforma particular (un sistema operativo específico) y bibliotecas particulares
preinstaladas en ese sistema operativo, sin las cuales el paquete funcionará. simplemente no
se instalará y fallará.

Los contenedores son una gran herramienta de portabilidad para esto. Pueden ayudarnos a
asegurarnos de que nuestra aplicación tendrá una forma canónica de ejecutarse y también
facilitará mucho el proceso de desarrollo (reproduciendo escenarios entre entornos,
replicando pruebas, incorporando nuevos miembros del equipo, etc.).

Así como los paquetes son la forma en que reutilizamos código y unificamos criterios, los
contenedores representan la forma en que creamos los diferentes servicios de la aplicación.
Cumplen con los criterios detrás del principio de separación de preocupaciones ( SoC ) de
la arquitectura. Cada servicio es otro tipo de componente que encapsulará un conjunto de
funcionalidades independientemente del resto de la aplicación. Estos contenedores deben
diseñarse de tal manera que favorezcan la mantenibilidad : si las responsabilidades están
claramente divididas, un cambio en un servicio no debería afectar a ninguna otra parte de la
aplicación.

Cubrimos los conceptos básicos de cómo crear un contenedor Docker a partir de un


proyecto de Python en la siguiente sección.
[ 294 ]
Arquitectura limpia Capítulo 10

caso de uso
Como ejemplo de cómo podríamos organizar los componentes de nuestra aplicación, y
cómo los conceptos anteriores podrían funcionar en la práctica, presentamos el siguiente
ejemplo sencillo.

El caso de uso es que existe una aplicación para la entrega de alimentos, y esta aplicación
tiene un servicio específico para el seguimiento del estado de cada entrega en sus
diferentes etapas. Nos vamos a centrar solo en este servicio en particular,
independientemente de cómo pueda aparecer el resto de la aplicación. El servicio debe ser
realmente simple : una API REST que, cuando se le pregunte sobre el estado de un pedido
en particular, devuelva una respuesta JSON con un mensaje descriptivo.

Vamos a suponer que la información sobre cada pedido en particular se almacena en


una base de datos, pero este detalle no debería importar en absoluto.

Nuestro servicio tiene dos preocupaciones principales por ahora: obtener la información
sobre un pedido en particular (desde donde sea que esté almacenada), y presentar esta
información de manera útil a los clientes (en este caso, entregando los resultados en formato
JSON, expuesto como un servicio web).

Como la aplicación tiene que ser mantenible y extensible, queremos mantener estas dos
preocupaciones lo más ocultas posible y centrarnos en la lógica principal. Por lo tanto, estos
dos detalles se abstraen y encapsulan en paquetes de Python que utilizará la aplicación
principal con la lógica central, como se muestra en el siguiente diagrama:

En las siguientes secciones, demostramos brevemente cómo podría aparecer el código,


en términos de paquetes principalmente, y cómo crear servicios a partir de estos, para
finalmente ver qué conclusiones podemos inferir.
[ 295 ]
Arquitectura limpia Capítulo 10

El código
La idea de crear paquetes de Python en este ejemplo es ilustrar cómo se pueden hacer
componentes abstractos y aislados para que funcionen de manera efectiva. En realidad, no
existe una necesidad real de que estos sean paquetes de Python; podríamos simplemente
crear las abstracciones correctas como parte del proyecto de "servicio de entrega" y,
mientras se conserva el aislamiento correcto, funcionará sin problemas.

La creación de paquetes tiene más sentido cuando hay una lógica que se va a repetir y se
espera que se use en muchas otras aplicaciones (que se importarán de esos paquetes)
porque queremos favorecer la reutilización del código. En este caso particular, no existen
tales
requisitos, por lo que podría estar más allá del alcance del diseño, pero tal distinción aún
deja más clara la idea de una "arquitectura conectable" o componente, algo que es realmente
un envoltorio que abstrae los detalles técnicos que tenemos. realmente no quiero tratar, y
mucho menos depender.

El paquete de almacenamiento se encarga de recuperar los datos que se requieren y


presentarlos a la siguiente capa (el servicio de entrega) en un formato conveniente, algo que
sea adecuado para las reglas comerciales. La aplicación principal ahora debería saber de
dónde provienen estos datos, cuál es su formato, etc. Esta es toda la razón por la que
tenemos tal abstracción en el medio para que la aplicación no use una fila o una entidad
ORM directamente, sino
algo viable.

modelos de dominio
Las siguientes definiciones se aplican a las clases de reglas comerciales. Tenga en cuenta
que están destinados a ser objetos comerciales puros, no vinculados a nada en particular.
No son modelos de un ORM, ni objetos de un framework externo, etc. La aplicación
debería funcionar con estos objetos (u objetos con los mismos criterios).

En cada caso, la dosificación documenta la finalidad de cada clase, de acuerdo con la regla
de negocio:
de escribir importación Unión

class DispatchedOrder:
"""Un pedido recién creado y notificado para iniciar su entrega."""

estado = "enviado"
def __init__(self, cuando):
self._when = cuando

[ 296 ]
Arquitectura limpia Capítulo 10

def mensaje(self) -> dict:


return {
"status": self.status,
"msg": "El pedido se envió el {0}".format(
self._when.isoformat()
),
}

class OrderInTransit:
"""Un pedido que se está enviando actualmente al cliente."""

estado = "en tránsito"

def __init__(self, ubicación_actual):


self._ubicación_actual = ubicación_actual

def mensaje(self) -> dict:


return {
"status": self.status,
"msg": "El pedido está en curso (ubicación actual: {})".format(
self._current_location
),
}

class OrderDelivered:
"""Un pedido que ya fue entregado al cliente."""

estado = "entregado"

def __init__(self, entregado_a las):


self._entregado_a las = entregado_a las

def mensaje(self) -> dict:


return {
"status": self.status,
"msg": "Pedido entregado el {0}".format(
self._delivered_at.isoformat()
),
}

clase OrdenEntrega:
def __init__(
self,
delivery_id: str,
[ 297 ]
Arquitectura limpia Capítulo 10

estado: Unión [Pedido enviado, Pedido en tránsito, Pedido entregado], ) -> Ninguno:
self._delivery_id = delivery_id
self._status = estado

def mensaje(auto) -> dict:


return {"id": self._delivery_id, **self._status.message()}

A partir de este código, ya podemos hacernos una idea de cómo se verá la aplicación :
queremos tener un objeto DeliveryOrder , que tendrá su propio estado (como
colaborador interno), y una vez que lo tengamos, llamaremos a su mensaje () método para
devolver esta información al usuario.

Llamar desde la aplicación


Así es como se van a utilizar estos objetos en la aplicación. Observe cómo esto depende de
los paquetes anteriores ( web y almacenamiento ), pero no al revés:

desde el almacenamiento import DBClient, DeliveryStatusQuery, OrderNotFoundError desde


web import NotFound, View, app, register_route

class DeliveryView(View):
async def _get(self, request, delivery_id: int):
dsq = DeliveryStatusQuery(int(delivery_id), await DBClient()) try
result = await dsq.get()
excepto OrderNotFoundError as e:
raise NotFound( str(e)) de e

devolver resultado.mensaje()

register_route(DeliveryView, "/status/<id_delivery:int>")

En la sección anterior se mostraron los objetos del dominio y aquí se muestra el código de
la aplicación. ¿No nos estamos perdiendo algo? Claro, pero ¿es algo que realmente
necesitamos saber ahora? No necesariamente.

El código dentro de los paquetes web y de almacenamiento se omitió deliberadamente


(aunque se recomienda al lector que lo mire ; el repositorio del libro contiene el ejemplo
completo). Además, y esto se hizo a propósito, los nombres de dichos paquetes se eligieron
para no revelar ningún detalle técnico : almacenamiento y web .
[ 298 ]
Arquitectura limpia Capítulo 10

Mira de nuevo el código en el listado anterior. ¿Puedes decir qué marcos se están
utilizando? ¿Dice si los datos provienen de un archivo de texto, una base de datos (si es
así, de qué tipo? ¿SQL? ¿NoSQL?) u otro servicio (la web, por ejemplo)? Suponga que
proviene de una base de datos relacional. ¿Hay alguna pista sobre cómo se recupera esta
información (¿consultas SQL manuales? ¿A través de un ORM?)?

¿Qué pasa con la red? ¿Podemos adivinar qué marcos se utilizan?

El hecho de que no podamos responder a ninguna de esas preguntas es probablemente


una buena señal. Esos son detalles, y los detalles deben ser encapsulados. No podemos
responder esas preguntas a menos que echemos un vistazo a lo que hay dentro de esos
paquetes.

Hay otra forma de responder a las preguntas anteriores, y viene en forma de una pregunta
en sí misma: ¿por qué necesitamos saber eso? Mirando el código, podemos ver que hay un
DeliveryOrder , creado con un identificador de una entrega, y que tiene un método get() , que
devuelve un objeto que representa el estado de la entrega. Si toda esta información es correcta,
eso es todo lo que debería preocuparnos. ¿Qué diferencia hace cómo se hace?

Las abstracciones que creamos hacen que nuestro código sea declarativo. En la
programación declarativa, declaramos el problema que queremos resolver, no cómo
queremos resolverlo. Es lo contrario de imperativo, en el que tenemos que hacer explícitos
todos los pasos necesarios para obtener algo (por ejemplo, conectarse a la base de datos,
ejecutar esta consulta, analizar el resultado, cargarlo en este objeto, etc.). En este caso,
estamos declarando que solo queremos saber el estado de la entrega dada por algún
identificador.

Estos paquetes se encargan de tratar los detalles y presentar lo que la aplicación necesita
en un formato conveniente, es decir, objetos del tipo presentado en la sección anterior.
Solo tenemos que saber que el paquete de almacenamiento contiene un objeto que, dado un
ID para una entrega y un cliente de almacenamiento (esta dependencia se inyecta en este
ejemplo para simplificar, pero también son posibles otras alternativas),
recuperará DeliveryOrder que A continuación, puede solicitar redactar el mensaje.

Esta arquitectura proporciona comodidad y facilita la adaptación a los cambios, ya


que protege el núcleo de la lógica empresarial de los factores externos que pueden
cambiar.

Imagine que queremos cambiar la forma en que se recupera la información. ¿Qué tan difícil
podría ser? La aplicación se basa en una API, como la siguiente:
dsq = DeliveryStatusQuery(int(delivery_id), espera DBClient())

[ 299 ]
Arquitectura limpia Capítulo 10

Entonces, se trataría simplemente de cambiar la forma en que funciona el método get() ,


adaptándolo al nuevo detalle de implementación. Todo lo que necesitamos es que este
nuevo objeto devuelva DeliveryOrder en su método get() y eso sería todo. Podemos cambiar
la consulta, el ORM, la base de datos, etc. y, en todos los casos, ¡no es necesario cambiar el
código de la aplicación!

Adaptadores
Aún así, sin mirar el código de los paquetes, podemos concluir que funcionan como
interfaces para los detalles técnicos de la aplicación.

De hecho, dado que estamos viendo la aplicación desde una perspectiva de alto nivel, sin
necesidad de mirar el código, podemos imaginar que dentro de esos paquetes debe haber
una
implementación del patrón de diseño del adaptador (presentado en el Capítulo 9 , Patrones
de diseño comunes ). Uno o más de estos objetos está adaptando una implementación
externa a la API definida por la aplicación. De esta forma, las dependencias que quieran
trabajar con la aplicación deben ajustarse a la API y habrá que hacer un adaptador.

Sin embargo, hay una pista relacionada con este adaptador en el código de la aplicación.
Observe cómo se construye la vista. Hereda de una clase llamada Vista que proviene de
nuestro paquete web . Podemos deducir que esta Vista es, a su vez, una clase derivada de
uno de los frameworks web que se podrían estar utilizando, creando un adaptador por
herencia. Lo importante a tener en cuenta es que una vez hecho esto, el único objeto que
importa es nuestra clase Vista , porque, en cierto modo, estamos creando nuestro propio
marco, que se basa en adaptar uno existente (pero nuevamente cambiar el marco significa
simplemente cambiar los adaptadores, no toda la aplicación).

Los servicios
Para crear el servicio, lanzaremos la aplicación Python dentro de un contenedor Docker.
Partiendo de una imagen base, el contenedor tendrá que instalar las dependencias para
que se ejecute la aplicación, que también tiene dependencias a nivel de sistema
operativo.

En realidad, esta es una opción porque depende de cómo se usen las dependencias. Si un
paquete que usamos requiere que se compilen otras bibliotecas en el sistema operativo en el
momento de la instalación, podemos evitar esto simplemente creando una rueda para
nuestra plataforma de la biblioteca e instalándola directamente. Si las bibliotecas son
necesarias en tiempo de ejecución, entonces no hay más remedio que hacerlas parte de la
imagen del contenedor.

[ 300 ]
Arquitectura limpia Capítulo 10

Ahora, discutimos una de las muchas formas de preparar una aplicación de Python para
que se ejecute dentro de un
Contenedor Docker. Esta es una de las numerosas alternativas para empaquetar un
proyecto de Python en
un contenedor. Primero, echamos un vistazo a cómo se ve la estructura de los directorios:
.
├── Dockerfile
├── libs
│├── README.rst
│├── almacenamiento
│└── web
├── Makefile
├── README.rst
├── setup.py
└─── statuswebit
__in├.─ _ py
└── servicio.py

El directorio libs se puede ignorar ya que es solo el lugar donde están las dependencias
colocados (se muestra aquí para tenerlos en cuenta cuando se les hace referencia en el
archivo setup.py
archivo, pero podrían colocarse en un repositorio diferente e instalarse de forma remota a
través de pip ).

Tenemos Makefile con algunos comandos auxiliares, luego el archivo setup.py y el


aplicación misma dentro del directorio statusweb . Una diferencia común entre los envases
aplicaciones y bibliotecas es que mientras las últimas especifican sus dependencias en
el archivo setup.py , las primeras tienen un archivo requirements.txt desde donde se
establecen las dependencias.
instalado a través de pip install -r requirements.txt . Normalmente, haríamos esto en el
Dockerfile , pero para simplificar las cosas en este ejemplo en particular, supondremos
que tomar las dependencias del archivo setup.py es suficiente. Esto se debe a que además de
esto
consideración, hay muchas más consideraciones a tener en cuenta cuando se trata de
con dependencias, como congelar la versión de los paquetes, rastrear
dependencias indirectas, usar herramientas adicionales como pipenv y más temas que están
fuera del alcance
del capitulo Además, también es habitual hacer que el archivo setup.py se lea
de requirements.txt para mantener la coherencia.

Ahora tenemos el contenido del archivo setup.py , que indica algunos detalles de la
aplicación:
desde setuptools import find_packages, setup
con open("README.rst", "r") como longdesc:
long_description = longdesc.read()

install_requires = ["web", "almacenamiento"]

configuración(

[ 301 ]
Arquitectura limpia Capítulo 10

name="delistatus",
description="Comprobar el estado de un pedido de entrega",
long_description=long_description,
author="Dev team",
version="0.1.0",
packages=find_packages(),
install_requires=install_requires,
entry_points={
"console_scripts": [
"estado-servicio = statusweb.service:principal", ],
},
)

Lo primero que notamos es que la aplicación declara sus dependencias, que son los
paquetes que creamos y colocamos en libs/ , a saber, web y storage , abstrayendo y
adaptando algunos componentes externos. Estos paquetes, a su vez, tendrán dependencias,
por lo que tendremos que asegurarnos de que el contenedor instale todas las bibliotecas
requeridas cuando se crea la imagen para que puedan instalarse correctamente, y luego este
paquete.

Lo segundo que notamos es la definición del argumento de la palabra clave entry_points


pasado a la función de configuración . Esto no es estrictamente obligatorio, pero es una
buena idea crear un punto de entrada. Cuando el paquete se instala en un entorno virtual,
comparte este directorio junto con todas sus dependencias. Un entorno virtual es una
estructura de directorios con las dependencias de un determinado proyecto. Tiene muchos
subdirectorios, pero los más importantes son:

<virtual-env-root>/lib/<python-version>/site-packages
<raíz-env-virtual>/bin

El primero contiene todas las bibliotecas instaladas en ese entorno virtual. Si tuviéramos
que crear un entorno virtual con este proyecto, ese directorio contendría los paquetes web y
de almacenamiento , junto con todas sus dependencias, más algunas básicas adicionales y el
proyecto actual en sí.
[ 302 ]
Arquitectura limpia Capítulo 10

El segundo, /bin/ , contiene los archivos binarios y los comandos disponibles cuando ese
entorno virtual está activo. De forma predeterminada, solo sería la versión de Python, pip y
algunos otros comandos básicos. Cuando creamos un punto de entrada, se coloca allí un
binario con ese nombre declarado y, como resultado, tenemos ese comando disponible para
ejecutar cuando el
entorno está activo. Cuando se llama a este comando, ejecutará la función que se especifica
con todo el contexto del entorno virtual. Eso significa que es un binario al que podemos
llamar directamente sin tener que preocuparnos de si el entorno virtual está activo o si las
dependencias están instaladas en la ruta que se está ejecutando actualmente.

La definición es la siguiente:
"estado-servicio = estadoweb.servicio:principal"

El lado izquierdo del signo igual declara el nombre del punto de entrada. En este caso,
tendremos disponible un comando llamado status-service . El lado derecho declara cómo se
debe ejecutar ese comando. Requiere el paquete donde se define la función, seguido del
nombre de la función después de : . En este caso, ejecutará la función principal declarada en
statusweb/service.py .

A esto le sigue una definición del Dockerfile:


DESDE python: 3.6.6-alpine3.6

EJECUTAR apk agregar --


update \
python-dev \
gcc \
musl-dev \
make

WORKDIR
/aplicación
AÑADIR.
/aplicación

EJECUTAR pip install /app/libs/web /app/libs/storage EJECUTAR


pip install /app

EXPONER 8080
CMD ["/usr/local/bin/status-service"]

La imagen se construye en base a una imagen ligera de Python, y luego se instalan las
dependencias del sistema operativo para que se puedan instalar nuestras bibliotecas.
Siguiendo la consideración anterior, este Dockerfile simplemente copia las bibliotecas, pero
también podría instalarse desde un archivo requirements.txt en consecuencia. Una vez que
todos los comandos de instalación de pip
están listos, copia la aplicación en el directorio de trabajo y el punto de entrada de Docker
(el comando CMD , que no debe confundirse con el de Python) llama al punto de entrada del
paquete donde colocamos la función. que inicia el proceso.

[ 303 ]
Arquitectura limpia Capítulo 10

Toda la configuración se pasa por variables de entorno, por lo que el código de nuestro
servicio deberá cumplir con esta norma.

En un escenario más complejo que involucre más servicios y dependencias, no solo


ejecutaremos la imagen del contenedor creado, sino que declararemos un archivo docker-
compose.yml con las definiciones de todos los servicios, imágenes base y cómo están
vinculados y interconectado.

Ahora que tenemos el contenedor ejecutándose, podemos iniciarlo y ejecutar una pequeña
prueba para tener una idea de cómo funciona:
$ curl http://localhost:8080/status/1
{"id":1,"status":"dispatched","msg":"El pedido se envió el 2018-08-
01T22:25:12+00:00 "}

Análisis
Hay muchas conclusiones que sacar de la implementación anterior. Si bien puede parecer
un buen enfoque, existen desventajas que vienen con los beneficios; después de todo,
ninguna
arquitectura o implementación es perfecta. Esto significa que una solución como esta no
puede ser buena para todos los casos, por lo que dependerá bastante de las circunstancias
del proyecto, el equipo, la organización y más.

Si bien es cierto que la idea principal de la solución es abstraer los detalles tanto como sea
posible, como veremos, algunas partes no se pueden abstraer por completo, y también los
contratos entre las capas implican una fuga de abstracción.

El flujo de dependencia
Tenga en cuenta que las dependencias fluyen en una sola dirección, a medida que se
acercan al kernel, donde se encuentran las reglas comerciales. Esto se puede rastrear
mirando las declaraciones de importación . La aplicación importa todo lo que necesita del
almacenamiento, por ejemplo, y en ninguna parte se invierte.

Romper esta regla crearía un acoplamiento. La forma en que el código está organizado
ahora significa que existe una dependencia débil entre la aplicación y el almacenamiento. La
API es tal que necesitamos un objeto con un método get() , y cualquier almacenamiento que
quiera conectarse a la
aplicación necesita implementar este objeto de acuerdo con esta especificación. Por lo tanto,
las dependencias se invierten : depende de cada almacenamiento implementar esta interfaz
para crear un objeto de acuerdo con lo que espera la aplicación.

[ 304 ]
Arquitectura limpia Capítulo 10

Limitaciones
No todo puede abstraerse. En algunos casos, simplemente no es posible, y en otros,
puede que no sea conveniente. Comencemos con el aspecto de conveniencia.

En este ejemplo, hay un adaptador del marco web elegido para una API limpia que se
presentará a la aplicación. En un escenario más complejo, tal cambio podría no ser posible.
Incluso con esta abstracción, partes de la biblioteca seguían siendo visibles para la
aplicación. Adaptar un marco completo puede no solo ser difícil sino también imposible
en algunos casos.
No es del todo un problema estar completamente aislado del marco web porque,
tarde o temprano, necesitaremos algunas de sus características o detalles técnicos.

Lo importante aquí no es el adaptador, sino la idea de ocultar los detalles técnicos tanto
como sea posible. Eso significa que lo mejor que se mostró en la lista para el código de la
aplicación no fue el hecho de que había un adaptador entre nuestra versión del marco web
y la actual, sino el hecho de que este último no fue
mencionado . por su nombre en cualquier parte del código visible. El servicio dejó en claro
que la web era solo una dependencia (se importaba un detalle) y reveló la intención detrás
de lo que se suponía que debía hacer. El objetivo es revelar la intención (como en el código)
y diferir los detalles tanto como sea posible.

En cuanto a qué cosas no se pueden aislar, esos son los elementos que están más cerca del
código. En este caso, la aplicación web estaba usando los objetos que operaban dentro de
ellos de forma
asíncrona. Esa es una restricción difícil que no podemos eludir. Es cierto que lo que sea
que esté dentro del paquete de almacenamiento se puede cambiar, refactorizar y modificar,
pero sean cuales sean estas modificaciones, aún debe conservar la interfaz, y eso incluye la
interfaz asincrónica.

Testabilidad
Nuevamente, al igual que con el código, la arquitectura puede beneficiarse al separar las
piezas en componentes más pequeños. El hecho de que las dependencias ahora estén
aisladas y controladas por componentes separados nos deja con un diseño más limpio
para la aplicación principal, y ahora es más fácil ignorar los límites para enfocarse en
probar el núcleo de la aplicación.

Podríamos crear un parche para las dependencias y escribir pruebas unitarias que sean más
simples (no necesitarán una base de datos) o lanzar un servicio web completo, por ejemplo.
Trabajar con objetos de dominio puro significa que será más fácil comprender el código y las
pruebas unitarias. Incluso los adaptadores no necesitarán tantas pruebas porque su lógica
debería ser muy simple.

[ 305 ]
Arquitectura limpia Capítulo 10

Intención reveladora
Estos detalles incluían mantener funciones cortas, preocupaciones separadas, dependencias
aisladas y asignar el significado correcto a las abstracciones en cada parte del código. La
revelación de intenciones fue un concepto crítico para nuestro código : cada nombre debe
elegirse sabiamente, comunicando claramente lo que se supone que debe hacer. Cada
función debe contar una historia.

Una buena arquitectura debe revelar la intención del sistema que implica. No debe
mencionar las herramientas con las que está construido; esos son detalles, y como
discutimos extensamente, los detalles deben ocultarse, encapsularse.

Resumen
Los principios para un buen diseño de software se aplican en todos los niveles. De la
misma manera que queremos escribir código legible, y para eso debemos tener en cuenta
el grado de revelación de la intención del código, la arquitectura también debe expresar la
intención del problema que está tratando de resolver.

Todas estas ideas están interconectadas. La misma intención reveladora que asegura que
nuestra
arquitectura se defina en términos del problema del dominio también nos lleva a abstraer
los detalles tanto como sea posible, crear capas de abstracción, invertir dependencias y
separar preocupaciones.

Cuando se trata de reutilizar código, los paquetes de Python son una alternativa excelente y
flexible.
criterios, como la cohesión y el principio de responsabilidad única ( SRP ), son las
consideraciones más importantes al decidir crear un paquete. En la línea de tener
componentes con cohesión y pocas responsabilidades, entra en juego el concepto de
microservicios, y para ello hemos visto cómo se puede desplegar un servicio en un
contenedor Docker a partir de una aplicación Python empaquetada.

Como ocurre con todo en la ingeniería de software, existen limitaciones y excepciones.


No siempre será posible abstraer las cosas tanto como nos gustaría o
aislar completamente las dependencias. A veces, simplemente no será posible (o práctico)
cumplir con los principios explicados aquí en el libro. Pero ese es probablemente el mejor
consejo que el lector debe tomar del libro : son solo principios, no leyes. Si no es posible, o
práctico, abstraerse de un marco, no debería ser un problema. Recuerde lo que se ha citado
del propio zen de Python a lo largo del libro : la practicidad supera a la pureza .
[ 306 ]
Arquitectura limpia Capítulo 10

Referencias
Aquí hay una lista de información que puede consultar:

SCREAM : La arquitectura que grita ( https:/)/8thlight.com/blog/uncle-


bob/2011/09/30/Screaming-Architecture.html
CLEAN-01 : La Arquitectura Limpia ( https:/)/8thlight.com/blog/uncle-bob/
2012/08/13/the-clean-architecture.html
HEX : arquitectura hexagonal (https:/)/staging.cockburn.us/hexagonal-
architecture/
PEP-508 : Especificación de dependencia para paquetes de software de Python (
https:/)/www. python.org/dev/peps/pep-0508/
Empaquetado y distribución de proyectos en Python (https://python-packaging-
user-guide.readthedocs.io/guides/distributing-packages-using-setuptools/#distributing-
packages

Resumiendo todo
El contenido del libro es una referencia, una posible forma de implementar una solución de
software siguiendo criterios. Estos criterios se explican a través de ejemplos y se presenta la
justificación de cada decisión. Es muy posible que el lector no esté de acuerdo con el
enfoque adoptado en los ejemplos, y esto es realmente deseable: cuantos más puntos de
vista, más rico es el debate. Pero independientemente de las opiniones, es importante dejar
en claro que lo que se presenta aquí no es de ninguna manera una directiva fuerte, algo que
debe seguirse de manera imperativa. Todo lo contrario, es una forma de presentar al lector
una solución y un conjunto de ideas que pueden resultarle útiles.

Como se presentó al comienzo del libro, el objetivo del libro no era darle recetas o fórmulas
que pueda aplicar directamente, sino más bien hacerle desarrollar el pensamiento crítico.
Los modismos y las funciones de sintaxis van y vienen, cambian con el tiempo. Pero las
ideas y los conceptos básicos de software permanecen. Con estas herramientas
proporcionadas y los ejemplos proporcionados, debería tener una mejor comprensión de
lo que significa código limpio.

Espero sinceramente que el libro le haya ayudado a convertirse en un mejor desarrollador


de lo que era antes de empezarlo, y le deseo la mejor de las suertes en sus proyectos.
[ 307 ]
Otros libros que puede
disfrutar
Si disfrutó de este libro, es posible que le interesen estos otros libros de Packt:

Recetas secretas de Python Ninja


cody jackson
ISBN: 978-1-78829-487-4

Conozca las diferencias entre los archivos .py y .pyc


Explore las diferentes formas de instalar y actualizar paquetes de Python
Comprender el funcionamiento del módulo PyPI que mejora los decoradores
integrados
Vea en qué se diferencian las corrutinas de los generadores y cómo se pueden
simular
subprocesamiento múltiple
Comprender cómo el módulo decimal mejora los números de punto flotante y su
operaciones
Estandarice los subintérpretes para mejorar la concurrencia
Descubra el analizador de cadenas de documentos incorporado de Python
Otros libros que puede disfrutar

Planos de programación de Python


Daniel FurtadoMarcus Pennington
ISBN: 978-1-78646-816-1

Aprenda conceptos de programación funcional y orientada a objetos mientras


desarrolla
proyectos
Lo que se debe y no se debe hacer al almacenar contraseñas en una base de datos
Desarrolle un sitio web completamente funcional usando el popular framework
Django
Use la biblioteca Beautiful Soup para realizar el desguace web
Comience con la computación en la nube mediante la creación de microservicios y sin
servidor
aplicaciones en AWS
Desarrolle microservicios escalables y cohesivos utilizando el marco Nameko
Crear dependencias de servicio para Redis y PostgreSQL
[ 309 ]
Otros libros que puede disfrutar

Deje un comentario: deje que otros lectores


sepan lo que piensa
Comparta sus opiniones sobre este libro con otras personas dejando una reseña en el sitio
donde lo compró. Si compró el libro en Amazon, déjenos una reseña honesta en la página
de Amazon de este libro. Esto es vital para que otros lectores potenciales puedan ver y usar
su opinión imparcial para tomar decisiones de compra, podemos entender lo que nuestros
clientes piensan sobre nuestros productos y nuestros autores pueden ver sus comentarios
sobre el título que han trabajado con Packt para crear. Solo le tomará unos minutos de su
tiempo, pero es valioso para otros clientes potenciales, nuestros autores y Packt. ¡Gracias!

[ 310 ]
Índice

patrón de estado 274 , 279


A método de plantilla 272
abstracciones 288 mejores prácticas, diseño de
acrónimos software sobre 93
sobre 72 código, estructuración 96
DRY/OAOO 72 ortogonalidad en software 94
EAFP/LBYL 76 Referencia negra
KISS 75 22
YAGNI 74 patrón borg
flujo de dependencia de análisis sobre 260
304 patrón de constructor 262
intención que revela 306
limitaciones 305 C
comprobabilidad 305
C3 linealización 84
anotaciones
objetos invocables 48
sobre 13 , 16
advertencias, Python
usando, en lugar de docstrings 18
sobre 49
interfaz de programación de aplicaciones
tipos incorporados, que se extienden
(API) 56 argumentos
51
función compacta firmas 92
argumentos predeterminados
copiar, a funciones 86
mutables 50
en funciones 85 , 91
arquitectura limpia
en métodos 85
sobre 286
pasar, a Python 85
referencia 307
argumentos variables 89
código limpio
número de variable 87
formato de código, función 9
programación asincrónica 215 , 217
guía de estilo de codificación,
configuración de comprobaciones
adherirse a 10 definición 8
automáticas
importancia 8
21
cobertura de código
sobre 236
B cumplimiento de cobertura de descanso,
puertas de calidad básica configuración 236
, mediante configuración de cobertura de prueba, advertencias
herramientas 20 patrones de 237
comportamiento dependencia del código,
alrededor de 270 consecuencias bajo nivel de
cadena de responsabilidad 270 , 272 abstracción 71
patrón de comando 273 sin reutilización de código 71
efectos dominó 71
duplicación de código,
limitaciones
propensión a errores 72
caro 72 objetos decoradores 134
fiabilidad 72 decoradores
análisis de reutilización de código 149
adaptadores 300 y separación de preocupaciones
llamando, desde aplicación 147 argumentos, pasando 131
298
modelos de dominio 296 código, rastreo 136
simplificación de código, a través de iteradores errores comunes, evitando 136
sobre 197 creando 143 , 145
bucles anidados 198 datos, preservando el objeto envuelto original
iteraciones repetidas 198 136
código , implementando 185
documentando 12 Principio DRY, utilizando con 146
evolucionando manejo incorrecto de efectos
244 secundarios 139
cohesión 71 cualidades 149
composición 77 efectos secundarios,
necesidad de 141 consideraciones, descriptores tipos 131
decoradores de clase, evitando 176 , 179 usa 135
código, reutilizando 175 usando, en Python 124
contenedor iterable con funciones anidadas 132
43 objetos programación defensiva
contenedor 45
administradores de contexto sobre 60
sobre 29 , 32 aserciones, usando Python 69
implementando 32 , 35 manejo de errores 61
corrutinas inyección de dependencia 122
sobre 203 principio de inversión de
dependencia (DIP) corrutinas avanzadas 209 sobre 120 , 289
datos, recibir del subgenerador 213 dependencias, inversión 121
datos, enviar al subgenerador 213 dependencias rígidas 121
delegar, en corrutinas más pequeñas 210 descriptor, utilizando en Python
interfaz del generador, métodos 204 decoradores integrados, para métodos
valor devuelto por el subgenerador, devolver 212 184 funciones y métodos 180
valores, devolución 209 ranuras 185
forma de rendimiento, uso 211 descriptores
acoplamiento 71 estado compartido global,
emisión 172 patrones creacionales sobre 152 , 168
sobre 256 análisis 180
patrón borg 260 , 262 aplicación 168
fábricas 256 evitar 168
estado compartido 257 , 260 consideraciones 175
único 257 descriptores de datos 165 , 167
implementación idiomática 169
D implementación, formularios 172
descriptores de datos 163 , implementación, en decoradores
165 decorar clases 127 , 185 maquinaria 153 , 156
130 decorar funciones 126 descriptores que no son datos
163 , 165
[ 312 ]
diccionario de objetos, accediendo a trabajando, en Python 85
173 tipos 163
usando, en Python 180 G
referencias débiles, usando 174 generadores
escenario de diseño por contrato sobre 189 , 191
sobre 56 creando 189
conclusiones 59 expresiones 192
invariantes 57 condiciones referencias 218
posteriores 57 , 59 dios-objetos 100
condiciones previas 57 , 58
Contratos pitónicos 59 H
efectos secundarios 57 Hexagonal Architecture
patrones de diseño referencia 307
sobre 282 sugerencia 16
patrones de comportamiento 269
consideraciones 254
patrones creacionales 256
I
influencia 282 modismos para iteración
sobre 193
patrones estructurales 262
código, simplificando a través de
usando 255
iteradores 197 generador, usando 196
usando, en código 283
itertools 196
docstrings 13 , 16
next() función 195
documentación 13 Principio de
índices 26
no repetirse (DRY) sobre 72
herencia
usando, con decoradores 146 , 236
sobre 77
pato tipificando principio 117
anti-patrones 79 , 81
beneficios 78
E Es interfaz 117
más fácil pedir perdón que permiso (EAFP) 76 principio de segregación de interfaz (ISP)
manejo de errores sobre 117
alrededor de 61 directrices 118 , 119
manejo de excepciones 62 objetos iterables
sustitución de valor 61 , 62 sobre 40
manejo de excepciones creando 40 , 43
alrededor de 62 secuencias, creando 43
en el nivel correcto de abstracción 64 , 65 iteraciones
bloques vacíos excepto, evitando 67 sobre 193
excepción original, explorando 69 modismos, usando 193
exposición de rastreos, evitando 66 patrón de iterador
en la interfaz de Python 200
para iteración 200

F , usando como iterables 201


argumentos de función
y acoplamiento 91

[ 313 ]
K N
KIS (mantenlo simple) 75
manipulación
L de
Liskov (LSP) nombres 37
sobre 110 descriptores que no son
detección de problemas, con herramientas 111 datos 163 patrón de
revisiones 116 objeto nulo 280
violaciones, casos 113
Look Before You Leap (LBYL) 76 O
LSP emite
Asignador relacional de objetos SQLAlchemy
firmas incompatibles, detectando con Pylint
(ORM SQLAlchemy) 274 atributos de
113
objetos
tipos de datos incorrectos, detección en Una vez y solo una vez (OAOO) 72
firmas de métodos con Mypy 112 35
principio abierto/cerrado (OCP)
atributos dinámicos 46
sobre 103 , 109
METRO métodos 35
sistema de eventos,
propiedades
extendiendo 35 , 38 , 39
107
métodos mágicos sistema de eventos, refactorización para
49 makefiles 21 extensibilidad 105 peligros de mantenibilidad
ejemplo 103 , 105
métodos, descriptor protocolo
__delete__(self, instancia) 159 P aplicación de
__set__(self, instancia, valor) 158 parches 239
__set_name__(self, propietario, nombre) 161 Características de PEP-8
acerca de 156 calidad del código 12
métodos, interfaz del generador coherencia 12
acerca de 204 grepabilidad 11
close() método 204 Interfaz de sistema operativo portátil (POSIX) 269
send(valor) 206 código de producción 246
throw(ex_type [, ex_value[, ex_traceback]]) 205 pruebas basadas en propiedades 248
simulacros de objetos Pylint
sobre 238 utilizado para comprobar el código 21
usando 239 utilizado para detectar firmas incompatibles 113
simulacros pytest
sobre 239 , 240 sobre 232
tipos 240 accesorios 235
herencia múltiple, pruebas parametrizadas 234
orden de resolución del método usadas, para escribir casos de
Python (MRO) 82 prueba 233
mixins 84 Pruebas de mutación de
Python
248 afirmaciones, usando
69 Advertencias de Mypy 49
referencia 20 decoradores, usando
125
usado, para detectar tipos de datos incorrectos en descriptores, usando 180
firmas de métodos 111 patrones de diseño, consideraciones
254
utilizado, para sugerencias de tipo 20 patrón de iterador 200
[ 314 ]
de herencia múltiple 82 dobles
proyectos, referencia 307 caso de uso 241
guiones bajos 35 desarrollo basado en pruebas (TDD)
251 capacidad de prueba 222
R pruebas 226
ciclo rojo-verde-refactor 251 límites de prueba que
refactorización 244 definen 225
efecto dominó 70 herramientas de prueba
226
S Pruebas unitarias
Screaming Arquitectura U
referencia 307 sobre 247
separación de preocupaciones (SoC) y desarrollo de software ágil 222 y
alrededor de 70 , 287 , 294 diseño de software 222
cohesión 71 marcos 226
acoplamiento 71 bibliotecas 226 pruebas de
creación de secuencias mutación 248
28 pruebas basadas en propiedades 248
principio de responsabilidad única (SRP) pytest 232
alrededor de 99 módulo unittest 228
clase, con múltiples responsabilidades 100 módulo unittest
responsabilidades, distribución 102 sobre 228
rebanadas 26 pruebas parametrizadas 230
componentes de software caso de uso
contenedores 294 sobre 295
paquetes 290 , 292 análisis 304
mejores prácticas de diseño de software reutilización de código 296
93 servicios 300 , 303
patrones estructurales usos, parámetros decoradores
sobre 263 , transformación 135
patrón adaptador 263 patrón
compuesto 264 patrón
decorador 266
W
objeto envuelto 125
patrón de fachada 268

T Y
YAGNI (No lo vas a necesitar) 74

También podría gustarte