Garantía de código limpio: una mirada a Python, parametrizado

Publicado: 2022-03-11

En esta publicación, voy a hablar sobre lo que considero que es la técnica o patrón más importante para producir código Pythonic limpio, a saber, la parametrización. Esta publicación es para ti si:

  • Usted es relativamente nuevo en todo el tema de los patrones de diseño y quizás esté un poco desconcertado por las largas listas de nombres de patrones y diagramas de clases. La buena noticia es que en realidad solo hay un patrón de diseño que absolutamente debes conocer para Python. Aún mejor, probablemente ya lo sepa, pero tal vez no todas las formas en que se puede aplicar.
  • Ha venido a Python desde otro lenguaje OOP como Java o C# y desea saber cómo traducir su conocimiento de patrones de diseño de ese lenguaje a Python. En Python y otros lenguajes tipificados dinámicamente, muchos patrones comunes en los lenguajes OOP tipificados estáticamente son "invisibles o más simples", como lo expresó el autor Peter Norvig.

En este artículo, exploraremos la aplicación de la "parametrización" y cómo puede relacionarse con los patrones de diseño convencionales conocidos como inyección de dependencia , estrategia , método de plantilla , fábrica abstracta , método de fábrica y decorador . En Python, muchos de estos resultan ser simples o se vuelven innecesarios por el hecho de que los parámetros en Python pueden ser objetos o clases a los que se puede llamar.

La parametrización es el proceso de tomar valores u objetos definidos dentro de una función o un método, y convertirlos en parámetros para esa función o método, con el fin de generalizar el código. Este proceso también se conoce como refactorización de "extraer parámetros". En cierto modo, este artículo trata sobre patrones de diseño y refactorización.

El caso más simple de Python parametrizado

Para la mayoría de nuestros ejemplos, usaremos el módulo de tortuga de la biblioteca estándar instructiva para hacer algunos gráficos.

Aquí hay un código que dibujará un cuadrado de 100x100 usando turtle :

 from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)

Supongamos que ahora queremos dibujar un cuadrado de diferente tamaño. Un programador muy joven en este punto estaría tentado a copiar y pegar este bloque y modificarlo. Obviamente, un método mucho mejor sería extraer primero el código de dibujo del cuadrado en una función y luego convertir el tamaño del cuadrado en un parámetro para esta función:

 def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)

Ahora podemos dibujar cuadrados de cualquier tamaño usando draw_square . Eso es todo lo que hay en la técnica esencial de parametrización, y acabamos de ver el primer uso principal: eliminar la programación de copiar y pegar.

Un problema inmediato con el código anterior es que draw_square depende de una variable global. Esto tiene muchas consecuencias negativas y hay dos formas sencillas de solucionarlo. La primera sería que draw_square creara la propia instancia de Turtle (de la que hablaré más adelante). Esto podría no ser deseable si queremos usar una sola Turtle para todos nuestros dibujos. Entonces, por ahora, simplemente usaremos la parametrización nuevamente para hacer de turtle un parámetro para draw_square :

 from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)

Esto tiene un nombre elegante: inyección de dependencia. Simplemente significa que si una función necesita algún tipo de objeto para hacer su trabajo, como draw_square necesita una Turtle , la persona que llama es responsable de pasar ese objeto como parámetro. No, de verdad, si alguna vez tuvo curiosidad acerca de la inyección de dependencia de Python, esto es todo.

Hasta ahora, hemos tratado con dos usos muy básicos. La observación clave para el resto de este artículo es que, en Python, hay una gran variedad de cosas que pueden convertirse en parámetros, más que en otros lenguajes, y esto la convierte en una técnica muy poderosa.

Cualquier cosa que sea un objeto

En Python, puede usar esta técnica para parametrizar cualquier cosa que sea un objeto, y en Python, la mayoría de las cosas con las que se encuentra son, de hecho, objetos. Esto incluye:

  • Instancias de tipos integrados, como la cadena "I'm a string" y el número entero 42 o un diccionario
  • Instancias de otros tipos y clases, por ejemplo, un objeto datetime.datetime
  • Funciones y métodos
  • Tipos incorporados y clases personalizadas

