Je hoort vaak dat metaprogrammeren iets is dat alleen Ruby ninja’s gebruiken, en dat het gewoon niet voor gewone stervelingen is. Maar de waarheid is dat metaprogrammeren helemaal niet iets engs is. Deze blog post zal dienen om deze manier van denken uit te dagen en om metaprogramming dichter bij de gemiddelde Ruby ontwikkelaar te brengen, zodat ook zij er de vruchten van kunnen plukken.
Het moet worden opgemerkt dat metaprogrammeren veel kan betekenen en het kan vaak erg verkeerd worden gebruikt en tot het uiterste gaan als het gaat om het gebruik, dus ik zal proberen er een paar voorbeelden uit de echte wereld in te gooien die iedereen zou kunnen gebruiken in het dagelijks programmeren.
Metaprogrammeren
Metaprogrammeren is een techniek waarmee je code kunt schrijven die zelf dynamisch code schrijft tijdens runtime. Dit betekent dat je methoden en klassen kunt definiëren tijdens runtime. Gek, toch? In een notendop, met behulp van metaprogramming kun je klassen heropenen en wijzigen, methoden vangen die niet bestaan en ze on the fly maken, code maken die DRY is door herhalingen te vermijden, en nog veel meer.
De basis
Voordat we in het serieuze metaprogramming duiken, moeten we de basis verkennen. En de beste manier om dat te doen is met voorbeelden. Laten we beginnen met een voorbeeld en Ruby metaprogrammeren stap voor stap begrijpen. Je kunt waarschijnlijk wel raden wat deze code doet:
class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" endend
We hebben een klasse gedefinieerd met twee methoden. De eerste methode in deze klasse is een klasse methode en de tweede is een instantie methode. Dit is basis spul in Ruby, maar er gebeurt veel meer achter deze code dat we moeten begrijpen voordat we verder gaan. Het is de moeite waard om erop te wijzen dat de klasse Developer
zelf eigenlijk een object is. In Ruby is alles een object, inclusief klassen. Omdat Developer
een instantie is, is het een instantie van de klasse Class
. Here is how the Ruby object model looks like:
p Developer.class # Classp Class.superclass # Modulep Module.superclass # Objectp Object.superclass # BasicObject
One important thing to understand here is the meaning of self
. The frontend
method is a regular method that is available on instances of class Developer
, but why is backend
method a class method? Every piece of code executed in Ruby is executed against a particular self. When the Ruby interpreter executes any code it always keeps track of the value self
for any given line. self
is always referring to some object but that object can change based on the code executed. For example, inside a class definition, the self
refers to the class itself which is an instance of class Class
.
class Developer p self end# Developer
Inside instance methods, self
refers to an instance of the class.
class Developer def frontend self endend p Developer.new.frontend# #<Developer:0x2c8a148>
In class methods, self
verwijst op een bepaalde manier naar de class zelf (die later in dit artikel uitgebreider aan de orde komt):
class Developer def self.backend self endendp Developer.backend# Developer
Dit is prima, maar wat is een class method nu eigenlijk? Alvorens die vraag te beantwoorden moeten we eerst wijzen op het bestaan van iets dat metaclass heet, ook bekend als singleton class en eigenclass. Klassenmethode frontend
die we eerder definieerden is niets anders dan een instance methode gedefinieerd in de metaclass voor het object Developer
! Een metaclass is in wezen een klasse die Ruby creëert en in de overervings-hiërarchie invoegt om methoden van de klasse te bevatten, en zo niet te interfereren met instanties die vanuit de klasse worden gecreëerd.
Metaclasses
Elk object in Ruby heeft zijn eigen metaclass. Het is op de een of andere manier onzichtbaar voor een ontwikkelaar, maar het is er en je kunt het heel gemakkelijk gebruiken. Omdat onze klasse Developer
in essentie een object is, heeft het zijn eigen metaclass. Laten we als voorbeeld een object van een klasse String
maken en zijn metaclass manipuleren:
example = "I'm a string object"def example.something self.upcaseendp example.something# I'M A STRING OBJECT
Wat we hier gedaan hebben is dat we een singleton methode something
aan een object hebben toegevoegd. Het verschil tussen klasse methodes en singleton methodes is dat klasse methodes beschikbaar zijn voor alle instanties van een klasse object terwijl singleton methodes alleen beschikbaar zijn voor die ene instantie. Klasse methoden worden veel gebruikt en singleton methoden niet zo veel, maar beide soorten methoden worden toegevoegd aan een metaklasse van dat object.
Het vorige voorbeeld zou als volgt herschreven kunnen worden:
example = "I'm a string object"class << example def something self.upcase endend
De syntax is anders maar het doet in feite hetzelfde. Laten we nu teruggaan naar het vorige voorbeeld waar we Developer
class hebben gemaakt en enkele andere syntaxen verkennen om een class methode te definiëren:
class Developer def self.backend "I am backend developer" endend
Dit is een basisdefinitie die bijna iedereen gebruikt.
def Developer.backend "I am backend developer"end
This is the same thing, we are defining the backend
class method for Developer
. We didn’t use self
but defining a method like this effectively makes it a class method.
class Developer class << self def backend "I am backend developer" end endend
Again, we are defining a class method, but using syntax similar to one we used to define a singleton method for a String
object. You may notice that we used self
here which refers to a Developer
object itself. First we opened Developer
class, making self equal to the Developer
class. Next, we do class << self
, making self equal to Developer
’s metaclass. Then we define a method backend
on Developer
’s metaclass.
class << Developer def backend "I am backend developer" endend
By defining a block like this, we are setting self
to Developer
’s metaclass for the duration of the block. As a result, the backend
method is added to Developer
’s metaclass, rather than the class itself.
Let’s see how this metaclass behaves in the inheritance tree:
As you saw in previous examples, there’s no real proof that metaclass even exists. But we can use a little hack that can show us the existence of this invisible class:
class Object def metaclass_example class << self self end endend
If we define an instance method in Object
class (yes, we can reopen any class anytime, that’s yet another beauty of metaprogramming), we will have a self
referring to the Object
object inside it. We kunnen dan de class << self
syntax gebruiken om de huidige zelf te veranderen om naar de metaklasse van het huidige object te verwijzen. Aangezien het huidige object zelf Object
klasse is, zou dit de metaklasse van de instantie zijn. De methode retourneert self
dat op dit punt zelf een metaklasse is. Dus door deze instance methode op een object aan te roepen kunnen we een metaclass van dat object krijgen. Laten we onze Developer
klasse opnieuw definiëren en wat gaan verkennen:
En voor het crescendo, laten we het bewijs zien dat frontend
een instantie methode van een klasse is en backend
een instantie methode van een metaklasse is:
Hoewel, om de metaklasse te krijgen hoeft u Object
niet daadwerkelijk te heropenen en deze hack toe te voegen. U kunt singleton_class
gebruiken dat Ruby biedt. Het is hetzelfde als metaclass_example
dat we hebben toegevoegd, maar met deze hack kunt u zien hoe Ruby onder de motorkap werkt:
p developer.class.singleton_class.instance_methods false#
Methoden definiëren met “class_eval” en “instance_eval”
Er is nog een manier om een methode voor een klasse te maken, en dat is door instance_eval
te gebruiken:
Dit stukje code evalueert Ruby interpreter in de context van een instantie, die in dit geval een Developer
object is. En wanneer je een methode op een object definieert, creëer je ofwel een klassemethode of een singleton methode. In dit geval is het een klassemethode – om precies te zijn, klassemethoden zijn singleton methoden, maar singleton methoden van een klasse, terwijl de andere singleton methoden van een object zijn.
Aan de andere kant, class_eval
evalueert de code in de context van een klasse in plaats van een instantie. Het heropent praktisch de klasse. Hier is hoe class_eval
kan worden gebruikt om een instantie-methode te maken:
Om samen te vatten, wanneer je class_eval
methode aanroept, verander je self
om naar de oorspronkelijke klasse te verwijzen en wanneer je instance_eval
aanroept, verandert self
om naar de metaklasse van de oorspronkelijke klasse te verwijzen.
Missing Methods on the Fly definiëren
Een ander stukje van de metaprogrammeer-puzzel is method_missing
. Wanneer je een methode op een object aanroept, gaat Ruby eerst in de klasse en doorzoekt de instantie methoden. Als het de methode daar niet vindt, gaat het verder zoeken in de voorouder keten. Als Ruby de methode nog steeds niet vindt, roept het een andere methode aan genaamd method_missing
dat is een instance methode van Kernel
dat elk object erft. Omdat we zeker weten dat Ruby deze methode uiteindelijk gaat aanroepen voor ontbrekende methoden, kunnen we dit gebruiken om enkele trucs te implementeren.
define_method
is een methode die is gedefinieerd in de Module
klasse die je kunt gebruiken om methoden dynamisch te maken. Om define_method
te gebruiken, roep je het op met de naam van de nieuwe methode en een blok waar de parameters van het blok de parameters van de nieuwe methode worden. Wat is het verschil tussen het gebruik van def
om een methode te maken en define_method
? Er is niet veel verschil, behalve dat je define_method
kunt gebruiken in combinatie met method_missing
om DRY code te schrijven. Om precies te zijn, u kunt define_method
gebruiken in plaats van def
om scopes te manipuleren bij het definiëren van een klasse, maar dat is een heel ander verhaal. Laten we eens kijken naar een eenvoudig voorbeeld:
Dit laat zien hoe define_method
is gebruikt om een instantie-methode te maken zonder een def
te gebruiken. Er is echter veel meer dat we met hen kunnen doen. Laten we eens kijken naar dit stukje code:
class Developer def coding_frontend p "writing frontend" end def coding_backend p "writing backend" endenddeveloper = Developer.newdeveloper.coding_frontend# "writing frontend"developer.coding_backend# "writing backend"
Deze code is niet DRY, maar met define_method
kunnen we hem DRY maken:
Dat is al veel beter, maar nog steeds niet perfect. Waarom? Als we bijvoorbeeld een nieuwe methode coding_debug
willen toevoegen, dan moeten we deze "debug"
in de array zetten. Maar met method_missing
kunnen we dit oplossen:
Dit stukje code is een beetje ingewikkeld, dus laten we het even uit elkaar halen. Het aanroepen van een methode die niet bestaat zal method_missing
doen opstarten. Hier willen we alleen een nieuwe methode maken als de naam van de methode begint met "coding_"
. Anders roepen we gewoon super aan om het werk te doen van het melden van een methode die eigenlijk ontbreekt. En we gebruiken gewoon define_method
om die nieuwe methode te maken. Dat is het! Met dit stukje code kunnen we letterlijk duizenden nieuwe methodes maken die beginnen met "coding_"
, en dat feit is wat onze code DRY maakt. Omdat define_method
toevallig privé is voor Module
, moeten we send
gebruiken om het aan te roepen.
Wrapping up
Dit is slechts het topje van de ijsberg. Om een Ruby Jedi te worden, is dit het beginpunt. Nadat je deze bouwstenen van metaprogrammeren onder de knie hebt en de essentie ervan echt begrijpt, kun je verder gaan met iets complexers, bijvoorbeeld je eigen Domain-specific Language (DSL) maken. DSL is een onderwerp op zich, maar deze basisconcepten zijn een eerste vereiste om geavanceerde onderwerpen te begrijpen. Sommige van de meest gebruikte juweeltjes in Rails zijn op deze manier gebouwd en je hebt waarschijnlijk de DSL ervan gebruikt zonder dat je het wist, zoals RSpec en ActiveRecord.
Hopelijk kan dit artikel je een stap dichter brengen bij het begrijpen van metaprogramming en misschien zelfs het bouwen van je eigen DSL, die je kunt gebruiken om efficiënter te coderen.