Capítulo 1. Introducción al Python robusto

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Este libro trata sobre cómo hacer que tu Python sea más manejable. A medida que tu base de código crece, necesitas una caja de herramientas específica de consejos, trucos y estrategias para construir código mantenible. Este libro te guiará hacia menos errores y desarrolladores más felices. Analizarás detenidamente cómo escribes el código y aprenderás las implicaciones de tus decisiones. Al hablar de cómo se escribe el código, recuerdo estas sabias palabras de C.A.R. Hoare:

Hay dos formas de construir un diseño de software: Una forma es hacerlo tan sencillo que sea evidente que no hay deficiencias, y la otra forma es hacerlo tan complicado que no haya deficiencias evidentes. El primer método es mucho más difícil.1

Este libro trata del desarrollo de sistemas de la primera manera. Será más difícil, sí, pero no temas. Seré tu guía en tu viaje para subir de nivel en Python de tal forma que, como dice C.A.R. Hoare más arriba, obviamente no haya deficiencias en tu código. En definitiva, éste es un libro sobre cómo escribir Python robusto.

En este capítulo vamos a tratar qué significa robustez y por qué deberías preocuparte por ella. Repasaremos cómo tu método de comunicación implica ciertas ventajas e inconvenientes, y cuál es la mejor forma de representar tus intenciones. "El Zen de Python" afirma que, al desarrollar código, "Debería haber una -y preferiblemente sólo una- forma obvia de hacerlo". Aprenderás a evaluar si tu código está escrito de forma obvia, y qué puedes hacer para solucionarlo. En primer lugar, debemos abordar los aspectos básicos. ¿Qué es la robustez en primer lugar?

Robustez

Todo libro necesita al menos una definición de diccionario, así que me quitaré esto de en medio pronto. Merriam-Webster ofrece muchas definiciones de robustez:

  1. tener o mostrar fuerza o salud vigorosa

  2. tener o mostrar vigor, fuerza o firmeza

  3. fuertemente formado o construido

  4. capaz de funcionar sin fallos en una amplia gama de condiciones

Son descripciones fantásticas de lo que hay que perseguir. Queremos un sistema sano, que cumpla las expectativas durante años. Queremos que nuestro software muestre solidez; debería ser obvio que este código resistirá el paso del tiempo. Queremos un sistema fuertemente construido, que se asiente sobre bases sólidas. Y lo que es más importante, queremos un sistema capaz de funcionar sin fallos; el sistema no debería volverse vulnerable a medida que se introducen cambios.

Es habitual pensar en un software como en un rascacielos, una gran estructura que se erige como un baluarte contra todo cambio y un dechado de inmortalidad. La verdad es, por desgracia, más confusa. Los sistemas de software evolucionan constantemente. Se corrigen errores, se ajustan las interfaces de usuario y se añaden, eliminan y vuelven a añadir funciones. Los marcos cambian, los componentes se desactualizan y surgen fallos de seguridad. El software cambia. Desarrollar software se parece más a gestionar la expansión urbana que a construir un edificio estático. Con bases de código en constante cambio, ¿cómo puedes hacer que tu código sea robusto? ¿Cómo puedes construir una base sólida que sea resistente a los fallos?

La verdad es que tienes que aceptar el cambio. Tu código será dividido, unido y reelaborado. Los nuevos casos de uso alterarán enormes franjas de código, y eso está bien. Acéptalo. Comprende que no basta con que tu código pueda cambiarse fácilmente; tal vez sea mejor que se elimine y se reescriba a medida que vaya quedando obsoleto. Eso no disminuye su valor; seguirá teniendo una larga vida en el candelero. Tu trabajo consiste en facilitar la reescritura de partes del sistema. Una vez que empiezas a aceptar la naturaleza efímera de tu código, empiezas a darte cuenta de que no basta con escribir código libre de errores para el presente; tienes que permitir que los futuros propietarios de la base de código puedan cambiar tu código con confianza. De eso trata este libro.

Vas a aprender a construir sistemas fuertes. Esta fuerza no proviene de la rigidez, como la que exhibe una barra de hierro. Viene de la flexibilidad. Tu código tiene que ser fuerte como un sauce alto, que se mece con el viento, flexionándose pero sin romperse. Tu software tendrá que hacer frente a situaciones que jamás se te ocurrirían. Tu código debe poder adaptarse a nuevas circunstancias, porque no siempre serás tú quien lo mantenga. Esos futuros mantenedores necesitan saber que trabajan en una base de código sana. Tu base de código debe comunicar su fuerza. Debes escribir código Python de forma que reduzca los fallos, incluso cuando los futuros mantenedores lo destrocen y lo reconstruyan.

Escribir código robusto significa pensar deliberadamente en el futuro. Quieres que los futuros mantenedores vean tu código y comprendan fácilmente tus intenciones, no que maldigan tu nombre durante sesiones de depuración a altas horas de la noche. Debes transmitir tus pensamientos, razonamientos y precauciones. Los futuros desarrolladores necesitarán dar nuevas formas a tu código, y querrán hacerlo sin preocuparse de que cada cambio pueda hacer que se derrumbe como un castillo de naipes tambaleante.

