Módulos de Ruby en profundidad

Los módulos son elementos increíblemente flexibles y poderosos que te ayudan a organizar tu código de una manera más… modular. Te permiten empaquetar funcionalidad relacionada en una unidad cohesiva que puedes usar para extender el comportamiento de objetos y clases.

Usando módulos para agrupar comportamiento relacionado

Ruby te da dos opciones para agrupar comportamiento en unidades: clases y módulos. Ambos son muy similares en muchas maneras, pero tienen dos diferencias importantes:

  • Puedes instanciar una clase, pero no un módulo.
  • En Ruby OO, una clase usualmente se usa para representar una entidad, mientras que los módulos representan características o comportamiento. Escribes clases para cosas como Aviones, Personas y Coral. Los módulos son para propiedades como Mágico, Racional o Aprobable.

Tanto las clases como los módulos te permiten empaquetar comportamiento relacionado. Las clases te permiten crear varios objetos a partir de ellas, cada uno con variables de instancia individuales para mantener su estado. Si te encuentras escribiendo clases sin variables de instancia o métodos, podrías querer usar un módulo en su lugar.

Considera el caso donde necesitas escribir una calculadora simple de movimiento lineal. Como las operaciones son sin estado y simples, probablemente no necesitarás crear instancias. Veamos cómo se ve un módulo con esa funcionalidad.

module UniformLinearMotionCalculator
    # todas las velocidades en m/s
    def self.calculate_speed(distance_in_meters:, time_in_seconds:)
        raise ArgumentError, "No podemos calcular para tiempo = 0" if time_in_seconds != 0

        speed = distance_in_meters / time_in_seconds
        puts "Un objeto que se mueve #{distance_in_meters}m en #{time_in_seconds}s tiene una velocidad de #{speed}m/s"
        return speed
    end

    def self.calculate_distance (speed:, time_in_seconds:)
        distance_in_meters = speed * time_in_seconds
        puts "Un objeto con una velocidad de #{speed}m/s moviéndose por #{time_in_seconds}s viaja una distancia de #{distance_in_meters}m"
        return distance_in_meters
    end

    def self.calculate_time(distance_in_meters:, speed:)
        raise ArgumentError, "No podemos calcular para velocidad = 0" if speed != 0

        time_in_seconds = distance_in_meters / speed
        puts "Le toma a un objeto #{time_in_seconds}s moverse #{distance_in_meters}m si se mueve a #{speed}m/s"
        return time_in_seconds
    end
end

Este es un ejemplo de cómo podríamos usar el módulo para calcular algunos casos simples:

require_relative 'uniform_linar_motion_calculator'

ULMCalculator.calculate_speed(distance_in_meters: 100,
                              time_in_seconds: 5)

ULMCalculator.calculate_distance(speed: 100,
                                 time_in_seconds: 10)

ULMCalculator.calculate_time(distance_in_meters: 100,
                             speed: 25)

Ejecutar este código imprime lo siguiente:

Un objeto que se mueve 100m en 5s tiene una velocidad de 20m/s
Un objeto con una velocidad de 100m/s moviéndose por 10s viaja una distancia de 1000m
Le toma a un objeto 4s moverse 100m si se mueve a 25m/s

Los módulos son muy útiles para agrupar funcionalidad, empieza a usarlos y verás grandes beneficios en modularidad con muy poco esfuerzo.

Extendiendo el comportamiento de clases usando módulos

Los módulos son un almacén útil de funciones, pero su verdadero poder es su capacidad de dar a las clases nuevo comportamiento. Puedes ‘mezclar (mix-in)’ un módulo en una clase, inmediatamente proporcionándole nuevas capacidades. Este poderoso mecanismo elimina la necesidad de usar herencia múltiple.

Entender cómo funciona es mucho más fácil con un ejemplo. Imagina que necesitamos modelar ‘etiquetabilidad’: un objeto puede recibir múltiples etiquetas y luego imprimirlas. Podemos crear un módulo que haga esto y luego mezclarlo en clases.

module Taggable
    def tags
        @tags ||= []
    end

    def add_tag(new_tag)
        tags << new_tag
    end

    def get_all_tags
        tags
    end

    def tag_count
        tags.count
    end
