Le système de macros d’Elixir permet aux développeurs de faire des choses incroyables, y compris certaines que vous ne devriez probablement pas
Un post récent sur le forum Elixir de Reddit a demandé comment Elixir accomplit la réutilisation du code sans héritage. Issu d’un milieu orienté objet, l’OP était habitué à résoudre ce problème en héritant de comportements avec des classes, mais ne savait pas comment l’aborder dans un Langage fonctionnel Comme Élixir.
La réponse générale est qu’Elixir favorise une composition approche, en construisant des comportements complexes en important des fonctionnalités d’autres modules. Mais le message m’a fait réfléchir : même s’il s’agit peut-être d’un terrible chose à faire, est-il possible d’implémenter l’héritage de style orienté objet classique dans Elixir ?
Il s’avère, oui: Le système de macros d’Elixir est suffisamment puissant pour implémenter l’héritage de style orienté objet avec des modules. Dans cet article, nous découvrirons le système de macro Elixir et comment (ab) l’utiliser pour implémenter l’héritage. Nous parlerons également un peu de la raison pour laquelle il est Probablement pas une bonne idée de le faire.
Métaprogrammation, d’une manière générale, est la capacité de manipuler le code en tant que données. Cela signifie qu’un langage de programmation peut lire, modifier et générer son propre code, et il est tout aussi puissant et dangereux que cela puisse paraître.
Elixir permet la métaprogrammation via son macro système. Le système macro nous permet écrire et exécuter du code qui génère du code, et cela ouvre la porte à toutes sortes d’extensions de langage et de mécanismes de réutilisation du code. Voyons un exemple simple, où nous avons des fonctions « mixin » que nous voulons injecter dans un module :
defmodule Mixin do
defmacro __using__(_) do
quote do
def mixin_func1(value), do: IO.inspect(value)
def mixin_func2(value), do: IO.inspect(value * 2)
end
end
end # add the "use" macro to inject functions into MyModule
defmodule MyModule do
use Mixin
end
MyModule.mixin_func2(5)
> 10
Au lieu de simplement exécuter du code, les macros peuvent code de retour exécutable par l’appelant-y compris le code généré dynamiquement. Cela se fait à l’aide d’un quote do ... end
bloquer à Devis le code et retourner sa représentation de données plutôt que de l’exécuter. Dans ce cas d’utilisation simple, la __using__
macro nous permet d’injecter efficacement notre code de définition de fonction dans MyModule
avec use Mixin
.
Pour un exemple plus concret, considérez ce morceau de code généré dans un projet par défaut dans le Cadre Phénix (avec quelques modifications). Dans cet exemple, des modules de contrôleur comme MyAppWeb.UserController
besoin d’avoir accès à certaines fonctionnalités. À la place de sous-classement une classe de contrôleur de base, nous utilisons une macro pour injecter le code souhaité :
defmodule MyAppWeb do
def controller do
quote do
use Phoenix.Controller, namespace: MyAppWeb
import Phoenix.LiveView.Controller
import Plug.Conn import MyAppWeb.Gettext
alias MyAppWeb.Router.Helpers, as: Routes
end
end defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
end
Du point de vue de la réutilisation du code, la métaprogrammation nous permet d’accomplir le même genre de choses que nous faisons avec l’héritage – ainsi que d’autres formes de réutilisation du code qui ne correspond pas parfaitement le modèle d’héritage. Notez que cet exemple importe non seulement certaines fonctionnalités, il appelle également une autre utilisation.
Mais les macros vont bien au-delà de la simple réutilisation de code : en plus d’injecter du code avec utilisation, les macros peuvent être utilisées pour construire des structures de contrôle ou langage spécifique au domaine API (DSL). Voici un exemple d’API du populaire bibliothèque de mappage de base de données Ecto:
import Ecto.Query, only: [from: 2] query = from u in "users", where: u.age > 18, select: u.name Repo.all(query
Ecto utilise une définition de macro pour from
au lieu d’une fonction, afin que les utilisateurs de l’API puissent écrire des expressions telles que u.age > 18
qui peuvent être transformées en requêtes de base de données plutôt que d’être évaluées directement.
Dans un appel de fonction, l’expression est évaluée comme d’habitude et la fonction reçoit le évaluer.
Dans une macro, l’expression est fournie comme code et peut être lu, manipulé ou exécuté au choix de l’auteur de la macro. Dans ce cas, l’expression est transformée en un fragment de requête de base de données SQL plutôt que d’être exécutée dans Elixir.
Pour approfondir cela, examinons un exemple de la Documentation Elixir.
Dans l’exemple suivant, une macro est utilisée pour générer une nouvelle structure de contrôle appelée Unless
.
L’exemple montre une implémentation utilisant une fonction et une autre utilisant une macro, pour illustrer la différence :
defmodule Unless do
def fun_unless(clause, do: expression) do
if(!clause, do: expression)
end defmacro macro_unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end
end
Dans l’exemple de fonction, le expression
est évalué immédiatement dans le cadre de l’exécution normale du programme. Mais en utilisant un macro, l’expression est injectée dans une structure de contrôle et exécutée uniquement lorsque le clause
est faux, ce qui est le comportement que nous attendons d’une structure de contrôle de flux !
iex> require Unless
iex> Unless.macro_unless true, do: IO.puts "this should never be printed"
nil
iex> Unless.fun_unless true, do: IO.puts "this should never be printed"
"this should never be printed"
nil
La Unless
la macro utilise un quote do ... end
bloc comme notre exemple précédent, mais utilise également unquote
pour injecter les expressions transmises dans le bloc de code cité.
Bien que la métaprogrammation soit extrêmement puissante, elle peut être « dangereuse » dans le sens où elle peut modifier les comportements de code attendus et rendre le code extrêmement difficile à comprendre s’il est utilisé de manière incorrecte. Comme le note la documentation Elixir :
Les macros ne doivent être utilisées qu’en dernier recours. Rappelez-vous que explicite vaut mieux qu’implicite. Un code clair vaut mieux qu’un code concis
L’écriture de macros est quelque peu rare pour le développement quotidien d’Elixir, à l’exception de certains cas d’utilisation courants et bien établis comme le __using__
exemple ci-dessus.
Construire une macro pour l’héritage de style orienté objet
Maintenant que nous avons vu comment les macros peuvent être utilisées dans Elixir pour étendre le comportement des modules, revenons à notre question initiale : pouvons-nous utiliser les macros Elixir pour implémenter l’héritage de style OO ? On peut, mais cela nécessitera une macro bien plus compliquée !
Tout d’abord, énonçons les objectifs de notre macro d’héritage :
- Nous voulons implémenter une macro « Inherit » qui prendra un module parent et injectera toutes les fonctions du module parent dans le module actuel.
- ça devrait marcher avec multiniveau héritage de sorte que si C hérite de B et que B hérite de A, alors C devrait également pouvoir appeler les fonctions A.
- Comme la plupart des langages OO, les fonctions doivent pouvoir être remplacées : nous devons permettre aux fonctions héritées d’être remplacées et réimplémentées.
- Enfin, nous devrions prendre en charge l’héritage multiple : la possibilité d’hériter de classes de base distinctes et d’obtenir des fonctions des deux.
Si nous implémentons correctement notre macro inherit, nous devrions pouvoir écrire du code comme celui-ci :
defmodule Base do
def f1(a), do: a * 1
def f2(a), do: a * 2
end defmodule Base2 do
def f3(a), do: a * 3
end
defmodule Derived do
use Inherit, Base
end
defmodule MyModule do
use Inherit, Derived
use Inherit, Base2
def f2(a), do: a * 10
end
# Call an inherited function
MyModule.f1(a) |> IO.inspect()
> 5
# Call an overridden function
MyModule.f2(a) |> IO.inspect()
> 50
# Call a function inherited with multiple-inheritance
MyModule.f3(5) |> IO.inspect()
> 15
Après quelques essais et erreurs pour résoudre quelques problèmes délicats décrits ci-dessous, j’ai pu trouver une macro qui a réussi tous les tests :
defmodule Inherit do
defmacro __using__(quoted_module) do
module = Macro.expand(quoted_module, __ENV__) module.__info__(:functions) |> Enum.map(fn {name, arity} ->
# Generate an argument list of length `arity`
args = arity== 0 && [] || 1..arity |> Enum.map(&Macro.var(:"arg#{&1}", nil))
quote do
defdelegate unquote(name)(unquote_splicing(args)), to: unquote(module)
defoverridable [{unquote(name), unquote(arity)}]
end
end)
end
end
Ce que nous faisons dans cette macro est
- implémenter une macro qui prend un module de base à hériter avec
defmacro
– notez que nous devons obtenir le module non cité avec Macro.expand - parcourir toutes les fonctions que le module expose en utilisant
module.__info__(:functions)
- utilisation
defdelegate
pour définir une fonction dans laquelle pointe l’implémentation de base – c’était la partie la plus délicate et est décrite plus en détail ci-dessous. - utilisation
defoverridable
pour permettre à la fonction d’être remplacée dans la classe dérivée si vous le souhaitez
Comme mentionné ci-dessus, la partie délicate de cette macro est de construire le defdelegate
appel, générant spécifiquement une définition de délégué avec le nombre correct d’arguments.
Par exemple, si nous devons définir une méthode do_stuff
avec 3 arguments, nous devons générer un code qui ressemble à ceci :
defdelegate do_stuff(a1, a2, a3), to: unquote(module)
C’est assez simple pour construire une liste de variables d’argument, mais comment l’insérer dans la définition de la fonction ? Il s’avère qu’un simple unquote
ne résoudra pas notre problème ici – ce serait essentiellement une définition de fonction avec un seul argument de liste. Au lieu de cela, nous utilisons une fonction appelée unquote_splicing
: il supprime les guillemets d’une liste, développant les éléments en place. Jetons un coup d’œil à un simple unquote
contre. unquote_splicing
Exemple:
iex(1)> quote do [1, unquote(list), 2] end
[1, [2, 3], 2]
iex(2)> quote do [1, unquote_splicing(list), 2] end
[1, 2, 3, 2]
Avec cette astuce en place pour développer les arguments, nous implémentons le bloc de définition de fonction comme suit :
quote do
defdelegate unquote(name)(unquote_splicing(args)), to: unquote(module)
defoverridable [{unquote(name), unquote(arity)}]
end
Et voila ! En quelques lignes seulement, nous avons implémenté l’héritage de style OO dans Elixir.
L’implémentation de l’héritage de style orienté objet dans Elixir est un excellent petit exercice pour en savoir un peu plus sur le système de macros, mais c’est Probablement pas une bonne idée de l’utiliser dans une application Elixir. Bien que l’héritage soit un outil courant dans d’autres langages, ce n’est pas un idiome Elixir courant et est susceptible de semer la confusion (et probablement un mépris bien mérité) s’il est utilisé dans une base de code Elixir.
Cela dit, la puissance du macrosystème réside dans le fait que nous boîte mettre en place ce type de structures spécialisées lorsque cela est nécessaire. Bien que je ne puisse pas imaginer utiliser une macro comme celle-ci pour le développement d’applications quotidiennes, il est certainement possible que des fonctionnalités comme celle-ci puissent être utilisées pour une bibliothèque spécialisée ou un DSL, similaire à l’utilisation spécialisée des macros par Ecto.
Pour en savoir plus sur la métaprogrammation dans Elixir, je vous conseille le livre Élixir de métaprogrammation : écrivez moins de code, faites-en plus (et amusez-vous !) par Chris McCord, auteur de Phoenix Framework.