En pocas palabras, no quieres que tus sistemas fallen, especialmente cuando ocurre lo inesperado. Las pruebas y la garantía de calidad son partes importantes de esto, pero ninguna de ellas hornea la calidad por completo. Son más adecuadas para iluminar lagunas en las expectativas y ofrecer una red de seguridad. En lugar de eso, debes hacer que tu software resista la prueba del tiempo. Para ello, debes escribir un código limpio y fácil de mantener.

El código limpio expresa su intención de forma clara y concisa, en ese orden. Cuando miras una línea de código y te dices: "ah, eso tiene todo el sentido", eso es un indicador de código limpio. Cuanto más tengas que pasar por un depurador, cuanto más tengas que mirar otro montón de código para averiguar qué está pasando, cuanto más tengas que pararte a mirar el código, menos limpio será. El código limpio no favorece los trucos ingeniosos si hace que el código sea ilegible para otros desarrolladores. Como dijo antes C.A.R. Hoare, no quieres que tu código sea tan obtuso que resulte difícil de entender tras una inspección visual.

El código mantenible es código que... bueno, puede mantenerse fácilmente. El mantenimiento comienza inmediatamente después de la primera confirmación y continúa hasta que ni un solo desarrollador vuelve a mirar el proyecto. Los desarrolladores corregirán errores, añadirán funciones, leerán código, extraerán código para utilizarlo en otras bibliotecas y mucho más. El código mantenible facilita estas tareas. El software vive años, si no décadas. Céntrate hoy en tu capacidad de mantenimiento.

No quieres ser la razón de que fallen los sistemas, tanto si trabajas activamente en ellos como si no. Tienes que ser proactivo para que tu sistema resista la prueba del tiempo. Necesitas una estrategia de pruebas que sea tu red de seguridad, pero también necesitas poder evitar caer en primer lugar. Así que, con todo esto en mente, te ofrezco mi definición de robustez en términos de tu base de código:

Una base de código robusta es resistente y no tiene errores a pesar de los cambios constantes.

¿Por qué es importante la robustez?

Se invierte mucha energía en hacer que el software haga lo que se supone que debe hacer, pero no es fácil saber cuándo has terminado. Los hitos del desarrollo no son fáciles de predecir. Factores humanos como la UX, la accesibilidad y la documentación no hacen sino aumentar la complejidad. Añade ahora las pruebas para asegurarte de que has cubierto una porción de comportamientos conocidos y desconocidos, y estarás ante ciclos de desarrollo muy largos.

La finalidad del software es aportar valor. A todas las partes interesadas les interesa ofrecer todo ese valor lo antes posible. Dada la incertidumbre que rodea a algunos calendarios de desarrollo, a menudo existe una presión adicional para cumplir las expectativas. Todos hemos estado en el lado equivocado de un calendario o un plazo poco realistas. Desgraciadamente, muchas de las herramientas para hacer que el software sea increíblemente robusto sólo se añaden a nuestro ciclo de desarrollo a corto plazo.

Es cierto que existe una tensión inherente entre la entrega inmediata de valor y la robustez del código. Si tu software es "suficientemente bueno", ¿por qué añadir aún más complejidad? Para responder a esto, considera la frecuencia con la que se iterará sobre esa pieza de software. Proporcionar valor de software no suele ser un ejercicio estático; es raro que un sistema proporcione valor y no vuelva a modificarse. El software está en constante evolución por su propia naturaleza. El código base debe estar preparado para proporcionar valor con frecuencia y durante largos periodos de tiempo. Aquí es donde entran en juego las prácticas sólidas de ingeniería de software. Si no puedes ofrecer funciones rápidamente y sin comprometer la calidad, tienes que reevaluar las técnicas para que tu código sea más fácil de mantener.

Si entregas tu sistema tarde, o roto, incurrirás en costes en tiempo real. Piensa en tu código base. Pregúntate qué ocurre si tu código se rompe dentro de un año porque alguien no ha sido capaz de entenderlo. ¿Cuánto valor pierdes? Tu valor puede medirse en dinero, tiempo o incluso vidas. Pregúntate qué ocurre si el valor no se entrega a tiempo. ¿Cuáles son las repercusiones? Si las respuestas a estas preguntas dan miedo, buenas noticias, el trabajo que estás haciendo es valioso. Pero también subraya por qué es tan importante eliminar futuros errores.

Varios desarrolladores trabajan simultáneamente en el mismo código base. Muchos proyectos de software durarán más que la mayoría de esos desarrolladores. Tienes que encontrar la forma de comunicárselo a los desarrolladores actuales y futuros, sin tener la ventaja de estar allí en persona para explicárselo. Los futuros desarrolladores se basarán en tus decisiones. Cada pista falsa, cada madriguera de conejo y cada aventura de afeitado de yaks2 les ralentizará, lo que impide obtener valor. Necesitas sentir empatía por los que vienen detrás de ti. Necesitas ponerte en su lugar. Este libro es tu puerta de entrada para pensar en tus colaboradores y mantenedores. Necesitas pensar en prácticas de ingeniería sostenibles. Necesitas escribir código que dure. El primer paso para hacer código que dure es ser capaz de comunicarte a través de tu código. Necesitas asegurarte de que los futuros desarrolladores entienden tu intención.

