Commenti

Ruby: variabili di classe ereditarie e class_inheritable_accessor

Oggi sono capitato di fronte ad un pattern interessante, immagino molto frequente nel caso di scrittura di plugin per Rails.

Immaginiamo, a scopo esemplificativo, di voler scrivere un semplice plugin Rails in grado di permettere questo tipo di chiamata all'interno dei tuoi modelli:

app/models/document.rb
1 class Document < ActiveRecord::Base
2   special_attribute :title
3   special_attribute :description
4 end

La chiamata a special_attribute supponiamo faccia miracoli ai due attributi :title e :description del modello, ma andiamo oltre. Supponiamo che plugin debba anche essere in grado di restituire tutti gli attributi "speciali" tramite un metodo special_attributes:

1 Document.special_attributes
2 # => [ :title, :description ]

Come lo implementereste? Io farei qualcosa del genere:

config/initializers/special_attributes.rb
 1 module SpecialAttributesPlugin
 2 
 3   def special_attribute(attribute)
 4     @@special_attributes ||= []
 5     @@special_attributes << attribute
 6   end
 7 
 8   def special_attributes
 9     @@special_attributes ||= []
10   end
11 
12 end
13 
14 # aggiungo il modulo ad ActiveRecord::Base
15 module ActiveRecord
16   class Base
17     extend SpecialAttributesPlugin::ActiveRecordAdapter
18   end
19 end

In questo modo il risultato effettivamente è quello sperato! Cosa abbiamo fatto? Si è usata una variabile di classe chiamata @@special_attributes per memorizzare i vari campi.

Attenzione però, se complichiamo un po' il caso d'uso, ci troviamo di fronte ad comportamento inaspettato. Supponiamo che ci siano delle sottoclassi di Document, per esempio SpreadsheetDocument e TextDocument. Saremmo in questo caso davanti ad una STI (Single Table Inheritance) --- caso tutt'altro che raro nella realtà. Vediamo se tutto funziona ancora come ci aspettiamo:

app/models/spreadsheet_document.rb
1 class SpreadsheetDocument < Document
2   special_attribute :author
3 end
4 
5 SpreadsheetDocument.special_attributes
6 # => [ :title, :description, :author ]

Fin qui tutto bene! Aggiungiamo TextDocument ora:

app/models/spreadsheet_document.rb
1 class TextDocument < Document
2   special_attribute :page_count
3 end
4 
5 SpreadsheetDocument.special_attributes
6 # => [ :title, :description, :author, :page_count ]

Ahia, ecco il problema: :page_count è ovviamente l'inaspettato intruso, non è un attributo di SpreadsheetDocument. La spiegazione è semplice: una variabile di classe come @@special_attributes viene condivisa tra la classe padre e tutte le classi figlie. Non fa quindi al caso nostro. Proviamo con un'altra feature di Ruby: le variabili di istanza di classe.

Ruby è un linguaggio totalmente OOP, dunque tutto è un'oggetto, le classi non fanno eccezione. Possiamo fare in modo di aggiungere una variabile di istanza all'oggetto classe!

config/initializers/special_attributes.rb
 1 module SpecialAttributesPlugin
 2 
 3   def special_attribute(attribute)
 4     @special_attributes ||= []
 5     @special_attributes << attribute
 6   end
 7 
 8   def special_attributes
 9     @special_attributes ||= []
10   end
11 
12 end
13 
14 # aggiungo il modulo ad ActiveRecord::Base
15 module ActiveRecord
16   class Base
17     extend SpecialAttributesPlugin::ActiveRecordAdapter
18   end
19 end

Come si può notare, abbiamo fatto diventare @special_attributes una variabile dell'istanza. Ora riproviamo a testarne il funzionamento.

1 Document.special_attributes
2 # => [ :title, :description ] --> corretto!
3 
4 SpreadsheetDocument.special_attributes
5 # => [ :author ] --> errato...
6 
7 TextDocument.special_attributes
8 # => [ :page_count ] --> errato...

Ora effettivamente ognuna delle classi ha la sua variabile, differente da quella delle altre classi, ma le classi figlie non partono col valore della variabile della classe padre, Document! Sfortunatamente Ruby non possiede nulla semplice per ovviare a questo problema.. ma in qualche modo è comunque possibile arrivare al comportamento desiderato, in poche righe di codice:

config/initializers/special_attributes.rb
 1 module SpecialAttributesPlugin
 2 
 3   def special_attribute(attribute)
 4     @special_attributes ||= []
 5     @special_attributes << attribute
 6   end
 7 
 8   def special_attributes
 9     @special_attributes ||= []
10   end
11 
12   def inherited(subclass)
13     subclass.instance_variable_set "@special_attributes", special_attributes.dup
14   end
15 
16 end
17 
18 # aggiungo il modulo ad ActiveRecord::Base
19 module ActiveRecord
20   class Base
21     extend SpecialAttributesPlugin::ActiveRecordAdapter
22   end
23 end

Il giochetto è sfruttare la callback inherited(subclass), che viene chiamata quando viene generata una classe figlia. Agganciandoci a quest'evento, siamo in grado di inizializzare la variabile di istanza della classe figlia con quella della classe padre. Attezione però a non passare la medesima variabile al figlio, ma una copia, altrimenti padre e figli condivideranno il medesimo oggetto, con risultati non attesi.

Spero siate riusciti a seguirmi. Ora, per sentirci tutti meglio, arriviamo allo shortcut.. ActiveSupport estende di default l'oggetto Class per supportare il metodo class_inheritable_accessor. Possiamo riscrivere il plugin in questo modo, ottenendo il medesimo risultato:

config/initializers/special_attributes.rb
 1 module SpecialAttributesPlugin
 2 
 3   def special_attribute(attribute)
 4     class_inheritable_accessor :special_attributes
 5     self.special_attributes ||= []
 6     self.special_attributes << attribute
 7   end
 8 
 9 end
10 
11 # aggiungo il modulo ad ActiveRecord::Base
12 module ActiveRecord
13   class Base
14     extend SpecialAttributesPlugin::ActiveRecordAdapter
15   end
16 end

Meglio, no?