
Cuando se habla de "código malo", la gente casi con toda seguridad se refiere a "código complejo" entre otros problemas populares. Lo que pasa con la complejidad es que sale de la nada. Un día comienzas tu proyecto bastante simple, el otro día lo encuentras en ruinas. Y nadie sabe cómo y cuándo sucedió.
Pero, esto finalmente sucede por una razón. La complejidad del código ingresa a su base de código de dos maneras posibles: con grandes fragmentos y adiciones incrementales. Y la gente es mala para revisarlos y encontrarlos a ambos.
Cuando entra una gran porción de código, el revisor tendrá el desafío de encontrar la ubicación exacta donde el código es complejo y qué hacer al respecto. Luego, la revisión tendrá que demostrar el punto: por qué este código es complejo en primer lugar. Y otros desarrolladores pueden estar en desacuerdo. ¡Todos conocemos este tipo de revisiones de código!

La segunda forma de complejidad de ingresar a su código es la adición incremental: cuando envía una o dos líneas a la función existente. Y es extremadamente difícil notar que su función estaba bien hace un commit, pero ahora es demasiado compleja. Se necesita una buena porción de concentración, habilidad de revisión y buenas prácticas de navegación de código para detectarlo. La mayoría de las personas (¡como yo!) Carecen de estas habilidades y permiten que la complejidad ingrese a la base de código regularmente.
Entonces, ¿qué se puede hacer para evitar que su código se vuelva complejo? ¡Necesitamos usar la automatización! Profundicemos en la complejidad del código y las formas de encontrarlo y finalmente resolverlo.
En este artículo, lo guiaré a través de lugares donde vive la complejidad y cómo combatirla allí. Luego discutiremos cómo el código simple y la automatización bien escritos permiten una oportunidad de estilos de desarrollo de "Refactorización continua" y "Arquitectura bajo demanda".
Complejidad explicada
Uno puede preguntarse: ¿qué es exactamente la "complejidad del código"? Y aunque suena familiar, existen obstáculos ocultos para comprender la ubicación exacta de la complejidad. Comencemos con las partes más primitivas y luego pasemos a entidades de nivel superior.
¿Recuerdas que este artículo se llama "Cascada de Complejidad"? Le mostraré cómo la complejidad de las primitivas más simples se desborda en las abstracciones más altas.
wemake-python-styleguide
python
como el idioma principal para mis ejemplos y wemake-python-styleguide
como la herramienta principal para encontrar las violaciones en mi código e ilustrar mi punto.
Expresiones
Todo su código consiste en expresiones simples como a + 1
e print(x)
. Si bien las expresiones en sí mismas son simples, pueden desbordar su código con complejidad de forma imperceptible en algún momento. Ejemplo: imagine que tiene un diccionario que representa algún modelo de User
y lo usa así:
def format_username(user) -> str: if not user['username']: return user['email'] elif len(user['username']) > 12: return user['username'][:12] + '...' return '@' + user['username']
Parece bastante simple, ¿no? De hecho, contiene dos problemas de complejidad basados en expresiones. overuses 'username'
y usa el número mágico 12
(¿por qué usamos este número en primer lugar, por qué no 13
o 10
?). Es difícil encontrar este tipo de cosas solo. Así es como se vería la mejor versión:
Hay diferentes problemas con la expresión también. También podemos tener expresiones sobreutilizadas : cuando usa el atributo some_object.some_attr
todas partes en lugar de crear una nueva variable local. También podemos tener condiciones lógicas demasiado complejas o acceso a puntos demasiado profundo .
Solución : cree nuevas variables, argumentos o constantes. Cree y use nuevas funciones o métodos de utilidad si es necesario.
Líneas
Las expresiones forman líneas de código (por favor, no confunda las líneas con las declaraciones: una sola declaración puede tomar varias líneas y varias declaraciones pueden ubicarse en una sola línea).
La primera y la métrica de complejidad más obvia para una línea es su longitud. Sí, lo escuchaste correctamente. Es por eso que nosotros (los programadores) preferimos apegarnos a la regla de 80
caracteres por línea y no porque se haya usado previamente en los teletipos. Últimamente hay muchos rumores al respecto, que dicen que no tiene sentido usar 80
caracteres para su código en 2k19. Pero, eso obviamente no es cierto.
La idea es simple. Puede tener el doble de lógica en una línea con 160
caracteres que en línea con solo 80
caracteres. Es por eso que este límite debe establecerse y aplicarse. Recuerde, esta no es una elección estilística . ¡Es una métrica de complejidad!
La segunda métrica de complejidad de la línea principal es menos conocida y menos utilizada. Se llama complejidad de Jones . La idea detrás de esto es simple: contamos los nodos de código (o ast
) en una sola línea para obtener su complejidad. Echemos un vistazo al ejemplo. Estas dos líneas son fundamentalmente diferentes en términos de complejidad pero tienen exactamente el mismo ancho en caracteres:
print(first_long_name_with_meaning, second_very_long_name_with_meaning, third) print(first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2))
Vamos a contar los nodos en el primero: una llamada, tres nombres. Cuatro nodos totalmente. El segundo tiene veintiún nodos ast
. Bueno, la diferencia es clara. Es por eso que utilizamos la métrica de Complejidad de Jones para permitir la primera línea larga y no permitir la segunda en función de una complejidad interna, no solo en longitud cruda.
¿Qué hacer con líneas con un puntaje alto de complejidad de Jones?
Solución : divídalos en varias líneas o cree nuevas variables intermedias, funciones de utilidad, nuevas clases, etc.
print( first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2), )
¡Ahora es mucho más legible!
Estructuras
El siguiente paso es analizar las estructuras del lenguaje como if
, for
, with
, etc. que se forman a partir de líneas y expresiones. Tengo que decir que este punto es muy específico del idioma. Mostraré varias reglas de esta categoría usando python
también.
Comenzaremos con if
. ¿Qué puede ser más fácil que un viejo if
? En realidad, if
comienza a complicarse muy rápido. Aquí hay un ejemplo de cómo se puede reimplement switch
if
:
if isinstance(some, int): ... elif isinstance(some, float): ... elif isinstance(some, complex): ... elif isinstance(some, str): ... elif isinstance(some, bytes): ... elif isinstance(some, list): ...
¿Cuál es el problema con este código? Bueno, imagine que tenemos decenas de tipos de datos que deberían cubrirse, incluidos los de aduanas que aún no conocemos. Entonces, este código complejo es un indicador de que estamos eligiendo un patrón incorrecto aquí. Necesitamos refactorizar nuestro código para solucionar este problema. Por ejemplo, uno puede usar typeclass
es o singledispatch
. Ellos hacen el mismo trabajo, pero son mejores.
python
nunca se detiene para divertirnos. Por ejemplo, puede escribir with
un número arbitrario de casos , que es demasiado complejo mentalmente y confuso:
with first(), second(), third(), fourth(): ...
También puede escribir comprensiones con cualquier número de expresiones if
y for
, lo que puede conducir a un código complejo e ilegible:
[ (x, y, z) for x in x_coords for y in y_coords for z in z_coords if x > 0 if y > 0 if z > 0 if x + y <= z if x + z <= y if y + z <= x ]
Compárelo con la versión simple y legible:
[ (x, y, z) for x, y, x in itertools.product(x_coords, y_coords, z_coords) if valid_coordinates(x, y, z) ]
También puede incluir accidentalmente multiple statements inside a try
caso de multiple statements inside a try
, lo cual no es seguro porque puede generar y manejar una excepción en un lugar esperado:
try: user = fetch_user()
Y eso ni siquiera es el 10% de los casos que pueden y saldrán mal con su código de python
. Hay muchos, muchos más casos extremos que deberían ser rastreados y analizados.
Solución : la única solución posible es utilizar una buena interfaz para el idioma que elija. Y refactorice los lugares complejos que resalta este linter. De lo contrario, tendrá que reinventar la rueda y establecer políticas personalizadas para los mismos problemas.
Las funciones
Expresiones, declaraciones y estructuras forman funciones. La complejidad de estas entidades fluye hacia funciones. Y ahí es donde las cosas comienzan a ponerse intrigantes. Porque las funciones tienen literalmente docenas de métricas de complejidad: tanto buenas como malas.
Comenzaremos con los más conocidos: la complejidad ciclomática y la longitud de la función medida en líneas de código. La complejidad ciclomática indica cuántas vueltas puede tomar su flujo de ejecución: es casi igual al número de pruebas unitarias que se requieren para cubrir completamente el código fuente. Es una buena métrica porque respeta la semántica y ayuda al desarrollador a realizar la refactorización. Por otro lado, la longitud de una función es una mala métrica. No es compatible con la métrica de complejidad de Jones explicada anteriormente, ya que ya sabemos: varias líneas son más fáciles de leer que una línea grande con todo dentro. Nos concentraremos solo en las buenas métricas e ignoraremos las malas.
Según mi experiencia, se deben contar varias métricas de complejidad útiles en lugar de la longitud de la función regular:
- Número de decoradores de funciones; más bajo es mejor
- Número de argumentos; más bajo es mejor
- Número de anotaciones más alto es mejor
- Número de variables locales; más bajo es mejor
- Número de devoluciones, rendimientos, espera; más bajo es mejor
- Número de declaraciones y expresiones; más bajo es mejor
La combinación de todas estas comprobaciones realmente le permite escribir funciones simples (todas las reglas también se aplican a los métodos).
Cuando intente hacer algunas cosas desagradables con su función, seguramente romperá al menos una métrica. Y esto decepcionará a nuestro linter y arruinará tu construcción. Como resultado, su función se guardará.
Solución : cuando una función es demasiado compleja, la única solución que tiene es dividir esta función en varias.
Clases
El siguiente nivel de abstracción después de las funciones son las clases. Y como ya adivinó, son aún más complejos y fluidos que las funciones. Porque las clases pueden contener múltiples funciones en su interior (que se llaman método) y tienen otras características únicas como herencia y mixins, atributos de nivel de clase y decoradores de nivel de clase. Entonces, tenemos que verificar todos los métodos como funciones y el cuerpo de la clase en sí.
Para las clases tenemos que medir las siguientes métricas:
- Número de decoradores de nivel de clase; más bajo es mejor
- Número de clases base; más bajo es mejor
- Número de atributos públicos a nivel de clase; más bajo es mejor
- Número de atributos públicos a nivel de instancia; más bajo es mejor
- Número de métodos; más bajo es mejor
Cuando cualquiera de estos es demasiado complicado, ¡tenemos que hacer sonar la alarma y fallar la construcción!
Solución : ¡refactorice su clase reprobada! Divida una clase compleja existente en varias simples o cree nuevas funciones de utilidad y use la composición.
Mención notable: también se puede rastrear la cohesión y las métricas de acoplamiento para validar la complejidad de su diseño OOP.
Módulos
Los módulos contienen múltiples declaraciones, funciones y clases. Y como ya habrá mencionado, generalmente recomendamos dividir las funciones y clases en otras nuevas. Es por eso que debemos tener en cuenta la complejidad del módulo: literalmente, fluye hacia los módulos desde clases y funciones.
Para analizar la complejidad del módulo tenemos que verificar:
- El número de importaciones y nombres importados; más bajo es mejor
- El número de clases y funciones; más bajo es mejor
- La complejidad promedio de funciones y clases dentro; más bajo es mejor
¿Qué hacemos en el caso de un módulo complejo?
Solución : sí, lo entendiste bien. Dividimos un módulo en varios.
Paquetes
Los paquetes contienen múltiples módulos. Por suerte, eso es todo lo que hacen.
Entonces, el número de módulos en un paquete pronto puede comenzar a ser demasiado grande, por lo que terminará con demasiados. Y es la única complejidad que se puede encontrar con los paquetes.
Solución : debe dividir los paquetes en subpaquetes y paquetes de diferentes niveles.
Complejidad efecto cascada
Ahora hemos cubierto casi todos los tipos posibles de abstracciones en su base de código. ¿Qué hemos aprendido de esto? La conclusión principal, por ahora, es que la mayoría de los problemas se pueden resolver con la complejidad de expulsión al mismo nivel de abstracción o superior.