Los dos últimos son los más sorprendentes, especialmente si vienes de otros idiomas, y necesitan más discusión.

Funciones como parámetros

La declaración de función en Python hace dos cosas:

  1. Crea un objeto de función.
  2. Crea un nombre en el ámbito local que apunta a ese objeto.

Podemos jugar con estos objetos en un REPL:

 > >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'

Y como todos los objetos, podemos asignar funciones a otras variables:

 > >> bar = foo > >> bar() 'Hello from foo'

Tenga en cuenta que bar es otro nombre para el mismo objeto, por lo que tiene la misma propiedad interna __name__ que antes:

 > >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>

Pero el punto crucial es que debido a que las funciones son solo objetos, en cualquier lugar donde vea que se usa una función, podría ser un parámetro.

Entonces, supongamos que extendemos nuestra función de dibujo de cuadrados arriba, y ahora, a veces, cuando dibujamos cuadrados, queremos hacer una pausa en cada esquina: una llamada a time.sleep() .

Pero supongamos que a veces no queremos hacer una pausa. La forma más sencilla de lograr esto sería agregar un parámetro de pause , quizás con un valor predeterminado de cero para que, de manera predeterminada, no hagamos una pausa.

Sin embargo, luego descubrimos que a veces queremos hacer algo completamente diferente en las esquinas. Tal vez queramos dibujar otra forma en cada esquina, cambiar el color del lápiz, etc. Podríamos tener la tentación de agregar muchos más parámetros, uno para cada cosa que necesitamos hacer. Sin embargo, una solución mucho mejor sería permitir que cualquier función se transfiera como la acción a realizar. Por defecto, haremos una función que no haga nada. También haremos que esta función acepte la turtle local y los parámetros de size , en caso de que sean necesarios:

 def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)

O bien, podríamos hacer algo un poco más genial, como dibujar recursivamente cuadrados más pequeños en cada esquina:

 def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square) 

Ilustración de cuadrados más pequeños dibujados recursivamente como se demuestra en el código parametrizado de python anterior

Hay, por supuesto, variaciones de esto. En muchos ejemplos, se usaría el valor de retorno de la función. Aquí, tenemos un estilo de programación más imperativo, y la función se llama solo por sus efectos secundarios.

En otros idiomas…

Tener funciones de primera clase en Python hace que esto sea muy fácil. En los lenguajes que carecen de ellos, o en algunos lenguajes tipificados estáticamente que requieren firmas de tipo para los parámetros, esto puede ser más difícil. ¿Cómo haríamos esto si no tuviéramos funciones de primera clase?

Una solución sería convertir draw_square en una clase, SquareDrawer :

 class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass

Ahora podemos crear una subclase SquareDrawer y agregar un método at_corner que haga lo que necesitamos. Este patrón de Python se conoce como patrón de método de plantilla: una clase base define la forma de toda la operación o el algoritmo y las partes variantes de la operación se colocan en métodos que deben implementar las subclases.

Si bien esto a veces puede ser útil en Python, extraer el código de variante en una función que simplemente se pasa como un parámetro a menudo será mucho más simple.

Una segunda forma en que podemos abordar este problema en lenguajes sin funciones de primera clase es envolver nuestras funciones como métodos dentro de clases, así:

 class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())

Esto se conoce como el patrón de estrategia. Nuevamente, este es sin duda un patrón válido para usar en Python, especialmente si la clase de estrategia en realidad contiene un conjunto de funciones relacionadas, en lugar de solo una. Sin embargo, a menudo todo lo que realmente necesitamos es una función y podemos dejar de escribir clases.

Otros llamables

En los ejemplos anteriores, he hablado sobre pasar funciones a otras funciones como parámetros. Sin embargo, todo lo que escribí fue, de hecho, cierto para cualquier objeto invocable. Las funciones son el ejemplo más simple, pero también podemos considerar los métodos.

Supongamos que tenemos una lista foo :

 foo = [1, 2, 3]

foo ahora tiene un montón de métodos adjuntos, como .append() y .count() . Estos "métodos enlazados" se pueden pasar y usar como funciones:

 > >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]

