Clean Code in Python - Es
Clean Code in Python - Es
Clean Code in Python - Es
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.
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.
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.
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.
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.
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.
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.
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
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 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 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.
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
[4]
Prefacio
Convenciones utilizadas
Hay una serie de convenciones de texto utilizadas a lo largo de este libro.
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,
[5]
Prefacio
Ponerse en contacto
Los comentarios de nuestros lectores es siempre bienvenido.
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.
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!
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.
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.
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.
[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 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.
[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.
[ 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.
[ 11 ]
Introducción, formato de código y herramientas Capítulo 1
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()
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
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.
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.
[ 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.
>>> d = {}
>>> d.update({1: "uno", 2: "dos"})
>>> d
{1: 'uno', 2: 'dos'}
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.
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__ :
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
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.
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.
>>> 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:
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'>}
Considere el siguiente ejemplo. Digamos que tenemos una función que espera que un
diccionario valide algunos datos:
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.
{
"status": 200, # <int>
"timestamp": "....", # cadena de formato ISO de la fecha y hora actual
"carga útil": { ... } # dict con los datos devueltos
}
{"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
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:
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.
[ 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
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).
[ 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/
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.
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.
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
Í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:
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)
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:
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
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 .
Elementos de clase:
def __init__(self, *valores):
self._values = lista(valores)
def __len__(self):
return len(self._values)
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.
[ 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:
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.
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)
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 db_backup():
ejecutar("base de datos pg_dump")
def main():
con DBHandler():
db_backup()
[ 31 ]
Código pitónico Capitulo 2
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 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.
importar contextlib
@contextlib.contextmanager
def db_handler():
stop_database()
yield
start_database()
con db_handler():
db_backup()
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()
@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
con contextlib.suppress(DataConversionException):
parse_data(input_json_or_dict)
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.
Como mencionamos anteriormente, por defecto todos los atributos de un objeto son
públicos. Considere el siguiente ejemplo para ilustrar esto:
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:
[ 36 ]
Código pitónico Capitulo 2
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
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"[^@]+@[^@]+\.[^@]+")
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
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 .
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
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.
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 .
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:
class DateRangeIterable:
"""Un iterable que contiene su propio objeto iterador."""
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:
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 :
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:
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)
La diferencia es que cada ciclo for está llamando a __iter__ nuevamente, y cada uno de
ellos está creando el generador nuevamente.
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) .
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 __len__(auto):
return len(auto._rango)
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
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.
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:
Límites de clase:
def __init__(self, ancho, alto):
self.width = ancho
self.height = alto
[ 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)
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:
>>> dyn.fallback_test
'[fallback resuelto] prueba'
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.
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
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__ .
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
def __init__(self):
self._counts = defaultdict(int)
[ 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.
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
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'
nombre = user_metadata.pop("nombre")
edad = user_metadata.pop("edad")
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.
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:
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__ .
class GoodList(UserList):
def __getitem__(self, index):
value = super().__getitem__(index) if index % 2 == 0:
prefijo = "par"
else:
prefijo = "impar"
return f"[{prefijo }] {valor}"
[ 52 ]
Código pitónico Capitulo 2
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.
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.
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
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.
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.
[ 57 ]
Rasgos generales del buen código Capítulo 3
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.
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.
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.
[ 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:
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.
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
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 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
¿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.
"""
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
retry_threshold: int = 5
retry_n_times: int = 3
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.
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
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 .
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.
class InternalDataError(Exception):
"""Una excepción con los datos de nuestro problema de dominio."""
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
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.
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.
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:
[ 71 ]
Rasgos generales del buen código Capítulo 3
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.
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.
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
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.
[ 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.
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.
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."""
@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')
class Namespace:
"""Crear un objeto a partir de argumentos de palabra clave."""
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 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)
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).
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.
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:
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.
[ 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 .
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.
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
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
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.
class TransactionalPolicy:
"""Ejemplo refactorizado para usar composición."""
def __len__(auto):
return len(auto._datos)
[ 81 ]
Rasgos generales del buen código Capítulo 3
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.
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
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"
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 __iter__(self):
rendimiento de self.str_token.split("-")
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__())
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.
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.
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.
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 .
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:
>>> 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
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
[ 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.
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 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:
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!).
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.
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.
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.
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.
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)
[ 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'
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.
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.
[ 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:
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
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
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).
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:
[ 97 ]
Rasgos generales del buen código Capítulo 3
[ 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á:
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.
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.
# 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.
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:
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.
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
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 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)
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.
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.
@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 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
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.
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
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 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:
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.
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.
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.
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.
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
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
...
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.
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
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).
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
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 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á.
@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 .
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 .
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.
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.
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).
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!
[ 119 ]
Los principios SOLID Capítulo 4
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.
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.
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.
[ 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.
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:
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.
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)
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.
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.
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
@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
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:
[ 127 ]
Uso de decoradores para mejorar nuestro código Capítulo 5
clase LoginEventSerializer:
def __init__(self, evento):
self.event = evento
clase LoginEvent:
SERIALIZER = LoginEventSerializer
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
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 show_original(campo_evento):
devuelve campo_evento
clase EventSerializer:
def __init__(self, serialization_fields: dict) -> Ninguno: self.serialization_fields
= serialization_fields
[ 129 ]
Uso de decoradores para mejorar nuestro código Capítulo 5
@Serialization(
nombre de
usuario=mostrar_original,
contraseña=ocultar_campo,
ip=mostrar_original,
marca de tiempo=formato_hora,
)
class LoginEvent:
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á.
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í:
[ 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
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.
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.
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.
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.
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
@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__ .
@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
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.
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
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:
#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) ...
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.
envuelto(*args, **kwargs)
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 ).
# 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
process_account(account_id)
Procesar una cuenta por Id.
¡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.
volver función_decorada
[ 138 ]
Uso de decoradores para mejorar nuestro código Capítulo 5
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 .
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()
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.
>>>
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.
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
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
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.
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 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 :
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.
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.
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:
[ 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.
registrador = registro.getLogger(__nombre__)
clase DBDriver:
def __init__(self, dbstring):
self.dbstring = 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")
>>> 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?
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.
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)
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 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.
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.
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.
def traced_function(función):
@functools.wraps(función)
[ 147 ]
Uso de decoradores para mejorar nuestro código Capítulo 5
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)
[ 148 ]
Uso de decoradores para mejorar nuestro código Capítulo 5
Antes de saltar a los ejemplos, primero identifiquemos los rasgos que deben tener los
buenos decoradores:
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).
Referencias
Aquí hay una lista de información que puede consultar:
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:
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
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__
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
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 .
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.
[ 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()
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
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í.
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
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 .
cliente.descriptor = "valor"
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:
campo de clase:
[ 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"),
)
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:
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
class User:
"""Solo los usuarios con privilegios de "administrador" pueden eliminar su dirección de
correo electrónico."""
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
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:
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.
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
clase ClientClass:
descriptor = DescriptorWithName("descriptor")
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.
Con este método, podemos reescribir los descriptores anteriores de la siguiente manera:
class DescriptorWithName:
def __init__(self, nombre=Ninguno):
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.
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.
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()
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
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)
{}
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).
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__ :
clase ClientClass:
descriptor = DataDescriptor()
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
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).
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.
¿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.
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:
@actual_ciudad.setter
[ 168 ]
Obtener más de nuestros objetos con descriptores Capítulo 6
@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.
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
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.
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
Clase Viajero:
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):
[ 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.
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
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'
>>> cliente1.descriptor
'valor para cliente 2'
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.
[ 173 ]
Obtener más de nuestros objetos con descriptores Capítulo 6
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).
class DescriptorClass:
def __init__(self, initial_value): self.value =
initial_value
self.mapping = WeakKeyDictionary()
[ 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.
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.
[ 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.
@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):
clase BaseFieldTransformation:
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.
def serialize(self):
return {
"username": self.username,
"password": self.password,
"ip": self.ip,
"timestamp": self.timestamp,
}
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?
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
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.
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...>
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
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 .
Método de clase:
def __init__(self, nombre):
self.name = nombre
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.
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 :
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.
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.
clase Coordinate2D:
__slots__ = ("lat", "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.
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.
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
Referencias
Aquí hay una lista de algunas cosas que puede consultar para obtener más información:
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.
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.
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 _initialize(self):
try:
first_value = next(self.purchases) excepto
StopIteration:
raise ValueError("no se proporcionan valores")
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
@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)
[ 191 ]
Uso de generadores Capítulo 7
>>> 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() :
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.
>>> 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 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() :
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:
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() :
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' # ...
>>> ...
>>> 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.
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í:
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.
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:
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.
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.
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 .
si coords no es Ninguno:
romper
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
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.
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.
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.
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
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) .
[ 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.
# generadores_iteración_2.py
class MappedRange:
"""Aplica una transformación a un rango de números."""
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.
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
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á.
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()
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:
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.
tamaño_página_anterior = tamaño_página
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.
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
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
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
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.
[ 209 ]
Uso de generadores Capítulo 7
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.
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 :
[ 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.
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
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 ):
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.
def main():
paso1 = rendimiento de secuencia("primero", 0, 5)
paso2 = rendimiento de secuencia("segundo", paso1, 10) return paso1
+ paso2
[ 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
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.
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:
>>> 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.
[ 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.
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.
Referencias
Aquí hay una lista de información que puede consultar:
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.
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
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:
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.
[ 220 ]
Pruebas unitarias y refactorización Capítulo 8
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.
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.
[ 221 ]
Pruebas unitarias y refactorización Capítulo 8
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.
[ 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.
clase MetricsClient:
"""cliente de métricas de terceros"""
Proceso de clase:
def __init__(self):
self.client = MetricsClient() # Un cliente de métricas de terceros
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:
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.
clase WrappedClient:
def __init__(self):
self.cliente = MetricsClient()
clase Proceso:
def __init__(self):
self.client = WrappedClient()
[ 224 ]
Pruebas unitarias y refactorización Capítulo 8
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
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.
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.
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.
[ 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
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
[ 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.
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")
self._context["votos negativos"].descartar(por_usuario)
self._context["votos positivos"].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",
)
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
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í:
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.
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 .
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
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.
@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
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
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.
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
----------- 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.
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.
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
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 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.
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
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 __len__(self):
return len(self._commits)
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..
# mock_2.py
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
[ 242 ]
Pruebas unitarias y refactorización Capítulo 8
return_value=build_date):
BuildStatus.notify(123, "OK")
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.
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
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.
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á.
clase BuildStatus:
[ 244 ]
Pruebas unitarias y refactorización Capítulo 8
@staticmethod
def build_date() -> str:
return datetime.utcnow().isoformat()
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 )
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
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.
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.
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
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
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.
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.
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:
[ 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.
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. .
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
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.).
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
@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
class SharedAttribute:
def __init__(self, initial_value=Ninguno): self.value
= initial_value
self._name = Ninguno
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 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 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 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 = {}
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
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).
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:
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:
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)
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
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'}
[ 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
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.
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.
Cadena de responsabilidad
Método de plantilla
Dominio
Estado
[ 269 ]
Patrones de diseño comunes Capítulo 9
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
[ 270 ]
Patrones de diseño comunes Capítulo 9
@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.
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.
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.
[ 273 ]
Patrones de diseño comunes Capítulo 9
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.
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.
[ 274 ]
Patrones de diseño comunes Capítulo 9
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.).
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.
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.
[ 278 ]
Patrones de diseño comunes Capítulo 9
@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 __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 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.
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 .
[ 280 ]
Patrones de diseño comunes Capítulo 9
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:
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:
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
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.
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
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.
[ 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.
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.
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.
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
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.
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
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.
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 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.).
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 .
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
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.
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.
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.
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.
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:
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.
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
class OrderInTransit:
"""Un pedido que se está enviando actualmente al cliente."""
class OrderDelivered:
"""Un pedido que ya fue entregado al cliente."""
estado = "entregado"
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
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.
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.
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?)?
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.
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
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 ).
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()
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.
<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 .
WORKDIR
/aplicación
AÑADIR.
/aplicación
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.
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.
Referencias
Aquí hay una lista de información que puede consultar:
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.
[ 310 ]
Índice
[ 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