¿Cuál es tu intención?

¿Por qué debes esforzarte por escribir código limpio y mantenible? ¿Por qué debes preocuparte tanto por la robustez? El núcleo de estas respuestas reside en la comunicación. No vas a entregar sistemas estáticos. El código seguirá cambiando. También tienes que tener en cuenta que los mantenedores cambian con el tiempo. Tu objetivo, al escribir código, es entregar valor. También es escribir tu código de forma que otros desarrolladores puedan aportar valor con la misma rapidez. Para ello, tienes que ser capaz de comunicar el razonamiento y la intención sin conocer nunca a tus futuros mantenedores.

Veamos un bloque de código que se encuentra en un hipotético sistema heredado. Quiero que calcules cuánto tiempo tardas en entender lo que hace este código. No pasa nada si no estás familiarizado con todos los conceptos que hay aquí, o si te parece que este código es enrevesado (¡lo es intencionadamente!).

# Take a meal recipe and change the number of servings
# by adjusting each ingredient
# A recipe's first element is the number of servings, and the remainder
# of elements is (name, amount, unit), such as ("flour", 1.5, "cup")
def adjust_recipe(recipe, servings):
    new_recipe = [servings]
    old_servings = recipe[0]
    factor = servings / old_servings
    recipe.pop(0)
    while recipe:
        ingredient, amount, unit = recipe.pop(0)
        # please only use numbers that will be easily measurable
        new_recipe.append((ingredient, amount * factor, unit))
    return new_recipe

Esta función toma una receta y ajusta cada ingrediente para manejar un nuevo número de raciones. Sin embargo, este código plantea muchas preguntas.

  • ¿Para qué sirve pop?

  • ¿Qué significa recipe[0]? ¿Por qué son las antiguas porciones?

  • ¿Por qué necesito un comentario para cifras que serán fácilmente medibles?

Esta es una Python un poco cuestionable, sin duda. No te culparé si sientes la necesidad de reescribirlo. Queda mucho mejor escrito así:

def adjust_recipe(recipe, servings):
    old_servings = recipe.pop(0)
    factor = servings / old_servings
    new_recipe = {ingredient: (amount*factor, unit)
                  for ingredient, amount, unit in recipe}
    new_recipe["servings"] = servings
    return new_recipe

Los partidarios del código limpio probablemente prefieran la segunda versión (yo ciertamente la prefiero). No hay bucles en bruto. Las variables no mutan. Devuelvo un diccionario en lugar de una lista de tuplas. Todos estos cambios pueden considerarse positivos, según las circunstancias. Pero puede que haya introducido tres errores sutiles.

  • En el fragmento de código original, estaba borrando la receta original. Ahora ya no. Aunque sólo sea una zona del código de llamada la que depende de este comportamiento, he roto las suposiciones de ese código de llamada.

  • Al devolver un diccionario, he eliminado la posibilidad de tener ingredientes duplicados en una lista. Esto puede afectar a las recetas que tienen varias partes (como un plato principal y una salsa) que utilizan el mismo ingrediente.

  • Si alguno de los ingredientes se llama "raciones", acabo de introducir una colisión con la nomenclatura.

Que sean fallos o no depende de dos cosas interrelacionadas: la intención del autor original y el código de llamada. El autor pretendía resolver un problema, pero no estoy seguro de por qué escribió el código como lo hizo. ¿Por qué aparecen elementos? ¿Por qué "raciones" es una tupla dentro de la lista? ¿Por qué se utiliza una lista? Presumiblemente, el autor original sabía por qué, y lo comunicó localmente a sus compañeros. Sus compañeros escribieron código de llamada basándose en esas suposiciones, pero con el paso del tiempo, esa intención se perdió. Sin comunicación al futuro, me quedan dos opciones para mantener este código:

  • Mira todo el código de llamada y confirma que no se confía en este comportamiento antes de implementarlo. Buena suerte si se trata de una API pública para una biblioteca con llamadas externas. Yo pasaría mucho tiempo haciendo esto, lo que me frustraría.

  • Haz el cambio y espera a ver qué consecuencias tiene (quejas de los clientes, pruebas rotas, etc.). Si tengo suerte, no pasará nada malo. Si no, pasaría mucho tiempo arreglando casos de uso, lo que me frustraría.

Ninguna de las dos opciones parece productiva en un entorno de mantenimiento (sobre todo si tengo que modificar este código). No quiero perder el tiempo; quiero ocuparme rápidamente de mi tarea actual y pasar a la siguiente. La cosa empeora si me planteo cómo llamar a este código. Piensa en cómo interactúas con código que no has visto antes. Puede que veas otros ejemplos de llamada a código, los copies para adaptarlos a tu caso de uso, y nunca te des cuenta de que necesitabas pasar una cadena específica llamada "raciones" como primer elemento de tu lista.

