October 7, 2024

Dos técnicas populares para envolver métodos en Ruby

Si necesitas envolver un método en Ruby, un par de maneras populares de lograrlo serían:

  • Prefijando un módulo con la envoltura
  • «Anotando aparte» el método original con un alias

Técnica: Prefijar un módulo con la envoltura

Define un módulo con el método envolvente, y luego «prefija» este módulo en aquel otro módulo (o clase) que define el método que quieres envolver.

Ejemplo:

class A
  def m = :m
end

module E1
  def m = "E1{ #{super} }1E"
end

A.new.m # => :m

A.prepend(E1)
A.new.m # => "E1{ m }1E"

El método Module#prepend inserta el módulo que le pasas como argumento de primero en la lista de búsqueda de métodos de la clase (o módulo) al que se le aplica el método prepend. Así, en este ejemplo, cuando invocamos el método m en el nuevo objeto de la clase A, la implementación de m que se ejecuta es la que está definida en el módulo E1 porque está de primero en la lista de búsqueda de métodos.

Con super invocamos el método original, que en este ejemplo el la siguiente implementación en la lista de búsqueda de métodos.

Múltiples envolturas

Módulos adicionales prefijados de esta manera tomarán precedencia y envolverán toda la composición acumulada hasta entonces. Siempre y cuando recuerdes invocar a super en algún lugar de los envoltorios, el método original será invocado. Si omites super, el método original (y todos los envoltorios anteriores) serán omitidos.

# ... continuación del ejemplo
module E2
  def m = "E2{ #{super} }2E"
end

module E3
  def m = :E3 # No llama a `super`
end

A.prepend(E2)
A.new.m # => "E2{ E1{ m }1E }2E"

A.prepend(E3) # oculta toda la composición acumulada hasta ahora
A.new.m # => :E3

El mismo módulo no se repite en la lista de resolución de métodos

prepend, al igual que include no causa que el mismo módulo se repita en la lista de resolución de métodos.

Así que, prefijar 2 veces un módulo envolvente sombre el mismo módulo/clase, no envolverá 2 veces el método.

class A
  def m = :m
end

module E
  def m = "E{ #{super} }E"
end

2.times{ A.prepend(E) }

A.new.m # => "E{ m }E"

Nota que la envoltura afecta una sola vez, y por ende el resultado no es "E{ E{ m }E }E".

Técnica: «Anotar aparte» el método original con un alias

Hay otra técnica muy popular en el ecosistema Ruby que se vale de utilizar el método Module#alias_method, con el que «anotas aparte» el método original antes de modificarlo.

El ejemplo anterior lo podríamos haber implementado con esta técnica de la siguiente manera:

class A
  def m = :m
end

class A
  private alias_method :e1__m, :m

  def m = "e1{ #{e1__m} }1e"
end

A.new.m # => e1{ m }1e

Una diferencia muy importante es que en vez de utilizar super, el envoltorio llama al método «anotado aparte», o e1_m en este ejemplo.

Múltiples envolturas

Al igual que con la técnica de prefijar módulos, puedes crear varios niveles de envoltura:

class A
  private alias_method :e2__m, :m

  def m = "e2{ #{e2__m} }2e"
end

Esto funciona siempre y cuando los nombres de las anotaciones son únicos.

El problema con los nombres utilizados

Es posible que durante la ejecución de un programa que haga uso de estas técnicas, diferentes partes del programa la repitan utilizando los mismos nombres para los aliases o para los módulos envolventes.

Es muy fácil imaginar esa posibilidad con la técnica de los aliases, porque se ha vuelto muy común en el ecosistema Ruby utilizar el prefijo original_ en el nombre de dicho alias. Así que no es descabellado que en diferentes momentos, el programa ejecute:


# Primera vez que el programa abre la clase A
class A 
  private alias_method :original_m, :m
  
  def m = "e1{ #{original_m} }1e"
end

Y luego, el mismo programa, también ejecute:

# Segunda vez que el programa abre la clase A
class A
  private alias_method :original_m, :m
  
  def m = "e2{ #{original_m} }2e"
end

Luego de la segunda definición de m, el método m causará que el programa quede atrapado en un bucle sin fin.

La razón es que para cuando la segunda apertura de la clase A se realice, el método m no es en realidad el método original, si no la primera envoltura. Es decir, desde ese momento original_m ya no será el método original, si no la primera envoltura (la que envuelve con "e1{...}1e"). Entonces ¿qué crees que sucederá cuando la primera envoltura llame a original_m?. Pues en ese momento, ese método se estará llamando a si mismo, activando la trampa.

A.new.m # => ... Bucle infinito ...

