元编程

元编程是一种将计算机程序(代码)当做数据的编程技术,从而修改,更新,替换已有的程序。例如可以将一些计算过程从运行时挪到编译时,并在编译期进行代码生成。仓颉语言提供的元编程能力,能支持代码复用,操作语法树,编译期求值,甚至自定义文法等功能。

下面是一个利用元编程解决具体问题的例子,利用仓颉宏为某些需要递归计算的函数进行记忆优化。

// macro_definition.cj
macro package memory

from std import ast.*

func checkBooleanAttr(attr: Tokens): Bool {
    // true or false
    if (attr.size != 1 || attr[0].kind != TokenKind.BOOL_LITERAL) {
        throw IllegalArgumentException("Attribute for memoize should be true or false")
    }
    return attr[0].value == "true"
}

public macro memoize(attr: Tokens, input: Tokens): Tokens {
    let memoized: Bool = checkBooleanAttr(attr)

    // no memorization
    if (!memoized) {
        return input
    }

    // optimizing with memory
    let fd = parseDecl(input)

    return quote(
        var memoMap: HashMap<Int64, Int64> = HashMap<Int64, Int64>()

        func $(fd.identifier)(n: Int64): Int64 {
            if (memoMap.contains(n)) {
                return memoMap.get(n).getOrThrow()
            }
            if (n == 0 || n == 1) {
                return n
            }
            let ret = Fib(n-1) + Fib(n-2)
            memoMap.put(n, ret)
            return ret
        }
    )
}

// macro_call.cj
import memory.*
from std import time.*
from std import collection.*

@memoize[true]
func Fib(n: Int64): Int64 {
    if (n == 0 || n == 1) {
        return n
    }
    return Fib(n-1) + Fib(n-2)
}

main() {
    println("Fibonacci:")

    let start1 = DateTime.now()
    let f1 = Fib(20)
    let end1 = DateTime.now()
    println("Fib(20): ${f1}")
    println("execution time: ${(end1 - start1).toMicroseconds()} us")

    let start2 = DateTime.now()
    let f2 = Fib(15)
    let end2 = DateTime.now()
    println("Fib(15): ${f2}")
    println("execution time: ${(end2 - start2).toMicroseconds()} us")

    let start3 = DateTime.now()
    let f3 = Fib(22)
    let end3 = DateTime.now()
    println("Fib(22): ${f3}")
    println("execution time: ${(end3 - start3).toMicroseconds()} us")
    0
}

上述代码中,memoize 是一个用户自定义的宏,它修饰一个函数,这个函数用来计算 Fibonacci 序列的第 n 个位置上的值。如果没有 memoize 这个宏修饰,每次调用 Fib 函数时,都会递归执行,耗时较长。使用 memoize 后,这个宏为 Fib 函数在编译期生成一些代码,记录下已经计算出的函数入参对应的函数返回值,下次可直接查表得到函数返回值,而不需要再次递归。

使用仓颉宏时,需要先编译宏定义文件,再编译宏调用文件生成可执行文件,最终运行可执行文件的输出结果如下:

// output when use @memoize[true]
Fibonacci:
Fib(20): 6765
execution time: 146 us
Fib(15): 610
execution time: 3 us
Fib(22): 17711
execution time: 16 us

当然,记忆优化的代价是额外使用了哈希表,若开发者不希望进行这样的优化,可以将 memoize 宏的属性入参设置为 false,即使用 @memoize[false] 修饰 Fib 函数,程序的运行结果如下:

// output when use @memoize[false]
Fibonacci:
Fib(20): 6765
execution time: 570 us
Fib(15): 610
execution time: 51 us
Fib(22): 17711
execution time: 1487 us

由以上运行结果可以看出,使用 @memoize[true] 时,Fib(15), Fib(22) 的计算时间显著减少,特别是 Fib(22) 的计算耗时,使用记忆优化后时长由 1487 us 减少到 16 us。

观察 memoize 宏定义,我们看到 memoize 宏使用了 Tokens 作为入参和返回值的类型,同时返回了一个 quote 表达式。为了使用仓颉宏,我们需要了解 Token, Tokens, quote, 以及宏的编译期执行(先编译宏定义文件,再编译宏调用文件)的概念,下面将分别介绍它们,最终我们能理解 memoize 宏的工作原理。

Tokens 相关类型和 quote 表达式

仓颉语言的元编程是基于语法实现的。编译器在语法分析的阶段可以完成编写或操作目标程序的工作,用于操作目标程序的程序我们称为元程序。元程序的输入输出都是词法单元(token),为此,仓颉提供了 Token 类型,Tokens 类型和 quote 表达式。其中,Token 类型是单个词法单元的类型,Tokens 类型是多个词法单元组成的结构的类型,quote 表达式是构造 Tokens 实例的一种表达式,下面依次对它们进行介绍。

