Code d'écriture de code : une introduction à la théorie et à la pratique de la métaprogrammation moderne
Publié: 2022-07-22Chaque fois que je réfléchis à la meilleure façon d'expliquer les macros, je me souviens d'un programme Python que j'ai écrit lorsque j'ai commencé à programmer. Je ne pouvais pas l'organiser comme je le voulais. J'ai dû appeler un certain nombre de fonctions légèrement différentes, et le code est devenu lourd. Ce que je cherchais, même si je ne le savais pas à l'époque, c'était la métaprogrammation .
métaprogrammation (nom masculin)
Toute technique par laquelle un programme peut traiter le code comme des données.
Nous pouvons construire un exemple qui illustre les mêmes problèmes que j'ai rencontrés avec mon projet Python en imaginant que nous construisons le back-end d'une application pour les propriétaires d'animaux. En utilisant les outils d'une bibliothèque, pet_sdk
, nous écrivons Python pour aider les propriétaires d'animaux à acheter de la nourriture pour chat :
Après avoir confirmé que le code fonctionne, nous passons à la mise en œuvre de la même logique pour deux autres types d'animaux de compagnie (oiseaux et chiens). Nous ajoutons également une fonctionnalité pour prendre des rendez-vous chez le vétérinaire :
Il serait bon de condenser la logique répétitive de Snippet 2 dans une boucle, nous avons donc décidé de réécrire le code. Nous réalisons rapidement que, chaque fonction étant nommée différemment, nous ne pouvons pas déterminer laquelle (par exemple, book_bird_appointment
, book_cat_appointment
) appeler dans notre boucle :
Imaginons une version turbocompressée de Python dans laquelle nous pouvons écrire des programmes qui génèrent automatiquement le code final que nous voulons - une version dans laquelle nous pouvons manipuler notre programme de manière flexible, facile et fluide comme s'il s'agissait d'une liste, de données dans un fichier ou de tout autre type de données commun ou entrée de programme :
Ceci est un exemple de macro , disponible dans des langages tels que Rust, Julia ou C, pour n'en nommer que quelques-uns, mais pas Python.
Ce scénario est un excellent exemple de la façon dont il pourrait être utile d'écrire un programme capable de modifier et de manipuler son propre code. C'est précisément l'attrait des macros, et c'est l'une des nombreuses réponses à une question plus vaste : comment pouvons-nous faire en sorte qu'un programme introspecte son propre code, le traite comme des données, puis agisse sur cette introspection ?
En gros, toutes les techniques qui peuvent accomplir une telle introspection relèvent du terme général de « métaprogrammation ». La métaprogrammation est un sous-domaine riche de la conception des langages de programmation, et elle peut être attribuée à un concept important : le code en tant que données.
Réflexion : À la défense de Python
Vous pourriez souligner que, bien que Python ne fournisse pas de prise en charge des macros, il offre de nombreuses autres façons d'écrire ce code. Par exemple, nous utilisons ici la méthode isinstance()
pour identifier la classe dont notre variable animal
est une instance et appeler la fonction appropriée :
Nous appelons ce type de métaprogrammation réflexion , et nous y reviendrons plus tard. Le code de Snippet 5 est encore un peu lourd mais plus facile à écrire pour un programmeur que celui de Snippet 2, dans lequel nous avons répété la logique pour chaque animal répertorié.
Défi
À l'aide de la méthode getattr
, modifiez le code précédent pour appeler dynamiquement les fonctions order_*_food
et book_*_appointment
appropriées. Cela rend sans doute le code moins lisible, mais si vous connaissez bien Python, cela vaut la peine de réfléchir à la façon dont vous pourriez utiliser getattr
au lieu de la fonction isinstance
et simplifier le code.
Homoiconicité : l'importance de Lisp
Certains langages de programmation, comme Lisp, portent le concept de métaprogrammation à un autre niveau via l' homoiconicité .
homoiconicité (nom masculin)
Propriété d'un langage de programmation selon laquelle il n'y a pas de distinction entre le code et les données sur lesquelles un programme fonctionne.
Lisp, créé en 1958, est le plus ancien langage homoiconique et le deuxième plus ancien langage de programmation de haut niveau. Tirant son nom de "LISt Processor", Lisp a été une révolution informatique qui a profondément façonné la façon dont les ordinateurs sont utilisés et programmés. Il est difficile d'exagérer à quel point Lisp a fondamentalement et distinctement influencé la programmation.
Emacs est écrit en Lisp, qui est le seul langage informatique qui soit beau. Neal Stephenson
Lisp a été créé un an seulement après FORTRAN, à l'ère des cartes perforées et des ordinateurs militaires qui remplissaient une pièce. Pourtant, les programmeurs utilisent encore Lisp aujourd'hui pour écrire de nouvelles applications modernes. Le principal créateur de Lisp, John McCarthy, était un pionnier dans le domaine de l'IA. Pendant de nombreuses années, Lisp a été le langage de l'IA, les chercheurs appréciant la capacité de réécrire dynamiquement leur propre code. La recherche actuelle sur l'IA est centrée sur les réseaux de neurones et les modèles statistiques complexes, plutôt que sur ce type de code de génération logique. Cependant, les recherches effectuées sur l'IA à l'aide de Lisp - en particulier les recherches effectuées dans les années 60 et 70 au MIT et à Stanford - ont créé le domaine tel que nous le connaissons, et son influence massive se poursuit.
L'avènement de Lisp a exposé pour la première fois les premiers programmeurs aux possibilités de calcul pratiques de choses comme la récursivité, les fonctions d'ordre supérieur et les listes chaînées. Il a également démontré la puissance d'un langage de programmation construit sur les idées du calcul lambda.
Ces notions ont déclenché une explosion dans la conception des langages de programmation et, comme l'a dit Edsger Dijkstra, l'un des plus grands noms de l'informatique, « […] a aidé un certain nombre de nos semblables les plus doués à penser à des pensées auparavant impossibles.
Cet exemple montre un programme Lisp simple (et son équivalent dans la syntaxe Python plus familière) qui définit une fonction "factorielle" qui calcule de manière récursive la factorielle de son entrée et appelle cette fonction avec l'entrée "7":
Zézayer | Python |
---|---|
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 )) | |
Coder en tant que données
En dépit d'être l'une des innovations les plus percutantes et conséquentes de Lisp, l'homoïconicité, contrairement à la récursivité et à de nombreux autres concepts pionniers de Lisp, n'a pas été intégrée à la plupart des langages de programmation actuels.
Le tableau suivant compare les fonctions homoiconiques qui renvoient du code à la fois dans Julia et Lisp. Julia est un langage homoiconique qui, à bien des égards, ressemble aux langages de haut niveau que vous connaissez peut-être (par exemple, Python, Ruby).
L'élément clé de la syntaxe dans chaque exemple est son caractère guillemet . Julia utilise un :
(deux-points) pour citer, tandis que Lisp utilise un '
(apostrophe simple) :
Julia | Zézayer |
---|---|
function function_that_returns_code() return :(x + 1 ) end | |
Dans les deux exemples, la citation à côté de l'expression principale ( (x + 1)
ou (+ x 1)
) la transforme du code qui aurait été évalué directement en une expression abstraite que nous pouvons manipuler. La fonction renvoie du code, pas une chaîne ou des données. Si nous devions appeler notre fonction et écrire print(function_that_returns_code())
, Julia imprimerait le code stringifié comme x+1
(et l'équivalent est vrai de Lisp). Inversement, sans le :
(ou '
en Lisp), nous obtiendrions une erreur indiquant que x
n'a pas été défini.
Revenons à notre exemple de Julia et étendons-le :
La fonction eval
peut être utilisée pour exécuter le code que nous générons ailleurs dans le programme. Notez que la valeur imprimée est basée sur la définition de la variable x
. Si nous essayions d' eval
notre code généré dans un contexte où x
n'était pas défini, nous aurions une erreur.
L'homoiconicité est un type puissant de métaprogrammation, capable de débloquer des paradigmes de programmation nouveaux et complexes dans lesquels les programmes peuvent s'adapter à la volée, générant du code pour s'adapter aux problèmes spécifiques au domaine ou aux nouveaux formats de données rencontrés.
Prenons le cas de WolframAlpha, où le Wolfram Language homo-iconique peut générer du code pour s'adapter à une gamme incroyable de problèmes. Vous pouvez demander à WolframAlpha, "Quel est le PIB de New York divisé par la population d'Andorre?" et, remarquablement, recevoir une réponse logique.
Il semble peu probable que quelqu'un pense à inclure ce calcul obscur et inutile dans une base de données, mais Wolfram utilise la métaprogrammation et un graphe de connaissances ontologique pour écrire du code à la volée pour répondre à cette question.
Il est important de comprendre la flexibilité et la puissance fournies par Lisp et d'autres langages homoiconiques. Avant de plonger plus loin, examinons certaines des options de métaprogrammation à votre disposition :
Définition | Exemples | Remarques | |
---|---|---|---|
Homoiconicité | Caractéristique du langage dans laquelle le code est une donnée de « première classe ». Puisqu'il n'y a pas de séparation entre le code et les données, les deux peuvent être utilisés de manière interchangeable. |
| Ici, Lisp inclut d'autres langages de la famille Lisp, comme Scheme, Racket et Clojure. |
Macros | Instruction, fonction ou expression qui prend du code en entrée et renvoie du code en sortie. |
| (Voir la note suivante sur les macros de C.) |
Directives de préprocesseur (ou précompilateur) | Un système qui prend un programme en entrée et, en fonction des instructions incluses dans le code, renvoie une version modifiée du programme en sortie. |
| Les macros de C sont implémentées à l'aide du système de préprocesseur de C, mais les deux sont des concepts distincts. La principale différence conceptuelle entre les macros C (dans lesquelles nous utilisons la directive de préprocesseur #define ) et d'autres formes de directives de préprocesseur C (par exemple, #if et #ifndef ) est que nous utilisons les macros pour générer du code tout en utilisant d'autres directives non #define directives du préprocesseur pour compiler conditionnellement un autre code. Les deux sont étroitement liés en C et dans certains autres langages, mais ce sont des types de métaprogrammation différents. |
Réflexion | Capacité d'un programme à examiner, modifier et introspecter son propre code. |
| La réflexion peut se produire au moment de la compilation ou de l'exécution. |
Génériques | La possibilité d'écrire du code valide pour un certain nombre de types différents ou pouvant être utilisé dans plusieurs contextes mais stocké au même endroit. Nous pouvons définir les contextes dans lesquels le code est valide de manière explicite ou implicite. | Génériques de style modèle :
Polymorphisme paramétrique :
| La programmation générique est un sujet plus large que la métaprogrammation générique, et la frontière entre les deux n'est pas bien définie. De l'avis de cet auteur, un système de typage paramétrique ne compte comme métaprogrammation que s'il est dans un langage typé statiquement. |
Examinons quelques exemples pratiques d'homoiconicité, de macros, de directives de préprocesseur, de réflexion et de génériques écrits dans divers langages de programmation :
Les macros (comme celle de Snippet 11) redeviennent populaires dans une nouvelle génération de langages de programmation. Pour réussir à les développer, il faut considérer un sujet clé : l'hygiène.
Macros hygiéniques et non hygiéniques
Qu'est-ce que cela signifie pour le code d'être « hygiénique » ou « non hygiénique » ? Pour clarifier, regardons une macro Rust, instanciée par les macro_rules!
fonction. Comme son nom l'indique, macro_rules!
génère du code basé sur des règles que nous définissons. Dans ce cas, nous avons nommé notre macro my_macro
, et la règle est « Créer la ligne de code let x = $n
», où n
est notre entrée :
Lorsque nous développons notre macro (en exécutant une macro pour remplacer son invocation par le code qu'elle génère), nous nous attendons à obtenir ce qui suit :
Apparemment, notre macro a redéfini la variable x
égale à 3, nous pouvons donc raisonnablement nous attendre à ce que le programme imprime 3
. En fait, il en imprime 5
! Surpris? Dans Rust, macro_rules!
est hygiénique en ce qui concerne les identifiants, de sorte qu'il ne "capturerait" pas les identifiants en dehors de son champ d'application. Dans ce cas, l'identifiant était x
. S'il avait été capturé par la macro, il aurait été égal à 3.
hygiène (nom masculin)
Une propriété garantissant que l'expansion d'une macro ne capturera pas d'identificateurs ou d'autres états au-delà de la portée de la macro. Les macros et systèmes de macros qui ne fournissent pas cette propriété sont appelés non hygiéniques .
L'hygiène dans les macros est un sujet quelque peu controversé parmi les développeurs. Les partisans insistent sur le fait que sans hygiène, il est trop facile de modifier subtilement le comportement de votre code par accident. Imaginez une macro nettement plus complexe que l'extrait 13 utilisé dans un code complexe avec de nombreuses variables et d'autres identifiants. Et si cette macro utilisait l'une des mêmes variables que votre code, et que vous ne l'aviez pas remarqué ?
Il n'est pas rare qu'un développeur utilise une macro d'une bibliothèque externe sans avoir lu le code source. Ceci est particulièrement courant dans les nouveaux langages qui offrent un support macro (par exemple, Rust et Julia) :
Cette macro non hygiénique en C capture le website
de l'identifiant et modifie sa valeur. Bien sûr, la capture d'identifiant n'est pas malveillante. C'est simplement une conséquence accidentelle de l'utilisation de macros.
Donc, les macros hygiéniques sont bonnes et les macros non hygiéniques sont mauvaises, n'est-ce pas ? Malheureusement, ce n'est pas si simple. Il y a de fortes raisons de penser que les macros hygiéniques nous limitent. Parfois, la capture d'identifiant est utile. Revoyons Snippet 2, où nous utilisons pet_sdk
pour fournir des services à trois types d'animaux de compagnie. Notre code original commençait comme ceci :
Vous vous souviendrez que Snippet 3 était une tentative de condenser la logique répétitive de Snippet 2 en une boucle inclusive. Mais que se passe-t-il si notre code dépend des identifiants cats
et dogs
, et que nous voulions écrire quelque chose comme ceci :
L'extrait 16 est un peu simple, bien sûr, mais imaginez un cas où nous voudrions qu'une macro écrive 100% d'une portion de code donnée. Les macros hygiéniques pourraient être limitantes dans un tel cas.
Alors que le macro débat hygiénique contre non hygiénique peut être complexe, la bonne nouvelle est que ce n'est pas un débat sur lequel vous devez prendre position. La langue que vous utilisez détermine si vos macros seront hygiéniques ou non, alors gardez cela à l'esprit lorsque vous utilisez des macros.
Macros modernes
Les macros ont un petit moment maintenant. Pendant longtemps, l'attention des langages de programmation impératifs modernes s'est éloignée des macros en tant qu'élément central de leur fonctionnalité, les évitant au profit d'autres types de métaprogrammation.
Les langages que les nouveaux programmeurs apprenaient dans les écoles (par exemple, Python et Java) leur disaient qu'ils n'avaient besoin que de réflexion et de génériques.
Au fil du temps, à mesure que ces langages modernes sont devenus populaires, les macros ont été associées à une syntaxe intimidante de préprocesseur C et C ++ - si les programmeurs en étaient même conscients.
Avec l'avènement de Rust et Julia, cependant, la tendance est revenue aux macros. Rust et Julia sont deux langages modernes, accessibles et largement utilisés qui ont redéfini et popularisé le concept de macros avec des idées nouvelles et innovantes. C'est particulièrement excitant dans Julia, qui semble sur le point de remplacer Python et R en tant que langage polyvalent facile à utiliser, "piles incluses".
Lorsque nous avons regardé pet_sdk
pour la première fois à travers nos lunettes "TurboPython", ce que nous voulions vraiment, c'était quelque chose comme Julia. Réécrivons Snippet 2 dans Julia, en utilisant son homoiconicité et certains des autres outils de métaprogrammation qu'il propose :
Décomposons l'extrait 17 :
- Nous parcourons trois tuples. Le premier d'entre eux est
("cat", :clean_litterbox)
, donc la variablepet
est assignée à"cat"
, et la variablecare_fn
est assignée au symbole:clean_litterbox
. - Nous utilisons la fonction
Meta.parse
pour convertir une chaîne enExpression
afin de pouvoir l'évaluer en tant que code. Dans ce cas, nous voulons utiliser la puissance de l'interpolation de chaîne, où nous pouvons mettre une chaîne dans une autre, pour définir quelle fonction appeler. - Nous utilisons la fonction
eval
pour exécuter le code que nous générons.@eval begin… end
est une autre façon d'écrireeval(...)
pour éviter de retaper le code. À l'intérieur du bloc@eval
se trouve du code que nous générons dynamiquement et que nous exécutons.
Le système de métaprogrammation de Julia nous libère vraiment pour exprimer ce que nous voulons comme nous le voulons. Nous aurions pu utiliser plusieurs autres approches, y compris la réflexion (comme Python dans Snippet 5). Nous aurions également pu écrire une fonction macro qui génère explicitement le code pour un animal spécifique, ou nous aurions pu générer le code entier sous forme de chaîne et utiliser Meta.parse
ou toute combinaison de ces méthodes.
Au-delà de Julia : autres systèmes de métaprogrammation modernes
Julia est peut-être l'un des exemples les plus intéressants et les plus convaincants d'un macro-système moderne, mais ce n'est en aucun cas le seul. Rust a également joué un rôle déterminant dans la présentation des macros aux programmeurs.
Dans Rust, les macros sont beaucoup plus centralisées que dans Julia, bien que nous n'explorons pas cela complètement ici. Pour une foule de raisons, vous ne pouvez pas écrire Rust idiomatique sans utiliser de macros. Dans Julia, cependant, vous pouvez choisir d'ignorer complètement l'homo-iconicité et le macro-système.
En conséquence directe de cette centralité, l'écosystème Rust a vraiment adopté les macros. Les membres de la communauté ont construit des bibliothèques incroyablement cool, des preuves de concept et des fonctionnalités avec des macros, y compris des outils qui peuvent sérialiser et désérialiser des données, générer automatiquement du SQL ou même convertir des annotations laissées dans le code vers un autre langage de programmation, le tout généré en code à temps de compilation.
Alors que la métaprogrammation de Julia pourrait être plus expressive et libre, Rust est probablement le meilleur exemple d'un langage moderne qui élève la métaprogrammation, car il est largement présent dans tout le langage.
Un œil vers l'avenir
C'est maintenant un moment incroyable pour s'intéresser aux langages de programmation. Aujourd'hui, je peux écrire une application en C++ et l'exécuter dans un navigateur Web ou écrire une application en JavaScript à exécuter sur un ordinateur de bureau ou un téléphone. Les barrières à l'entrée n'ont jamais été aussi basses et les nouveaux programmeurs ont des informations à portée de main comme jamais auparavant.
Dans ce monde de choix et de liberté des programmeurs, nous avons de plus en plus le privilège d'utiliser des langages riches et modernes, qui sélectionnent des fonctionnalités et des concepts de l'histoire de l'informatique et des langages de programmation antérieurs. C'est excitant de voir les macros ramassées et dépoussiérées dans cette vague de développement. J'ai hâte de voir ce que feront les développeurs de la nouvelle génération lorsque Rust et Julia leur présenteront les macros. N'oubliez pas que le « code en tant que données » est plus qu'un simple slogan. C'est une idéologie fondamentale à garder à l'esprit lorsque l'on discute de la métaprogrammation dans n'importe quelle communauté en ligne ou cadre universitaire.
"Code as data" est plus qu'un simple slogan.
Les 64 ans d'histoire de la métaprogrammation ont fait partie intégrante du développement de la programmation telle que nous la connaissons aujourd'hui. Alors que les innovations et l'histoire que nous avons explorées ne sont qu'un pan de la saga de la métaprogrammation, elles illustrent la puissance et l'utilité robustes de la métaprogrammation moderne.