Un problema similar, pero relativamente menos grave, sucede con la técnica de prefijar el módulo envolvente. Es concebible que varias partes de un mismo programa traten de envolver el mismo método, con la misma técnica, utilizando el mismo nombre de módulo.

Tal vez no sea un problema tan frecuente como con la técnica del alias, debido a que no es tan popular y tampoco está tan arraigado el uso de la misma palabra para definir los elementos auxiliares (en el caso del alias, estoy hablando de la palabra «original»). Sin embargo es posible.

Nuevamente, no es descabellado pensar que el programa ejecute primero:

module E1
  def m = "E1{ #{super} }1E"
end

A.prepend(E1)

Y luego, el mismo programa, ejecute:

module E1
  def m = "E1'{ #{super} }'1E"
end

A.prepend(E1)

Sin embargo, en este caso, y a diferencia de la técnica con aliases, invocar el método m no atrapará al programa en un bucle infinito, si no más bien, ofuscará la primera envoltura con la segunda.

Es decir, éste sería el resultado:

A.new.m => "E1'{ m }'1E"

Cuando probablemente, uno hubiera deseado este resultado:

A.new.m => "E1'{ E1{ m }1E }'1E"

Nota que al menos, el programa no queda atrapado en un bucle infinito.

Solución

Todos estos problemas se pueden mitigar con un uso responsable de espacios de nombres.

En Ruby, los espacios de nombres normalmente se trabajan con módulos y constantes.

Los módulos sirven como una colección de constantes, y las constantes pueden tener asociadas módulos, haciendo posible una estructura de árbol que se navega con el operador ::.

Como ejemplo, supongamos 2 proyectos independientes que definen envoltorios del método m, y un tercero que los requiere a los 2:

# framework.rb
class A
  def m = :m
end
# org_1/proyecto_1.rb
require 'framework'

module Org1
module Org1::Proy1
module Org1::Proy1::E1
  def m = "O1_P1_E1{ #{super} }1E_1P_1O"
end

A.prepend(Org1::Proy1::E1)
# org_2/proyecto_1.rb

module Org2
module Org2::Proy1
module Org2::Proy1::E1
  def m = "O2_P1_E1{ #{super} }1E_1P_2O"
end

A.prepend(Org2::Proy1::E1)
# program.rb

require 'org1/proyecto_1'
require 'org2/proyecto_2'

puts A.m

El resultado de este programa sería: O2_P1_E1{ O1_P1_E1{ #{super} }1E_1P_1O }1E_1P_2O.

Si no fueran por los espacios de nombre, ambos proyectos se tendrían que coordinar y ponerse de acuerdo para no utilizar los mismos nombres, o el resultado hubiera sido: O2_P1_E1{ #{super} }1E_1P_2O.

Pero en el caso de la técnica del uso de alias_method, utilicé un convenio muy personal que tengo para crear espacios de nombres «improvisados». La idea es que al nombrar variables, utilizo 2 «subrayados» como análogo al operador ::. I.e, en el ejemplo, en la variable e1__m, e1 es un espacio de nombres improvisado, y m correspondería entenderlo (al programador) como el nombre de la variable en dicho espacio. Ruby no te protegería de que otro bloque de código haga uso del mismo nombre de variable, sin embargo, sería muy raro que sucediera y si sucede, es posiblemente a propósito.

Conclusión

Mi recomendación es utilizar la técnica de prefijar módulos, y definirlos dentro de un espacio de nombres del que tengas control absoluto.

Anotaciones finales

«Congela» los módulos con freeze

Otra forma de proteger los módulos de ofuscación accidental, es «congelarlos» con freeze. En el ejemplo:

module E
  def m
    "E1{ #{super} }1E"
  end
  
  freeze
end

En este ejemplo, el módulo E no puede volver a abrirse (fácilmente) para modificar. Y si un bloque de código fuera de tu control (o tuyo por error, o exploración) intenta abrir el módulo E, recibirá un error:

`<module:E>': can't modify frozen module: E (FrozenError)

Es una prevención adicional que puedes combinar incluso con el uso de espacios de nombres propios (donde tienes el control absoluto del mismo), en cuyo caso te protegería de ti mismo. Si ves ese error, entonces debes tomar la decisión de si quitar el frozen o reconocer que lo que estás haciendo en ese momento está mal, y buscar una alternativa.

Otras técnicas

Existen otras técnicas que valen la pena explorar:

  • Refinamientos
  • Meta programación con define_method, instance_method y variables de instancia de los objetos «módulo» o «clase»
  • Concernientes
  • Explotar el método method_missing
  • La gema around_the_world.

Son técnicas que tal vez explore y escriba de ellas en el futuro, o quedan de «tarea» para el lector ;-)

Tags: módulos modules method métodos wrap module método es ruby módulo envolver