Además de estos métodos de instancia, existen otros tipos de objetos a los que se puede __call__ classmethods staticmethods /tipos mismos.

Clases como parámetros

En Python, las clases son de "primera clase": son objetos de tiempo de ejecución como dictados, cadenas, etc. Esto puede parecer incluso más extraño que las funciones como objetos, pero afortunadamente, en realidad es más fácil demostrar este hecho que las funciones.

La declaración de clase con la que está familiarizado es una buena forma de crear clases, pero no es la única forma, también podemos usar la versión de tipo de tres argumentos. Las siguientes dos sentencias hacen exactamente lo mismo:

 class Foo: pass Foo = type('Foo', (), {})

En la segunda versión, tenga en cuenta las dos cosas que acabamos de hacer (que se hacen de manera más conveniente usando la declaración de clase):

  1. En el lado derecho del signo igual, creamos una nueva clase, con un nombre interno de Foo . Este es el nombre que obtendrá si hace Foo.__name__ .
  2. Con la asignación, luego creamos un nombre en el ámbito actual, Foo, que se refiere a ese objeto de clase que acabamos de crear.

Hicimos las mismas observaciones para lo que hace la declaración de función.

La idea clave aquí es que las clases son objetos a los que se les pueden asignar nombres (es decir, se pueden poner en una variable). Dondequiera que vea una clase en uso, en realidad solo está viendo una variable en uso. Y si es una variable, puede ser un parámetro.

Podemos desglosarlo en varios usos:

Clases como Fábricas

Una clase es un objeto invocable que crea una instancia de sí mismo:

 > >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>

Y como objeto, se puede asignar a otras variables:

 > >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>

Volviendo a nuestro ejemplo anterior de tortugas, un problema con el uso de tortugas para dibujar es que la posición y orientación del dibujo dependen de la posición y orientación actual de la tortuga, y también puede dejarla en un estado diferente que podría no ser útil para llamador. Para resolver esto, nuestra función draw_square podría crear su propia tortuga, moverla a la posición deseada y luego dibujar un cuadrado:

 def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Sin embargo, ahora tenemos un problema de personalización. Supongamos que la persona que llama desea establecer algunos atributos de la tortuga o usar un tipo diferente de tortuga que tiene la misma interfaz pero tiene un comportamiento especial.

Podríamos resolver esto con la inyección de dependencia, como lo hicimos antes: la persona que llama sería responsable de configurar el objeto Turtle . Pero, ¿qué pasa si nuestra función a veces necesita hacer muchas tortugas para diferentes propósitos de dibujo, o si tal vez quiere iniciar cuatro hilos, cada uno con su propia tortuga para dibujar un lado del cuadrado? La respuesta es simplemente hacer que la clase Turtle sea un parámetro para la función. Podemos usar un argumento de palabra clave con un valor predeterminado, para simplificar las cosas para las personas que llaman a las que no les importa:

 def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Para usar esto, podríamos escribir una función make_turtle que crea una tortuga y la modifica. Supongamos que queremos ocultar la tortuga al dibujar cuadrados:

 def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)

O podríamos crear una subclase de Turtle para incorporar ese comportamiento y pasar la subclase como parámetro:

 class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)

En otros idiomas…

Varios otros lenguajes OOP, como Java y C#, carecen de clases de primera clase. Para instanciar una clase, debe usar la new palabra clave seguida de un nombre de clase real.

Esta limitación es la razón de patrones como abstract factory (que requiere la creación de un conjunto de clases cuyo único trabajo es instanciar otras clases) y el patrón Factory Method. Como puede ver, en Python, solo se trata de extraer la clase como parámetro porque una clase es su propia fábrica.

Clases como clases base

Supongamos que nos encontramos creando subclases para agregar la misma característica a diferentes clases. Por ejemplo, queremos una subclase Turtle que escribirá en un registro cuando se cree:

 import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")

Pero luego, nos encontramos haciendo exactamente lo mismo con otra clase:

 class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")

