Ieri dădeam un interviu pentru un post de dezvoltator Ruby la o companie din Denver, când intervievatorul meu mi-a pus o bună provocare de metaprogramare: mi-a cerut să scriu un auditor automat de metode.
El a descris o metodă de clasă care să ia numele unei metode de instanță și să genereze o urmă de hârtie automată, înregistrată, de fiecare dată când acea metodă țintă este apelată. Ceva de genul acesta:
Ceea ce ar produce o ieșire care ar arăta astfel:
Am venit cu o soluție care a funcționat, dar care a implicat niște hack-uri potențial periculoase pentru Object#send
. Înainte de a-mi prezenta soluția, am spus „probabil că aceasta este o idee proastă, dar…” și am avut dreptate. Ideile bune rareori încep așa.
Soluția pe care a propus-o a implicat folosirea deseori trecută cu vederea Module#prepend
și am crezut că este suficient de mișto pentru a justifica o prezentare.
Strategia a fost de a folosi comportamentul lui Module#prepend
pentru a crea o metodă de înfășurare generată dinamic pentru Messenger#share
.
În acel wrapper, vom avea un mesaj „Performing” care se va declanșa înainte de executarea corpului metodei, execuția propriu-zisă a metodei și apoi un mesaj final „Exiting” odată ce execuția metodei s-a încheiat cu succes.
Dar pentru a înțelege cum să folosim Module#prepend
pentru a face acest lucru, avem nevoie mai întâi de o bună înțelegere a modului în care funcționează moștenirea metodelor în Ruby.
Ancestor Chains
Care obiect Ruby se află la capătul a ceea ce se numește un Ancestor Chain. Acesta este o listă a obiectelor strămoșești de la care obiectul moștenește. Ruby folosește acest lanț al strămoșilor pentru a determina ce versiune a unei metode (dacă există) este executată atunci când obiectul primește un mesaj.
De fapt, puteți vizualiza arborele strămoșilor pentru orice obiect prin apelarea Module#ancestors
. De exemplu, iată lanțul strămoșilor pentru clasa noastră Messenger
:
Când #include
sau #prepend
un modul într-o clasă, Ruby face schimbări în lanțul strămoșilor acelei clase, modificând ce metode sunt găsite și în ce ordine. Marea diferență între #include
și #prepend
este unde se face această modificare. Să creăm un modul numit Auditable
care va conține (în cele din urmă) codul care face magia auditului nostru:
Dacă folosim Module#include
pentru a importa metodele (în prezent inexistente) din Auditable
, Ruby va strecura acel modul ca strămoș al clasei noastre Messenger
. Aici, vedeți cu ochii voștri:
Când apelăm o metodă din Messenger
, Ruby se va uita la ceea ce este definit în clasa Messenger
și – dacă nu găsește o metodă care să corespundă apelului – urcă în lanțul de strămoși până la Auditable
pentru a căuta din nou. Dacă nu găsește ceea ce caută la Auditable
, trece la Object
și așa mai departe.
Aceasta este #include
. Dacă în schimb folosim Module#prepend
pentru a importa conținutul modulului, obținem un efect total diferit:
#prepend
face din Messenger
un strămoș al lui Auditable
. Așteptați. Ce?
Aceasta ar putea părea că Messenger
este acum o superclasă a lui Auditable
, dar nu este exact ceea ce se întâmplă aici. Instanțele clasei Messenger
sunt tot instanțe ale clasei Messenger
, dar Ruby va căuta acum metode de utilizat pentru Messenger
în modulul Auditable
înainte de a le căuta în clasa Messenger
însăși.
Și de asta, prieteni, vom profita pentru a construi acest auditor: dacă vom crea o metodă numită Auditable#share
, Ruby o va găsi înainte de a o găsi pe Messenger#share
. Putem folosi apoi un apel super
în Auditable#share
pentru a accesa (și executa!) metoda originală definită pe Messenger
.
Modulul în sine
Nu vom crea de fapt o metodă numită Auditable#share
. De ce nu? Pentru că dorim ca acesta să fie un utilitar flexibil. Dacă vom codifica în forță metoda Auditable#share
, vom putea să o folosim doar pe metodele care implementează metoda #share
. Sau, mai rău, va trebui să reimplementăm același model de auditor pentru fiecare metodă pe care vrem să o auditam. Nu, mulțumesc.
Așa că, în schimb, vom defini metoda noastră în mod dinamic și metoda clasei audit_method
pentru a o declanșa:
Când audit_method
este apelată într-o clasă care o implementează, se creează o metodă în modulul Auditable
cu același nume ca și metoda de auditat. În cazul nostru, se va crea o metodă numită Auditable#share
. După cum am menționat, Ruby va găsi această metodă înainte de a găsi metoda originală de pe Messenger
, deoarece preapreciem modulul Auditable
în clasa de implementare.
Acest lucru înseamnă că putem folosi un apel super pentru a ajunge în susul lanțului de strămoși și a executa Messenger#send
. Când facem acest lucru, transmitem și argumentele pe care le-am colectat (*arguments
) în susul lanțului.
După ce am apelat metoda originală, tipărim mesajul nostru de ieșire și ne încheiem ziua. Bună treabă, gașcă!
Consumând totul
Acum este doar o chestiune de a prepend
a introduce acest modul în clasa noastră Messenger
și ar trebui să putem începe:
Și, Doamne ferește, funcționează:
Implicațiile de aici sunt uriașe pentru audit, dar există mai multe lucruri în acest truc. Puteți folosi prepending-ul pentru a schimba comportamentul obiectelor fără a schimba obiectele în sine. Acesta este un mod adaptabil și clar de a crea componente de ordin superior în Ruby. Testarea performanței, gestionarea erorilor. Puteți face multe aici.
Concluzie
Modulele Ruby sunt mai complicate decât cred majoritatea oamenilor. Nu este la fel de simplu ca și cum ai „arunca” codul dintr-un loc în altul, iar înțelegerea acestor diferențe deblochează niște instrumente foarte bune pentru centura ta de utilități.
Ați observat poate că astăzi am vorbit doar despre Module#include
și Module#prepend
și că nu am atins Module#extend
. Acest lucru se datorează faptului că funcționează foarte diferit față de verii săi. Voi scrie în curând o explicație în profunzime despre Module#extend
pentru a completa setul.
Pentru moment, dacă doriți să aflați mai multe vă recomand să citiți modulele Ruby: Include vs Prepend vs Extend de Léonard Hetsch. A fost foarte utilă pentru a pune toate acestea cap la cap.
.