Éste es el tipo de decisiones que te harán rascarte la cabeza. Todos las hemos visto en grandes bases de código. No se escriben maliciosamente, sino orgánicamente a lo largo del tiempo con las mejores intenciones. Las funciones empiezan siendo sencillas, pero a medida que crecen los casos de uso y contribuyen varios desarrolladores, ese código tiende a transformarse y oscurecer la intención original. Esta es una señal segura de que la mantenibilidad se está resintiendo. Necesitas expresar la intención en tu código desde el principio.

¿Y si el autor original utilizara mejores patrones de nomenclatura y un mejor uso de los tipos? ¿Qué aspecto tendría ese código?

def adjust_recipe(recipe, servings):
    """
    Take a meal recipe and change the number of servings
    :param recipe: a `Recipe` indicating what needs to be adusted
    :param servings: the number of servings
    :return Recipe: a recipe with serving size and ingredients adjusted
                    for the new servings
    """
    # create a copy of the ingredients
    new_ingredients = list(recipe.get_ingredients())
    recipe.clear_ingredients()

    for ingredient in new_ingredients:
            ingredient.adjust_proportion(Fraction(servings, recipe.servings))
    return Recipe(servings, new_ingredients)

Esto tiene mucho mejor aspecto, está mejor documentado y expresa claramente la intención original. El desarrollador original codificó sus ideas directamente en el código. A partir de este fragmento, sabes que lo siguiente es cierto:

  • Utilizo una clase Recipe. Esto me permite abstraer ciertas operaciones. Presumiblemente, dentro de la propia clase hay un invariante que permite duplicar los ingredientes. (Hablaré más sobre clases e invariantes en el Capítulo 10.) Esto proporciona un vocabulario común que hace más explícito el comportamiento de la función.

  • Los servicios son ahora una parte explícita de una clase Recipe, en lugar de tener que ser el primer elemento de la lista, que se trataba como un caso especial. Esto simplifica enormemente el código de llamada y evita colisiones involuntarias.

  • Es muy evidente que quiero borrar ingredientes de la receta antigua. No hay ninguna razón ambigua de por qué necesitaba hacer un .pop(0).

  • Los ingredientes son una clase aparte, y manejan fracciones en lugar de un float explícito. Está más claro para todos los implicados que estoy tratando con unidades fraccionarias, y puedo hacer fácilmente cosas como limit_denominator(), que se puede llamar cuando la gente quiera restringir las unidades de medida (en lugar de depender de un comentario).

He sustituido las variables por tipos, como un tipo de receta y un tipo de ingrediente. También he definido operaciones (clear_ingredients, adjust_proportion) para comunicar mi intención. Al hacer estos cambios, he dejado muy claro el comportamiento del código para los futuros lectores. Ya no tienen que venir a hablar conmigo para entender el código. En su lugar, comprenden lo que hago sin tener que hablar conmigo. Esto es comunicación asíncrona en estado puro.

Comunicación asíncrona

Es raro escribir sobre comunicación asíncrona en un libro de Python sin mencionar async y await. Pero me temo que tengo que hablar de la comunicación asíncrona en un lugar mucho más complejo: el mundo real.

La comunicación asíncrona significa que la producción de información y el consumo de esa información son independientes entre sí. Hay un lapso de tiempo entre la producción y el consumo. Puede ser de unas horas, como en el caso de colaboradores en zonas horarias diferentes. O pueden ser años, cuando los futuros mantenedores intentan profundizar en el funcionamiento interno del código. No puedes predecir cuándo alguien necesitará comprender tu lógica. Puede que ni siquiera estés trabajando en esa base de código (o para esa empresa) para cuando consuman la información que has producido.

Contrasta eso con la comunicación sincrónica. La comunicación sincrónica es el intercambio de ideas en directo (en tiempo real). Esta forma de comunicación directa es una de las mejores maneras de expresar tus pensamientos, pero, por desgracia, no es escalable y no siempre estarás cerca para responder a las preguntas.

Para evaluar lo apropiado que es cada método de comunicación cuando se trata de comprender las intenciones, veamos dos ejes: la proximidad y el coste.

Laproximidad es lo cerca en el tiempo que tienen que estar los comunicadores para que esa comunicación sea fructífera. Algunos métodos de comunicación destacan en la transferencia de información en tiempo real. Otros métodos de comunicación destacan en la comunicación años después.

El coste es la medida del esfuerzo para comunicar. Debes sopesar el tiempo y el dinero invertidos en comunicar con el valor aportado. A continuación, tus futuros consumidores tienen que sopesar el coste de consumir la información con el valor que intentan aportar. Escribir código y no proporcionar ningún otro canal de comunicación es tu línea de base; tienes que hacerlo para producir valor. Para evaluar el coste de los canales de comunicación adicionales, esto es lo que yo tengo en cuenta:

Descubribilidad

¿Cómo de fácil fue encontrar esta información fuera de un flujo de trabajo normal? ¿Es efímero el conocimiento? ¿Es fácil buscar la información?

Coste de mantenimiento

¿Hasta qué punto es precisa la información? ¿Con qué frecuencia hay que actualizarla? ¿Qué ocurre si la información no está actualizada?

Coste de producción

¿Cuánto tiempo y dinero se invirtió en producir la comunicación?

En la Figura 1-1, trazo el coste de algunos métodos habituales de comunicación y la proximidad necesaria, basándome en mi propia experiencia.

A graph plotting cost vs proximity of different communication methods.
Figura 1-1. Trazado del coste y la proximidad de los métodos de comunicación

Hay cuatro cuadrantes que componen el gráfico coste/proximidad.

Bajo coste, alta proximidad requerida

Son baratos de producir y consumir, pero no son escalables en el tiempo. La comunicación directa y la mensajería instantánea son grandes ejemplos de estos métodos. Trátalos como instantáneas de información en el tiempo; sólo son valiosos cuando el usuario está escuchando activamente. No confíes en estos métodos para comunicarte con el futuro.

Alto coste, alta proximidad requerida

Son eventos costosos, y a menudo sólo ocurren una vez (como reuniones o conferencias). Estos eventos deben aportar mucho valor en el momento de la comunicación, porque no aportan mucho valor en el futuro. ¿Cuántas veces has asistido a una reunión que te ha parecido una pérdida de tiempo? Sientes la pérdida directa de valor. Las charlas suponen un coste multiplicativo para cada asistente (tiempo empleado, espacio de alojamiento, logística, etc.). Las revisiones del código rara vez se miran una vez que se han hecho.

Alto coste, poca proximidad requerida

Son costosos, pero ese coste puede amortizarse con el tiempo en valor aportado, debido a la escasa proximidad necesaria. Los correos electrónicos y los tablones ágiles contienen una gran cantidad de información, pero no son descubribles por los demás. Son estupendos para conceptos más grandes que no necesitan actualizaciones frecuentes. Se convierte en una pesadilla intentar cribar todo el ruido sólo para encontrar la pepita de información que buscas. Las grabaciones de vídeo y la documentación del diseño son estupendas para comprender instantáneas en el tiempo, pero es costoso mantenerlas actualizadas. No confíes en estos métodos de comunicación para comprender las decisiones cotidianas.

Bajo coste, poca proximidad requerida

Son baratos de crear y fácilmente consumibles. Los comentarios del código, el historial del control de versiones y los README del proyecto entran en esta categoría, ya que están junto al código fuente que escribimos. Los usuarios pueden ver esta comunicación años después de que se produjera. Cualquier cosa que un desarrollador encuentre durante su flujo de trabajo diario es inherentemente descubrible. Estos métodos de comunicación son un ajuste natural para el primer lugar donde alguien buscará el código fuente. Sin embargo, tu código es una de tus mejores herramientas de documentación, ya que es el registro vivo y la única fuente de verdad de tu sistema.

Tema de debate

Este esquema de la Figura 1-1 se creó a partir de casos de uso generalizados. Piensa en las vías de comunicación que utilizáis tú y tu organización. ¿Dónde las trazarías en el gráfico? ¿Es fácil consumir información precisa? ¿Es costoso producir información? Tus respuestas a estas preguntas pueden dar como resultado un gráfico ligeramente distinto, pero la única fuente de verdad estará en el software ejecutable que entregues.

Los métodos de comunicación de bajo coste y proximidad son las mejores herramientas para comunicarse con el futuro. Debes esforzarte por minimizar el coste de producción y de consumo de la comunicación. De todos modos, tienes que escribir software para aportar valor, así que la opción de menor coste es hacer de tu código tu principal herramienta de comunicación. Tu código se convierte en la mejor opción posible para expresar claramente tus decisiones, opiniones y soluciones.

Sin embargo, para que esta afirmación sea cierta, el código también tiene que ser barato de consumir. Tu intención tiene que aparecer claramente en tu código. Tu objetivo es reducir al mínimo el tiempo que necesita un lector de tu código para comprenderlo. Lo ideal es que un lector no necesite leer tu implementación, sino sólo la firma de tu función. Mediante el uso de buenos tipos, comentarios y nombres de variables, debe quedar meridianamente claro lo que hace tu código.

Ejemplos de intención en Python

Ahora que ya he explicado qué es la intención y por qué es importante, veamos los ejemplos desde la perspectiva de Python. ¿Cómo puedes asegurarte de que estás expresando correctamente tus intenciones? Voy a ver dos ejemplos diferentes de cómo una decisión afecta a las intenciones: las colecciones y la iteración.

Colecciones

Cuando eliges una colección, estás comunicando una información específica. Debes elegir la colección adecuada para la tarea en cuestión. De lo contrario, los mantenedores deducirán de tu código una intención equivocada.