Las únicas cosas que varían entre estos dos son:

  1. la clase básica
  2. El nombre de la subclase, pero en realidad no nos importa eso y podríamos generarlo automáticamente a partir del atributo __name__ de la clase base.
  3. El nombre utilizado dentro de la llamada de debug , pero nuevamente, podríamos generar esto a partir del nombre de la clase base.

Ante dos bits de código muy similares con una sola variante, ¿qué podemos hacer? Al igual que en nuestro primer ejemplo, creamos una función y extraemos la parte variante como parámetro:

 def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)

Aquí, tenemos una demostración de clases de primera clase:

  • Pasamos una clase a una función, dándole al parámetro un nombre convencional cls para evitar el conflicto con la palabra clave class (también verá class_ y klass usan para este propósito).
  • Dentro de la función, creamos una clase; tenga en cuenta que cada llamada a esta función crea una nueva clase.
  • Devolvimos esa clase como el valor de retorno de la función.

También configuramos LoggingThing.__name__ que es completamente opcional pero puede ayudar con la depuración.

Otra aplicación de esta técnica es cuando tenemos un montón de funciones que a veces queremos agregar a una clase, y es posible que deseemos agregar varias combinaciones de estas funciones. Crear manualmente todas las diferentes combinaciones que necesitamos podría volverse muy difícil de manejar.

En lenguajes donde las clases se crean en tiempo de compilación en lugar de en tiempo de ejecución, esto no es posible. En su lugar, debe usar el patrón decorador. Ese patrón puede ser útil a veces en Python, pero en la mayoría de los casos puede usar la técnica anterior.

Normalmente, en realidad evito crear muchas subclases para personalizar. Por lo general, existen métodos más simples y más pitónicos que no involucran clases en absoluto. Pero esta técnica está disponible si la necesita. Véase también el tratamiento completo de Brandon Rhodes del patrón decorador en Python.

Clases como excepciones

Otro lugar en el que ve que se usan clases es en la cláusula except de una declaración try/except/finally. No hay sorpresas por adivinar que también podemos parametrizar esas clases.

Por ejemplo, el siguiente código implementa una estrategia muy genérica de intentar una acción que podría fallar y volver a intentar con retroceso exponencial hasta que se alcanza un número máximo de intentos:

 import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)

Hemos extraído tanto la acción a realizar como las excepciones a capturar como parámetros. El parámetroExceptions_to_catch puede ser una sola clase, como IOError httplib.client.HTTPConnectionError exceptions_to_catch o una tupla de dichas clases. (Queremos evitar las cláusulas "excepto" o incluso la except Exception porque se sabe que esto oculta otros errores de programación).

Advertencias y Conclusión

La parametrización es una técnica poderosa para reutilizar código y reducir la duplicación de código. No está exenta de algunos inconvenientes. En la búsqueda de la reutilización del código, a menudo surgen varios problemas:

  • Código demasiado genérico o abstracto que se vuelve muy difícil de entender.
  • Código con una proliferación de parámetros que oscurece el panorama general o introduce errores porque, en realidad, solo se prueban correctamente ciertas combinaciones de parámetros.
  • Acoplamiento inútil de diferentes partes de la base de código porque su "código común" se ha factorizado en un solo lugar. A veces, el código en dos lugares es similar solo accidentalmente, y los dos lugares deben ser independientes entre sí porque es posible que deban cambiar de forma independiente.

A veces, un poco de código "duplicado" es mucho mejor que estos problemas, así que use esta técnica con cuidado.

En esta publicación, hemos cubierto patrones de diseño conocidos como inyección de dependencia , estrategia , método de plantilla , fábrica abstracta , método de fábrica y decorador . En Python, muchos de estos realmente resultan ser una simple aplicación de parametrización o definitivamente se vuelven innecesarios por el hecho de que los parámetros en Python pueden ser objetos o clases a los que se puede llamar. Con suerte, esto ayuda a aligerar la carga conceptual de "cosas que se supone que debe saber como desarrollador Python real" y le permite escribir código Pythonic conciso.

Otras lecturas:

  • Patrones de diseño de Python: para código elegante y de moda
  • Patrones de Python: para patrones de diseño de Python
  • Registro de Python: un tutorial detallado