DEV Community

loading...

Demystification of Macros in Nim

beef331 profile image Jason Beetham Updated on ・5 min read

At first glance Nim’s metaprogramming certainly may drive you to scream and run away from your computer to the nearest ditch. Luckily though macros are not overly complicated, they are just a way for programmers to write code with code so we can generate logic and behaviour programatically. This write-up will document creating macros.

Implementing Go’s Walrus Operator

Go has an operator which defines a variable and sets the value without having a keyword. This can be done rather simply in Nim, but first let’s look at what we need using dumpTree.

import macros
dumpTree:
  var a = "Test"

The compiler will output anything below dumpTree, so now we can see the AST required for that code.

StmtList
  VarSection
    IdentDefs
      Ident "a"
      Empty
      StrLit "Test"

So if we want to automate this we see that we need the Varsection, an ident, and a value. So let's look at two ways of doing this.

Method #1

import macros
macro `:=`(name, value: untyped): untyped= newVarStmt(name, value)

That's it, we did it, to be certain though we can use the .repr procedure to check.

import macros
macro `:=`(name, value: untyped): untyped= 
  result = newVarStmt(name, value)
  echo result.repr

a := "Test"

Running the above code we will see the compiler echo out the desired var a = "Test"

Method #2

This method is equally as short, but uses a nice tool. quote do lets you write code and it will generate the AST for you, so it's as simple as below. The same checks can be done like above, but I will skip them for the sake of reducing redundancy, as being redundant and repeating yourself is redundant.

import macros
macro `:=`(name, value: untyped): untyped = 
  quote do:
    var `name` = `value`
a := "Test"

Making a Compact If Statement

Sometimes you will want to change the behaviour or function of the language, although Nim does not let you change the syntax you can make your own block logic. One case of this is making a custom if statement. Below is a desired implementation

let a = "Yellow"
expandIf:
  a == "Hello": echo "Good bye"
  a == "Yellow":
    echo "Would be a lot cooler if you liked blue."
    echo "Yellow sucks"
  _: echo "You did not speak."

Each condition aside from the underscore will be in if or elif bodies. So first we replicate the desired outcome and dumpTree it like our previous implementation.

import macros
dumpTree:
  if a == "Hello": echo "Good bye."
  elif a == "Yellow":
    echo "Would be a lot cooler if you liked blue."
    echo "Yellow sucks."
  else: echo "You did not speak."

The above AST looks like such.

StmtList
  IfStmt
    ElifBranch
      Infix
        Ident "=="
        Ident "a"
        StrLit "Hello"
      StmtList
        Command
          Ident "echo"
          StrLit "Good bye."
    ElifBranch
      Infix
        Ident "=="
        Ident "a"
        StrLit "Yellow"
      StmtList
        Command
          Ident "echo"
          StrLit "Would be a lot cooler if you liked blue."
        Command
          Ident "echo"
          StrLit "Yellow sucks."
    Else
      StmtList
        Command
          Ident "echo"
          StrLit "You did not speak."

We can also dumptree the body of our original expandIf idea, and see the resulting AST nodes we can sample from.

StmtList
      Infix
        Ident "=="
        Ident "a"
        StrLit "Hello"
        StmtList
          Command
            Ident "echo"
            StrLit "Good bye"
      Infix
        Ident "=="
        Ident "a"
        StrLit "Yellow"
        StmtList
          Command
            Ident "echo"
            StrLit "Would be a lot cooler if you liked blue."
          Command
            Ident "echo"
            StrLit "Yellow sucks"
      Call
        Ident "_"
        StmtList
          Command
            Ident "echo"
            StrLit "You did not speak."

Looking at both of them you can see in the expandIf body we can get the required infixes for the ElseIfBranch node. If we can seperate the StmtList from each infix, we then can use the newIfStmt macro to generate the elif branch using cond which is the infix and body which is the StmtList.

