Ayer estaba haciendo una entrevista para un puesto de desarrollador de Ruby en una empresa aquí en Denver cuando mi entrevistador me planteó un buen reto de metaprogramación: me pidió que escribiera un auditor de métodos automatizado.
Describió un método de clase que tomaría el nombre de un método de instancia y generaría un rastro de papel automático y registrado cada vez que se llamara a ese método de destino. Algo así:
Eso produciría una salida que se vería así:
Se me ocurrió una solución que funcionaba pero que implicaba algunos hacks potencialmente peligrosos para Object#send
. Antes de presentar mi solución, dije «esto es probablemente una mala idea, pero…» y tenía razón. Las buenas ideas rara vez empiezan así.
La solución que propuso implicaba el uso de la a menudo olvidada Module#prepend
y pensé que era lo suficientemente genial como para justificar un artículo.
La estrategia era utilizar el comportamiento de Module#prepend
para crear un método envolvente generado dinámicamente para Messenger#share
.
En esa envoltura, tendremos un mensaje «Performing» que se dispara antes de que se ejecute el cuerpo del método, la ejecución del método en sí, y luego un mensaje final «Exiting» una vez que la ejecución del método se ha completado con éxito.
Pero para entender cómo usar Module#prepend
para hacer esto, primero necesitamos una buena comprensión de cómo funciona la herencia de métodos en Ruby.
Cadenas de Ancestros
Cada objeto Ruby se encuentra al final de lo que se llama una Cadena de Ancestros. Es una lista de los objetos ancestros de los que hereda el objeto. Ruby utiliza esta cadena de ancestros para determinar qué versión de un método (si es que hay alguna) se ejecuta cuando el objeto recibe un mensaje.
Puedes ver el árbol de ancestros de cualquier objeto llamando a Module#ancestors
. Por ejemplo, aquí está la cadena de ancestros de nuestra clase Messenger
:
Cuando introducimos #include
o #prepend
un módulo en una clase, Ruby realiza cambios en la cadena de ancestros de esa clase, modificando qué métodos se encuentran y en qué orden. La gran diferencia entre #include
y #prepend
es dónde se hace ese cambio. Creemos un módulo llamado Auditable
que (eventualmente) contendrá el código que hace nuestra magia de auditoría:
Si usamos Module#include
para importar los métodos (actualmente inexistentes) de Auditable
, Ruby meterá ese módulo como ancestro de nuestra clase Messenger
. Aquí, compruébalo tú mismo:
Cuando llamemos a un método de Messenger
, Ruby mirará lo que está definido en la clase Messenger
y -si no encuentra un método que coincida con la llamada- subirá por la cadena de ancestros hasta Auditable
para buscar de nuevo. Si no encuentra lo que busca en Auditable
pasa a Object
y así sucesivamente.
Eso es #include
. Si en cambio utilizamos Module#prepend
para importar el contenido del módulo, obtenemos un efecto totalmente diferente:
#prepend
hace que Messenger
sea un ancestro de Auditable
. Espera. ¿Qué?
Esto podría parecer que Messenger
es ahora una superclase de Auditable
, pero eso no es exactamente lo que está sucediendo aquí. Las instancias de la clase Messenger
siguen siendo instancias de la clase Messenger
, pero ahora Ruby buscará los métodos a utilizar para Messenger
en el módulo Auditable
antes de buscarlos en la propia clase Messenger
.
Y eso, amigos, es lo que vamos a aprovechar para construir este auditor: si creamos un método llamado Auditable#share
, Ruby lo encontrará antes de encontrar Messenger#share
. Podemos entonces utilizar una llamada super
en Auditable#share
para acceder (¡y ejecutar!) el método original definido en Messenger
.
El propio módulo
En realidad no vamos a crear un método llamado Auditable#share
. ¿Por qué no? Porque queremos que esto sea una utilidad flexible. Si codificamos el método Auditable#share
, sólo podremos usarlo en métodos que implementen el método #share
. O peor, tendríamos que reimplementar este mismo patrón de auditor para cada método que queramos auditar. No gracias.
Así que en su lugar, vamos a definir nuestro método de forma dinámica y el método de la clase audit_method
para dispararlo:
Cuando se llama a audit_method
en una clase implementadora, se crea un método en el módulo Auditable
con el mismo nombre que el método a auditar. En nuestro caso, se creará un método llamado Auditable#share
. Como hemos dicho, Ruby encontrará este método antes de encontrar el método original en Messenger
porque estamos anteponiendo el módulo Auditable
en la clase implementadora.
Eso significa que podemos usar una superllamada para llegar a la cadena de ancestros y ejecutar Messenger#send
. Cuando lo hacemos, pasamos los argumentos que hemos recogido (*arguments
) hacia arriba de la cadena también.
Una vez que hemos llamado al método original, imprimimos nuestro mensaje de salida y lo damos por terminado. Buen trabajo, chicos!
Juntando todo
Ahora es sólo cuestión de prepend
poner este módulo en nuestra clase Messenger
y deberíamos estar listos para ir:
Y vaya si funciona:
Las implicaciones aquí son enormes para la auditoría, pero hay más en este truco. Puedes usar el prepending para cambiar el comportamiento de los objetos sin cambiar los objetos mismos. Esta es una manera adaptable y clara de crear componentes de orden superior en Ruby. Pruebas de rendimiento, manejo de errores. Puedes hacer mucho aquí.
Conclusión
Los módulos de Ruby son más complicados de lo que la mayoría de la gente piensa. No es tan simple como «volcar» el código de un lugar a otro, y la comprensión de esas diferencias desbloquea algunas herramientas realmente aseado para su cinturón de utilidad.
Usted puede haber notado que sólo hablé de Module#include
y Module#prepend
hoy, y que no toqué en Module#extend
. Eso es porque funciona de manera muy diferente a sus primos. Pronto escribiré una explicación en profundidad de Module#extend
para completar el conjunto.
Por ahora, si quieres aprender más te recomiendo que leas Ruby modules: Include vs Prepend vs Extend de Léonard Hetsch. Fue realmente útil para armar todo esto.