Token 类型

Token 是元编程提供给用户可操作的词法单元,含义上等同编译器中词法分析器输出的 token。一个 Token 类型中包括的信息有:Token 类型(TokenKind)、构成 Token 的字符串、Token 的位置。

可以通过传入具体的 TokenKind 来构建单个 Token 对象:

Token(TokenKind.ADD) // Token representing `+`

这里的 TokenKind 是用于表示 Token 类型的 enum,即用于表示各种 Token。使用 TokenKind 的构造器可以构造出仓颉所有的 Token (TokenKind 可用值详见附录) 。

需要注意的是,由多个字符组成的 Token 与对应相同字符的多个 Token,含义不同。例如:

let a0: Unit = ()
let a1 = String()

此处第一行的 () 可以是 一个类型为 TokenKind.UNIT_LITERAL(Unit 类型的字面值常量)的 Token,或者两个 Token,其类型分别为 TokenKind.LPAREN(左括号)和 TokenKind.RPAREN(右括号),而第二行的 (),则只能是 TokenKind.LPAREN 和 TokenKind.RPAREN。

注:TokenKind、Token 类型皆由仓颉标准库 ast 包提供,使用时需要导入

from std import ast.TokenKind
from std import ast.Token

本章中为了方便描述,使用 from std import ast.* 将整个 ast 包导入。

还存在其他构造 Token 的方式,如下:

Token() // Return illegal Token.
Token(k: TokenKind)
Token(k: TokenKind, v: String)

例如,有以下方式可以构造 Token

let tk1 = Token()
let tk2 = Token(TokenKind.FUNC) // Construct a Token, which is the keyword func
let tk3 = Token(TokenKind.IDENTIFIER, "foo") // Construct an identifier Token with value foo

Tokens 类型

Tokens 类型是用仓颉编写元程序必须的输入输出类型。存在如下 3 种构造 Tokens 实例的方式:

Tokens()
Tokens(tokArr: Array<Token>)
Tokens(tokArrList: ArrayList<Token>)

除了以上构造 Tokens 的方式外,还有 quote 表达式可以构造 Tokens,详见下一小节。简单来说,Tokens 可以理解为是由词素(Token)组成的数组,同时 Tokens 类型支持如下操作:

size: 返回 Tokens 中所包含 Token 的数目。
get(index: Int64): 用于获取指定下标的 Token 元素。
[]: 返回下标索引指定的 Token。
+: 拼接两个 Tokens 或者拼接 Tokens 和 Token。
dump(): 打印包含的所有 Token,供调试使用。

下面的例子中包含了上述的所有操作:

from std import ast.*

main() {
    let ts1: Tokens = quote(1 + 2)
    let ts2: Tokens = quote(3)
    // ts includes Token: '1', '+', '2', '+' and '3'
    let ts: Tokens = ts1 + Token(TokenKind.ADD) + ts2
    println("ts.size = ${ts.size}")
    println("ts.dump():")
    ts.dump()
    let index0 = ts.get(0)
    println("ts.get(0): ${index0.value}")
    let index1 = ts[1]
    println("ts[1]: ${index1.value}")
    0
}

这个例子中,通过 quote 表达式获取了仓颉代码(如 1 + 2 + 3)的 Tokens 对象

quote 表达式

quote 是仓颉的一个关键字,quote 表达式可以将代码表示为 Tokens 对象。具体来说,对于上一节的例子:

let ts1: Tokens = quote(1 + 2)

这里 quote 的作用是:将1 + 2这行代码转换成由 '1','+','2' 这 3 个 Token 组成的 Tokens。

插值运算符

在 quote 表达式中,仓颉支持代码插值操作,使用插值运算符 $ 表示。这里的插值表达式类似于某种占位符,会被最终替换成相应的值,即 toTokens 后的结果。

说明:

插值运算符修饰的表达式必须实现 ast 包中的 toTokens 接口,否则无法正常给出插值结果并给出报错信息。关于 ast 包的介绍及其 API,请参见《仓颉库使用指南》中“ast 包”的内容。

默认情况下,插值运算符后面的表达式,需要使用小括号限定作用域,如 $(foo)。但是当后面只跟单个标识符的时候,小括号可省略,即可写为:$foo。下面是有关 quote 和插值的一个示例,这个例子中,展示了将二元表达式 (1+2) 转换为 Tokens 对象,然后调用 ast 包提供的 parseExpr 接口将其变成 AST 类型,即 BinaryExpr,通过 quote 和插值可以将这个 AST 类型变成 Tokens 对象。

