La métaprogrammation Ruby est encore plus cool qu'il n'y paraît
Publié: 2022-03-11Vous entendez souvent dire que la métaprogrammation est quelque chose que seuls les ninjas Ruby utilisent, et que ce n'est tout simplement pas pour le commun des mortels. Mais la vérité est que la métaprogrammation n'est pas quelque chose d'effrayant du tout. Ce billet de blog servira à remettre en question ce type de pensée et à rapprocher la métaprogrammation du développeur Ruby moyen afin qu'il puisse également en tirer parti.
Il convient de noter que la métaprogrammation peut signifier beaucoup et qu'elle peut souvent être très mal utilisée et aller à l'extrême en matière d'utilisation. Je vais donc essayer de donner quelques exemples concrets que tout le monde pourrait utiliser dans la programmation quotidienne.
Métaprogrammation
La métaprogrammation est une technique par laquelle vous pouvez écrire du code qui écrit du code par lui-même dynamiquement au moment de l'exécution. Cela signifie que vous pouvez définir des méthodes et des classes pendant l'exécution. Fou, non? En un mot, en utilisant la métaprogrammation, vous pouvez rouvrir et modifier des classes, attraper des méthodes qui n'existent pas et les créer à la volée, créer du code DRY en évitant les répétitions, et plus encore.
Les bases
Avant de plonger dans la métaprogrammation sérieuse, nous devons explorer les bases. Et la meilleure façon de le faire est par l'exemple. Commençons par un et comprenons pas à pas la métaprogrammation Ruby. Vous pouvez probablement deviner ce que fait ce code :
class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" end end
Nous avons défini une classe avec deux méthodes. La première méthode de cette classe est une méthode de classe et la seconde est une méthode d'instance. Ce sont des éléments de base dans Ruby, mais il se passe beaucoup plus derrière ce code que nous devons comprendre avant d'aller plus loin. Il convient de souligner que la classe Developer
elle-même est en fait un objet. Dans Ruby, tout est un objet, y compris les classes. Étant donné que Developer
est une instance, il s'agit d'une instance de la classe Class
. Voici à quoi ressemble le modèle d'objet Ruby :
p Developer.class # Class p Class.superclass # Module p Module.superclass # Object p Object.superclass # BasicObject
Une chose importante à comprendre ici est la signification de self
. La méthode frontend
est une méthode standard disponible sur les instances de la classe Developer
, mais pourquoi la méthode backend
est-elle une méthode de classe ? Chaque morceau de code exécuté dans Ruby est exécuté contre un self particulier. Lorsque l'interpréteur Ruby exécute un code, il garde toujours une trace de la valeur self
pour une ligne donnée. self
fait toujours référence à un objet mais cet objet peut changer en fonction du code exécuté. Par exemple, à l'intérieur d'une définition de classe, self
fait référence à la classe elle-même qui est une instance de la classe Class
.
class Developer p self end # Developer
Dans les méthodes d'instance, self
fait référence à une instance de la classe.
class Developer def frontend self end end p Developer.new.frontend # #<Developer:0x2c8a148>
À l'intérieur des méthodes de classe, self
fait référence à la classe elle-même d'une certaine manière (ce qui sera discuté plus en détail plus loin dans cet article) :
class Developer def self.backend self end end p Developer.backend # Developer
C'est bien, mais qu'est-ce qu'une méthode de classe après tout ? Avant de répondre à cette question, nous devons mentionner l'existence de quelque chose appelé métaclasse, également connu sous le nom de classe singleton et classe propre. frontend
de méthode de classe que nous avons définie précédemment n'est rien d'autre qu'une méthode d'instance définie dans la métaclasse de l'objet Developer
! Une métaclasse est essentiellement une classe que Ruby crée et insère dans la hiérarchie d'héritage pour contenir les méthodes de classe, n'interférant ainsi pas avec les instances créées à partir de la classe.
Métaclasses
Chaque objet dans Ruby a sa propre métaclasse. Il est en quelque sorte invisible pour un développeur, mais il est là et vous pouvez l'utiliser très facilement. Puisque notre classe Developer
est essentiellement un objet, elle a sa propre métaclasse. Par exemple, créons un objet d'une classe String
et manipulons sa métaclasse :
example = "I'm a string object" def example.something self.upcase end p example.something # I'M A STRING OBJECT
Ce que nous avons fait ici, c'est que nous avons ajouté une méthode singleton something
à un objet. La différence entre les méthodes de classe et les méthodes singleton est que les méthodes de classe sont disponibles pour toutes les instances d'un objet de classe, tandis que les méthodes singleton ne sont disponibles que pour cette seule instance. Les méthodes de classe sont largement utilisées, contrairement aux méthodes singleton, mais les deux types de méthodes sont ajoutés à une métaclasse de cet objet.
L'exemple précédent pourrait être réécrit comme ceci :
example = "I'm a string object" class << example def something self.upcase end end
La syntaxe est différente mais elle fait effectivement la même chose. Revenons maintenant à l'exemple précédent où nous avons créé la classe Developer
et explorons d'autres syntaxes pour définir une méthode de classe :
class Developer def self.backend "I am backend developer" end end
C'est une définition de base que presque tout le monde utilise.
def Developer.backend "I am backend developer" end
C'est la même chose, nous définissons la méthode de classe backend
pour Developer
. Nous n'avons pas utilisé self
mais définir une méthode comme celle-ci en fait effectivement une méthode de classe.
class Developer class << self def backend "I am backend developer" end end end
Encore une fois, nous définissons une méthode de classe, mais en utilisant une syntaxe similaire à celle que nous avons utilisée pour définir une méthode singleton pour un objet String
. Vous remarquerez peut-être que nous avons utilisé ici self
qui fait référence à un objet Developer
lui-même. Nous avons d'abord ouvert la classe Developer
, rendant self égal à la classe Developer
. Ensuite, nous faisons class << self
, rendant self égal à la métaclasse de Developer
. Ensuite, nous définissons une méthode backend
sur la métaclasse de Developer
.
class << Developer def backend "I am backend developer" end end
En définissant un bloc comme celui-ci, nous définissons self
sur la métaclasse de Developer
pour la durée du bloc. Par conséquent, la méthode backend
est ajoutée à la métaclasse de Developer
, plutôt que la classe elle-même.
Voyons comment cette métaclasse se comporte dans l'arbre d'héritage :
Comme vous l'avez vu dans les exemples précédents, il n'y a aucune preuve réelle que la métaclasse existe même. Mais nous pouvons utiliser un petit hack qui peut nous montrer l'existence de cette classe invisible :
class Object def metaclass_example class << self self end end end
Si nous définissons une méthode d'instance dans la classe Object
(oui, nous pouvons rouvrir n'importe quelle classe à tout moment, c'est encore une autre beauté de la métaprogrammation), nous aurons un self
faisant référence à l'objet Object
à l'intérieur. Nous pouvons ensuite utiliser la syntaxe class << self
pour modifier le self actuel afin qu'il pointe vers la métaclasse de l'objet actuel. Puisque l'objet actuel est la classe Object
elle-même, ce serait la métaclasse de l'instance. La méthode renvoie self
qui est à ce stade une métaclasse elle-même. Ainsi, en appelant cette méthode d'instance sur n'importe quel objet, nous pouvons obtenir une métaclasse de cet objet. Définissons à nouveau notre classe Developer
et commençons à explorer un peu :

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>>"
Et pour le crescendo, voyons la preuve que frontend
est une méthode d'instance d'une classe et backend
est une méthode d'instance d'une métaclasse :
p developer.class.instance_methods false # [:frontend] p developer.class.metaclass_example.instance_methods false # [:backend]
Cependant, pour obtenir la métaclasse, vous n'avez pas besoin de rouvrir Object
et d'ajouter ce hack. Vous pouvez utiliser singleton_class
fourni par Ruby. C'est la même chose que metaclass_example
que nous avons ajouté, mais avec ce hack, vous pouvez réellement voir comment Ruby fonctionne sous le capot :
p developer.class.singleton_class.instance_methods false # [:backend]
Définition de méthodes à l'aide de "class_eval" et "instance_eval"
Il existe une autre façon de créer une méthode de classe, et c'est en utilisant 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"
Ce morceau de code de l'interpréteur Ruby évalue dans le contexte d'une instance, qui est dans ce cas un objet Developer
. Et lorsque vous définissez une méthode sur un objet, vous créez soit une méthode de classe, soit une méthode singleton. Dans ce cas, il s'agit d'une méthode de classe - pour être exact, les méthodes de classe sont des méthodes singleton mais des méthodes singleton d'une classe, tandis que les autres sont des méthodes singleton d'un objet.
D'autre part, class_eval
évalue le code dans le contexte d'une classe au lieu d'une instance. Il rouvre pratiquement la classe. Voici comment class_eval
peut être utilisé pour créer une méthode d'instance :
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>"
Pour résumer, lorsque vous appelez la méthode class_eval
, vous modifiez self
pour faire référence à la classe d'origine et lorsque vous appelez instance_eval
, self
change pour faire référence à la métaclasse de la classe d'origine.
Définir les méthodes manquantes à la volée
Une autre pièce du puzzle de la métaprogrammation est method_missing
. Lorsque vous appelez une méthode sur un objet, Ruby entre d'abord dans la classe et parcourt ses méthodes d'instance. S'il n'y trouve pas la méthode, il poursuit sa recherche dans la chaîne des ancêtres. Si Ruby ne trouve toujours pas la méthode, il appelle une autre méthode nommée method_missing
qui est une méthode d'instance de Kernel
dont chaque objet hérite. Puisque nous sommes sûrs que Ruby va éventuellement appeler cette méthode pour les méthodes manquantes, nous pouvons l'utiliser pour implémenter quelques astuces.
define_method
est une méthode définie dans la classe Module
que vous pouvez utiliser pour créer des méthodes dynamiquement. Pour utiliser define_method
, vous l'appelez avec le nom de la nouvelle méthode et un bloc où les paramètres du bloc deviennent les paramètres de la nouvelle méthode. Quelle est la différence entre utiliser def
pour créer une méthode et define_method
? Il n'y a pas beaucoup de différence sauf que vous pouvez utiliser define_method
en combinaison avec method_missing
pour écrire du code DRY. Pour être exact, vous pouvez utiliser define_method
au lieu de def
pour manipuler les portées lors de la définition d'une classe, mais c'est une toute autre histoire. Prenons un exemple simple :
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!"
Cela montre comment define_method
a été utilisé pour créer une méthode d'instance sans utiliser de def
. Cependant, nous pouvons faire beaucoup plus avec eux. Jetons un coup d'œil à cet extrait de code :
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"
Ce code n'est pas DRY, mais en utilisant define_method
, nous pouvons le rendre 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"
C'est beaucoup mieux, mais toujours pas parfait. Pourquoi? Si nous voulons ajouter une nouvelle méthode coding_debug
par exemple, nous devons mettre ce "debug"
dans le tableau. Mais en utilisant method_missing
nous pouvons résoudre ce problème :
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
Ce morceau de code est un peu compliqué alors décomposons-le. Appeler une méthode qui n'existe pas déclenchera method_missing
. Ici, nous voulons créer une nouvelle méthode uniquement lorsque le nom de la méthode commence par "coding_"
. Sinon, nous appelons simplement super pour faire le travail de rapport d'une méthode qui manque réellement. Et nous utilisons simplement define_method
pour créer cette nouvelle méthode. C'est ça! Avec ce morceau de code, nous pouvons créer littéralement des milliers de nouvelles méthodes commençant par "coding_"
, et c'est ce qui rend notre code DRY. Étant donné que define_method
est privé pour Module
, nous devons utiliser send
pour l'invoquer.
Emballer
Ce n'est que la pointe de l'iceberg. Pour devenir un Ruby Jedi, c'est le point de départ. Après avoir maîtrisé ces éléments de base de la métaprogrammation et bien compris son essence, vous pouvez passer à quelque chose de plus complexe, par exemple créer votre propre langage spécifique à un domaine (DSL). Le DSL est un sujet en soi, mais ces concepts de base sont une condition préalable à la compréhension des sujets avancés. Certains des joyaux les plus utilisés dans Rails ont été construits de cette manière et vous avez probablement utilisé son DSL sans même le savoir, comme RSpec et ActiveRecord.
J'espère que cet article vous permettra de mieux comprendre la métaprogrammation et peut-être même de créer votre propre DSL, que vous pourrez utiliser pour coder plus efficacement.