Considera este código que toma una lista de libros de cocina y proporciona una correspondencia entre los autores y el número de libros escritos:

def create_author_count_mapping(cookbooks: list[Cookbook]):
    counter = {}
    for cookbook in cookbooks:
        if cookbook.author not in counter:
            counter[cookbook.author] = 0
        counter[cookbook.author] += 1
    return counter

¿Qué te dice mi uso de las colecciones? ¿Por qué no paso un diccionario o un conjunto? ¿Por qué no devuelvo una lista? Basándome en mi uso actual de las colecciones, esto es lo que puedes suponer:

  • Paso una lista de libros de cocina. Puede haber libros de cocina duplicados en esta lista (podría estar contando una estantería de libros de cocina de una tienda con varios ejemplares).

  • Devuelvo un diccionario. Los usuarios pueden buscar un autor concreto o iterar sobre todo el diccionario. No tengo que preocuparme por los autores duplicados en la colección devuelta.

¿Y si quisiera comunicar que no se deben pasar duplicados a esta función? Una lista comunica la intención equivocada. En su lugar, debería haber elegido un conjunto para comunicar que este código no manejará duplicados en absoluto.

Elegir una colección informa a los lectores sobre tus intenciones concretas. Aquí tienes una lista de los tipos de colección más comunes, y la intención que transmiten:

Lista

Es una colección sobre la que se puede iterar. Es mutable: puede modificarse en cualquier momento. Muy raramente esperarás recuperar elementos concretos de la mitad de la lista (utilizando un índice de lista estático). Puede haber elementos duplicados. Los libros de cocina de una estantería pueden estar almacenados en una lista.

Cadena

Una colección inmutable de caracteres. El nombre de un libro de recetas sería una cadena.

Generador

Una colección sobre la que se itera, y en la que nunca se indexa. El acceso a cada elemento se realiza de forma perezosa, por lo que puede llevar tiempo y/o recursos cada iteración del bucle. Son excelentes para colecciones computacionalmente caras o infinitas. Una base de datos online de recetas de cocina podría devolverse como un generador; no quieres obtener todas las recetas del mundo cuando el usuario sólo va a mirar los 10 primeros resultados de una búsqueda.

Tupla

Una colección inmutable. No esperas que cambie, por lo que es más probable que extraigas elementos concretos del centro de la tupla (mediante índices o desempaquetando). Rara vez se itera sobre ella. La información sobre un libro de cocina concreto podría representarse como una tupla, como (cookbook_name, author, pagecount).

Configura

Una colección iterable que no contiene duplicados. No puedes confiar en el orden de los elementos. Los ingredientes de un libro de cocina podrían almacenarse como un conjunto.

Diccionario

Una correspondencia entre claves y valores. Las claves son únicas en todo el diccionario. Los diccionarios suelen iterarse o indexarse utilizando claves dinámicas. El índice de un libro de cocina es un buen ejemplo de asignación de claves a valores (del tema al número de página).

No utilices la colección equivocada para tus fines. Demasiadas veces me he encontrado con una lista que no debería tener duplicados o con un diccionario que en realidad no se utilizaba para asignar claves a valores. Cada vez que hay una desconexión entre lo que pretendes y lo que hay en el código, creas una carga de mantenimiento. Los mantenedores deben hacer una pausa, averiguar lo que realmente querías decir, y luego trabajar en torno a sus suposiciones erróneas (y a tus suposiciones erróneas también).

Éstas son colecciones básicas, pero hay más formas de expresar la intención. Aquí tienes algunos tipos de colecciones especiales que son aún más expresivos a la hora de comunicar al futuro:

frozenset

Un conjunto que es inmutable.

OrderedDict

Un diccionario que conserva el orden de los elementos en función del tiempo de inserción. A partir de CPython 3.6 y Python 3.7, los diccionarios incorporados también conservarán el orden de los elementos en función del tiempo de inserción.

defaultdict

Un diccionario que proporciona un valor por defecto si falta la clave. Por ejemplo, podría reescribir mi ejemplo anterior de la siguiente manera:

from collections import defaultdict
def create_author_count_mapping(cookbooks: list[Cookbook]):
    counter = defaultdict(lambda: 0)
    for cookbook in cookbooks:
        counter[cookbook.author] += 1
    return counter

Esto introduce un nuevo comportamiento para los usuarios finales: si consultan el diccionario en busca de un valor que no existe, recibirán un 0. Esto puede ser beneficioso en algunos casos de uso, pero si no lo es, puedes devolver dict(counter) en su lugar.

Counter

Un tipo especial de diccionario utilizado para contar cuántas veces aparece un elemento. Esto simplifica enormemente nuestro código anterior a lo siguiente:

from collections import Counter
def create_author_count_mapping(cookbooks: list[Cookbook]):
    return Counter(book.author for book in cookbooks)