from std import ast.*

main() {
    let tokens: Tokens = quote(1 + 2)
    // parseExpr is API provided by libast to parse input Tokens.
    // BinaryExpr is type provided by libast.
    var ast: BinaryExpr = match (parseExpr(tokens) as BinaryExpr) {
        case Some(v) => v
        case None => throw Exception()
    }

    let a = quote($ast)                    // without parentheses
    let b = quote($(ast))                  // with parentheses
    let c = quote($ast.leftExpr)      // without parentheses
    let d = quote($(ast.leftExpr))    // with parentheses
    println("$ast.leftExpr:")
    c.dump()
    println("===================")
    println("$(ast.leftExpr):")
    d.dump()
    return 0
}

其中 BinaryExpr 和 parseExpr 是 ast 包提供的类型和成员函数。

$ 只定界到紧跟它的一个标识符。例如,quote($ast.leftExpr) 将返回 '1','+','2','.','getLeftExpr','(' 和 ')' 这 7 个 Token,ast 之后的 .leftExpr 均被解释为 Token。

quote的入参可以为任意合法的仓颉代码且可以为空,但当前编译器不支持传入代码中有宏调用表达式。

仓颉实现元编程的主要方式是使用宏。仓颉的宏是语法宏,其定义形式上类似于函数,也和函数一样可以被调用。不同点是:

  1. 宏定义所在的 package 需使用 macro package 来声明。
  2. 宏定义需要使用关键字 macro
  3. 宏定义的输入和输出类型必须是 Tokens。
  4. 宏调用需要使用 @

从输入代码序列到输出新的代码序列的这个映射过程称为宏展开。宏在仓颉代码编译时进行展开,一直展开到目标代码中没有宏为止。宏展开的过程会实际执行宏定义体,即宏是在编译期完成求值,展开后的结果重新作用于仓颉的语法树,继续后面的编译和执行流程。

macro package

仓颉宏的定义需要放在由 macro package 声明的包中,被 macro package 限定的包仅允许宏定义对外可见(注:也允许重导出的声明对外可见),其他声明包内可见。宏定义允许返回空 Tokens 对象,不包含任何代码(Tokens)。但是如果该(返回空 Tokens 对象的)宏调用是其他表达式的一部分,编译将报错,因为空的 Tokens 对象无法构成有效的表达式。

  • 示例
// file define.cj
macro package define         // 编译 define.cjo 携带 macro 属性
from std import ast.*
public func A() {}          // error: 宏包不允许定义外部可见的非宏定义,此处需报错
public macro M(input: Tokens): Tokens { // macro M 外部可见
  return input
}

需要特殊说明的是,在 macro package 中允许 macro package 和非 macro package 被重导出,在非 macro package 中仅允许非 macro package 被重导出。

  • 示例
// A.cj -- cjc A.cj --compile-macro
macro package A
from std import ast.*

public macro M1(input: Tokens): Tokens {
    return input
}

// B.cj -- cjc B.cj --output-type=dylib -o libB.so
package B
// public import A.* // error: it is not allowed to re-export a macro package in a package.

public func F1(input: Int64): Int64 {
    return input
}

// C.cj -- cjc C.cj --compile-macro -L. -lB
macro package C
public import A.* // correct: macro package is allowed to re-exprot in a macro package.
public import B.* // correct: non-macro package is also allowed to re-exprot in a macro package.
from std import ast.*

public macro M2(input: Tokens): Tokens {
    return @M1(input) + Token(TokenKind.NL) + quote(F1(1))
}

// main.cj -- cjc main.cj -o main -L. -lB
import C.*

main() {
    @M2(let a = 1)
}

其中 main.cjM2 宏展开后得到

let a = 1
F1(1)

这里在 package C 中重导出 B 包里的符号是因为宏扩展中使用了 quote(F1(1)),方便宏的使用者仅需导入宏包,就可以正确的编译宏展开后的代码。

这里有两点需要关注

  1. 当前编译 package C 和 main 时,都需要显式的链接 libB.so;
  2. main.cj中需要使用 import C.* 导入宏和重导出符号,如果仅使用 import C.M2 依旧会报 undeclared identifier 'F1' 的错误信息。

仓颉的宏系统分为两种:非属性宏和属性宏。非属性宏只有一个入参,其输入是被宏修饰的代码。属性宏有两个入参,其增加的属性入参赋予开发者向仓颉宏传入额外信息的能力。

非属性宏

非属性宏的定义格式如下:

public macro MacroName(args: Tokens): Tokens {
    ... // Macro body
}

