La métaprogrammation Ruby est encore plus cool qu'il n'y paraît

Publié: 2022-03-11

Vous 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.

Métaprogrammation Ruby : écriture de code
Tweeter

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 :

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 :

Métaclasse 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.