Ieri stavo facendo un colloquio per una posizione di sviluppatore Ruby in un’azienda qui a Denver quando il mio intervistatore mi ha posto una bella sfida di metaprogrammazione: mi ha chiesto di scrivere un revisore automatico di metodi.
Ha descritto un metodo di classe che avrebbe preso il nome di un metodo di istanza e generato una traccia automatica e registrata ogni volta che quel metodo target veniva chiamato. Qualcosa come questo:
Che avrebbe prodotto un output simile a questo:
Ho trovato una soluzione che ha funzionato ma ha coinvolto alcuni hack potenzialmente pericolosi per Object#send
. Prima di presentare la mia soluzione, ho detto “questa è probabilmente una cattiva idea, ma…” e avevo ragione. Le buone idee raramente iniziano in questo modo.
La soluzione che ha proposto ha coinvolto l’uso del spesso trascurato Module#prepend
e ho pensato che fosse abbastanza figo da giustificare un articolo.
La strategia era di usare il comportamento di Module#prepend
per creare un metodo wrapper generato dinamicamente per Messenger#share
.
In questo wrapper, avremo un messaggio “Performing” prima che il corpo del metodo venga eseguito, l’esecuzione del metodo stesso, e poi un messaggio finale “Exiting” una volta che l’esecuzione del metodo è terminata con successo.
Ma per capire come usare Module#prepend
per fare questo, abbiamo prima bisogno di capire bene come funziona l’ereditarietà dei metodi in Ruby.
Catene di antenati
Ogni oggetto Ruby si trova alla fine di quella che è chiamata una catena di antenati. È una lista di oggetti antenati da cui l’oggetto eredita. Ruby usa questa catena di antenati per determinare quale versione di un metodo (se esiste) viene eseguita quando l’oggetto riceve un messaggio.
Puoi effettivamente vedere l’albero degli antenati per qualsiasi oggetto chiamando Module#ancestors
. Per esempio, ecco la catena degli antenati per la nostra classe Messenger
:
Quando inseriamo #include
o #prepend
un modulo in una classe, Ruby apporta dei cambiamenti alla catena degli antenati di quella classe, modificando quali metodi vengono trovati e in quale ordine. La grande differenza tra #include
e #prepend
è dove viene fatto questo cambiamento. Creiamo un modulo chiamato Auditable
che conterrà (eventualmente) il codice che fa la nostra magia di auditing:
Se usiamo Module#include
per importare i metodi (attualmente inesistenti) da Auditable
, Ruby infilerà quel modulo come antenato della nostra classe Messenger
. Ecco, guardate voi stessi:
Quando chiamiamo un metodo su Messenger
, Ruby guarderà cosa è definito nella classe Messenger
e – se non trova un metodo che corrisponde alla chiamata – risalirà la catena degli antenati fino a Auditable
per cercare di nuovo. Se non trova quello che sta cercando su Auditable
si sposta su Object
e così via.
Questo è #include
. Se invece usiamo Module#prepend
per importare il contenuto del modulo, otteniamo un effetto completamente diverso:
#prepend
rende Messenger
un antenato di Auditable
. Aspetta. Cosa?
Questo potrebbe sembrare che Messenger
sia ora una superclasse di Auditable
, ma non è esattamente quello che sta succedendo qui. Le istanze della classe Messenger
sono ancora istanze della classe Messenger
ma Ruby ora cercherà i metodi da usare per Messenger
nel modulo Auditable
prima di cercarli nella classe Messenger
stessa.
E questo, amici, è ciò di cui approfitteremo per costruire questo revisore: se creiamo un metodo chiamato Auditable#share
, Ruby lo troverà prima di trovare Messenger#share
. Possiamo quindi usare una chiamata super
in Auditable#share
per accedere (ed eseguire!) il metodo originale definito su Messenger
.
Il modulo stesso
In realtà non creeremo un metodo chiamato Auditable#share
. Perché no? Perché vogliamo che questa sia un’utilità flessibile. Se codifichiamo il metodo Auditable#share
, saremo in grado di usarlo solo sui metodi che implementano il metodo #share
. O peggio, dovremmo reimplementare questo stesso modello di auditor per ogni metodo che vogliamo controllare. No grazie.
Così, invece, definiremo il nostro metodo dinamicamente e il metodo di classe audit_method
per attivarlo:
Quando audit_method
viene chiamato in una classe implementante, viene creato un metodo nel modulo Auditable
con lo stesso nome del metodo da controllare. Nel nostro caso, verrà creato un metodo chiamato Auditable#share
. Come detto, Ruby troverà questo metodo prima di trovare il metodo originale su Messenger
perché stiamo anteponendo il modulo Auditable
nella classe implementatrice.
Questo significa che possiamo usare una super chiamata per risalire la catena degli antenati ed eseguire Messenger#send
. Quando lo facciamo, passiamo anche gli argomenti che abbiamo raccolto (*arguments
) su per la catena.
Una volta che abbiamo chiamato il metodo originale, stampiamo il nostro messaggio di uscita e la finiamo qui. Bel lavoro, ragazzi!
Collegando il tutto
Ora si tratta solo di prepend
aggiungere questo modulo alla nostra classe Messenger
e dovremmo essere pronti a partire:
E perbacco se funziona:
Le implicazioni qui sono enormi per l’auditing, ma c’è di più in questo trucco. Potete usare il prepending per cambiare il comportamento degli oggetti senza cambiare gli oggetti stessi. Questo è un modo adattabile e chiaro per creare componenti di ordine superiore in Ruby. Test di performance, gestione degli errori. Puoi fare molto qui.
Conclusione
I moduli Ruby sono più complicati di quanto si pensi. Non è semplice come “scaricare” il codice da un posto all’altro, e capire queste differenze sblocca alcuni strumenti davvero puliti per la tua cintura di utilità.
Potresti aver notato che ho parlato solo di Module#include
e Module#prepend
oggi, e che non ho toccato Module#extend
. Questo perché funziona in modo molto diverso dai suoi cugini. Scriverò presto una spiegazione approfondita di Module#extend
per completare il set.
Per ora, se volete saperne di più vi consiglio di leggere Ruby modules: Include vs Prepend vs Extend di Léonard Hetsch. È stato molto utile per mettere insieme tutto questo.