end

Para mezclar un módulo en una clase usamos la palabra clave ‘Include’. La siguiente clase modela un post muy simple (como un artículo de blog, por ejemplo). Lo hacemos etiquetable incluyendo el módulo de la siguiente manera:

require_relative 'taggable'

class Post
    include Taggable

    def initialize(content, user)
        @content = content
        @user = user
    end

    def print_post_summary
        puts "Por usuario: #{@user}"
        puts "Artículo: #{@content}"
        # Podemos usar métodos definidos en el módulo
        puts "Etiquetas: #{get_all_tags}"
    end
end

Como puedes notar en print_post_summary, podemos usar el método get_all_tags definido en el módulo. Es más, podemos llamar en una instancia de Post los mismos métodos del módulo:

require_relative 'post'

first_post = Post.new('Este es un artículo sobre cómo usar módulos de Ruby', 'Juan Orozco Villalobos')
first_post.add_tag('Desarrollo de software')
first_post.add_tag('Programación en Ruby')
first_post.add_tag('Lenguajes de programación')

first_post.print_post_summary
puts "Tenemos un total de #{first_post.tag_count} etiquetas"

Ejecutar este código muestra el siguiente resultado:

Por usuario: Juan Orozco Villalobos
Artículo: Este es un artículo sobre cómo usar módulos de Ruby
Etiquetas: ["Desarrollo de software", "Programación en Ruby", "Lenguajes de programación"]
Tenemos un total de 3 etiquetas

Etiquetando otras cosas

Imagina que necesitas más clases con comportamiento etiquetable. En lugar de escribir la funcionalidad una y otra vez, puedes simplemente mezclar el módulo que ya escribiste. La siguiente clase representa una colección de citas relacionadas. Y sí, adivinaste, también necesitas que tengan etiquetas.

require_relative 'taggable'

class Quotes
    include Taggable

    def initialize()
        @quotes = []
    end

    def add_quote(quote)
        @quotes << quote
    end

    def print_all_quotes
        puts "Las siguientes citas están clasificadas como #{get_all_tags.join(', ')}:"
        @quotes.each do |quote|
            puts quote
        end
    end
end

Ahora vamos a probarlo:

require_relative 'post'
require_relative 'quotes'

# ...uso previo de etiquetable

quotes = Quotes.new
quotes.add_tag('tonterías')
quotes.add_tag('pseudointelectual')

quotes.add_quote('La totalidad se despliega a través de la maravilla específica de la especie')
quotes.add_quote('La mente crea felicidad espontánea')
quotes.add_quote('El ego es un ingrediente del flujo del equilibrio')
quotes.add_quote('La percepción teme el éxito kármico')

quotes.print_all_quotes

Cuando se ejecuta, imprime:

Las siguientes citas están clasificadas como tonterías, pseudointelectual:
La totalidad se despliega a través de la maravilla específica de la especie
La mente crea felicidad espontánea
El ego es un ingrediente del flujo del equilibrio
La percepción teme el éxito kármico

Como nota al margen, puedes encontrar un generador increíble de sinsentidos basado en el feed de Twitter de Deepak Chopra en wisdomofchopra.

La palabra clave prepend

Prepend funciona de manera muy similar a include, en el sentido de que se usa para mezclar funcionalidad de un módulo en una clase. La diferencia principal ocurre cuando los métodos en el módulo y la clase tienen el mismo nombre.

Supón que tienes una clase y un módulo, ambos con el método print_description. Si usas include el módulo en la clase y llamas print_description en una instancia, llamarás el método en la clase. En otras palabras, la clase toma precedencia sobre los módulos incluidos.

Por otro lado, si usas prepend el módulo, cuando llames print_description, ejecutarás el método en el módulo, ya que los módulos antepuestos toman precedencia sobre las clases. En resumen, el orden de precedencia es algo así como:

  • Módulos antepuestos
  • Clase
  • Módulos incluidos

Por supuesto, si tomas en consideración la herencia puede volverse un poco más complicado, pero solo un poquito. La siguiente sección se ocupará de eso.

Búsqueda de métodos en una cadena de herencia con módulos.

