宏的简介

宏可以理解为一种特殊的函数。一般的函数在输入的值上进行计算,然后输出一个新的值,而宏的输入和输出都是程序本身。在输入一段程序(或程序片段,例如表达式),输出一段新的程序,这段输出的程序随后用于编译和执行。为了把宏的调用和函数调用区分开来,我们在调用宏时使用 @ 加上宏的名称。

让我们从一个简单的例子开始:假设我们想在调试过程中打印某个表达式的值,同时打印出表达式本身。

let x = 3
let y = 2
@dprint(x)        // 打印 "x = 3"
@dprint(x + y)    // 打印 "x + y = 5"

显然,dprint 不能被写为常规的函数,由于函数只能获得输入的值,不能获得输入的程序片段。但是,我们可以将 dprint 实现为一个宏。一个基本的实现如下:

macro package define

import std.ast.*

public macro dprint(input: Tokens): Tokens {
    let inputStr = input.toString()
    let result = quote(
        print($(inputStr) + " = ")
        println($(input)))
    return result
}

在解释每行代码之前,我们先测试这个宏可以达到预期的效果。首先,在当前目录下创建一个 macros 文件夹,并在 macros 文件夹中创建 dprint.cj 文件,将以上内容复制到 dprint.cj 文件中。另外在当前目录下创建 main.cj,包含以下测试代码:

import define.*

main() {
    let x = 3
    let y = 2
    @dprint(x)
    @dprint(x + y)
}

请注意,得到的目录结构如下:

// Directory layout.
src
|-- macros
|     `-- dprint.cj
`-- main.cj

在当前目录(src)下,运行编译命令:

cjc macros/*.cj --compile-macro
cjc main.cj -o main

然后运行 ./main,可以看到如下输出:

x = 3
x + y = 5

让我们依次查看代码的每个部分:

  • 第 1 行:macro package define

    宏必须声明在独立的包中(不能和其他 public 函数一起),含有宏的包使用 macro package 来声明。这里我们声明了一个名为 define 的宏包。

  • 第 2 行:import std.ast.*

    实现宏需要的数据类型,例如 Tokens 和后面会讲到的语法节点类型,位于仓颉标准库的 ast 包中,因此任何宏的实现都需要首先引入 ast 包。

  • 第 3 行:public macro dprint(input: Tokens): Tokens

    在这里我们声明一个名为 dprint 的宏。由于这个宏是一个非属性宏(之后我们会解释这个概念),它接受一个类型为 Tokens 的参数。该输入代表传给宏的程序片段。宏的返回值也是一个程序片段。

  • 第 4 行:let inputStr = input.toString()

    在宏的实现中,首先将输入的程序片段转化为字符串。在前面的测试案例中,inputStr 成为 "x""x + y"

  • 第 5-7 行:let result = quote(...)

    这里 quote 表达式是用于构造 Tokens 的一种表达式,它将括号内的程序片段转换为 Tokens。在 quote 的输入中,可以使用插值 $(...) 来将括号内的表达式转换为 Tokens,然后插入到 quote 构建的 Tokens 中。对于以上代码,$(inputStr) 插入 inputStr 字符串的值(包含字符串两端的引号),$(input) 插入 input,即输入的程序片段。因此,如果输入的表达式是 x + y,那么形成的Tokens为:

    print("x + y" + " = ")
    println(x + y)
    
  • 第 8 行:return result

    最后,我们将构造出来的代码返回,这两行代码将被编译,运行时将输出 x + y = 5

回顾 dprint 宏的定义,我们看到 dprint 使用 Tokens 作为入参,并使用 quote 和插值构造了另一个 Tokens 作为返回值。为了使用仓颉宏,我们需要详细了解 Tokensquote 和插值的概念,下面我们将分别介绍它们。