元编程
元编程是一种将计算机程序(代码)当做数据的编程技术,从而修改,更新,替换已有的程序。例如可以将一些计算过程从运行时挪到编译时,并在编译期进行代码生成。仓颉语言提供的元编程能力,能支持代码复用,操作语法树,编译期求值,甚至自定义文法等功能。
下面是一个利用元编程解决具体问题的例子,利用仓颉宏为某些需要递归计算的函数进行记忆优化。
// 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
的入参可以为任意合法的仓颉代码且可以为空,但当前编译器不支持传入代码中有宏调用表达式。
宏
仓颉实现元编程的主要方式是使用宏。仓颉的宏是语法宏,其定义形式上类似于函数,也和函数一样可以被调用。不同点是:
- 宏定义所在的
package
需使用macro package
来声明。 - 宏定义需要使用关键字
macro
。 - 宏定义的输入和输出类型必须是 Tokens。
- 宏调用需要使用
@
。
从输入代码序列到输出新的代码序列的这个映射过程称为宏展开。宏在仓颉代码编译时进行展开,一直展开到目标代码中没有宏为止。宏展开的过程会实际执行宏定义体,即宏是在编译期完成求值,展开后的结果重新作用于仓颉的语法树,继续后面的编译和执行流程。
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.cj
中 M2
宏展开后得到
let a = 1
F1(1)
这里在 package C 中重导出 B 包里的符号是因为宏扩展中使用了 quote(F1(1))
,方便宏的使用者仅需导入宏包,就可以正确的编译宏展开后的代码。
这里有两点需要关注
- 当前编译 package C 和 main 时,都需要显式的链接 libB.so;
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
,在宏定义内,可以对 attrTokens
和 inputTokens
进行一系列的组合,拼接等变换操作,最后返回新的 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
内,出现了宏调用 AddToMul
和 Bar
。这种嵌套场景下,代码变换的规则是:将嵌套内层的宏 (AddToMul
和 Bar
) 展开后,再去展开外层的宏 (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
宏中,在代码示例的宏调用场景下,由于 Outer
和 Inner
在调用时不存在这样的嵌套关系,因此编译器将报告一个错误。
内层宏也可以通过发送键/值对的方式与外层宏通信。当内层宏执行时,通过调用标准库函数 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
}