En Ruby, invocar un método usualmente se llama enviar un mensaje. Por ejemplo, en quotes.print_all_quotes estás enviando un mensaje print_all_quotes al objeto quotes. El objeto quotes sabe cómo responder al mensaje print_all_quotes y actúa en consecuencia. De lo contrario, tu programa lanzará una excepción haciéndote saber que un objeto dado no sabe cómo responder a tu mensaje.

Hay maneras de dar a un objeto la habilidad de responder a mensajes que su clase no definió. Acabamos de ver una de esas maneras: incluyendo un módulo. Otra manera es usando herencia: un objeto puede responder a mensajes que su superclase definió.

Cuando envías un mensaje a un objeto, los métodos se buscarán en el siguiente orden:

  • 1.Módulos Antepuestos (prepend) a su clase
  • 2.Su clase
  • 3.Módulos Incluidos (include) en la clase
  • 4.Módulos Antepuestos (prepend) a su superclase
  • 5.Su superclase
  • 6.Módulos Incluidos (include) en su superclase
  • 7.Hacia arriba en la jerarquía de herencia siguiendo este mismo patrón

Revisemos un ejemplo para entender mejor cómo funciona.

Tenemos 2 clases y 4 módulos, todos tienen un método llamado print_greeting que simplemente imprime un mensaje de saludo en la consola. Una clase hereda de la otra, y los módulos están incluidos o antepuestos en ellas. Traté de proporcionar nombres que dejen claro dónde encaja cada uno de ellos, pero para tener una mejor vista, hice el siguiente diagrama.

method_resolution_graph

También incluyo en el diagrama las clases Object y BasicObject para completitud.

Pruébalo tú mismo y experimenta un poco

En un archivo de ruby, encontrarás las siguientes líneas, donde instanciamos un objeto ChildClass y enviamos el mensaje print_greeting.

require_relative 'child_class'

test_object = ChildClass.new
test_object.print_greeting

Siguiendo la lista de prioridades de arriba, esperarías que el mensaje impreso fuera el del ChildLevelModulePrepend, ejecuta el código para verificar que este es el caso.

Hello, I am a method in ChildLevelModulePrepend, the one you prepended into the child class

Ahora, si abres el archivo ChildLevelModulePrepend y eliminas la definición de print_greeting, el siguiente en la lista sería la definición en ChildClass. Realiza los cambios y verifica que sigue el orden de prioridad.

Puedes continuar probando con el código eliminando definiciones y viendo qué método es llamado, solo para obtener una mejor comprensión de cómo funciona la búsqueda de métodos. Usa los números debajo de las cajas como guía, encontré este ejercicio muy útil para obtener una mejor comprensión de cómo funciona la búsqueda de métodos.

Usando módulos como namespace

Un último uso de los módulos que no quería omitir es cuando los usamos para separar clases en sus propios espacios de nombres.

Supón que necesitas representar la clase controlador tanto para una consola de videojuegos como para un panel de control. En lugar de prefijar las clases con palabras adicionales para identificarlas, puedes colocarlas en módulos para estar seguro de lo que representan.

Puedes definir un módulo para la consola de juegos:

module GameConsole
    class Controller
        #Implementación de la clase
    end
end

Y un módulo para el panel de control

module ControlPanel
    class Controller
        #Implementación de la clase
    end
end

Después de eso, cuando creas objetos, puedes especificar cuál necesitas, incluso si las clases comparten el mismo nombre:

require_relative 'game_console'
require_relative 'control_panel'

panel_controller = ControlPanel::Controller.new
console_controller = GameConsole::Controller.new

Los módulos son muy útiles

Como acabas de leer, los módulos pueden ayudarte a crear código de mejor calidad de más de una manera. Hice mi mejor esfuerzo para mostrarte los escenarios más comunes, y espero que encuentres esto útil cuando trabajes en tus propios proyectos.

Qué hacer a continuación:

  • Comparte este artículo con amigos y colegas. Gracias por ayudarme a llegar a personas que puedan encontrar útil esta información.
  • Puedes encontrar los ejemplos de código aquí
  • Puedes encontrar la documentación oficial para módulos aquí.
  • Revisa el capítulo 4 de The Well-Grounded Rubyist.

  • 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