Tómate un minuto para reflexionar sobre ese último ejemplo. Observa cómo el uso de Counter nos proporciona un código mucho más conciso sin sacrificar la legibilidad. Si tus lectores están familiarizados con Counter, el significado de esta función (y cómo funciona la implementación) es inmediatamente evidente. Éste es un gran ejemplo de cómo comunicar la intención al futuro mediante una mejor selección de los tipos de colecciones. Exploraré más a fondo las colecciones en el Capítulo 5.

Hay muchos otros tipos que puedes explorar, como array, bytes y range. Siempre que te encuentres con un nuevo tipo de colección, incorporada o no, pregúntate en qué se diferencia de otras colecciones y qué transmite a los futuros lectores.

Iteración

La iteración es otro ejemplo en el que la abstracción que eliges dicta la intención que transmites.

¿Cuántas veces has visto un código como éste?

text = "This is some generic text"
index = 0
while index < len(text):
    print(text[index])
    index += 1

Este sencillo código imprime cada carácter en una línea separada. Esto está perfectamente bien para una primera pasada en Python para este problema, pero la solución evoluciona rápidamente hacia lo más pythoniano (código escrito en un estilo idiomático que pretende enfatizar la simplicidad y es reconocible para la mayoría de los desarrolladores de Python):

for character in text:
    print(character)

Tómate un momento y reflexiona sobre por qué es preferible esta opción. El bucle for es una opción más adecuada; comunica las intenciones con mayor claridad. Al igual que los tipos de colección, la construcción de bucle que elijas comunica explícitamente conceptos diferentes. Aquí tienes una lista de algunas construcciones de bucle comunes y lo que comunican:

for bucles

for se utilizan para iterar sobre cada elemento de una colección o rango y realizar una acción/efecto secundario.

for cookbook in cookbooks:
    print(cookbook)
while bucles

while se utilizan para iterar mientras se cumpla una determinada condición.

while is_cookbook_open(cookbook):
    narrate(cookbook)
Comprensiones

Las comprensiones se utilizan para transformar una colección en otra (normalmente, esto no tiene efectos secundarios, sobre todo si la comprensión es perezosa).

authors = [cookbook.author for cookbook in cookbooks]
Recursión

La recursión se utiliza cuando la subestructura de una colección es idéntica a la estructura de una colección (por ejemplo, cada hijo de un árbol es también un árbol).

def list_ingredients(item):
    if isinstance(item, PreparedIngredient):
        list_ingredients(item)
    else:
        print(ingredient)

Quieres que cada línea de tu código base aporte valor. Además, quieres que cada línea comunique claramente cuál es ese valor a los futuros desarrolladores. Esto impulsa la necesidad de minimizar cualquier cantidad de código repetitivo, andamiaje y superfluo. En el ejemplo anterior, estoy iterando sobre cada elemento y realizando un efecto secundario (imprimir un elemento), lo que convierte al bucle for en una construcción de bucle ideal. No estoy desperdiciando código. En cambio, el bucle while requiere que hagamos un seguimiento explícito del bucle hasta que se produzca una determinada condición. En otras palabras, necesito rastrear una condición específica y mutar una variable en cada iteración. Esto distrae del valor que proporciona el bucle y supone una carga cognitiva no deseada.

Ley de la menor sorpresa

Las distracciones de la intención son malas, pero hay una clase de comunicación que es aún peor: cuando el código sorprende activamente a tus futuros colaboradores. Debes adherirte a la Ley de la Menor Sorpresa; cuando alguien lee el código base, casi nunca debe sorprenderse del comportamiento o la implementación (y cuando se sorprenda, debe haber un gran comentario cerca del código para explicar por qué es así). Por eso es primordial comunicar la intención. Un código claro y limpio reduce la probabilidad de mala comunicación.

Nota

La Ley de la Menor Sorpresa, también conocida como Ley del Menor Asombro, establece que un programa debe responder siempre al usuario de la forma que menos le asombre.3 Un comportamiento sorprendente lleva a la confusión. La confusión lleva a suposiciones erróneas. Las suposiciones erróneas conducen a errores. Y así es como se consigue un software poco fiable.

Ten en cuenta que puedes escribir código completamente correcto y aun así sorprender a alguien en el futuro. Al principio de mi carrera, perseguía un error desagradable que se bloqueaba debido a una memoria corrupta. Poner el código bajo un depurador o poner demasiadas sentencias de impresión afectaba a la sincronización, de modo que el fallo no se manifestaba (un verdadero "heisenbug").4 Había literalmente miles de líneas de código relacionadas con este fallo.

Así que tuve que hacer una bisección manual, dividiendo el código por la mitad, ver qué mitad tenía realmente el fallo eliminando la otra mitad, y luego volver a hacerlo todo en esa mitad de código. Tras dos semanas tirándome de los pelos, finalmente decidí inspeccionar una función que sonaba inocua llamada getEvent. Resulta que esta función en realidad estaba configurando un evento con datos no válidos. Ni que decir tiene que me quedé muy sorprendido. La función era completamente correcta en lo que hacía, pero como no me di cuenta de la intención del código, pasé por alto el fallo durante al menos tres días. Sorprender a tus colaboradores les costará su tiempo.

