La metaprogramación de Ruby es incluso mejor de lo que parece
Publicado: 2022-03-11A menudo escuchas que la metaprogramación es algo que solo usan los ninjas de Ruby, y que simplemente no es para los mortales comunes. Pero la verdad es que la metaprogramación no da miedo en absoluto. Esta publicación de blog servirá para desafiar este tipo de pensamiento y acercar la metaprogramación al desarrollador promedio de Ruby para que también puedan aprovechar sus beneficios.
Debe tenerse en cuenta que la metaprogramación puede significar mucho y, a menudo, puede ser muy mal utilizada e ir al extremo en lo que respecta al uso, por lo que intentaré incluir algunos ejemplos del mundo real que todos podrían usar en la programación diaria.
Metaprogramación
La metaprogramación es una técnica mediante la cual puede escribir código que escribe código por sí mismo dinámicamente en tiempo de ejecución. Esto significa que puede definir métodos y clases durante el tiempo de ejecución. loco, ¿verdad? En pocas palabras, con la metaprogramación puede reabrir y modificar clases, capturar métodos que no existen y crearlos sobre la marcha, crear código SECO evitando repeticiones y más.
Los basicos
Antes de sumergirnos en la metaprogramación seria, debemos explorar los conceptos básicos. Y la mejor manera de hacerlo es con el ejemplo. Comencemos con uno y comprendamos la metaprogramación de Ruby paso a paso. Probablemente puedas adivinar lo que está haciendo este código:
class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" end end
Hemos definido una clase con dos métodos. El primer método de esta clase es un método de clase y el segundo es un método de instancia. Esto es algo básico en Ruby, pero hay mucho más detrás de este código que debemos entender antes de continuar. Vale la pena señalar que la propia clase Developer
es en realidad un objeto. En Ruby todo es un objeto, incluidas las clases. Dado que Developer
es una instancia, es una instancia de la clase Class
. Así es como se ve el modelo de objetos de Ruby:
p Developer.class # Class p Class.superclass # Module p Module.superclass # Object p Object.superclass # BasicObject
Una cosa importante a entender aquí es el significado de self
. El método frontend
es un método regular que está disponible en instancias de la clase Developer
, pero ¿por qué el método backend
es un método de clase? Cada fragmento de código ejecutado en Ruby se ejecuta contra un yo en particular. Cuando el intérprete de Ruby ejecuta cualquier código, siempre realiza un seguimiento del valor self
para cualquier línea dada. self
siempre se refiere a algún objeto, pero ese objeto puede cambiar según el código ejecutado. Por ejemplo, dentro de una definición de clase, el self
se refiere a la clase misma, que es una instancia de la clase Class
.
class Developer p self end # Developer
Dentro de los métodos de instancia, self
se refiere a una instancia de la clase.
class Developer def frontend self end end p Developer.new.frontend # #<Developer:0x2c8a148>
Dentro de los métodos de clase, self
se refiere a la clase misma de alguna manera (que se discutirá con más detalle más adelante en este artículo):
class Developer def self.backend self end end p Developer.backend # Developer
Esto está bien, pero ¿qué es un método de clase después de todo? Antes de responder a esa pregunta, debemos mencionar la existencia de algo llamado metaclase, también conocido como clase singleton y clase propia. La frontend
del método de clase que definimos anteriormente no es más que un método de instancia definido en la metaclase para el objeto Developer
. Una metaclase es esencialmente una clase que Ruby crea e inserta en la jerarquía de herencia para contener métodos de clase, por lo que no interfiere con las instancias que se crean a partir de la clase.
Metaclases
Cada objeto en Ruby tiene su propia metaclase. De alguna manera es invisible para un desarrollador, pero está ahí y puede usarlo muy fácilmente. Dado que nuestra clase Developer
es esencialmente un objeto, tiene su propia metaclase. Como ejemplo vamos a crear un objeto de una clase String
y manipular su metaclase:
example = "I'm a string object" def example.something self.upcase end p example.something # I'M A STRING OBJECT
Lo que hicimos aquí fue agregar un método singleton something
a un objeto. La diferencia entre los métodos de clase y los métodos singleton es que los métodos de clase están disponibles para todas las instancias de un objeto de clase, mientras que los métodos singleton solo están disponibles para esa única instancia. Los métodos de clase se usan ampliamente, mientras que los métodos singleton no tanto, pero ambos tipos de métodos se agregan a una metaclase de ese objeto.
El ejemplo anterior podría reescribirse así:
example = "I'm a string object" class << example def something self.upcase end end
La sintaxis es diferente pero efectivamente hace lo mismo. Ahora volvamos al ejemplo anterior en el que creamos la clase Developer
y exploremos otras sintaxis para definir un método de clase:
class Developer def self.backend "I am backend developer" end end
Esta es una definición básica que casi todo el mundo usa.
def Developer.backend "I am backend developer" end
Esto es lo mismo, estamos definiendo el método de clase de backend
-end para Developer
. No usamos self
pero definir un método como este lo convierte efectivamente en un método de clase.
class Developer class << self def backend "I am backend developer" end end end
Nuevamente, estamos definiendo un método de clase, pero usando una sintaxis similar a la que usamos para definir un método singleton para un objeto String
. Puede notar que usamos self
aquí, que se refiere a un objeto Developer
en sí mismo. Primero abrimos la clase Developer
, haciéndonos iguales a la clase Developer
. A continuación, hacemos class << self
, haciendo que self sea igual a la metaclase de Developer
. Luego definimos un backend
de método en la metaclase de Developer
.
class << Developer def backend "I am backend developer" end end
Al definir un bloque como este, estamos configurando self
en la metaclase de Developer
durante la duración del bloque. Como resultado, el método de backend
-end se agrega a la metaclase de Developer
, en lugar de la clase en sí.
Veamos cómo se comporta esta metaclase en el árbol de herencia:
Como viste en los ejemplos anteriores, no hay pruebas reales de que la metaclase exista. Pero podemos usar un pequeño truco que puede mostrarnos la existencia de esta clase invisible:
class Object def metaclass_example class << self self end end end
Si definimos un método de instancia en la clase Object
(sí, podemos reabrir cualquier clase en cualquier momento, esa es otra belleza más de la metaprogramación), tendremos una self
referencia al objeto Object
dentro de ella. Luego podemos usar la sintaxis class << self
para cambiar el yo actual para que apunte a la metaclase del objeto actual. Dado que el objeto actual es la clase Object
en sí misma, esta sería la metaclase de la instancia. El método devuelve self
que en este punto es una metaclase en sí misma. Entonces, al llamar a este método de instancia en cualquier objeto, podemos obtener una metaclase de ese objeto. Definamos nuestra clase Developer
nuevamente y comencemos a explorar un poco:

class Developer def frontend p "inside instance method, self is: " + self.to_s end class << self def backend p "inside class method, self is: " + self.to_s end end end developer = Developer.new developer.frontend # "inside instance method, self is: #<Developer:0x2ced3b8>" Developer.backend # "inside class method, self is: Developer" p "inside metaclass, self is: " + developer.metaclass_example.to_s # "inside metaclass, self is: #<Class:#<Developer:0x2ced3b8>>"
Y para el crescendo, veamos la prueba de que el frontend
es un método de instancia de una clase y el backend
es un método de instancia de una metaclase:
p developer.class.instance_methods false # [:frontend] p developer.class.metaclass_example.instance_methods false # [:backend]
Aunque, para obtener la metaclase, no necesita volver a abrir Object
y agregar este truco. Puede usar singleton_class
que proporciona Ruby. Es lo mismo que metaclass_example
que agregamos, pero con este truco puedes ver cómo funciona Ruby debajo del capó:
p developer.class.singleton_class.instance_methods false # [:backend]
Definición de métodos utilizando "class_eval" e "instance_eval"
Hay una forma más de crear un método de clase, y es mediante el uso de instance_eval
:
class Developer end Developer.instance_eval do p "instance_eval - self is: " + self.to_s def backend p "inside a method self is: " + self.to_s end end # "instance_eval - self is: Developer" Developer.backend # "inside a method self is: Developer"
Este fragmento de código del intérprete de Ruby se evalúa en el contexto de una instancia, que en este caso es un objeto Developer
. Y cuando está definiendo un método en un objeto, está creando un método de clase o un método singleton. En este caso es un método de clase - para ser exactos, los métodos de clase son métodos singleton pero métodos singleton de una clase, mientras que los demás son métodos singleton de un objeto.
Por otro lado, class_eval
evalúa el código en el contexto de una clase en lugar de una instancia. Prácticamente reabre la clase. Así es como se puede usar class_eval
para crear un método de instancia:
Developer.class_eval do p "class_eval - self is: " + self.to_s def frontend p "inside a method self is: " + self.to_s end end # "class_eval - self is: Developer" p developer = Developer.new # #<Developer:0x2c5d640> developer.frontend # "inside a method self is: #<Developer:0x2c5d640>"
Para resumir, cuando llama al método class_eval
, cambia self
para referirse a la clase original y cuando llama a instance_eval
, self
cambia para referirse a la metaclase de la clase original.
Definición de métodos faltantes sobre la marcha
Una pieza más del rompecabezas de la metaprogramación es method_missing
. Cuando llama a un método en un objeto, Ruby primero ingresa a la clase y explora sus métodos de instancia. Si no encuentra el método allí, continúa buscando en la cadena de antepasados. Si Ruby aún no encuentra el método, llama a otro método llamado method_missing
que es un método de instancia de Kernel
que hereda cada objeto. Dado que estamos seguros de que Ruby llamará a este método eventualmente para los métodos faltantes, podemos usar esto para implementar algunos trucos.
define_method
es un método definido en la clase Module
que puede usar para crear métodos dinámicamente. Para usar define_method
, lo llama con el nombre del nuevo método y un bloque donde los parámetros del bloque se convierten en los parámetros del nuevo método. ¿Cuál es la diferencia entre usar def
para crear un método y define_method
? No hay mucha diferencia, excepto que puede usar define_method
en combinación con method_missing
para escribir código SECO. Para ser exactos, puede usar define_method
en lugar de def
para manipular los ámbitos al definir una clase, pero esa es otra historia. Veamos un ejemplo sencillo:
class Developer define_method :frontend do |*my_arg| my_arg.inject(1, :*) end class << self def create_backend singleton_class.send(:define_method, "backend") do "Born from the ashes!" end end end end developer = Developer.new p developer.frontend(2, 5, 10) # => 100 p Developer.backend # undefined method 'backend' for Developer:Class (NoMethodError) Developer.create_backend p Developer.backend # "Born from the ashes!"
Esto muestra cómo se usó define_method
para crear un método de instancia sin usar un def
. Sin embargo, hay mucho más que podemos hacer con ellos. Echemos un vistazo a este fragmento de código:
class Developer def coding_frontend p "writing frontend" end def coding_backend p "writing backend" end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"
Este código no está SECO, pero usando define_method
podemos hacerlo SECO:
class Developer ["frontend", "backend"].each do |method| define_method "coding_#{method}" do p "writing " + method.to_s end end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"
Eso es mucho mejor, pero aún no es perfecto. ¿Por qué? Si queremos agregar un nuevo método coding_debug
, por ejemplo, debemos colocar este "debug"
en la matriz. Pero usando method_missing
podemos arreglar esto:
class Developer def method_missing method, *args, &block return super method, *args, &block unless method.to_s =~ /^coding_\w+/ self.class.send(:define_method, method) do p "writing " + method.to_s.gsub(/^coding_/, '').to_s end self.send method, *args, &block end end developer = Developer.new developer.coding_frontend developer.coding_backend developer.coding_debug
Este fragmento de código es un poco complicado, así que vamos a desglosarlo. Llamar a un método que no existe activará method_missing
. Aquí, queremos crear un nuevo método solo cuando el nombre del método comience con "coding_"
. De lo contrario, simplemente llamamos a super para que haga el trabajo de informar un método que realmente falta. Y simplemente estamos usando define_method
para crear ese nuevo método. ¡Eso es todo! Con esta pieza de código podemos crear literalmente miles de nuevos métodos comenzando con "coding_"
, y ese hecho es lo que hace que nuestro código SE SECO. Dado que define_method
es privado para Module
, necesitamos usar send
para invocarlo.
Terminando
Esto es sólo la punta del iceberg. Para convertirse en un Ruby Jedi, este es el punto de partida. Después de dominar estos componentes básicos de la metaprogramación y comprender verdaderamente su esencia, puede proceder a algo más complejo, por ejemplo, crear su propio lenguaje específico de dominio (DSL). DSL es un tema en sí mismo, pero estos conceptos básicos son un requisito previo para comprender temas avanzados. Algunas de las gemas más usadas en Rails se construyeron de esta manera y probablemente usaste su DSL sin siquiera saberlo, como RSpec y ActiveRecord.
Con suerte, este artículo puede acercarlo un paso más a la comprensión de la metaprogramación y tal vez incluso a construir su propio DSL, que puede usar para codificar de manera más eficiente.