宏的调用格式如下:

@MacroName(...)

宏调用使用 () 括起来。括号里面可以是任意合法 tokens,也可以是空。

以下地方的宏调用可省略括号。

@MacroName func name() {}        // Before a FuncDecl
@MacroName struct name {}        // Before a StructDecl
@MacroName class name {}         // Before a ClassDecl
@MacroName var a = 1             // Before a VarDecl
@MacroName enum e {}             // Before a Enum
@MacroName interface i {}        // Before a InterfaceDecl
@MacroName extend e <: i {}      // Before a ExtendDecl
@MacroName mut prop i: Int64 {}  // Before a PropDecl
@MacroName @AnotherMacro(input)  // Before a macro call

此外,可省略括号的宏调用,只能出现在被修饰的声明允许出现的位置。

如前面提到的,宏展开过程作用于仓颉语法树,宏展开后,编译器会继续进行后续的编译过程,因此,用户需要保证宏展开后的代码依然是合法的仓颉代码,否则可能引发编译问题。

Tokens 类型定义位于 ast 包中,而宏定义的输入和输出都是 Tokens,因此宏定义必须导入 ast 包。宏定义必须比宏调用点先编译。编译器约束宏的定义与宏的调用不允许在同一包里。即存在宏调用的包中,不允许出现任意宏的定义。由于宏需在包中导出给另一个包使用,因此编译器约束宏定义必须使用 public 修饰。

以在 Linux 平台编译本章开头的示例为例(--compile-macro 的使用方式将在"宏的编译和调试"中进行说明):

# 编译宏定义
cjc macro_definition.cj --compile-macro

# 编译宏调用
cjc macro_call.cj -o main.out

若在 Windows 平台编译本章开头的示例:

# 编译宏定义
cjc macro_definition.cj --compile-macro

# 编译宏调用
cjc macro_call.cj -o main.exe

若用 CJVM 虚拟机则需要用解释器编译本章开头的示例:

# 编译宏定义
cjc macro_definition.cj --compile-macro

# 编译宏调用
cjc macro_call.cj --interp-macro -o main.cbc

下面是几个宏应用的典型示例。

  • 示例 1
// file macro_definition.cj
macro package macro_definition

from std import ast.*

public macro TestDef(input: Tokens): Tokens {
    println("I'm in macro body")
    return input
}

// file macro_call.cj
package macro_calling

import macro_definition.*

main(): Int64 {
    println("I'm in function body")
    let a: Int64 = @TestDef(1 + 2)
    println("a = ${a}")
    return 0
}

上述两段代码分别位于不同文件中,优先编译宏定义文件:macro_definition.cj。在 Linux 系统中,将生成用于包管理的 macro_definition.cjo 和实际的动态库文件。macro_call.cj 的编译需要依赖这两个文件。

我们在用例中添加了打印信息,其中宏定义中的 I'm in macro body 将在编译 macro_call.cj 的期间输出,即对宏定义求值。同时,宏调用点被展开,即在编译

let a: Int64 = @TestDef(1 + 2)

时,将宏返回的 Tokens 更新到调用点的语法树上,得到如下代码:

let a: Int64 = 1 + 2

也就是说,可执行程序中的代码实际变为了:

main(): Int64 {
    println("I'm in function body")
    let a: Int64 = 1 + 2
    println("a = ${a}")
    return 0
}

a 经过计算得到的值为 3,在打印 a 的值时插值为 3。至此,上述程序的运行结果为:

I'm in function body
a = 3

下面看一个更有意义的用宏处理函数的例子,这个宏 ModifyFunc 宏的作用是给 MyFunc 增加 Composer 参数,并在counter++前后插入一段代码。

  • 示例 2
// file macro_definition.cj
macro package macro_definition

from std import ast.*

public macro ModifyFunc(input: Tokens): Tokens {
    println("I'm in macro body")
    return quote(
    func MyFunc(composer: Composer) {
        composer.start(123)
        counter++
        composer.end()
    })
}

// file macro_call.cj
package macro_calling

import macro_definition.*

struct Composer {
    public init() { }
    public func start (id: Int32) { println("start ${id}") }
    public func end () { println("end") }
}

var counter = 0

@ModifyFunc
func MyFunc() {
    counter++
}

main(): Int64 {
    println("I'm in function body")
    let comp = Composer()
    MyFunc(comp)
    println("MyFunc called: ${counter} times")
    return 0
}

同样的,上述两段代码分别位于不同文件中,先编译宏定义文件 macro_definition.cj,再编译宏调用 macro_call.cj 生成可执行文件。

