Gestern war ich in einem Vorstellungsgespräch für eine Stelle als Ruby-Entwickler bei einem Unternehmen hier in Denver, als mein Gesprächspartner mich vor eine gute Metaprogrammier-Herausforderung stellte: Er bat mich, einen automatisierten Methoden-Auditor zu schreiben.
Er beschrieb eine Klassenmethode, die den Namen einer Instanzmethode nehmen und jedes Mal, wenn diese Zielmethode aufgerufen wird, einen automatischen, protokollierten Papierpfad erzeugen würde. Etwa so:
Das würde eine Ausgabe erzeugen, die so aussieht:
Ich habe eine Lösung gefunden, die funktioniert, aber einige potenziell gefährliche Hacks für Object#send
beinhaltet. Bevor ich meine Lösung präsentierte, sagte ich: „Das ist wahrscheinlich eine schlechte Idee, aber…“, und ich hatte Recht. Gute Ideen fangen selten so an.
Die Lösung, die er vorschlug, beinhaltete die Verwendung des oft übersehenen Module#prepend
, und ich dachte, sie sei cool genug, um einen Bericht darüber zu rechtfertigen.
Die Strategie bestand darin, das Verhalten von Module#prepend
zu verwenden, um eine dynamisch erzeugte Wrapper-Methode für Messenger#share
zu erstellen.
In diesem Wrapper wird eine „Performing“-Meldung ausgelöst, bevor der Körper der Methode ausgeführt wird, die Methodenausführung selbst und dann eine abschließende „Exiting“-Meldung, wenn die Ausführung der Methode erfolgreich abgeschlossen ist.
Um zu verstehen, wie man das mit Module#prepend
macht, muss man erst einmal verstehen, wie die Methodenvererbung in Ruby funktioniert.
Ahnenketten
Jedes Ruby-Objekt steht am Ende einer sogenannten Ahnenkette. Dabei handelt es sich um eine Liste der Vorgängerobjekte, von denen das Objekt erbt. Ruby verwendet diese Ahnenkette, um zu bestimmen, welche Version einer Methode (wenn überhaupt) ausgeführt wird, wenn das Objekt eine Nachricht erhält.
Sie können den Ahnenbaum für jedes Objekt anzeigen, indem Sie Module#ancestors
aufrufen. Hier ist zum Beispiel die Vorfahrenkette für unsere Klasse Messenger
:
Wenn wir #include
oder #prepend
ein Modul in eine Klasse einfügen, nimmt Ruby Änderungen an der Vorfahrenkette dieser Klasse vor und ändert, welche Methoden gefunden werden und in welcher Reihenfolge. Der große Unterschied zwischen #include
und #prepend
besteht darin, wo diese Änderung vorgenommen wird. Erzeugen wir ein Modul namens Auditable
, das (irgendwann) den Code enthält, der unsere Prüfungsmagie bewirkt:
Wenn wir Module#include
verwenden, um die (derzeit nicht vorhandenen) Methoden aus Auditable
zu importieren, wird Ruby dieses Modul als Vorfahre unserer Klasse Messenger
einfügen. Sehen Sie selbst:
Wenn wir eine Methode von Messenger
aufrufen, schaut Ruby nach, was in der Klasse Messenger
definiert ist, und – wenn es keine Methode findet, die dem Aufruf entspricht – klettert es in der Ahnenkette bis zu Auditable
, um erneut zu suchen. Wenn es auf Auditable
nicht findet, was es sucht, geht es weiter zu Object
und so weiter.
Das ist #include
. Wenn wir stattdessen Module#prepend
verwenden, um den Inhalt des Moduls zu importieren, erhalten wir einen völlig anderen Effekt:
#prepend
macht Messenger
zu einem Vorfahren von Auditable
. Wait. Was?
Das könnte so aussehen, dass Messenger
jetzt eine Superklasse von Auditable
ist, aber das ist nicht genau das, was hier passiert. Instanzen der Klasse Messenger
sind immer noch Instanzen der Klasse Messenger
, aber Ruby sucht jetzt nach Methoden für Messenger
im Modul Auditable
, bevor es sie in der Klasse Messenger
selbst sucht.
Und das, meine Freunde, ist es, was wir uns zunutze machen werden, um diesen Auditor zu bauen: Wenn wir eine Methode namens Auditable#share
erstellen, wird Ruby diese finden, bevor es Messenger#share
findet. Wir können dann einen super
-Aufruf in Auditable#share
verwenden, um auf die ursprüngliche Methode zuzugreifen (und sie auszuführen!), die auf Messenger
definiert ist.
Das Modul selbst
Wir werden nicht wirklich eine Methode namens Auditable#share
erstellen. Warum nicht? Weil wir wollen, dass dies ein flexibles Dienstprogramm ist. Wenn wir die Methode Auditable#share
fest kodieren, können wir sie nur für Methoden verwenden, die die Methode #share
implementieren. Oder noch schlimmer, wir müssten dasselbe Auditor-Muster für jede Methode, die wir jemals prüfen wollen, neu implementieren. Nein danke.
Also werden wir stattdessen unsere Methode dynamisch definieren und die audit_method
-Klassenmethode, um sie auszulösen:
Wenn audit_method
in einer implementierenden Klasse aufgerufen wird, wird eine Methode im Auditable
-Modul mit demselben Namen wie die zu prüfende Methode erstellt. In unserem Fall wird eine Methode namens Auditable#share
erstellt. Wie bereits erwähnt, findet Ruby diese Methode, bevor es die ursprüngliche Methode auf Messenger
findet, weil wir das Auditable
-Modul in der implementierenden Klasse vorangestellt haben.
Das bedeutet, dass wir einen Superaufruf verwenden können, um die Vorgängerkette zu erreichen und Messenger#send
auszuführen. Wenn wir das tun, geben wir die Argumente, die wir gesammelt haben (*arguments
), auch die Kette hinauf.
Wenn wir die ursprüngliche Methode aufgerufen haben, drucken wir unsere Exit-Meldung und machen Schluss. Gute Arbeit, Leute!
Alles zusammenbringen
Jetzt müssen wir nur noch prepend
das Modul in unsere Messenger
Klasse einbinden und schon sollte es losgehen:
Und siehe da, es funktioniert:
Die Implikationen für die Prüfung sind enorm, aber dieser Trick hat noch mehr zu bieten. Sie können das Verhalten von Objekten durch Voranstellen ändern, ohne die Objekte selbst zu ändern. Dies ist ein anpassungsfähiger und klarer Weg, um Komponenten höherer Ordnung in Ruby zu erstellen. Leistungstests, Fehlerbehandlung. Man kann hier eine Menge tun.
Fazit
Ruby-Module sind komplizierter als die meisten Leute denken. Es ist nicht so einfach, Code von einem Ort zum anderen zu „dumpen“, und wenn man diese Unterschiede versteht, kann man einige wirklich nette Werkzeuge für seinen Utility-Gürtel freischalten.
Du hast vielleicht bemerkt, dass ich heute nur über Module#include
und Module#prepend
gesprochen habe, und dass ich Module#extend
nicht erwähnt habe. Das liegt daran, dass sie ganz anders funktioniert als ihre Cousins. Ich werde demnächst eine ausführliche Erklärung zu Module#extend
schreiben, um die Reihe zu vervollständigen.
Wenn du mehr erfahren willst, empfehle ich dir die Lektüre von Ruby Modules: Include vs Prepend vs Extend von Léonard Hetsch. Es war sehr hilfreich bei der Zusammenstellung des Ganzen.