I går var jeg til samtale om en stilling som Ruby-udvikler hos en virksomhed her i Denver, da min interviewer stillede mig over for en god metaprogrammeringsudfordring: han bad mig skrive en automatiseret metodeauditor.
Han beskrev en klassemetode, der ville tage navnet på en instansmetode og generere et automatisk, logget papirspor, hver gang denne målmetode blev kaldt. Noget i stil med dette:
Det ville give et output, der så således ud:
Jeg kom med en løsning, der fungerede, men som involverede nogle potentielt farlige hacks til Object#send
. Inden jeg præsenterede min løsning, sagde jeg “det er nok en dårlig idé, men…”, og jeg havde ret. Gode ideer starter sjældent på den måde.
Løsningen, som han foreslog, involverede brug af den ofte oversete Module#prepend
, og jeg syntes, at den var fed nok til at berettige en skrivning.
Strategien var at bruge adfærden i Module#prepend
til at skabe en dynamisk genereret wrappermetode til Messenger#share
.
I denne wrapper vil vi have en “Performing”-meddelelse, der affyres, før metodens krop udføres, selve udførelsen af metoden og derefter en endelig “Exiting”-meddelelse, når metodens udførelse er afsluttet med succes.
Men for at forstå, hvordan vi kan bruge Module#prepend
til at gøre dette, skal vi først have en god forståelse af, hvordan metodearvning fungerer i Ruby.
Ancestor Chains
Hvert Ruby-objekt sidder for enden af det, der kaldes en Ancestor Chain. Det er en liste over de forfædreobjekter, som objektet arver fra. Ruby bruger denne Ancestor Chain til at bestemme, hvilken version af en metode (hvis der overhovedet er nogen) der udføres, når objektet modtager en meddelelse.
Du kan faktisk se ancestor-træet for et hvilket som helst objekt ved at kalde Module#ancestors
. Her er for eksempel ancestor-kæden for vores Messenger
-klasse:
Når vi #include
eller #prepend
et modul ind i en klasse, foretager Ruby ændringer i denne klasses ancestor-kæde og justerer, hvilke metoder der findes og i hvilken rækkefølge. Den store forskel mellem #include
og #prepend
er, hvor denne ændring foretages. Lad os oprette et modul kaldet Auditable
, som (i sidste ende) skal indeholde den kode, der udfører vores revisionsmagi:
Hvis vi bruger Module#include
til at importere de (i øjeblikket ikke-eksisterende) metoder fra Auditable
, vil Ruby klemme dette modul ind som en forfader til vores Messenger
-klasse. Se selv her:
Når vi kalder en metode på Messenger
, vil Ruby se på, hvad der er defineret i Messenger
-klassen, og – hvis den ikke kan finde en metode, der passer til kaldet – kravle op i ancestor-kæden til Auditable
for at kigge igen. Hvis den ikke finder det, den søger på Auditable
, går den videre til Object
og så videre.
Det er #include
. Hvis vi i stedet bruger Module#prepend
til at importere indholdet af modulet, får vi en helt anden effekt:
#prepend
gør Messenger
til en forfader til Auditable
. Vent. Hvad?
Det kunne se ud som om, at Messenger
nu er en overklasse til Auditable
, men det er ikke helt det, der sker her. Instanser af klassen Messenger
er stadig instanser af klassen Messenger
, men Ruby vil nu søge efter metoder til brug for Messenger
i modulet Auditable
, før den leder efter dem på selve klassen Messenger
.
Og det, venner, er det, vi skal udnytte til at bygge denne auditor: Hvis vi opretter en metode kaldet Auditable#share
, vil Ruby finde den, før den finder Messenger#share
. Vi kan derefter bruge et super
-opkald i Auditable#share
til at få adgang til (og udføre!) den oprindelige metode, der er defineret på Messenger
.
Modulet selv
Vi vil faktisk ikke oprette en metode kaldet Auditable#share
. Hvorfor ikke? Fordi vi ønsker, at dette skal være et fleksibelt hjælpeprogram. Hvis vi hardcode metoden Auditable#share
, vil vi kun kunne bruge den på metoder, der implementerer #share
-metoden. Eller endnu værre, vi ville være nødt til at genimplementere det samme auditormønster for hver eneste metode, vi nogensinde vil auditere. Nej tak.
Så i stedet vil vi definere vores metode dynamisk og audit_method
-klassens metode til at affyre den:
Når audit_method
kaldes i en implementerende klasse, oprettes der en metode i Auditable
-modulet med samme navn som den metode, der skal auditeres. I vores tilfælde oprettes der en metode, der hedder Auditable#share
. Som nævnt vil Ruby finde denne metode, før den finder den oprindelige metode på Messenger
, fordi vi sætter Auditable
-modulet i den implementerende klasse i forvejen.
Det betyder, at vi kan bruge et superkald til at nå op i ancestor-kæden og udføre Messenger#send
. Når vi gør det, sender vi også de argumenter, vi har indsamlet (*arguments
), opad i kæden.
Når vi har kaldt den oprindelige metode, udskriver vi vores exit-meddelelse, og så er det slut. Godt arbejde, bande!
Samler det hele sammen
Nu er det bare et spørgsmål om at prepend
føje dette modul til vores Messenger
klasse, og så skulle vi være klar til at gå i gang:
Og god jøsses, det virker:
Det har store konsekvenser for auditering, men der er mere i dette trick. Du kan bruge prepending til at ændre objekters adfærd uden at ændre selve objekterne. Dette er en tilpasningsdygtig og overskuelig måde at skabe komponenter af højere orden i Ruby. Test af ydeevne, fejlhåndtering. Du kan gøre en masse her.
Slutning
Ruby-moduler er mere komplicerede, end de fleste tror. Det er ikke så simpelt som at “dumpe” kode fra et sted til et andet, og hvis du forstår disse forskelle, kan du låse op for nogle virkelig smarte værktøjer til dit værktøjsbælte.
Du har måske bemærket, at jeg kun har talt om Module#include
og Module#prepend
i dag, og at jeg ikke har berørt Module#extend
. Det skyldes, at den fungerer meget anderledes end sine fætre og kusiner. Jeg vil snart skrive en uddybende forklaring på Module#extend
for at fuldende sættet.
For nu vil jeg anbefale dig at læse Ruby-moduler, hvis du vil lære mere: Include vs Prepend vs Extend af Léonard Hetsch. Den var virkelig nyttig for at sætte alt dette sammen.