La metaprogrammazione di Ruby è ancora più interessante di quanto sembri

Pubblicato: 2022-03-11

Si sente spesso dire che la metaprogrammazione è qualcosa che usano solo i ninja di Ruby e che semplicemente non è per i comuni mortali. Ma la verità è che la metaprogrammazione non è affatto qualcosa di spaventoso. Questo post sul blog servirà a sfidare questo tipo di pensiero e ad avvicinare la metaprogrammazione allo sviluppatore Ruby medio in modo che anche loro possano trarne vantaggio.

Metaprogrammazione Ruby: codice di scrittura del codice
Twitta

Va notato che la metaprogrammazione potrebbe significare molto e spesso può essere molto usata in modo improprio e andare all'estremo quando si tratta di utilizzo, quindi cercherò di inserire alcuni esempi del mondo reale che tutti potrebbero usare nella programmazione quotidiana.

Metaprogrammazione

La metaprogrammazione è una tecnica mediante la quale è possibile scrivere codice che scrive codice da solo in modo dinamico in fase di esecuzione. Ciò significa che puoi definire metodi e classi durante il runtime. Pazzo, vero? In poche parole, usando la metaprogrammazione puoi riaprire e modificare classi, catturare metodi che non esistono e crearli al volo, creare codice che sia DRY evitando ripetizioni e altro ancora.

Le basi

Prima di immergerci in una seria metaprogrammazione, dobbiamo esplorare le basi. E il modo migliore per farlo è l'esempio. Iniziamo con uno e comprendiamo passo dopo passo la metaprogrammazione di Ruby. Probabilmente puoi indovinare cosa sta facendo questo codice:

 class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" end end

Abbiamo definito una classe con due metodi. Il primo metodo in questa classe è un metodo di classe e il secondo è un metodo di istanza. Questa è roba di base in Ruby, ma c'è molto di più dietro questo codice che dobbiamo capire prima di procedere oltre. Vale la pena sottolineare che la stessa classe Developer è in realtà un oggetto. In Ruby tutto è un oggetto, comprese le classi. Poiché Developer è un'istanza, è un'istanza della classe Class . Ecco come appare il modello a oggetti Ruby:

Modello a oggetti Ruby

 p Developer.class # Class p Class.superclass # Module p Module.superclass # Object p Object.superclass # BasicObject

Una cosa importante da capire qui è il significato di self . Il metodo frontend è un metodo normale disponibile su istanze della classe Developer , ma perché il metodo backend è un metodo di classe? Ogni pezzo di codice eseguito in Ruby viene eseguito contro un particolare . Quando l'interprete Ruby esegue qualsiasi codice, tiene sempre traccia del valore self per una determinata riga. self si riferisce sempre a qualche oggetto ma quell'oggetto può cambiare in base al codice eseguito. Ad esempio, all'interno di una definizione di classe, self si riferisce alla classe stessa che è un'istanza di class Class .

 class Developer p self end # Developer

All'interno dei metodi di istanza, self si riferisce a un'istanza della classe.

 class Developer def frontend self end end p Developer.new.frontend # #<Developer:0x2c8a148>

All'interno dei metodi di classe, self si riferisce alla classe stessa in un modo (che sarà discusso in modo più dettagliato più avanti in questo articolo):

 class Developer def self.backend self end end p Developer.backend # Developer

Va bene, ma dopotutto cos'è un metodo di classe? Prima di rispondere a questa domanda dobbiamo menzionare l'esistenza di qualcosa chiamato metaclasse, noto anche come classe singleton ed autoclasse. Il frontend del metodo di classe che abbiamo definito in precedenza non è altro che un metodo di istanza definito nella metaclasse per l'oggetto Developer ! Una metaclasse è essenzialmente una classe che Ruby crea e inserisce nella gerarchia di ereditarietà per contenere i metodi di classe, senza interferire quindi con le istanze create dalla classe.

Metaclassi

Ogni oggetto in Ruby ha la sua metaclasse. È in qualche modo invisibile per uno sviluppatore, ma è lì e puoi usarlo molto facilmente. Poiché la nostra classe Developer è essenzialmente un oggetto, ha la sua metaclasse. Ad esempio creiamo un oggetto di una classe String e manipoliamo la sua metaclasse:

 example = "I'm a string object" def example.something self.upcase end p example.something # I'M A STRING OBJECT

Quello che abbiamo fatto qui è che abbiamo aggiunto un metodo singleton a something oggetto. La differenza tra metodi di classe e metodi singleton è che i metodi di classe sono disponibili per tutte le istanze di un oggetto di classe mentre i metodi singleton sono disponibili solo per quella singola istanza. I metodi di classe sono ampiamente utilizzati mentre i metodi singleton non così tanto, ma entrambi i tipi di metodi vengono aggiunti a una metaclasse di quell'oggetto.

