Gisteren was ik aan het interviewen voor een Ruby developer positie bij een bedrijf hier in Denver toen mijn interviewer me voor een goede metaprogrammering uitdaging stelde: hij vroeg me om een geautomatiseerde methode auditor te schrijven.
Hij beschreef een klasse methode die de naam van een instance methode zou nemen en een automatisch, gelogd papieren spoor zou genereren elke keer dat die target methode wordt aangeroepen. Zoiets als dit:
Dat zou output opleveren die er als volgt uitzag:
Ik kwam met een oplossing die werkte, maar die enkele potentieel gevaarlijke hacks in Object#send
inhield. Voordat ik mijn oplossing presenteerde, zei ik “dit is waarschijnlijk een slecht idee, maar…” en ik had gelijk. Goede ideeën beginnen zelden zo.
De oplossing die hij voorstelde betrof het gebruik van de vaak over het hoofd geziene Module#prepend
en ik dacht dat het cool genoeg was om een write-up te rechtvaardigen.
De strategie was om het gedrag van Module#prepend
te gebruiken om een dynamisch gegenereerde wrapper methode voor Messenger#share
te maken.
In die wrapper laten we een “Uitvoeren” bericht afvuren voordat de body van de methode wordt uitgevoerd, de uitvoering van de methode zelf, en dan een laatste “Afsluiten” bericht zodra de uitvoering van de methode met succes is voltooid.
Maar om te begrijpen hoe Module#prepend
te gebruiken om dit te doen, moeten we eerst een goed begrip hebben van hoe methode overerving werkt in Ruby.
Ancestor Chains
Elk Ruby object zit aan het eind van wat een Ancestor Chain wordt genoemd. Het is een lijst van de voorouder objecten waar het object van erft. Ruby gebruikt deze Ancestor Chain om te bepalen welke versie van een methode (als die er al is) wordt uitgevoerd wanneer het object een bericht ontvangt.
U kunt in feite de voorouderboom voor elk object bekijken door Module#ancestors
op te roepen. Hier is bijvoorbeeld de voorouder-keten voor onze klasse Messenger
:
Wanneer we #include
of #prepend
een module in een klasse plaatsen, brengt Ruby wijzigingen aan in de voorouder-keten van die klasse, waarbij wordt aangepast welke methoden worden gevonden en in welke volgorde. Het grote verschil tussen #include
en #prepend
is waar die verandering wordt aangebracht. Laten we een module met de naam Auditable
maken, die (uiteindelijk) de code zal bevatten die onze controle magie uitvoert:
Als we Module#include
gebruiken om de (momenteel niet-bestaande) methoden uit Auditable
te importeren, zal Ruby die module erin persen als een voorouder van onze Messenger
klasse. Kijk zelf maar:
Wanneer we een methode op Messenger
aanroepen, zal Ruby kijken naar wat er is gedefinieerd in de Messenger
klasse en – als het geen methode kan vinden die overeenkomt met de aanroep – omhoog klimmen in de voorouder keten naar Auditable
om opnieuw te kijken. Als het niet vindt wat het zoekt in Auditable
, gaat het verder naar Object
enzovoort.
Dat is #include
. Als we in plaats daarvan Module#prepend
gebruiken om de inhoud van de module te importeren, krijgen we een heel ander effect:
#prepend
maakt Messenger
een voorouder van Auditable
. Wacht. Wat?
Dit lijkt erop dat Messenger
nu een superklasse is van Auditable
, maar dat is niet precies wat hier gebeurt. Instanties van klasse Messenger
zijn nog steeds instanties van klasse Messenger
, maar Ruby zal nu zoeken naar methoden om te gebruiken voor Messenger
in de Auditable
module voordat het zoekt naar hen op de Messenger
klasse zelf.
En dat, vrienden, is waar we gebruik van zullen maken om deze auditor te bouwen: als we een methode genaamd Auditable#share
maken, zal Ruby dat vinden voordat het Messenger#share
vindt. We kunnen dan een super
aanroep in Auditable#share
gebruiken om toegang te krijgen (en uit te voeren!) tot de oorspronkelijke methode gedefinieerd op Messenger
.
De module zelf
We gaan niet echt een methode genaamd Auditable#share
maken. Waarom niet? Omdat we willen dat dit een flexibel hulpprogramma wordt. Als we de methode Auditable#share
hard coderen, kunnen we deze alleen gebruiken voor methoden die de methode #share
implementeren. Of erger nog, we zouden dit zelfde auditor patroon opnieuw moeten implementeren voor elke methode die we ooit willen controleren. Geen dank.
Dus in plaats daarvan gaan we onze methode dynamisch definiëren en de audit_method
-klasse methode om het af te vuren:
Wanneer audit_method
wordt aangeroepen in een implementerende klasse, wordt er een methode aangemaakt in de Auditable
-module met dezelfde naam als de te controleren methode. In ons geval wordt een methode genaamd Auditable#share
aangemaakt. Zoals gezegd, Ruby zal deze methode vinden voordat het de originele methode op Messenger
vindt, omdat we de Auditable
module in de implementerende klasse prependen.
Dit betekent dat we een super aanroep kunnen gebruiken om de voorouder keten te bereiken en Messenger#send
uit te voeren. Als we dat doen, geven we de argumenten die we hebben verzameld (*arguments
) ook door.
Als we de oorspronkelijke methode hebben aangeroepen, drukken we onze exit-boodschap af en houden we op. Goed werk, jongens!
Bringing it all Together
Nu is het alleen nog een kwestie van prepend
deze module in onze Messenger
klasse te plaatsen en we zouden goed moeten zijn om te gaan:
En jeetje het werkt:
De implicaties hier zijn enorm voor auditing, maar er is meer aan deze truc. Je kunt prepending gebruiken om het gedrag van objecten te veranderen zonder de objecten zelf te veranderen. Dit is een aanpasbare en duidelijke manier om componenten van hogere orde te maken in Ruby. Prestatie testen, fout afhandeling. Je kunt hier veel doen.
Conclusie
Ruby modules zijn ingewikkelder dan de meeste mensen denken. Het is niet zo eenvoudig als het “dumpen” van code van de ene plaats naar de andere, en het begrijpen van die verschillen ontsluit een aantal echt leuke tools voor uw hulpriem.
Het is u misschien opgevallen dat ik het vandaag alleen over Module#include
en Module#prepend
heb gehad, en dat ik Module#extend
niet heb aangeraakt. Dat komt omdat het heel anders werkt dan zijn neven. Ik zal binnenkort een diepgaande uitleg van Module#extend
schrijven om de set compleet te maken.
Voor nu, als je meer wilt leren, raad ik je aan Ruby modules te lezen: Include vs Prepend vs Extend door Léonard Hetsch. Het was echt behulpzaam bij het samenstellen van dit alles.