I går intervjuade jag en Ruby-utvecklare på ett företag här i Denver när min intervjuare gav mig en bra metaprogrammeringsutmaning: han bad mig skriva en automatiserad metodrevisor.
Han beskrev en klassmetod som skulle ta namnet på en instansmetod och generera en automatisk, loggad pappersspårning varje gång som målmetoden kallas. Något i stil med detta:
Det skulle ge utdata som såg ut så här:
Jag kom fram till en lösning som fungerade men som innebar några potentiellt farliga hackningar av Object#send
. Innan jag presenterade min lösning sa jag ”detta är förmodligen en dålig idé, men…” och jag hade rätt. Bra idéer börjar sällan på det sättet.
Lösningen som han föreslog innebar att man använde den ofta förbisedda Module#prepend
och jag tyckte att den var tillräckligt häftig för att motivera en artikel.
Strategin gick ut på att använda beteendet hos Module#prepend
för att skapa en dynamiskt genererad omslagsmetod för Messenger#share
.
I denna wrapper har vi ett ”Performing”-meddelande som avfyras innan metodkroppen exekveras, själva utförandet av metoden och sedan ett sista ”Exiting”-meddelande när utförandet av metoden har slutförts framgångsrikt.
Men för att förstå hur vi ska använda Module#prepend
för att göra detta behöver vi först ha en god förståelse för hur metodarv fungerar i Ruby.
Ancestor Chains
Varje Ruby-objekt sitter i slutet av vad som kallas en Ancestor Chain. Det är en lista över de anhörigobjekt som objektet ärver från. Ruby använder denna Ancestor Chain för att bestämma vilken version av en metod (om någon alls) som exekveras när objektet tar emot ett meddelande.
Du kan faktiskt se Ancestor Tree för vilket objekt som helst genom att ringa Module#ancestors
. Här är till exempel anförankringskedjan för vår Messenger
-klass:
När vi #include
eller #prepend
en modul i en klass gör Ruby ändringar i klassens anförankringskedja och justerar vilka metoder som hittas och i vilken ordning. Den stora skillnaden mellan #include
och #prepend
är var ändringen görs. Låt oss skapa en modul som heter Auditable
som (så småningom) kommer att innehålla koden som utför vår revisionsmagi:
Om vi använder Module#include
för att importera de (för närvarande obefintliga) metoderna från Auditable
, kommer Ruby att klämma in den modulen som en anfader till vår Messenger
-klass. Se själv:
När vi anropar en metod i Messenger
kommer Ruby att titta på vad som är definierat i Messenger
-klassen och – om den inte kan hitta en metod som matchar anropet – klättra uppåt i anförankringskedjan till Auditable
för att leta igen. Om den inte hittar det den söker på Auditable
går den vidare till Object
och så vidare.
Det är #include
. Om vi istället använder Module#prepend
för att importera modulens innehåll får vi en helt annan effekt:
#prepend
gör Messenger
till en förfader till Auditable
. Vänta. Vad?
Det kan se ut som om Messenger
nu är en överklass till Auditable
, men det är inte riktigt vad som händer här. Instanser av klassen Messenger
är fortfarande instanser av klassen Messenger
, men Ruby kommer nu att leta efter metoder att använda för Messenger
i modulen Auditable
innan den letar efter dem i själva klassen Messenger
.
Och det, mina vänner, är vad vi kommer att dra nytta av för att bygga den här revisorn: om vi skapar en metod som heter Auditable#share
, kommer Ruby att hitta den innan den hittar Messenger#share
. Vi kan sedan använda ett super
-anrop i Auditable#share
för att komma åt (och exekvera!) den ursprungliga metoden som definierats på Messenger
.
Modulen själv
Vi kommer faktiskt inte att skapa en metod som heter Auditable#share
. Varför inte? Därför att vi vill att detta ska vara ett flexibelt verktyg. Om vi hårdkodar metoden Auditable#share
kommer vi bara att kunna använda den på metoder som implementerar metoden #share
. Eller ännu värre, vi skulle vara tvungna att återimplementera samma granskningsmönster för varje metod som vi någonsin vill granska. Nej tack.
Så istället ska vi definiera vår metod dynamiskt och audit_method
-klassens metod för att avfyra den:
När audit_method
anropas i en implementerande klass skapas en metod i Auditable
-modulen med samma namn som den metod som ska granskas. I vårt fall skapas en metod som heter Auditable#share
. Som nämnts kommer Ruby att hitta den här metoden innan den hittar den ursprungliga metoden på Messenger
eftersom vi prependierar Auditable
-modulen i den implementerande klassen.
Det betyder att vi kan använda ett superanrop för att nå upp i anförankringskedjan och exekvera Messenger#send
. När vi gör det skickar vi vidare argumenten som vi har samlat in (*arguments
) uppåt i kedjan också.
När vi har anropat den ursprungliga metoden skriver vi ut vårt exitmeddelande och slutar för dagen. Bra jobbat!
Sammanfogning av allting
Nu är det bara en fråga om att prepend
lägga den här modulen till vår Messenger
klass och vi borde vara redo att köra:
Och herregud, det fungerar:
Den här implikationen är enorm för auditering, men det finns mer att göra med det här tricket. Du kan använda prepending för att ändra objektens beteende utan att ändra själva objekten. Detta är ett anpassningsbart och tydligt sätt att skapa komponenter av högre ordning i Ruby. Prestandatestning, felhantering. Du kan göra mycket här.
Slutsats
Ruby-moduler är mer komplicerade än vad de flesta tror. Det är inte så enkelt som att ”dumpa” kod från ett ställe till ett annat, och att förstå dessa skillnader låser upp några riktigt snygga verktyg för ditt verktygsbälte.
Du kanske har lagt märke till att jag bara pratade om Module#include
och Module#prepend
idag, och att jag inte berörde Module#extend
. Det beror på att den fungerar väldigt annorlunda än sina kusiner. Jag kommer snart att skriva en djupgående förklaring av Module#extend
för att komplettera uppsättningen.
För tillfället, om du vill lära dig mer rekommenderar jag att du läser Ruby-moduler: Include vs Prepend vs Extend av Léonard Hetsch. Den var verkligen till hjälp när jag satte ihop allt detta.