Gran parte de esta sorpresa acaba proviniendo de la complejidad. Hay dos tipos de complejidad: la complejidad necesaria y la complejidad accidental. La complejidad necesaria es la complejidad inherente a tu dominio. Los modelos de aprendizaje profundo son necesariamente complejos; no son algo de lo que navegues por su funcionamiento interno y lo entiendas en unos minutos. Optimizar el mapeo objeto-relacional (ORM) es necesariamente complejo; hay que tener en cuenta una gran variedad de posibles entradas de usuario. No podrás eliminar la complejidad necesaria, así que lo mejor que puedes hacer es intentar contenerla, no sea que se extienda por tu base de código y acabe convirtiéndose en complejidad accidental.

Por el contrario, la complejidad accidental es la complejidad que produce afirmaciones superfluas, despilfarradoras o confusas en el código. Es lo que ocurre cuando un sistema evoluciona con el tiempo y los desarrolladores van introduciendo características sin reevaluar el código antiguo para determinar si sus afirmaciones originales son ciertas. Una vez trabajé en un proyecto en el que añadir una única opción de la línea de comandos (y los medios asociados para establecerla mediante programación) afectaba a no menos de 10 archivos. ¿Por qué añadir un simple valor tendría que requerir cambios en todo el código base?

Sabes que tienes complejidad accidental si alguna vez has experimentado lo siguiente:

  • Cosas que parecen sencillas (añadir usuarios, cambiar un control de IU, etc.) no son triviales de implementar.

  • Dificultad para que los nuevos desarrolladores comprendan tu código base. Los nuevos desarrolladores de un proyecto son los mejores indicadores de la capacidad de mantenimiento de tu código ahora mismo, sin necesidad de esperar años.

  • Las estimaciones para añadir funcionalidad son siempre elevadas, pero aun así se te escapa el calendario.

Elimina la complejidad accidental y aísla la necesaria siempre que sea posible. Ésos serán los escollos para tus futuros colaboradores. Estas fuentes de complejidad agravan la falta de comunicación, ya que oscurecen y difuminan la intención por toda la base de código.

Tema de debate

¿Qué complejidades accidentales tienes en tu base de código? ¿Hasta qué punto sería difícil entender conceptos sencillos si te dejaran caer en la base de código sin comunicación con otros desarrolladores? ¿Qué puedes hacer para simplificar las complejidades identificadas en este ejercicio (especialmente si están en código que cambia a menudo)?

A lo largo del resto del libro, examinaré diferentes técnicas para comunicar la intención en Python.

Reflexiones finales

El código robusto importa. El código limpio importa. Tu código tiene que ser mantenible durante toda la vida útil de la base de código, y para garantizar ese resultado, tienes que prever activamente lo que comunicas y cómo lo comunicas. Necesitas plasmar claramente tus conocimientos lo más cerca posible del código. Te parecerá una carga mirar continuamente hacia delante, pero con la práctica se convierte en algo natural, y empiezas a cosechar los beneficios a medida que trabajas en tu propia base de código.

Cada vez que trasladas un concepto del mundo real al código, estás creando una abstracción, ya sea mediante el uso de una colección o tu decisión de mantener las funciones separadas. Cada abstracción es una elección, y cada elección comunica algo, ya sea intencionadamente o no. Te animo a que pienses en cada línea de código que estás escribiendo y te preguntes: "¿Qué aprenderá de esto un futuro desarrollador?". Se lo debes a los futuros mantenedores para que puedan aportar valor a la misma velocidad que tú puedes hacerlo hoy. De lo contrario, tu base de código se hinchará, los plazos se retrasarán y la complejidad aumentará. Tu trabajo como desarrollador es mitigar ese riesgo.

Busca posibles puntos conflictivos, como abstracciones incorrectas (como colecciones o iteración) o complejidad accidental. Estas son las principales áreas en las que la comunicación puede romperse con el tiempo. Si este tipo de puntos conflictivos se encuentran en áreas que cambian a menudo, es prioritario abordarlos ahora.

En el próximo capítulo, vas a tomar lo que has aprendido en este capítulo y aplicarlo a un concepto fundamental de Python: los tipos. Los tipos que elijas expresan tu intención a futuros desarrolladores, y elegir los tipos correctos conducirá a una mejor mantenibilidad.

1 Charles Antony Richard Hoare. "El viejo traje del emperador". Commun. ACM 24, 2 (feb. 1981), 75-83. https://doi.org/10.1145/358549.358561.

2 El yak-shaving describe una situación en la que con frecuencia tienes que resolver problemas no relacionados antes de poder siquiera empezar a abordar el problema original. Puedes conocer los orígenes del término en https://oreil.ly/4iZm7.

3 Geoffrey James. El Tao de la Programación. https://oreil.ly/NcKNK.

4 Un error que muestra un comportamiento diferente al ser observado. SIGSOFT '83: Actas del simposio de ingeniería de software ACM SIGSOFT/SIGPLAN sobre Depuración de alto nivel.

Get Python robusto now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.