L'esempio precedente potrebbe essere riscritto in questo modo:

 example = "I'm a string object" class << example def something self.upcase end end

La sintassi è diversa ma effettivamente fa la stessa cosa. Ora torniamo all'esempio precedente in cui abbiamo creato la classe Developer ed esploriamo alcune altre sintassi per definire un metodo di classe:

 class Developer def self.backend "I am backend developer" end end

Questa è una definizione di base che usano quasi tutti.

 def Developer.backend "I am backend developer" end

Questa è la stessa cosa, stiamo definendo il metodo della classe backend per Developer . Non abbiamo usato self , ma definire un metodo come questo lo rende effettivamente un metodo di classe.

 class Developer class << self def backend "I am backend developer" end end end

Ancora una volta, stiamo definendo un metodo di classe, ma usando una sintassi simile a quella usata per definire un metodo singleton per un oggetto String . Potresti notare che qui abbiamo usato self che si riferisce a un oggetto Developer stesso. Per prima cosa abbiamo aperto la classe Developer , rendendo self uguale alla classe Developer . Successivamente, eseguiamo class << self , rendendo self uguale alla metaclasse dello Developer . Quindi definiamo un metodo backend sulla metaclasse dello Developer .

 class << Developer def backend "I am backend developer" end end

Definendo un blocco come questo, ci stiamo impostando self metaclasse dello Developer per la durata del blocco. Di conseguenza, il metodo backend -end viene aggiunto alla metaclasse dello Developer , anziché alla classe stessa.

Vediamo come si comporta questa metaclasse nell'albero dell'ereditarietà:

Metaclasse nell'albero dell'ereditarietà

Come hai visto negli esempi precedenti, non c'è alcuna prova reale che la metaclasse esista. Ma possiamo usare un piccolo trucco che può mostrarci l'esistenza di questa classe invisibile:

 class Object def metaclass_example class << self self end end end

