Hier, je passais un entretien pour un poste de développeur Ruby dans une entreprise ici à Denver lorsque mon interlocuteur a posé un bon défi de métaprogrammation : il m’a demandé d’écrire un vérificateur de méthodes automatisé.
Il a décrit une méthode de classe qui prendrait le nom d’une méthode d’instance et générerait une trace écrite automatique et enregistrée chaque fois que cette méthode cible est appelée. Quelque chose comme ceci:
Ce qui produirait un résultat qui ressemblerait à ceci:
J’ai trouvé une solution qui fonctionnait mais qui impliquait des piratages potentiellement dangereux de Object#send
. Avant de présenter ma solution, je me suis dit « c’est probablement une mauvaise idée, mais… » et j’avais raison. Les bonnes idées commencent rarement de cette façon.
La solution qu’il a proposée impliquait l’utilisation de la Module#prepend
souvent négligée et j’ai pensé que c’était assez cool pour mériter un article.
La stratégie était d’utiliser le comportement de Module#prepend
pour créer une méthode wrapper générée dynamiquement pour Messenger#share
.
Dans ce wrapper, nous aurons un message « Performing » déclenché avant l’exécution du corps de la méthode, l’exécution de la méthode elle-même, puis un message final « Exiting » une fois l’exécution de la méthode terminée avec succès.
Mais pour comprendre comment utiliser Module#prepend
pour faire cela, nous devons d’abord bien comprendre comment fonctionne l’héritage des méthodes en Ruby.
Chaînes d’ancêtres
Chaque objet Ruby se trouve à la fin de ce qu’on appelle une chaîne d’ancêtres. Il s’agit d’une liste des objets ancêtres dont l’objet hérite. Ruby utilise cette chaîne d’ancêtres pour déterminer quelle version d’une méthode (s’il y en a une) est exécutée lorsque l’objet reçoit un message.
Vous pouvez en fait visualiser l’arbre des ancêtres pour n’importe quel objet en appelant Module#ancestors
. Par exemple, voici la chaîne d’ancêtres de notre classe Messenger
:
Lorsque nous #include
ou #prepend
un module dans une classe, Ruby apporte des modifications à la chaîne d’ancêtres de cette classe, en tordant quelles méthodes sont trouvées et dans quel ordre. La grande différence entre #include
et #prepend
est l’endroit où ce changement est effectué. Créons un module appelé Auditable
qui contiendra (éventuellement) le code qui fait notre magie d’audit:
Si nous utilisons Module#include
pour importer les méthodes (actuellement inexistantes) de Auditable
, Ruby serrera ce module comme un ancêtre de notre classe Messenger
. Ici, voyez par vous-même:
Quand nous appelons une méthode sur Messenger
, Ruby regardera ce qui est défini dans la classe Messenger
et – s’il ne trouve pas de méthode correspondant à l’appel – remontera la chaîne des ancêtres jusqu’à Auditable
pour chercher à nouveau. S’il ne trouve pas ce qu’il cherche sur Auditable
, il passe à Object
et ainsi de suite.
C’est #include
. Si nous utilisons plutôt Module#prepend
pour importer le contenu du module, nous obtenons un effet totalement différent:
#prepend
fait de Messenger
un ancêtre de Auditable
. Attendez. Quoi?
On pourrait croire que Messenger
est maintenant une superclasse de Auditable
, mais ce n’est pas exactement ce qui se passe ici. Les instances de la classe Messenger
sont toujours des instances de la classe Messenger
mais Ruby va maintenant chercher les méthodes à utiliser pour Messenger
dans le module Auditable
avant de les chercher sur la classe Messenger
elle-même.
Et ça, mes amis, c’est ce dont nous allons profiter pour construire cet auditeur : si nous créons une méthode appelée Auditable#share
, Ruby va la trouver avant de trouver Messenger#share
. Nous pouvons alors utiliser un appel super
dans Auditable#share
pour accéder (et exécuter !) la méthode originale définie sur Messenger
.
Le module lui-même
Nous n’allons pas réellement créer une méthode appelée Auditable#share
. Pourquoi pas ? Parce que nous voulons que ce soit un utilitaire flexible. Si nous codons en dur la méthode Auditable#share
, nous ne pourrons l’utiliser que sur les méthodes qui implémentent la méthode #share
. Ou pire, nous devrions réimplémenter ce même pattern d’auditeur pour chaque méthode que nous voudrons jamais auditer. Non merci.
Alors, à la place, nous allons définir notre méthode dynamiquement et la méthode de la classe audit_method
pour la déclencher :
Lorsque audit_method
est appelée dans une classe d’implémentation, une méthode est créée dans le module Auditable
avec le même nom que la méthode à auditer. Dans notre cas, cela créera une méthode appelée Auditable#share
. Comme nous l’avons mentionné, Ruby trouvera cette méthode avant de trouver la méthode originale sur Messenger
parce que nous prépointerons le module Auditable
dans la classe d’implémentation.
Cela signifie que nous pouvons utiliser un super appel pour remonter la chaîne des ancêtres et exécuter Messenger#send
. Lorsque nous le faisons, nous passons les arguments que nous avons recueillis (*arguments
) en haut de la chaîne aussi.
Une fois que nous avons appelé la méthode originale, nous imprimons notre message de sortie et appelons ça un jour. Bon travail, les gars!
Réunir tout ça
Maintenant, c’est juste une question de prepend
mettre ce module dans notre classe Messenger
et nous devrions être prêts à partir:
Et bon sang, ça marche:
Les implications ici sont énormes pour l’audit, mais il y a plus à cette astuce. Vous pouvez utiliser la préposition pour changer le comportement des objets sans changer les objets eux-mêmes. C’est une façon adaptable et claire de créer des composants d’ordre supérieur en Ruby. Tests de performance, gestion des erreurs. Vous pouvez faire beaucoup de choses ici.
Conclusion
Les modules Ruby sont plus compliqués que la plupart des gens le pensent. Ce n’est pas aussi simple que de « déverser » du code d’un endroit à l’autre, et la compréhension de ces différences débloque des outils vraiment soignés pour votre ceinture utilitaire.
Vous avez peut-être remarqué que je n’ai parlé que de Module#include
et Module#prepend
aujourd’hui, et que je n’ai pas touché à Module#extend
. C’est parce qu’il fonctionne très différemment de ses cousins. Je rédigerai bientôt une explication approfondie de Module#extend
pour compléter l’ensemble.
Pour l’instant, si vous voulez en savoir plus, je vous recommande de lire Ruby modules : Include vs Prepend vs Extend par Léonard Hetsch. Il a été vraiment utile pour mettre tout cela ensemble.