Les macros avec Nim - tutoriel de métaprogrammation
Ce tutoriel a pour objectif d'être une introduction aux capacités de métaprogrammation du langage de programmation Nim. Il vise à donner autant de détails que possible pour démarrer vos projets les plus fous. Il existe de nombreuses ressources que ce soit à travers les livres ou sur Internet mais vous devriez trouvez ici (à terme) une description complète du processus de développement de macros.
⚠️ Une partie du tutoriel n'a pas encore été traduit de l'anglais vers le français.
Plan:
Introduction
Qu'est-ce que la métaprogrammation ?
La métaprogrammation consiste à programmer du code informatique. Autrement dit, l'entrée et la sortie de programmes réalisant de la métaprogrammation seront eux-mêmes des bouts de code.
Mon language préféré ne me permet pas d'écrire des macros. Pourquoi écrire des macros (avec Nim)?
Le principal objectif est d'écrire facilement des longues portions de code qui sont répétitives ou pour s'adapter par exemple à de nombreuses architectures.
Il est également possible d'écrire de mini-langages de programmation nommés DSL ("domain-specific languages") pour une utilisation précise, comme la description de contenu d'une fenêtre graphique avec Owlkettle
ou pour spécifier les paramètres d'un réseau de neurones Arraymancer
. Les macros sont écrites une fois par le développeur d'une bibliothèque, et les utilisateurs de cette bibliothèque vont voir leur code modifié
par les macros sans même utiliser de macros par eux-mêmes.
Quel rapport avec les macros ?
Les macros sont ces fonctions qui vont travailler sur des bouts de code et générer du code en sortie. Nous verrons par la suite que ce code est représenté sous la forme d'arbre syntaxique nommé AST.
Quatre niveaux d'abstraction
Il existe quatre niveaux d'abstraction en métaprogrammation qui sont chacun associés à un type de procédure ou itérateur:
- Procédures/fonctions/itérateurs ordinaires (Pas de métaprogrammation)
- Les procédures génériques et les classes de type (Métaprogrammation au niveau du type)
- Les « modèles »
template
en anglais (Un méchanisme de copier-coller avancé) - Les
Macro
s (Substitution d'arbre syntaxiqueAST
)
Il faut garder en tête que la métaprogrammation est un méchanisme complexe, et il est fortement recommandé d'utiliser le niveau d'abstraction le plus faible possible, et pas de métaprogrammation du tout lorsque cela est possible. Il existe plusieurs raisons à cela. Premièrement, il est difficile de relire du code source utilisant de la métaprogrammation. Cela demande beaucoup de temps pour vérifier que le code source ne génère pas d'erreur et trouver l'origine d'une erreur s'il y en a une. Sans commentaire, une macro est presque illisible. Vous verrez par la suite qu'il est difficile de comprendre l'objectif et le fonctionnement d'une macro rien qu'en la lisant. Deuxièmement, il est difficile de faire de la gestion d'exception lorsqu'on manipule du code source. Il faut vérifier le code source qu'on reçoit en entrée d'une macro, et comme les possibilités sont très nombreuses, il est presque impossible de trier des codes sources valides en entrée d'une macro. Cela pose des problématiques de sécurité évidentes. C'est une des raisons pour laquelle la plupart des langages de programmation ont évité d'introduire des capacités de métaprogrammation. Enfin, les temps de compilation sont proportionnels au travail que doit réaliser le compilateur. Plus le niveau de métaprogrammation est avancé, plus le temps de compilation augmente, rendant le développement plus complexe et forçant ainsi la fragmentation du code en plusieurs modules.
Je vous propose dans ce tutoriel une présentation de ces quatre niveaux de métaprogrammation. Nous verrons au passage des notions nécessaires au développement de macros, comme les paramètres non typés, l'hygiénisation des variables, l'introspection de code, les arbres syntaxiques. En bonus, nous verrons des bouts de code (« snippets » en anglais) qui vous seront peut-être utiles en dehors de la métaprogrammation. Avant d'aborder les macros et les arbres syntaxiques, nous commençons donc avec les procédures génériques, puis les modèles avec les paramètres non typés.
import std/macros
Procédures Génériques
Un des objectifs de la programmation est l'automatisation de tâches répétitives. Certains programmes sont fastidieux à écrire et nous écrivons souvent des codes similaires.
Imaginez que vous voulez programmer une addition. Votre algorithme est probablement général et ne dépend peut-être pas du type de l'entrée. Votre algorithme pourrait recevoir aussi bien des entiers que des nombres flottants en entrée.
Vous ne voulez pas réécrire chacun de vos algorithmes pour chacun des types qui conviendraient.
# What to not do!
proc add(x, y: int): int =
return x + y
proc add(x, y: float): float =
return x + y
echo add(2, 3)
echo add(3.7, 4.5)
5 8.199999999999999
En effet, que se passerait-il si vous vouliez ajouter une fonction pour un autre type comme int32
ou float16
?
Vous devrez alors copier-coller votre fonction et changer le type. Bien que cela semble anodin, cela se révèle vite problématique lorsque vous trouvez un bug dans l'algorithme.
Il vous faut alors corriger autant de fonctions que de types supportés. De plus, le code devient peu lisible, puisque chaque fonction apparaît de nombreuses fois.
Une première solution consiste à utiliser les types « génériques implicites ». On utilise le mot-clé or
comme pour une expression booléenne avec les types qui conviendraient.
Durant la phase de compilation, le compilateur Nim choisit quel type convient à la situation.
proc add(x,y: (int or float)): (int or float) =
return x + y
add 2, 3 # Selects int
add 3.7, 4.5 # Selects float
Il se peut que vous ne sachiez pas vraiment à l'avance combien de types exactement pourraient être utilisés pour votre algorithme. Vous voudriez peut-être faire des modifications pour certains types précis. Il convient alors d'utiliser un type générique (non implicite). Il s'agit d'un type représenté par une variable. Par convention, on désigne cette variable par une lettre majuscule qui est souvent T, U, V, etc …
proc add[T](x,y: T): T =
when T is string:
x = x.parseFloat()
y = y.parseFloat()
var c = x + y
when T is string:
return $c
else:
return c
add 2, 3 # Selects int
add 3.7, 4.5 # Selects float
add "3.7", "4.5"
Templates
⚠️ Afin d'exécuter chaque code dans la suite de ce tutoriel, vous devrez importer le paquet std/macros
.
import std/macros
Nous pouvons voir les templates comme des procédures qui font de la substitution de code, comme un couper-coller qui serait réalisé à la compilation.
Les procédures templates
reçoivent généralement en dernier paramètre un bout de code.
Le type qui correspond à un bout de code est untyped
.
Comme nous souhaitons que le template retourne un bout de code, le type de retour est untyped
pour presque tous les cas d'usage.
## Exemple provenant de std/manual
template `!=` (a, b: untyped): untyped =
not (a == b)
doAssert(4 != 5) # Appelle le template `!=` définit ci-dessus.
Le langage définit l'opérateur booléen !=
exactement comme ci-dessus. Le code source de Nim avec cet exemple est consultable librement à cette addresse.
On peut facilement dupliquer du code à l'aide d'un bloc personnalisé. Attention, on exécute deux fois de suite l'instruction, et donc on ne peux donc pas placer d'affectation en-dessous de ce template.
template duplicate(statements: untyped) =
statements # statements est remplacé par `echo 5` lors de l'appel
statements
duplicate: # A template can receive its last argument as a code
echo 5
5 5
Ci-dessous, on généralise l'idée pour répéter le code autant de fois que désiré.
## Exemple provenant de Nim In Action de Dominik Picheta
from std/os import sleep
# On garde les instructions en second argument
template repetition(compteur: int, instructions: untyped) =
for i in 0 ..< compteur:
instructions
repetition 5:
echo("Salut. Je vais dormir 100 millisecondes!")
sleep(100)
## Le code est remplacé par:
## for i in 0 ..< 5:
## echo("Salut. Je vais dormir 100 millisecondes!")
## sleep(100)
Salut. Je vais dormir 100 millisecondes! Salut. Je vais dormir 100 millisecondes! Salut. Je vais dormir 100 millisecondes! Salut. Je vais dormir 100 millisecondes! Salut. Je vais dormir 100 millisecondes!
Le mot-clé Do-While
Nim possède peu de mots-clés et de méchanismes de flots de contrôle, afin de garder le langage simple à appréhender. Cependant, on peut toujours définir un mot-clé doWhile
que l'on retrouve dans d'autres langages comme C
ou Javascript
.
Ce mot-clé est quasiment identique à la boucle While
, à l'exception près qu'elle teste la condition après le bloc d'instruction. Cela permet de toujours exécuter au-moins une fois le bloc d'instruction.
Par exemple, ce code C affiche Hello World
au moins une fois, indépendamment de la valeur de départ de la variable i
.
int i = 10; // On doit déclarer une variable pour la boucle
do{
printf("Hello World\n");
i += 1;
}while(i < 10); // do{}while; est une unique instruction
// sur plusieurs lignes, d'où le point-virgule à la fin
Nous allons recréer ce code C avec Nim. Techniquement, nous allons nous servir d'une boucle while pour construire la boucle do-while. Nous ne pourrons cependant pas obtenir la même syntaxe qu'en C, où la condition est affichée à la fin du bloc d'instruction.
template doWhile(conditional, loop: untyped) =
loop
while conditional:
loop
var i = 10
doWhile i < 10:
echo "Hello World"
i.inc
## Le template modifie le code pour que soit exécuté:
## echo "Hello World"
## i.inc
## while i < 10:
## echo "Hello World"
## i.inc
##
## Ceci est strictement équivalent au code C présenté ci-dessous.
Hello World
Vous noterez cependant que syntaxiquement le code source qu'il est alors permis d'écrire est différent du code C++.
En effet, dans le code source C, apparaissent dans l'ordre:
- le mot-clé
do
- le bloc d'instruction
- le mot-clé
while
- la condition (expression booléenne)
Avec Nim, on a dans cet ordre: In Nim, we have in this order:
- le mot-clé
doWhile
- la condition
- le bloc d'instruction
Nous ne pouvons pas modifier la syntaxe de Nim pour correspondre à la syntaxe du C.
Évaluer le temps d'exécution
Pour évaluer le temps d'exécution d'un bout de code, on récupère l'heure avant et après l'exécution, et on affiche la différence.
Avec Nim, on utilise la fonction getMonoTime
.
Plutôt que d'écrire quatre lignes supplémentaires pour chaque bout de code dont on veut mesurer le temps d'exécution, il nous suffit d'écrire
le template suivant:
## Évaluation du temps d'exécution
import std/[times, monotimes] # times permet un affichage plus lisible d'un `MonoTime`
template benchmark(nomBenchmark: string, code: untyped) =
block:
let t0 = getMonoTime() # https://nim-lang.org/docs/monotimes.html#getMonoTime
code
let écoulé = getMonoTime() - t0
echo "CPU Time [", nomBenchmark, "] ", écoulé
benchmark "test1": # Devrait retourner une valeur proche de 100 ms
sleep(100)
CPU Time [test1] 100 milliseconds, 183 microseconds, and 909 nanoseconds
Le code qui est indenté en-dessous du bloc benchmark
sera délimité par le code du benchmark.
Puisque la substitution du code est réalisée au moment de la compilation, cette transformation ne modifie pas les temps obtenus.
Exercice: Modifier le code précédent pour effectuer une moyenne des temps obtenus après autant de répétitions que demander par l'utilisateur.
Macros
Les Templates utilisent les paramètres untyped
comme des briques de LEGO©, c'est-à-dire comme du code indivisible qui ne peut-être inspecté pour ses propriétés.
Si par exemple, nous ne voulions pas que l'utilisateur de notre template passe en argument un code contenant des déclarations, nous ne pourrions le vérifier avec un template.
L'utilisateur obtiendrait alors une erreur due à son mauvais usage de la fonction sans que nous puissions faire quelque chose pour l'en empêcher.
Les macro
s sont en quelque sorte des template
améliorées qui peuvent analyser le code qu'elles reçoivent en argument.
« Tandis que les templates remplacent du code, les macros réalisent une introspection. »
Ici, une introspection de code signifie en analyser le contenu: présence de définitions, analyser les types utilisés, etc…
Au-delà, de l'introspection, les macro
s vont pouvoir retourner une version modifiée du code passé en argument en injectant des variables dans le code original.
En premier exemple de macro
, j'ai choisi la macro la plus simple possible puisqu'elle ne retourne rien, ou plus précisément, une liste vide d'instructions.
Le code qui lui est passé en argument provoquerait une boucle infinie si exécuté. Heureusement, le code généré par la macro étant vide, rien n'est exécuté.
macro jetteAuxOubliettes(statements: untyped): untyped =
result = newStmtList()
jetteAuxOubliettes:
while true:
echo "Si tu ne fais rien, je te spammerai indéfiniment !"
Arbre syntaxique abstrait
Un arbre syntaxique (abstrait) (en anglais AST pour "abstract syntaxic tree") est une représentation du code interne au compilateur, qui est dite intermédiaire, car elle représente le code entre le code source (compréhensible par un humain) et le code généré (difficilement compréhensible par un humain mais pour un compilateur: code C, C++, Objective-C, ou Javascript selon le backend
).
Chaque code source Nim a son équivalent en AST. En revanche plusieurs codes sources peuvent correspondre à un AST.
Les commentaires et espaces du code source sont supprimés.
L'arbre syntaxique représente le code source sous la forme d'une arborescence ordonnée. L'AST est formée de nœuds qui possèdent chacun un ou plusieurs nœuds enfants. Ces nœuds ne peuvent être intervertis sans changer le sens du code.
Pour obtenir une représentation du code syntaxique d'un code, on peut écrire ce code sous une macro
spéciale appelée dumpTree
.
AST Manipulation
In Nim, the code is read and transformed in an internal intermediate representation called an Abstract Syntax Tree (AST). To get a representation of the AST corresponding to a code, we can use the macro
dumpTree
.
# N'oubliez pas d'importer std/macros!
# Vous pouvez utiliser --hints:off pour mieux discerner l'Arbre syntaxique
dumpTree:
echo "Salut!"
Vous trouverez dans la sortie du compilateur l'AST suivant:
StmtList
Command
Ident "echo"
StrLit "Salut!"
Ce code contient quatre nœuds. StmtList
est à la racine de l'arbre, puis chaque indentation désigne que l'on passe à un nœud enfant, à un niveau inférieur dans la hiérarchie.
StmtList
est la contraction de statements list qui signifie bloc d'instructions. Il rassemble ensemble toutes les instructions dans le bloc.
Le nœud suivant Command
indique que l'on utilise une procédure dont le nom est donné par son nœud enfant Ident
. Un Ident
peut-être le nom d'une variable, d'un objet ou d'une procédure.
Le nœud Command
précise la façon dont la procédure est appelée. Je ne détaille pas ici, mais cela a un rapport avec l'UFCS: Uniform Function Call Syntax qui est une propriété du langage qui indique qu'une fonction ou procédure peut être appelée indifféremment avec trois syntaxes distinctes.
Nous avons ensuite deux nœuds avec du texte accolé à la suite. Les nœuds correspondants à des noms de variables ou de procédures sont des nœuds de type Ident
.
Les chaines de caractères sont des nœuds de type StrLit
.
Afin de vous donner une idée de ce qui se passe en général, voici un exemple d'un code nettement plus complexe.
# Don't forget to import std/macros!
# You can use --hints:off to display only the AST tree
dumpTree:
type
myObject {.packed.} = ref object of RootObj
left: seq[myObject]
right: seq[myObject]
Ce code donne en sortie l'arbre syntaxique suivant:
StmtList
TypeSection
TypeDef
PragmaExpr
Ident "myObject"
Pragma
Ident "packed"
Empty
RefTy
ObjectTy
Empty
OfInherit
Ident "RootObj"
RecList
IdentDefs
Ident "left"
BracketExpr
Ident "seq"
Ident "myObject"
Empty
IdentDefs
Ident "right"
BracketExpr
Ident "seq"
Ident "myObject"
Empty
L'AST retourné par dumpTree
démarrera sauf quelques exceptions toujours par StmtList
.
Les définitions de type se retrouvent toujours dans une TypeSection
qui possèdent autant d'enfants de type TypeDef
que de définitions.
Les types objets sont définis par des ObjectTy
.
Afin de mieux visualiser l'hiérarchie, vous trouverez ci-dessous un schéma de l'AST:
Il n'est pas nécessaire que vous compreniez l'ensemble de la génération de l'AST. Sachez simplement que vous pouvez l'obtenir avec la commande DumpTree
.
Si jamais vous avez besoin d'écrire vous même un AST pour une macro, sachez que des exemples pour toutes les structures et mots-clefs sont dans la documentation des macros:
std/macros
Premier exemple de Macro: multiplication par deux
La première macro que je vous présente provient de cette vidéo Youtube réalisée par Jeff Delaunay sur sa chaîne Fireship.
Lorsque un utilisateur désire afficher des valeurs entières sous cette macro, les valeurs seront multipliées par deux.
macro timesTwo(statements: untyped): untyped =
for s in result:
for node in s:
if node.kind == nnkIntLit:
node.intVal = node.intVal*2
timesTwo:
echo 1 # 2
echo 2 # 4
echo 3 # 6
Avant d'expliciter le fonctionnement de la macro, on va comparer l'AST du code donné en entrée, avec celui que l'on pense obtenir avec le code:
dumpTree:
echo 1
echo 2
echo 3
dumpTree:
echo 2
echo 4
echo 6
Le compilateur retourne:
StmtList
Command
Ident "echo"
IntLit 1
Command
Ident "echo"
IntLit 2
Command
Ident "echo"
IntLit 3
StmtList
Command
Ident "echo"
IntLit 2
Command
Ident "echo"
IntLit 4
Command
Ident "echo"
IntLit 6
Cette sortie ressemble à s'y méprendre au premier exemple d'AST vu précédemment. Au lieu du StrLit "Salut!", on a désormais IntLit suivi du nombre présent dans le code source ou dans la sortie.
By compiling this code, you will get the corresponding AST. This simple AST is made of four nodes:
StmtList
Command
Ident "echo"
IntLit 1
StmtList
stands for statements list. It groups together all the instructions in your block.
The Command
node indicates that you use a function whose name is given by its child Ident
node. An Ident
can be any variable, object, procedure name.
Our integer literal whose value is 1 has the node kind IntLit
.
Notice that the order of the nodes in the AST is crucial. If we invert the two last nodes, we would get the AST of the code 1 echo
which does not compile.
StmtList
Command
IntLit 1
Ident "echo"
StmtList
, Command
, IntLit
and Ident
are the NodeKind of the code's AST.
Inside your macro, they are denoted with the extra prefix nnk
, e.g. nnkIdent
.
You can get the full list of node kinds at the std/macros source code.
macro timesTwoAndEcho(statements: untyped): untyped =
for s in result:
for node in s:
if node.kind == nnkIntLit:
node.intVal = node.intVal*2
echo repr result
timesTwoAndEcho:
echo 1
echo 2
echo 3
The output of a macro is an AST, and we can try to write it for a few examples:
StmtList
Command
Ident "echo"
IntLit 2
Command
Ident "echo"
IntLit 4
Command
Ident "echo"
IntLit 6
Please note that line breaks are not part of the Nim's AST!
Here, the output AST is almost the same as the input. We only change the integer literal value.
Our root node in the input AST is a statement list.
To fetch the Command
children node, we may use the list syntax.
A Node contains the list of its childrens. To get the first children, it suffices to write statements[0]
.
To loop over all the child nodes, one can use a for statement in statements
loop.
We need to fetch the nodes under a Command
instruction that are integer literals.
So for each node in the statement, we test if the node kind is equal to nnkIntLit
. We get their value with the attribute node.intVal
.
I present down my first macro as an example. I want to print the memory layout of a given type. My goal is to find misaligned fields making useless unocuppied memory in a type object definition. This happens when the attributes have types of different sizes. The order of the attributes then changes the memory used by an object. To deal with important chunks of memory, the processor stores an object and its attributes with some rules.
It likes when adresses are separated by powers of two. If it is not, it inserts a padding (unoccupied memory) between two attributes.
We can pack a structure with the pragma {.packed.}
, which removes this extra space. This has the disadvantage to slow down memory accesses.
We would like to detect the presence of holes in an object.
The first step is to look at the AST of the input code we want to parse.
One can look first at the most basic type definition possible, before trying to complexify the AST to get a feel of all the edge cases.
dumpTree:
type
Thing = object
a: float32
StmtList
TypeSection
TypeDef
Ident "Thing"
Empty
ObjectTy
Empty
Empty
RecList
IdentDefs
Ident "a"
Ident "float32"
Empty
We have to get outputs as much complex as possible to detect edge cases, while keeping the information to the minimum to easily read the AST and locate errors. I present here first some samples of type definition on which I will run my macro.
typeMemoryRepr:
type
Thing2 = object
oneChar: char
myStr: string
type
Thing = object of RootObj
a: float32
b: uint64
c: char
Type with pragmas aren't supported yet
when false: # erroneous code
typeMemoryRepr:
type
Thing {.packed.} = object
oneChar: char
myStr: string
It is not easy (if even possible) to list all possible types. Yet by adding some other informations we can get a better picture of the general AST of a type.
dumpTree:
type
Thing {.packed.} = object of RootObj
a: float32
b: string
StmtList
TypeSection
TypeDef
PragmaExpr
Ident "Thing"
Pragma
Ident "packed"
Empty
ObjectTy
Empty
OfInherit
Ident "RootObj"
RecList
IdentDefs
Ident "a"
Ident "float32"
Empty
IdentDefs
Ident "b"
Ident "string"
Empty
Notice how the name of the type went under the PragmaExpr section. We have to be careful about this when trying to parse the type.
A macro does always the same steps:
- Search for a node of a specific kind, inside the input AST or check that the given node is of the expected kind.
- Fetch properties of the selected node.
- Form AST output in function of these input node's properties.
- Continue exploring the AST.
Your macros will require a long docstring and many comments both with thorough details.
I present now my macro typeMemoryRepr
inspired from the nim memory guide on memory representation.
In this guide, we manually print types fields address, to get an idea of the memory layout and the space taken by each variable and its fields.
type Thing = object
a: uint32
b: uint8
c: uint16
var t: Thing
echo "size t.a ", t.a.sizeof
echo "size t.b ", t.b.sizeof
echo "size t.c ", t.c.sizeof
echo "size t ", t.sizeof
echo "addr t.a ", t.a.addr.repr
echo "addr t.b ", t.b.addr.repr
echo "addr t.c ", t.c.addr.repr
echo "addr t ", t.addr.repr
All these echo's are redundant and have to be changed each time we change the type field. For types with more than four or five fields, this becomes not manageable.
I have split this macro into different procedures.
The echoSizeVarFieldStmt
will take the name of a variable, let us say a
and of its field field
and return the code:
echo a.field.sizeof
We create a NimNode of kind StmtList
(a statement list), that contains IdentNode
s.
The first IdentNode
is the command echo
.
We do not represent spaces in the AST. Each term separated by a dot is an Ident and part of a nnkDotExpr
.
It suffices to output the above code under a dumpTree
block, to understand the AST we have to generate.
dumpTree:
echo a.field.sizeof
proc echoSizeVarFieldStmt(variable: string, nameOfField: string): NimNode =
## quote do:
## echo `variable`.`nameOfField`.sizeof
newStmtList(nnkCommand.newTree(
newIdentNode("echo"),
nnkDotExpr.newTree(
nnkDotExpr.newTree(
newIdentNode(variable),
newIdentNode(nameOfField) # The name of the field is the first ident
),
newIdentNode("sizeof")
)
))
The echoAddressVarFieldStmt
will take the name of a variable, let us say a
and of its field field
and return its address:
echo a.field.addr.repr
proc echoAddressVarFieldStmt(variable: string, nameOfField: string): NimNode =
## quote do:
## echo `variable`.`nameOfField`.addr.repr
newStmtList(nnkCommand.newTree(
newIdentNode("echo"),
nnkDotExpr.newTree(
nnkDotExpr.newTree(
nnkDotExpr.newTree(
newIdentNode(variable),
newIdentNode(nameOfField)
),
newIdentNode("addr")
),
newIdentNode("repr")
)
))
macro typeMemoryRepr(typedef: untyped): untyped =
## This macro takes a type definition as an argument and:
## * defines the type (outputs typedef as is)
## * initializes a variable of this type
## * echoes the size and address of the variable
## Then, for each field:
## * echoes the size and address of the variable field
# We begin by running the type definition.
result = quote do:
`typedef`
# Parse the type definition to find the TypeDef section's node
# We create the output's AST along parsing.
# We will receive a statement list as the root of the AST
for statement in typedef:
# We select only the type section in the StmtList
if statement.kind == nnkTypeSection:
let typeSection = statement
for i in 0 ..< typeSection.len:
if typeSection[i].kind == nnkTypeDef:
var tnode = typeSection[i]
# The name of the type is the first Ident child. We can get the ident's string with strVal or repr
let nameOfType = typeSection[i].findChild(it.kind == nnkIdent)
## Generation of AST:
# We create a variable of the given type definition (hopefully not already defined) name for the "myTypenameVar"
let nameOfTestVariable = "my" & nameOfType.strVal.capitalizeAscii() & "Var"
let testVariable = newIdentNode(nameOfTestVariable)
result = result.add(
quote do:
var `testVariable`:`nameOfType` # instanciate variable with type defined in typedef
echo `testVariable`.sizeof # echo the total size
echo `testVariable`.addr.repr # gives the address in memory
)
# myTypeVar.field[i] memory size and address in memory
tnode = tnode[2][2] # The third child of the third child is the fields's AST
assert tnode.kind == nnkRecList
for i in 0 ..< tnode.len:
# myTypeVar.field[i].sizeof
result = result.add(echoSizeVarFieldStmt(nameOfTestVariable, tnode[i][0].strVal))
# myTypeVar.field[i].addr.repr
result = result.add(echoAddressVarFieldStmt(nameOfTestVariable, tnode[i][0].strVal))
echo result.repr
typeMemoryRepr:
type
Thing = object of RootObj
a: float32
b: string
32 ptr Thing(a: 0.0, b: "") 4 ptr 0.0 16 ptr ""
Trying to parse a type ourselve is risky, since there are numerous easily forgettable possibilities (due to pragma expressions, cyclic types, and many kind of types: object, enum, type alias, etc..., case of fields, branching and conditionals inside the object, … ).
There is actually already a function to do so and this will be the object of a future release of this tutorial.
The following macro enables to create enums with power of two values.
import std/[enumerate, math]
# jmgomez on Discord
macro power2Enum(body: untyped): untyped =
let srcFields = body[^1][1..^1]
var dstFields = nnkEnumTy.newTree(newEmptyNode())
for idx, field in enumerate(srcFields):
dstFields.add nnkEnumFieldDef.newTree(field, newIntLitNode(pow(2.0, idx.float).int))
body[^1] = dstFields
echo repr body
body
type Test {.power2Enum.} = enum
a, b, c, d
A macro is not always the best alternative. A simple set and a cast gives the same result.
# Rika
type
Setting = enum
a, b, c
Settings = set[Setting]
let settings: Settings = {a, c}
echo cast[uint8](settings)
5
References and Bibliography
Press Ctrl
+ Click
to open following links in a new tab.
First, there are four official resources at the Nim's website:
- Nim by Example
- Nim Tutorial (Part III)
- Manual section about macros
- The Standard Documentation of the std/macros library The 2. and 3. documentations are complementary learning resources while the last one will be your up-to-date exhaustive reference. It provides dumped AST (explained later) for all the nodes.
Many developers have written their macro's tutorial:
- Nim in Y minutes
- Jason Beetham a.k.a ElegantBeef's dev.to tutorial. This tutorial contains a lot of good first examples.
- Pattern matching (sadly outdated) in macros by DevOnDuty
- Tomohiro's FAQ section about macros
- The Making of NimYAML's article of flyx
There are plentiful of posts in the forum that are good references:
- What is "Metaprogramming" paradigm used for ?
- Custom macro inserts macro help
- See generated code after template processing
- Fast array assignment
- Variable injection
- Proc inspection
- etc … Please use the forum search bar with specific keywords like
macro
,metaprogramming
,generics
,template
, …
Last but no least, there are three Nim books:
- Nim In Action, ed. Manning and github repo
- Mastering Nim, auto-published by A. Rumpf/Araq, Nim's creator.
- Nim Programming Book, by S.Salewski
We can also count many projects that are macro- or template-based:
-
genny and benchy. Benchy is a template based library that benchmarks your code snippet under bench blocks. Genny is used to export a Nim library to other languages (C, C++, Node, Python, Zig). In general, treeform projects source code are good Nim references
-
My favorite DSL : the neural network domain specific language (DSL) of the tensor library Arraymancer mratsim develops this library, and made a list of all his DSL in the forum.
-
Jester library is a HTML DSL, where each block defines a route in your web application.
-
nimib with which this blog post has been written.
-
Nim4UE. You can develop Nim code for the Unreal Engine 5 game engine. The macro system parses your procs and outputs DLL for UE.