Se definiamo un metodo di istanza nella classe Object (sì, possiamo riaprire qualsiasi classe in qualsiasi momento, questa è l'ennesima bellezza della metaprogrammazione), avremo un self che si riferisce all'oggetto Object al suo interno. Possiamo quindi usare la sintassi class << self per cambiare il corrente in modo che punti alla metaclasse dell'oggetto corrente. Poiché l'oggetto corrente è la classe Object stessa, questa sarebbe la metaclasse dell'istanza. Il metodo restituisce self che a questo punto è una metaclasse stessa. Quindi chiamando questo metodo di istanza su qualsiasi oggetto possiamo ottenere una metaclasse di quell'oggetto. Definiamo nuovamente la nostra classe Developer e iniziamo ad esplorare un po':

 class Developer def frontend p "inside instance method, self is: " + self.to_s end class << self def backend p "inside class method, self is: " + self.to_s end end end developer = Developer.new developer.frontend # "inside instance method, self is: #<Developer:0x2ced3b8>" Developer.backend # "inside class method, self is: Developer" p "inside metaclass, self is: " + developer.metaclass_example.to_s # "inside metaclass, self is: #<Class:#<Developer:0x2ced3b8>>"

E per il crescendo, vediamo la prova che frontend è un metodo di istanza di una classe e backend è un metodo di istanza di una metaclasse:

 p developer.class.instance_methods false # [:frontend] p developer.class.metaclass_example.instance_methods false # [:backend]

Tuttavia, per ottenere la metaclasse non è necessario riaprire effettivamente Object e aggiungere questo hack. Puoi usare singleton_class fornito da Ruby. È lo stesso di metaclass_example che abbiamo aggiunto, ma con questo hack puoi effettivamente vedere come funziona Ruby sotto il cofano:

 p developer.class.singleton_class.instance_methods false # [:backend]

Definizione di metodi utilizzando "class_eval" e "instance_eval"

C'è un altro modo per creare un metodo di classe, e cioè usando instance_eval :

 class Developer end Developer.instance_eval do p "instance_eval - self is: " + self.to_s def backend p "inside a method self is: " + self.to_s end end # "instance_eval - self is: Developer" Developer.backend # "inside a method self is: Developer"

Questo pezzo di codice che l'interprete Ruby valuta nel contesto di un'istanza, che in questo caso è un oggetto Developer . E quando stai definendo un metodo su un oggetto, stai creando un metodo di classe o un metodo singleton. In questo caso si tratta di un metodo di classe - per essere esatti, i metodi di classe sono metodi singleton ma metodi singleton di una classe, mentre gli altri sono metodi singleton di un oggetto.

D'altra parte, class_eval valuta il codice nel contesto di una classe anziché di un'istanza. Praticamente riapre la classe. Ecco come è possibile utilizzare class_eval per creare un metodo di istanza:

 Developer.class_eval do p "class_eval - self is: " + self.to_s def frontend p "inside a method self is: " + self.to_s end end # "class_eval - self is: Developer" p developer = Developer.new # #<Developer:0x2c5d640> developer.frontend # "inside a method self is: #<Developer:0x2c5d640>"

Per riassumere, quando chiami il metodo class_eval , cambi self per fare riferimento alla classe originale e quando chiami instance_eval , self cambia per fare riferimento alla metaclasse della classe originale.

Definire al volo i metodi mancanti

Un altro pezzo del puzzle di metaprogrammazione è method_missing . Quando chiami un metodo su un oggetto, Ruby entra prima nella classe e sfoglia i suoi metodi di istanza. Se non trova il metodo lì, continua la ricerca nella catena degli antenati. Se Ruby continua a non trovare il metodo, chiama un altro metodo chiamato method_missing che è un metodo di istanza del Kernel che ogni oggetto eredita. Poiché siamo sicuri che Ruby chiamerà questo metodo alla fine per i metodi mancanti, possiamo usarlo per implementare alcuni trucchi.

define_method è un metodo definito nella classe Module che puoi usare per creare metodi in modo dinamico. Per utilizzare define_method , lo chiami con il nome del nuovo metodo e un blocco in cui i parametri del blocco diventano i parametri del nuovo metodo. Qual è la differenza tra l'utilizzo di def per creare un metodo e define_method ? Non c'è molta differenza tranne che puoi usare define_method in combinazione con method_missing per scrivere codice DRY. Per essere esatti, puoi usare define_method invece di def per manipolare gli ambiti quando definisci una classe, ma questa è tutta un'altra storia. Diamo un'occhiata a un semplice esempio:

 class Developer define_method :frontend do |*my_arg| my_arg.inject(1, :*) end class << self def create_backend singleton_class.send(:define_method, "backend") do "Born from the ashes!" end end end end developer = Developer.new p developer.frontend(2, 5, 10) # => 100 p Developer.backend # undefined method 'backend' for Developer:Class (NoMethodError) Developer.create_backend p Developer.backend # "Born from the ashes!"

Questo mostra come define_method è stato utilizzato per creare un metodo di istanza senza utilizzare un def . Tuttavia, c'è molto di più che possiamo fare con loro. Diamo un'occhiata a questo frammento di codice:

 class Developer def coding_frontend p "writing frontend" end def coding_backend p "writing backend" end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"

Questo codice non è DRY, ma usando define_method possiamo renderlo DRY:

 class Developer ["frontend", "backend"].each do |method| define_method "coding_#{method}" do p "writing " + method.to_s end end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"

È molto meglio, ma non è ancora perfetto. Come mai? Ad esempio, se vogliamo aggiungere un nuovo metodo coding_debug , dobbiamo inserire questo "debug" nell'array. Ma usando method_missing possiamo risolvere questo problema:

 class Developer def method_missing method, *args, &block return super method, *args, &block unless method.to_s =~ /^coding_\w+/ self.class.send(:define_method, method) do p "writing " + method.to_s.gsub(/^coding_/, '').to_s end self.send method, *args, &block end end developer = Developer.new developer.coding_frontend developer.coding_backend developer.coding_debug

Questo pezzo di codice è un po' complicato, quindi analizziamolo. La chiamata a un metodo che non esiste avvierà method_missing . Qui, vogliamo creare un nuovo metodo solo quando il nome del metodo inizia con "coding_" . Altrimenti chiamiamo semplicemente super per fare il lavoro di segnalazione di un metodo che effettivamente manca. E stiamo semplicemente usando define_method per creare quel nuovo metodo. Questo è tutto! Con questo pezzo di codice possiamo creare letteralmente migliaia di nuovi metodi che iniziano con "coding_" , e questo è ciò che rende il nostro codice DRY. Poiché define_method sembra essere privato per Module , dobbiamo usare send per invocarlo.

Avvolgendo

Questa è solo la punta dell'iceberg. Per diventare un Ruby Jedi, questo è il punto di partenza. Dopo aver padroneggiato questi elementi costitutivi della metaprogrammazione e averne compreso veramente l'essenza, puoi procedere a qualcosa di più complesso, ad esempio creare il tuo linguaggio specifico del dominio (DSL). DSL è un argomento in sé, ma questi concetti di base sono un prerequisito per comprendere argomenti avanzati. Alcune delle gemme più utilizzate in Rails sono state costruite in questo modo e probabilmente hai usato la sua DSL senza nemmeno saperlo, come RSpec e ActiveRecord.

Si spera che questo articolo possa avvicinarti di un passo alla comprensione della metaprogrammazione e forse anche alla creazione della tua DSL, che puoi utilizzare per codificare in modo più efficiente.