Se esploriamo Rails Lodge, uno dei più famosi repository di plugins per Rails, noteremo che una buona percentuale delle estensioni presentate hanno un titolo del tipo: 'Acts As ...'.
I primi due esempi di questo strano e originale "behavior" sono da ricercarsi in 'Acts As List' e 'Acts As Tree', all'epoca non propriamente plugins (ora lo sono diventati) ma parte integrante del core di Rails.
Il concetto base che sottende tutti questi prodotti, suggerito proprio dalla particella 'Acts As', (cioè 'Agisci Come'), è quello di 'trasformare' il funzionamento di uno (o alcuni) models della nostra applicazione facendoli agire come liste (nel caso di Acts As List), come valute (Acts As Currency), o come un qualiasi altro comportamento voluto, attraverso l'utilizzo di una 'macro' che deve essere inserita all'interno dell'oggetto interessato come nell'esempio sottostante:
class Todo < ActiveRecord::Base acts_as_list end
Nella pratica acts_as_list
è una funzione che il plugin inietta (attraverso il suo init.rb
) all'interno della classe ActiveRecord::Base
rendendola di fatto disponibile a tutti i models
.
Tale funzione ha il solo scopo di rendere disponibili, alla classe che la implementa, tutta una serie di metodi caratteristici di un particolare oggetto (ad esempio acts_as_list
aggiunge metodi quali higher_item
, move_lower
, etc.) nonché di modificare alcuni dei comportamenti classici di un model (ad esempio Acts As Paranoid sovrascrive il metodo destroy
della classe che lo implementa impedendo il cancellamento fisico di ogni suo record).
La chiave di questo funzionamento risiede nell'utilizzo sapiente dei Mixins, oggetto della prima sezione di questo articolo.
I Mixin
Matzumoto, creatore di Ruby, definisce i Mixins nel seguente modo: 'single inheritance with implementation sharing'. Nella pratica un Mixin è rappresentato semplicemente da un modulo che viene utilizzato come contenitore di metodi e successivamente 'mixxato' ad una o più classi in modo da rendere tali funzioni a loro disponibili.
La definizione di Matz riesce quindi a cogliere un aspetto essenziale di questa struttura; pur restando nel campo dell'ereditarietà singola utilizzando i Mixins è possibile condividere implementazioni tra più classi (simulando in questo modo alcuni aspetti dell'ereditarietà multipla).
module Gasolio def add_to_serbatoio(litri) [...] end def get_stato_serbatoio [...] end end module Ruota def get_momento_angolare [...] end end class Bicicletta include Ruota end class Aereo include Gasolio end class Macchina include Gasolio include Ruota end
In questo esempio i moduli Ruota e Gasolio contengono un set di istruzioni specifiche all'aspetto di cui trattano e ognuna delle tre classi incorpora i moduli che sono propedeutici al proprio funzionamento. L'istruzione include
rende le funzioni dichiarate all'interno del modulo richiesto disponibili a tutte le istanze dell'oggetto richiedente, quindi in questo caso potremmo eseguire:
mountain_bike = Bicicletta.new mountain_bike.get_momento_angolare
Quanto appena enunciato evidenzia come la definizione di Matz sia perfettamente aderente al concetto di Mixin che si dimostra essere un'ottima alternativa (anche se più limitata) all'ereditarietà multipla. Inoltre si cominciano ad intravedere delle somiglianze tra il funzionamento di questa implementazione e quello di un 'Acts_as' plugin; infatti l'istruzione include Gasolio
emula in parte lo stesso funzionamento delle 'acts_as' macro viste in precedenza.
Prima di passare alla sezione successiva è bene ricordare che oltre all'istruzione include
esiste un'istruzione chiamata extend
che aggiunge le funzioni presenti nel modulo come metodi di classe anziché di istanza.
Acts as Timed
Ora abbiamo tutti gli strumenti necessari per creare il nostro primo 'Acts_as' plugin, per dotare l'esempio di una qualche connotazione funzionale il plugin aggiungerà due metodi ad ogni model che lo invochi; il primo (give_me_the_time
), di classe, ritornerà la data e l'ora corrente mentre il secondo (is_this_record_very_old?
), di istanza, ritornerà true
se la colonna created_at
del record corrente è più vecchia di 5 giorni.
Ecco una lista step-by-step di ciò che stiamo per implementare:
- un modulo ActsAsTimed che dovrà contenere tutta la logica del plugin
- una funzione
acts_as_timed
che dovrà funzionare da 'appiglio' (hook) per tutti i models che desiderino utilizzare le funzionalità di questo plugin - un sotto-modulo con i metodi di classe che dovremo fornire alla classe che implementa questo plugin
- un sotto-modulo simile al precedente ma con i metodi di istanza
- un'istruzione per agganciare
ActsAsTimed
adActiveRecord::Base
Per prima cosa creiamo un'applicazione Rails
rails timed_sample cd timed_sample
Ora creiamo la struttura che conterrà il nostro plugin, per farlo utilizziamo uno dei 'generators' che Rails ci mette a disposizione:
ruby script/generate plugin acts_as_timed cd vendor/plugins/acts_as_timed/
Bene, ora concentriamoci sull'unico file presente nella cartella 'lib': acts_as_timed.rb. Questo file conterrà tutta lo logica applicativa del plugin che stiamo creando, apriamolo quindi con un editor di testi e scriviamo il seguente codice:
module ActsAsTimed def self.included(base) base.extend(BaseMethods) end module BaseMethods def acts_as_timed self.send :include,InstanceMethods self.send :extend,ClassMethods end end module ClassMethods end module InstanceMethods end end
Analizziamo quanto appena scritto: la prima funzione self.included(base)
viene chiamata automaticamente ogniqualvolta ActsAsTimed
venga incluso all'interno di una classe (con la variabile base
contenente la classe che ha invocato l'inclusione); all'interno di questo metodo c'è un'unica riga di codice base.extend(BaseMethods)
che, come visto in precedenza, aggiunge alla classe contenuta nella variabile base
i metodi del sotto-modulo BaseMethods
sotto forma di funzioni di classe (perché è stato usato extend
e non include
).
Quindi se a questo punto si dovesse includere ActsAsTimed
in ActiveRecord::Base
dovremmo trovare nell'elenco dei suoi metodi anche acts_as_timed
, proviamo:
ruby script/console >> ActiveRecord::Base.send :include, ActsAsTimed => #... >> ActiveRecord::Base.methods.include?("acts_as_timed") => true
Perfetto! A questo punto possiamo proseguire ed analizzare cosa succede all'interno del metodo acts_as_timed
. Le due righe di codice presenti utilizzano la stessa funzione send
dell'esempio appena illustrato, tale funzione ha l'unico scopo di invocare l'azione passata come primo parametro inviando a quest'ultima i parametri dal secondo in poi. Quindi il tutto potrebbe essere tradotto in:
self.include InstanceMethods self.extend ClassMethods
purtroppo però include
è un metodo privato e non può essere lanciato a meno di non trovarsi all'interno della classe stessa; send
serve solamente ad aggirare questo problema in quanto dà la possibilità di invocare qualsiasi metodo all'interno di una classe (privato o non). Lo scopo delle due righe di codice appena trattate dovrebbe essere oramai abbastanza chiaro: la prima include nella classe che ha invocato acts_as_timed
tutte le funzioni presenti nel sotto-modulo InstanceMethods
come metodi di istanza, la seconda fa la stessa cosa con il modulo ClassMethods
utilizzando però la funzione extend (quindi i metodi all'interno di ClassMethods verranno resi disponibili come metodi di classe).
Non ci resta ora che popolare questi due sotto-moduli con i metodi give_me_the_time
e is_this_record_very_old?
; ecco come si dovrebbe presentare il file completo:
module ActsAsTimed def self.included(base) base.extend(BaseMethods) end module BaseMethods def acts_as_timed self.send :include,InstanceMethods self.send :extend,ClassMethods end end module ClassMethods def give_me_the_time Time.new end end module InstanceMethods def is_this_record_very_old? raise "Column created_at not found" unless self.class.columns_hash.keys.include?("created_at") raise "Column created_at not initialized" if created_at.nil? created_at < Time.new - 5.days end end end
Adesso dobbiamo solo fare in modo che ActsAsTimed
venga incluso automaticamente all'interno di ActiveRecord::Base
, per farlo dobbiamo aggiungere la seguente istruzione nel file init.rb
che si trova nella cartella principale del plugin:
ActiveRecord::Base.send :include, ActsAsTimed
Infine testiamo quanto abbiamo realizzato (per farlo dobbiamo prima creare un model):
ruby script/generate model order name:string rake db:create rake db:migrate
Ora inseriamo la macro acts_as_timed
all'interno di Order
, per farlo editiamo come segue il file app/models/order.rb
:
class Order < ActiveRecord::Base acts_as_timed end
Non ci resta che eseguire la console della nostra applicazione per verificare il buon funzionamento del plugin:
ruby script/console >> Order.give_me_the_time => Sun Jan 25 23:12:26 +0000 2009 >> Order.create(:name=>'hello').is_this_record_very_old? => false
Conclusioni
Recentemente si sta ricominciando a parlare con interesse di Aspect Oriented Programming, cioè di quel paradigma di programmazione che tende ad associare ad ogni oggetto del sistema un certo numero di aspetti, dai quali questo oggetto è condizionato e dai quali riceve nuove e diverse funzionalità (ad esempio alcuni oggetti del sistema potrebbero adottare l'aspetto 'logging' e quindi loggare ogni chiamata ai propri metodi); in un certo senso questa classe di plugins avvicina Ruby a questo paradigma di programmazione, infatti se pensiamo che ognuno di questi plugin fa in modo che l'oggetto che lo implementa 'si comporti come ...' risultano evidenti alcune somiglianze tra questo comportamento e gli 'aspetti' dell'aspect oriented programming (l'aspetto logging appena accennato trova infatti la sua controparte in Acts As Scribe).