Esto nos lleva a la idea más importante de este artículo: no permita que su código se desborde con la complejidad. Daré varios ejemplos de cómo suele suceder.
Imagine que está implementando una nueva característica. Y ese es el único cambio que haces:
Se ve bien, pasaría este código en revisión. Y no pasaría nada malo. Pero, lo que me falta es que la complejidad desbordó esta línea. Eso es lo que wemake-python-styleguide
:

Ok, ahora tenemos que resolver esta complejidad. Hagamos una nueva variable:
class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... is_sub_paid = sub.is_due(tz.now() + delta) if user.is_active and user.has_sub() and is_sub_paid: ... ... ...
Ahora, la complejidad de la línea está resuelta. Pero espera un minuto. ¿Qué pasa si nuestra función tiene demasiadas variables ahora? Porque hemos creado una nueva variable sin verificar primero su número dentro de la función. En este caso, tendremos que dividir este método en varios de esta manera:
class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid ...
Ahora hemos terminado! Derecho? No, porque ahora tenemos que verificar la complejidad de la clase de Product
. Imagínese que ahora tiene demasiados métodos ya que hemos creado uno nuevo _has_paid_sub
.
Ok, ejecutamos nuestro linter para verificar la complejidad nuevamente. Y resulta que nuestra clase de Product
es realmente demasiado compleja en este momento. Nuestras acciones? ¡Lo dividimos en varias clases!
class Policy(object): ... class SubcsriptionPolicy(Policy): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid class Product(object): _purchasing_policy: Policy ... ...
¡Por favor dime que es la última iteración! Bueno, lo siento, pero ahora tenemos que verificar la complejidad del módulo. ¿Y adivina qué? Ahora tenemos demasiados miembros del módulo. ¡Entonces, tenemos que dividir los módulos en módulos separados! Luego verificamos la complejidad del paquete. Y posiblemente también lo divida en varios subpaquetes.
¿Lo has visto? Debido a las reglas de complejidad bien definidas, nuestra modificación de una sola línea resultó ser una gran sesión de refactorización con varios módulos y clases nuevos. Y no hemos tomado una sola decisión nosotros mismos: todos nuestros objetivos de refactorización fueron impulsados por la complejidad interna y el linter que lo revela.
Eso es lo que yo llamo un proceso de "Refactorización continua". Estás obligado a hacer la refactorización. Siempre.
Este proceso también tiene una consecuencia interesante. Le permite tener "Arquitectura bajo demanda". Déjame explicarte. Con la filosofía de "Arquitectura bajo demanda" siempre comienza con algo pequeño. Por ejemplo, con un solo archivo logic/domains/user.py
. Y comienzas a poner todo lo relacionado con el User
allí. Porque en este momento probablemente no sabes cómo se verá tu arquitectura. Y no te importa. Solo tienes como tres funciones.
Algunas personas caen en la trampa de la arquitectura frente a la complejidad del código. Pueden complicar demasiado su arquitectura desde el principio con las capas completas de repositorio / servicio / dominio. O pueden complicar demasiado el código fuente sin una separación clara. Lucha y vive así durante años (¡si podrán vivir durante años con el código como este!).
El concepto "Arquitectura bajo demanda" resuelve estos problemas. Empiezas poco a poco, cuando llega el momento: divides y refactorizas cosas:
- Comienzas con
logic/domains/user.py
y pones todo ahí - Más tarde creas
logic/domains/user/repository.py
cuando tienes suficientes cosas relacionadas con la base de datos - Luego lo divide en
logic/domains/user/repository/queries.py
y logic/domains/user/repository/commands.py
cuando la complejidad le indica que lo haga. - Luego creas
logic/domains/user/services.py
con cosas relacionadas con http
- Luego crea un nuevo módulo llamado
logic/domains/order.py
- Y así sucesivamente
Eso es todo Es una herramienta perfecta para equilibrar su arquitectura y la complejidad del código. Y obtenga tanta arquitectura como realmente necesite en este momento.
Conclusión
Good linter hace mucho más que encontrar comas faltantes y malas citas. Good linter le permite confiar en él con las decisiones de arquitectura y ayudarlo con el proceso de refactorización.
Por ejemplo, wemake-python-styleguide
podría ayudarlo con la complejidad del código fuente de python
, le permite:
- Lucha con éxito contra la complejidad en todos los niveles.
- Aplicar la enorme cantidad de estándares de nombres, mejores prácticas y comprobaciones de coherencia
- Intégrelo fácilmente en una base de código heredada con la ayuda de la opción
diff
o la herramienta flakehell
, de modo que las viejas infracciones serán perdonadas, pero no se permitirán nuevas. - Habilítelo en su [CI] (), incluso como una acción de Github
No permita que la complejidad desborde su código, ¡ use un buen linter !