Duck typing y la importancia de buenas interfaces públicas

Si camina como un pato y grazna como un pato, entonces debe ser un pato

Vimos en un artículo anterior que es mucho mejor depender de abstracciones que de clases concretas. Las entidades concretas son más propensas a cambiar que las abstracciones, y esos cambios se propagarán por nuestro código fuente afectando todas las clases que dependen de ellas.

También aprendimos que los lenguajes de tipado estático nos ofrecen una gran construcción para representar una abstracción: la interfaz. La mayoría de lenguajes también proporcionan clases abstractas, que son como clases regulares que no puedes instanciar.

¿Cómo podemos representar abstracciones en un lenguaje de tipado dinámico como Ruby? La respuesta es Duck Typing.

Los duck types son abstracciones encarnadas por una interfaz pública implementada a través de clases. Si esto suena un poco críptico, es porque la forma más fácil de entender los duck types es a través de un ejemplo. Imagina que tenemos una clase Rocket, con un método llamado upkeep, que recibe una colección de profesionales de mantenimiento para realizar tareas de mantenimiento en diferentes partes del cohete.

    class Rocket
        attr_reader :engines, :computers, :fuel_tank
        # ...
        def upkeep(maintainers)
            maintainers.each do |maintainer|
                case maintainer
                when PropulsionEngineer
                    maintainer.verify_engines(engines)
                when ComputerTechnician
                    maintainer.perform_computer_maintenance(computers)
                when PropellantCrew
                    maintainer.fix_fuel_tank(fuel_tank)
                end
            end
        end
        # ...
    end

    class PropulsionEngineer
        def verify_engines(engines)
            engines.each {|engine| check_engine(engine)}
        end

        def check_engine(engine)
        #...
        end
    end

    class ComputerTechnician
        def perform_computer_maintenance(computers)
            computers.each do |computer|
                verify_circuitry(computer)
                update_software(computer)
            end
        end

        def verify_circuitry(computer)
        #...
        end

        def update_software(computer)
        #...
        end
    end

    class PropellantCrew
        def fix_fuel_tank(fuel_tank)
        #...
        end
    end

El código anterior funciona, pero la solución está lejos de ser ideal. Tal como está ahora, Rocket depende de PropulsionEngineer, ComputerTechnician y PropellantCrew. Cualquier cambio en la firma de los métodos usados en upkeep obligará a Rocket a cambiar. Además, la inclusión de pasos adicionales de mantenimiento requiere que abramos la clase Rocket y agregemos código extra.

Es importante darse cuenta de que aunque las clases de los otros objetos son importantes, todo lo que le importa a Rocket es el mantenimiento. Lo que estas clases carecen es una interfaz común en la que Rocket pueda confiar: un duck type de mantenedor.

Necesitamos dejar de pensar en términos de la clase a la que pertenece un objeto y enfocarnos más en su comportamiento: más importante que el tipo del objeto son los mensajes a los que puede responder.

Un duck type Maintainer

Ahora que descubrimos que necesitamos un duck, necesitamos saber a qué mensajes necesita responder. En nuestro caso, Rocket solo se preocupa porque otras clases realicen trabajo de mantenimiento, así que tiene sentido que respondan al mensaje perform_maintenance. Refactorizamos y obtenemos el siguiente código:

    class Rocket
        attr_reader :engines, :computers, :fuel_tank
        # ...
        def upkeep(maintainers)
            maintainers.each do |maintainer|
                maintainer.perform_maintenance(self)
            end
        end
        # ...
    end

       class PropulsionEngineer
        def perform_maintenance(rocket)
            rocket.engines.each{|engine| check_engine(engine)}
        end
        #...
    end

    class ComputerTechnician
        def perform_maintenance(rocket)
            rocket.computers.each do |computer|
                verify_circuitry(computer)
                update_software(computer)
            end
        end
        #...

    end

    class PropellantCrew
        def perform_maintenance(rocket)
            fix_fuel_tank(rocket.fuel_tank)
        end
        #...
    end

Al crear una abstracción para el comportamiento del mantenedor, el nuevo método upkeep es mucho más simple. Más importante es el hecho de que ya no depende de clases concretas, mientras un objeto implemente perform_maintenance el cohete trabajará gustosamente con él. Es el Principio de Inversión de Dependencias aplicado en un lenguaje de tipado dinámico.

Encontrando ducks ocultos en tu código

Puede que haya patos (jeje) escondidos en tu código: interfaces públicas que necesitan ser definidas, pero de las que aún no te das cuenta. El code smell más común que apunta a la presencia de un duck type oculto es usar el mecanismo integrado de un lenguaje para identificar el tipo de un objeto para determinar qué método debería ser llamado.

En el ejemplo que acabamos de ver, la declaración case verificaba si el objeto era un PropulsionEnginner y llamaba al método correcto. Las declaraciones case en el tipo de un objeto no son el único escenario al que le debes prestar atención, otras alarmas comunes para ducks ocultos son:

En Ruby:

  • kind_of?
  • is_a?
  • responds_to?

En Python:

  • type/is
  • isinstance
  • hasattr
  • callable

Aunque estas cosas no siempre significan que hay algo mal en tu código, es importante preguntarte si objetos con diferentes tipos realizan la misma acción con un nombre y especificaciones diferentes. Si ese es el caso, podrías querer definir una interfaz común para llamar esa acción.

La importancia de la documentación

Los lenguajes de tipado dinámico, a diferencia de sus contrapartes de tipado estático, usualmente no te ofrecen construcciones como interfaces o clases abstractas para representar tus abstracciones. Los duck types son otro tipo de abstracción: no viven en un único archivo que puedas abrir y leer, son una interfaz pública implementada a través de diferentes clases.

Aunque este enfoque tiene ventajas, no puedes contar con el compilador para encontrar errores de tipo. Es tu responsabilidad escribir documentación para respaldar estos tipos y escribir pruebas que sirvan como documentación adicional. Hará las cosas mucho más fáciles para ti y para otras personas trabajando en el mismo proyecto.

Qué hacer a continuación:

  • Comparte este artículo con amigos y colegas. Gracias por ayudarme a llegar a personas que podrían encontrar útil esta información.
  • Envíame un email con preguntas, comentarios o sugerencias (está en la página Autor)

Juan Luis Orozco Villalobos

¡Hola! Soy Juan, ingeniero de software y consultor en Budapest. Me especializo en computación en la nube e IA, y me encanta ayudar a otros a aprender sobre tecnología e ingeniería