import macros
macro expandIf(statement: untyped): untyped=
  var
    branches: seq[(NimNode, NimNode)] #Condition, Body
    elseBody: NimNode #Else code
  for cond in statement:
    #Based off the dumpTree, we know this is the else body.
    let ifBody = cond.findChild(it.kind == nnkStmtList)
    if cond.kind == nnkInfix:
      cond.del 3 #Removes Stmtlist
      branches.add((cond, ifBody))
    elif cond.kind == nnkCall and $cond[0] == "_":
      #Based off the dumpTree, we know this is the else body.
      elseBody = ifBody

  result = newIfStmt(branches) #Generates if stmt
  result.add newNimNode(nnkElse).add(elseBody) #Appends else body
  echo result.repr



expandIf:
  11 == 13 : echo "Test"
  12 == 14: echo "Huh"
  _ : echo "duh"

In the above code you can see the extraction using findChild. Removal of the StmtList with the cond.del 3, and finally the creation of the if. When we compile the compiler sends us a nice message thanks to the echo result.repr, which is exactly what we wanted a fully constructed if/else statement.

if 11 == 13:
  echo "Test"
elif 12 == 14:
  echo "Huh"
else:
  echo "duh"

Constructor Macro

Our next macro will be one that is designed to make constructors so we can quickly and easily pass parameters to the a constructor of an object and have logic called after it's creation. Below is the planned implementation

type CoolThing = object
  a, b: int
  c: string
CoolThing.construct(true):
  a: required
  b: 10
  c: "Magic"
  _:
    echo result

From the AST we know that each parameter will pass a Call node, that has a Stmtlist. So firstly we extract the useful information.

macro construct(T : typedesc[object | distinct | ref], expNode : static bool, body: untyped): untyped=
  var 
    postConstructLogic: NimNode
    requiredParams: seq[NimNode]
    optionalParams: seq[NimNode]

  for call in body:
    if $call[0] == "_": postConstructLogic = call[1] #This is the post constructed node position
    elif call[1][0].kind == nnkIdent and $call[1][0] == "required" : requiredParams.add(call) #We know it's required
    else: optionalParams.add(call) #Optional detected

Then we need to extract all the information from the type declaration and make strides to ensure we get the actual type data.

  var node = T.getImpl #Get the Type Implementation
  let nameSym = $T

  #If distinct get original type
  if(node[2].kind == nnkDistinctTy):
    node = node[2][0].getImpl

  #Check if it's a ref object
  var isRef = false
  if(node[2].kind == nnkRefTy):
    isRef = true
    node = node[2][0][2] #Get Reclist
  else: node = node[2][2] #Get Reclist

  var identType = initTable[string, NimNode]()

  #Go for all vars and adding them to an identTable
  for varDecl in node:
    #In typedef nnkSym is the type
    let varType = varDecl[1]
    for vari in varDecl:
      if vari.kind == nnkIdent: identType[$vari] = varType

  #Proc name
  let constrName = (if isRef: "new" else: "init") & nameSym

Finally with all the extracted information from the type system, and also from the macro we can actually let the code be the coder.

#For each parameter generate a new identdef
  for req in requiredParams:
    parameters.add(newIdentDefs(req[0], identType[$req[0]]))

  for opt in optionalParams:
    parameters.add(newIdentDefs(opt[0], identType[$opt[0]], opt[1][0]))

  #Generate the constructor
  #Ident tells the constructor the type
  var objConstr = newNimNode(nnkObjConstr).add(ident($T))
  #Generates a: a, for all arguements
  for param in requiredParams:
    objConstr.add(newColonExpr(param[0], param[0]))
  for param in optionalParams:
    objConstr.add(newColonExpr(param[0], param[0]))
  #Set result so we can use the object later in the `_` code
  let assignment = newAssignment(ident("result"), objConstr)
  #If ref the convention is new, else init
  let nameNode = if expNode : postfix(ident(constrName), "*") else: ident(constrName)
  #Dont have nil if there is no postConstructLogic
  let procBody = if postConstructLogic.isNil: newStmtList(assignment) else: newStmtList(assignment,postConstructLogic)
  #Where all our work ends
  result = newProc(nameNode,
                   parameters,
                   procBody)

Very much a draw the rest of the owl, but nonetheless the above explains my workflow for designing and writting macros for Nim to enable more sensible less redundant code. Now constructor can be called with initCoolThing(10) and the echo will fire. The entire macro can be seen in one place on Github.

Discussion (0)

pic
Editor guide