这个例子中,ModifyFunc 宏的输入是一个函数声明,因此可以省略括号:

@ModifyFunc
func MyFunc() {
    counter++
}

经过宏展开后,得到如下代码:

func MyFunc(composer: Composer) {
    composer.start(123)
    counter++
    composer.end()
}

MyFunc 会在 main 中调用,它接受的实参也是在 main 中定义的,从而形成了一段合法的仓颉程序。运行时打印如下:

I'm in function body
start 123
end
MyFunc called: 1 times

属性宏

和非属性宏相比,属性宏的定义会增加一个 Tokens 类型的输入,这个增加的入参可以让开发者输入额外的信息。比如开发者可能希望在不同的调用场景下使用不同的宏展开策略,则可以通过这个属性入参进行标记位设置。同时,这个属性入参也可以传入任意 Tokens,这些 Tokens 可以与被宏修饰的代码进行组合拼接等。下面是一个简单的例子:

// Macro definition with attribute
public macro Foo(attrTokens: Tokens, inputTokens: Tokens): Tokens {
    return attrTokens + inputTokens  // Concatenate attrTokens and inputTokens.
}

如上面的宏定义,属性宏的入参数量为 2,入参类型为 Tokens,在宏定义内,可以对 attrTokensinputTokens 进行一系列的组合,拼接等变换操作,最后返回新的 Tokens

带属性的宏与不带属性的宏的调用类似,属性宏调用时新增的入参 attrTokens 通过 [] 传入,其调用形式为:

// attribute macro with parentheses
var a: Int64 = @Foo[1+](2+3)

// attribute macro without parentheses
@Foo[public]
struct Data {
    var count: Int64 = 100
}
  • 宏 Foo 调用,当参数是 2+3 时,与 [] 内的属性 1+ 进行拼接,经过宏展开后,得到 var a: Int64 = 1+2+3
  • 宏 Foo 调用,当参数是 struct Data 时,与 [] 内的属性 public 进行拼接,经过宏展开后,得到
public struct Data {
    var count: Int64 = 100
}

