La metaprogramación de Ruby es incluso mejor de lo que parece

Publicado: 2022-03-11

A 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.

Metaprogramación de Ruby: Código de escritura de código
Pío

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:

modelo de objetos de rubí

 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:

Metaclase en á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.