关于属性宏,需要注意以下几点:

  • 带属性的宏,与不带属性的宏相比,能修饰的 AST 是相同的,可以理解为带属性的宏对可传入参数做了增强。

  • 要求属性宏调用时,[] 内中括号匹配,且可以为空。中括号内只允许对中括号的转义 \[\],该转义中括号不计入匹配规则,其他字符会被作为 Token,不能进行转义。

    @Foo[[miss one](2+3) // Illegal
    @Foo[[matched]](2+3) // Legal
    @Foo[](2+3)          // Legal, empty in []
    @Foo[\[](2+3)        // Legal, use escape for [
    @Foo[\(](2+3)        // Illegal, only [ and ] allowed in []
    
  • 宏的定义和调用的类型要保持一致:如果宏定义有两个入参,即为属性宏定义,调用时必须加上 [],且内容可以为空;如果宏定义有一个入参,即为非属性宏定义,调用时不能使用 []

宏导入时的别名

如果有两个宏定义的包 p1 和 p2,p1 p2 中都定义了一个宏,叫 Foo,我们在使用宏的文件中,同时导入了 p1 和 p2,那如何使用这个宏 Foo? 一种解决方法就是,在导入宏时使用别名。

// f1.cj
macro package p1
from std import ast.*
public macro Foo(input: Tokens) {
    return input
}

// f2.cj
macro package p2
from std import ast.*
public macro Foo(input: Tokens) {
    return input
}

// use.cj
import p1.Foo as Foo1
import p2.Foo as Foo2

@Foo1 // call Foo in p1
class A{}

@Foo2 // call Foo in p2
class B{}

main() {
    let a = A()
    let b = B()
    0
}

如果,开发者在 use.cj 中直接使用 @Foo,而不使用别名,编译器会报错,提示 "ambiguous match"。

另外,支持包名 + 宏名的方式调用宏。如下:

import p1.Foo
import p2.Foo

@p1.Foo
class A{}

@p2.Foo
class A{}

也支持对包名使用别名,如:

import p0.* as p1.* // p0 is a macro package

@p1.Foo // p1 is an alias for p0
class A{}

嵌套宏

仓颉语言不支持宏定义的嵌套;有条件地支持在宏定义和宏调用中进行宏调用。

宏调用在宏定义内

下面是一个宏定义中包含其他宏调用的例子。

// file pkg1.cj
macro package pkg1

from std import ast.*

public macro GetIdent(attr:Tokens, input:Tokens):Tokens {
    return quote(
        let decl = (parseDecl(input) as VarDecl).getOrThrow()
        let name = decl.identifier.value
        let size = name.size - 1
        let $(attr) = Token(TokenKind.IDENTIFIER, name[0..size])
    )
}

// file pkg2.cj
macro package pkg2

from std import ast.*
import pkg1.*

public macro Prop(input:Tokens):Tokens {
    let v = parseDecl(input)
    @GetIdent[ident](input)
    return quote(
        $(input)
        public prop $(ident): $(decl.declType) {
            get() {
                this.$(v.identifier)
            }
        }
    )
}

// file pkg3.cj
package pkg3

import pkg2.*
class A {
    @Prop
    private let a_: Int64 = 1
}

main() {
    let b = A()
    println("${b.a}")
}

注意,按照宏定义必须比宏调用点先编译的约束,上述 3 个文件的编译顺序必须是:pkg1 -> pkg2 -> pkg3。如下宏定义:

public macro Prop(input:Tokens):Tokens {
    let v = parseDecl(input)
    @GetIdent[ident](input)
    return quote(
        $(input)
        public prop $(ident): $(decl.declType) {
            get() {
                this.$(v.identifier)
            }
        }
    )
}

会先被展开成如下代码,再进行编译。

public macro Prop(input: Tokens): Tokens {
    let v = parseDecl(input)

    let decl = (parseDecl(input) as VarDecl).getOrThrow()
    let name = decl.identifier.value
    let size = name.size - 1
    let ident = Token(TokenKind.IDENTIFIER, name[0 .. size])

    return quote(
        $(input)
        public prop $(ident): $(decl.declType) {
            get() {
                this.$(v.identifier)
            }
        }
    )
}

宏调用在宏调用中

嵌套宏的常见场景,是宏修饰的代码块中,出现了宏调用。一个具体的例子如下:

// file pkg1.cj
macro package pkg1

from std import ast.*

public macro Foo(input: Tokens): Tokens {
    return input
}

public macro Bar(input: Tokens): Tokens {
    return input
}

// file pkg2.cj
macro package pkg2

from std import ast.*

public macro AddToMul(inputTokens: Tokens): Tokens {
    var expr: BinaryExpr = match (parseExpr(inputTokens) as BinaryExpr) {
        case Some(v) => v
        case None => throw Exception()
    }
    var op0: Expr = expr.leftExpr
    var op1: Expr = expr.rightExpr
    return quote(($(op0)) * ($(op1)))
}

// file pkg3.cj
package pkg3

import pkg1.*
import pkg2.*
@Foo
struct Data {
    let a = 2
    let b = @AddToMul(2+3)

    @Bar
    public func getA() {
        return a
    }

    public func getB() {
        return b
    }
}

main(): Int64 {
    let data = Data()
    var a = data.getA() // a = 2
    var b = data.getB() // b = 6
    println("a: ${a}, b: ${b}")
    return 0
}

如上代码所示,宏 Foo 修饰了 struct Data,而在 struct Data 内,出现了宏调用 AddToMulBar。这种嵌套场景下,代码变换的规则是:将嵌套内层的宏 (AddToMulBar) 展开后,再去展开外层的宏 (Foo)。允许出现多层宏嵌套,代码变换的规则总是由内向外去依次展开宏。

嵌套宏可以出现在带括号和不带括号的宏调用中,二者可以组合,但用户需要保证没有歧义,且明确宏的展开顺序:

var a = @Foo(@Foo1(2 * 3)+@Foo2(1 + 3))  // Foo1, Foo2 have to be defined.

@Foo1 // Foo2 expands first, then Foo1 expands.
@Foo2[attr: struct] // Attribute macro can be used in nested macro.
struct Data{
    @Foo3 @Foo4[123] var a = @Bar1(@Bar2(2 + 3) + 3)  // Bar2, Bar1, Foo4, Foo3 expands in order.
    public func getA() {
        return @Foo(a + 2)
    }
}

在嵌套场景下,存在多个宏时,有时不同宏间需要共享一些信息,往往通过在宏定义文件里定义某些全局变量的方式实现。不同的宏均可以访问、修改这些变量,其访问顺序与宏调用展开的顺序一致。

内层宏可以调用库函数 AssertParentContext 来保证内层宏调用一定嵌套在特定的外层宏调用中。如果内层宏调用这个函数时没有嵌套在给定的外层宏调用中,该函数将抛出一个错误。库函数 InsideParentContext 同样用于检查内层宏调用是否嵌套在特定的外层宏调用中,该函数返回一个布尔值。下面是一个简单的例子:

宏定义如下:

public macro Outer(input: Tokens): Tokens {
    return input
}

public macro Inner(input: Tokens): Tokens {
    AssertParentContext("Outer")
    return input
}

宏调用如下:

@Outer var a = 0
@Inner var b = 0 // error: The macro call 'Inner' should with the surround code contains a call 'Outer'.

如上代码所示,Inner 宏在定义时使用了 AssertParentContext 函数用于检查其在调用阶段是否位于 Outer 宏中,在代码示例的宏调用场景下,由于 OuterInner 在调用时不存在这样的嵌套关系,因此编译器将报告一个错误。

内层宏也可以通过发送键/值对的方式与外层宏通信。当内层宏执行时,通过调用标准库函数 SetItem 向外层宏发送信息;随后,当外层宏执行时,调用标准库函数 GetChildMessages 接收每一个内层宏发送的信息(一组键/值对映射)。下面是一个简单的例子:

宏定义如下:

public macro Outer(input: Tokens): Tokens {
    let messages = GetChildMessages("Inner")
    for (m in messages) {
        let value1 = m.getString("key1") // get value: "value1"
        let value2 = m.getString("key2") // get value: "value2"
    }
    return input
}

public macro Inner(input: Tokens): Tokens {
    AssertParentContext("Outer")
    SetItem("key1", "value1")
    SetItem("key2", "value2")
    return input
}

宏调用如下:

@Outer(
    @Inner var cnt = 0
)

在上面的代码中,内层宏 Inner 通过 SetItem 向外层宏发送信息;Outer 宏通过 GetChildMessages 函数接收到 Inner 发送的一组信息对象(Outer 中可以调用多次 Inner);最后通过该信息对象的 getString 函数接收对应的值。

内置宏

Attribute

仓颉语言内部提供 Attribute,开发者通过内置的 Attribute 来对某个声明设置属性值,从而达到标记声明的目的。属性值可以是 identifier 或者 string。下面是一个简单的例子:

@Attribute[State] var cnt = 0
@Attribute["Binding"] var bcnt = 0

同时,libast 提供了 getAttrs() 方法用于获取节点的属性,以及 hasAttr(attrs: String) 方法用于判断当前节点是否具有某个属性。下面是一个具体的例子:

宏定义如下:

public macro Component(input: Tokens): Tokens {
    var varDecl = parseDecl(input)
    if (varDecl.hasAttr("State")) { // 如果改节点被标记了属性且值为 “State” 返回 true, 否则返回 false
        var attrs = varDecl.getAttrs() // 返回一组 Tokens
        println(attrs[0].value)
    }
    return input
}

宏调用如下:

@Component(
    @Attribute[State] var cnt = 0
)

源码位置

仓颉语言内部提供了如下三个内置宏,用于在编译阶段获取源码的相关信息。

  • @sourcePackage() 展开后是一个 String 类型的字面量,内容为当前宏所在的源码的包名。
  • @sourceFile() 展开后是一个 String 类型的字面量,内容为当前宏所在的源码的文件名。
  • @sourceLine() 展开后是一个 Int64 类型的字面量,内容为当前宏所在的源码的代码行。

下面是相关示例:

// source.cj
package default

main() {
    var pkgName = @sourcePackage()  // pkgName 的值为 "default"
    var fileName = @sourceFile()    // fileName 的值为 "source.cj"
    var line = @sourceLine()        // line  的值为 7 (假设该语句位于文件的第 7 行)
    return 0
}

memoize 宏分析

至此,关于 Tokens,quote,以及宏定义,宏调用的概念已经介绍完毕。再回到记忆优化宏 memoize 这个例子,可以较清楚地理解其背后的工作原理。

在 macro_definition.cj 这个文件中,我们定义一个宏 memoize,其使用了 Tokens 类型,以及 parseDecl API,它们都是在标准库 ast 包中定义的,需要导入 ast 包。

我们使用了属性宏,这样可以让用户传入标记位(@memoize[true] 里的 true 即是标记位),选择是否进行记忆优化。memoize 宏定义的入参和返回值类型都是 Tokens,即表示一段代码块,对于宏来说代码即数据。在宏定义内部,若不进行优化,则将输入的代码原样返回;若进行优化,则重新构造一段代码,这段代码里有函数 Fib 前添加的记录函数入参 - 函数值的 HashMap 变量,Fib 函数也被重新设计,增加了查表优化功能。这段代码是放在 quote 表达式里的,表示要将其转为 Tokens 返回。

宏调用 @memoize[true] 修饰后的 Fib 函数最终会在编译期被展开为如下代码:

var memoMap: HashMap<Int64, Int64> = HashMap<Int64, Int64>()
func Fib(n: Int64): Int64 {
    if (memoMap.contains(n)) {
        return memoMap.get(n).getOrThrow()
    }
    if (n == 0 || n == 1) {
        return n
    }
    let ret = Fib(n-1) + Fib(n-2)
    memoMap.put(n, ret)
    return ret
}

可以看到,memoize 这个宏的主要作用是在编译期做一些代码生成的工作,让原程序执行效率更高,且优化细节不直接暴露在宏调用处,代码简洁,可读性高。

我们在使用时,先编译好宏定义文件,再编译宏调用文件。宏定义编译一次后,可被多个宏调用文件使用。若只是宏调用文件发生变动,宏定义不变,无需重新编译宏定义文件。

这里请注意,我们的宏 memoize 目前只能修饰 Fib 函数,函数名可以不同,但是不能用来修饰参数列表和返回值类型不同的函数,即 memoize 宏目前并不是通用的。开发者可以针对自己的需求,去写一个更通用的版本,比如解析函数的参数列表获取入参,解析返回值类型,根据这些信息去构造 HashMap,另外需要解析函数体获取最后的函数返回值,这些信息均可以通过 ast 包提供的 API 获取。

宏的编译和调试

宏的编译和使用流程

编译调用宏的文件,要求预先编译好宏定义文件,宏定义和宏调用要在不同的 package 中,下面是个简单的例子。

同普通仓颉工程一样,建议将源码放在 src 目录下,目录结构如下:

// Directory layout.
src
`-- macros
      |-- m.cj

`-- demo.cj

宏定义放在 macros(用户自定义) 子目录下:

// m.cj
// In this file, we define the macro Inner, Outer.
macro package define
from std import ast.*

public macro Inner(input: Tokens) {
    return input
}

public macro Outer(input: Tokens) {
    return input
}

实际调用宏的文件如下:

// demo.cj
import define.*
@Outer
class Demo {
    @Inner var state = 1
    var cnt = 42
}

@Outer
main() {
    println("test macro")
    0
}

以下为 Linux 平台的编译命令(具体编译选项会随着 cjc 更新而演进,以最新 cjc 的编译选项为准):

# 当前目录: src

# 先编译宏定义文件在当前目录产生默认的动态库文件(允许指定动态库的路径,但不能指定动态库的名字)
cjc macros/m.cj --compile-macro

# 编译使用宏的文件,宏替换完成,产生可执行文件
cjc demo.cj -o demo

# 编译时增加 --parallel-macro-expansion 选项
# 两个 @Outer 宏调用可以并行展开,从而缩短编译时间
cjc --parallel-macro-expansion demo.cj -o demo

# 运行可执行文件
./demo

宏的调试

借助宏在编译期做代码生成时,如果发生错误,处理起来十分棘手,这是开发者经常遇到但一般很难定位的问题。这是因为,开发者写的源码,经过宏的变换后变成了不同的代码片段。编译器抛出的错误信息是基于宏最终生成的代码进行提示的,但这些代码在开发者的源码中没有体现。

为了解决这个问题,仓颉宏提供 debug 模式,在这个模式下,开发者可以从编译器为宏生成的 debug 文件中看到完整的宏展开后的代码,如下所示:

// code before macro expansion
// demo.cj
@Outer
class Demo {
    @Inner var state = 1
    var cnt = 42
}

在编译使用宏的文件时,在选项中,增加 --debug-macro,即使用仓颉宏的 debug 模式。

cjc --debug-macro demo.cj

debug 模式下,会生成临时文件 demo.cj.macrocall,对应宏展开的部分如下:

// demo.cj.macrocall
// ===== Emitted by Macro Outer at line 3 =====
class Demo {
    var stateNew = 1
    var cnt = 42
    func getcnt( ) {
        return cnt
    }
}
// ===== End of the Emit =====

如果宏展开后的代码有语义错误,则编译器的错误信息会溯源到宏展开后代码的具体行列号。仓颉宏的 debug 模式有以下注意事项:

  • 宏的 debug 模式会重排源码的行列号信息,不适用于某些特殊的换行场景。比如
// before expansion
@M{} - 2 // macro M return 2

// after expansion
// ===== Emmitted my Macro M at line 1 ===
2
// ===== End of the Emit =====
- 2

这些因换行符导致语义改变的情形,不应使用 debug 模式。

  • 不支持宏调用在宏定义内的调试,会编译报错。
public macro M(input: Tokens) {
    let a = @M2(1+2) // M2 is in macro M, not suitable for debug mode.
    return input + quote($a)
}
  • 不支持带括号宏的调试。
// main.cj

main() {
    // For macro with parenthesis, newline introduced by debug will change the semantics
    // of the expression, so it is not suitable for debug mode.
    let t = @M(1+2)
    0
}