使用新版本 (2024-07-19 16:10发布的)
1、宏的简介
宏可以理解为一种特殊的函数
。一般的函数在输入的值上进行计算
,然后输出一个新的值,而宏的输入和输出都是程序本身
。在输入一段程序(或程序片段,例如表达式),输出一段新的程序,这段输出的程序随后用于编译和执行
。为了把宏的调用和函数调用区分开来,我们在调用宏时使用 @ 加上宏的名称
。
让我们从一个简单的例子开始:假设我们想在调试过程中打印某个表达式的值,同时打印出表达式本身。
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 作为返回值。为了使用仓颉宏,我们需要详细了解 Tokens
、quote
和插值
的概念,下面我们将分别介绍它们。
2、Tokens 相关类型和 quote 表达式
2.1 Token 类型
宏操作的基本类型是 Tokens
,代表一个程序片段。Tokens 由若干个 Token 组成
,每个 Token 可以理解为用户可操作的词法单元
。一个 Token 可能是一个标识符
(例如变量名等)、字面量
(例如整数、浮点数、字符串)、关键字
或运算符
。每个 Token 包含它的类型
、内容
和位置信息
。
Token 的类型取值为 enum TokenKind
中的元素。TokenKind 的可用值详见《仓颉编程语言库 API》文档。通过提供 TokenKind
和(对于标识符和字面量)Token 的字符串
,可以直接构造任何 Token。具体的构造函数如下:
public struct Token {
public let kind: TokenKind
public let pos: Position
public let value: String
public var delimiterNum: UInt16 = 1
public init()
public init(kind: TokenKind)
public init(kind: TokenKind, value: String)
}
下面给出一些Token构造的例子:
import std.ast.*
let tk1 = Token(TokenKind.ADD) // '+'运算符
let tk2 = Token(TokenKind.FUNC) // func关键字
let tk3 = Token(TokenKind.IDENTIFIER, "x") // x标识符
let tk4 = Token(TokenKind.INTEGER_LITERAL, "3") // 整数字面量
let tk5 = Token(TokenKind.STRING_LITERAL, "xyz") // 字符串字面量
2.2 Tokens 类型
一个 Tokens 代表由多个 Token 组成的序列
。我们可以通过 Token 数组直接构造 Tokens
。下面是 3 种基本的构造 Tokens 实例的方式:
Tokens() // 构造空列表
Tokens(tks: Array<Token>)
Tokens(tks: ArrayList<Token>)
此外,Tokens 类型支持以下函数:
size
:返回 Tokens 中包含 Token 的数量get(index: Int64)
:获取指定下标的 Token 元素[]
:获取指定下标的 Token 元素+
:拼接两个 Tokens,或者直接拼接 Tokens 和 Tokendump()
:打印包含的所有 Token,供调试使用toString()
:打印 Tokens 对应的程序片段
在下面的案例中,我们使用构造函数直接构造 Token 和 Tokens,然后打印详细的调试信息:
import std.ast.*
let tks = Tokens(Array<Token>([
Token(TokenKind.INTEGER_LITERAL, "1"),
Token(TokenKind.ADD),
Token(TokenKind.INTEGER_LITERAL, "2")
]))
main() {
println(tks)
tks.dump()
}
预期输出如下(具体的位置信息可能不同):
在 dump 信息中,包含了每个 Token 的类型
(description)和值
(token_literal_value),最后打印每个 Token 的位置信息
。
2.3 quote 表达式和插值
在大多数情况下,直接构造和拼接 Tokens 会比较繁琐。因此,仓颉语言提供了 quote 表达式
来从代码模版来构造 Tokens
。之所以说是代码模版,因为在 quote 中可以使用 $(...) 来插入上下文中的表达式
。插入的表达式的类型需要支持被转换为 Tokens(具体来说,实现了 ToTokens 接口)。在标准库中,以下类型实现了 ToTokens 接口:
- 所有的节点类型(节点将在语法节点中讨论)
- Token 和 Tokens 类型
- 所有基础数据类型:整数、浮点数、Bool、Rune和String
Array<T>
和ArrayList<T>
,这里对 T 的类型有限制,并根据 T 的类型不同,输出不同的分隔符,详细请见《仓颉编程语言库 API》文档。
下面的例子展示 Array 和基础数据类型的插值。
import std.ast.*
let intList = Array<Int64>([1, 2, 3, 4, 5])
let float: Float64 = 1.0
let str: String = "Hello"
let tokens = quote(
arr = $(intList)
x = $(float)
s = $(str)
)
main() {
println(tokens)
}
更多插值的用法可以参考 使用 quote 插值语法节点。
3、语法节点
在仓颉语言的编译过程中,首先通过词法分析
将代码转换成 Tokens
,然后对 Tokens 进行语法解析
,得到一个语法树
。每个语法树的节点
可能是一个表达式
、声明
、类型
、模式
等。仓颉 ast 库提供了每种节点对应的类,它们之间具有适当的继承关系。其中,主要的抽象类如下:
- Node:所有语法节点的父类
- TypeNode:所有类型节点的父类
- Expr:所有表达式节点的父类
- Decl:所有声明节点的父类
- Pattern:所有模式节点的父类
具体节点的类型众多,具体细节请参考 《仓颉编程语言库 API》文档。在下面的案例中,我们主要使用以下节点:
- BinaryExpr:二元运算表达式
- FuncDecl:函数的声明
3.1 节点的解析
通过 ast 库,基本上每种节点都可以从 Tokens 解析。有两种调用解析的方法。
3.1.1 使用解析表达式和声明的函数。
parseExpr(input: Tokens): Expr
:将输入的 Tokens 解析为表达式parseExprFragment(input: Tokens, startFrom!: Int64 = 0): (Expr, Int64)
:将输入 Tokens 的一个片段解析为表达式,片段从 startFrom 索引开始,解析可能只消耗从索引 startFrom 开始的片段的一部分,并返回第一个未被消耗的 Token 的索引(如果消耗了整个片段,返回值为 input.size)parseDecl(input: Tokens, astKind!: String = "")
:将输入的 Tokens 解析为声明,astKind 为额外的设置,具体请见《仓颉编程语言库 API》文档。parseDeclFragment(input: Tokens, startFrom!: Int64 = 0): (Decl, Int64)
:将输入 Tokens 的一个片段解析为声明,startFrom 参数和返回索引的含义和 parseExpr 相同。
我们通过代码案例展示这些函数的使用:
let tks1 = quote(a + b)
let tks2 = quote(a + b, x + y)
let tks3 = quote(
func f1(x: Int64) {
return x + 1 }
)
let tks4 = quote(
func f1(x: Int64) {
return x + 1 }
func f2(x: Int64) {
return x + 2 }
)
let binExpr1 = parseExpr(tks1)
println("binExpr1 is BinaryExpr: ${binExpr1 is BinaryExpr}")
let (binExpr2, mid) = parseExprFragment(tks2)
let (binExpr3, end) = parseExprFragment(tks2, startFrom: mid + 1) // 跳过逗号
println("size = ${tks2.size}, mid = ${mid}, end = ${end}")
let funcDecl1 = parseDecl(tks3)
println("funcDecl1 is FuncDecl: ${funcDecl1 is FuncDecl}")
let (funcDecl2, mid2) = parseDeclFragment(tks4)
let (funcDecl3, end2) = parseDeclFragment(tks4, startFrom: mid2)
println("size = ${tks4.size}, mid = ${mid2}, end = ${end2}")
输出结果是:
binExpr1 is BinaryExpr: true
size = 7, mid = 3, end = 7
funcDecl1 is FuncDecl: true
size = 29, mid = 15, end = 29
3.1.2 使用构造函数进行解析
大多数节点类型都支持 init(input: Tokens) 构造函数
,将输入的 Tokens 解析为相应类型的节点,例如:
import std.ast.*
let binExpr = BinaryExpr(quote(a + b))
let funcDecl = FuncDecl(quote(func f1(x: Int64) {
return x + 1 }))
如果解析失败将抛出异常。这种解析方式适用于类型已知的代码片段
,解析后不需要再手动转换成具体的子类型。
3.2 节点的组成部分
从 Tokens 解析出节点之后,我们可以查看节点的组成部分。作为例子,我们列出 BinaryExpr 和 FuncDecl 的组成部分,关于其他节点的更详细的解释请见《仓颉编程语言库 API》文档。
- BinaryExpr 节点:
- leftExpr: Expr:运算符左侧的表达式
- op: Token:运算符
- rightExpr: Expr:运算符右侧的表达式
- FuncDecl 节点(部分):
- identifier: Token:函数名
- funcParams:
ArrayList<FuncParam>
:参数列表 - declType: TypeNode:返回值类型
- block: Block:函数体
- FuncParam节点(部分):
- identifier: Token:参数名
- paramType: TypeNode:参数类型
- Block节点(部分):
nodes: ArrayList<Node>
:块中的表达式和声明
每个组成部分都是 public mut prop
,因此可以被查看和更新。我们通过一些例子展示更新的结果。
3.2.1 BinaryExpr 案例
let binExpr = BinaryExpr(quote(x * y))
binExpr.leftExpr = BinaryExpr(quote(a + b))
println(binExpr.toTokens())
binExpr.op = Token(TokenKind.ADD)
println(binExpr.toTokens())
首先,通过解析,获得 binExpr 为节点 x * y,图示如下:
*
/ \
x y
第二步,我们将左侧的节点(即 x)替换为 a + b,因此,获得的语法树如下:
*
/ \
+ y
/ \
a b
当输出这个语法树的时候,我们必须在 a + b
周围添加括号,得到 (a + b) * y
(如果输出a + b * y
,含义为先做乘法,再做加法,与语法树的含义不同)。ast 库具备在输出语法树时自动添加括号的功能。
第三步,我们将语法树根部的运算符从 *
替换为 +
,因此得到语法树如下:
+
/ \
+ y
/ \
a b
这个语法树可以输出为 a + b + y,因为加法本身就是左结合的,不需要在左侧添加括号。
3.2.1 FuncDecl 案例
let funcDecl = FuncDecl(quote(func f1(x: Int64) {
x + 1 }))
funcDecl.identifier = Token(TokenKind.IDENTIFIER, "foo")
println("Number of parameters: ${funcDecl.funcParams.size}")
funcDecl.funcParams[0].identifier = Token(TokenKind.IDENTIFIER, "a")
println("Number of nodes in body: ${funcDecl.block.nodes.size}")
let binExpr = (funcDecl.block.nodes[0] as BinaryExpr).getOrThrow()
binExpr.leftExpr = parseExpr(quote(a))
println(funcDecl.toTokens())
在这个案例中,我们首先通过解析构造出了一个 FuncDecl 节点,然后分别修改了该节点的函数名、参数名,以及函数体中表达式的一部分。输出结果是:
3.3 使用 quote 插值语法节点
任何 AST 节点
都可以在 quote 语句中插值,部分 AST 节点的 ArrayList 列表
也可以被插值(主要对应实际情况中会出现这类节点列表的情况)。插值直接通过 $(node)
表达即可,其中 node 是任意节点类型的实例。
下面,我们通过一些案例展示节点的插值。
var binExpr = BinaryExpr(quote(1 + 2))
let a = quote($(binExpr))
let b = quote($binExpr)
let c = quote($(binExpr.leftExpr))
let d = quote($binExpr.leftExpr) // 注意输出
println("a: ${a.toTokens()}")
println("b: ${b.toTokens()}")
println("c: ${c.toTokens()}")
println("d: ${d.toTokens()}")
一般来说,插值运算符后面的表达式使用小括号限定作用域,例如 $(binExpr)
。但是当后面只跟单个标识符的时候
,小括号可省略,即可写为 $binExpr
。因此,在案例中 a 和 b 都在 quote 中插入了 binExpr节点,结果为 1 + 2。然而,如果插值运算符后面的表达式更复杂,不加小括号可能造成作用域出错。例如,表达式 binExpr.leftExpr 求值为 1 + 2 的左表达式,即 1,因此 c 正确赋值为 1。但 d 中的插值被解释为 ($binExpr).leftExpr
,因此结果是 1 + 2.leftExpr。为了明确插值的作用域,我们推荐在插值运算符中使用小括号。
下面的案例展示节点列表(ArrayList)的插值。
import std.ast.*
import std.collection.*
main() {
var incrs = ArrayList<Node>()
for (i in 1..=5) {
incrs.append(parseExpr(quote(x += $(i))))
}
var foo = quote(
func foo(n: Int64) {
let x = n
$(incrs)
x
})
println(foo)
}
在这个案例中,我们创建了一个节点列表 incrs
,包含表达式 x += 1,…,x += 5。对 incrs 的插值将节点依次列出
,在每个节点后换行
。这适用于插入需要依次执行的表达式和声明的情况。
下面的案例展示在某些情况下,需要在插值周围添加括号,以保证正确性。
var binExpr1 = BinaryExpr(quote(x + y))
var binExpr2 = BinaryExpr(quote($(binExpr1) * z)) // 错误:得到 x + y * z
println("binExpr2: ${binExpr2.toTokens()}")
println("binExpr2.leftExpr: ${binExpr2.leftExpr.toTokens()}")
println("binExpr2.rightExpr: ${binExpr2.rightExpr.toTokens()}")
var binExpr3 = BinaryExpr(quote(($(binExpr1)) * z)) // 正确:得到 (x + y) * z
println("binExpr3: ${binExpr3.toTokens()}")
首先,我们构建了表达式 x + y,然后将该表达式插入到模版 $(binExpr1) * z
中。这里的意图是得到一个先计算 x + y,然后再乘 z 的表达式,但是,插值的结果是 x + y * z,先做 y * z,然后再加 x。这是因为插值不会自动添加括号以保证被插入的表达式的原子性(这和前一阶介绍的 leftExpr 的替换不同)。因此,需要在 $(binExpr1)
周围添加小括号,保证得到正确的结果。
4、宏的实现
本章节介绍仓颉宏的定义和使用,仓颉宏可以分为非属性宏
和属性宏
。同时本章节还会介绍宏出现嵌套时的行为
。
4.1 非属性宏
非属性宏只接受被转换的代码,不接受其他参数(属性),其定义格式如下:
import std.ast.*
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
宏展开过程作用于仓颉语法树,宏展开后,编译器会继续进行后续的编译过程,因此,用户需要保证宏展开后的代码依然是合法的仓颉代码
,否则可能引发编译问题。当宏用于声明时
,如果省略括号,宏的输入必须是语法合法的声明,IDE 也会提供相应的语法检查和高亮。
下面是几个宏应用的典型示例。
示例 1
宏定义文件 macro_definition.cj
macro package macro_definition
import std.ast.*
public macro testDef(input: Tokens): Tokens {
println("I'm in macro body")
return input
}
- 宏调用文件
main.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
}
上述代码的编译过程可以参考宏的编译和使用。
我们在用例中添加了打印信息,其中宏定义中的 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++前后插入一段代码。
宏定义文件 macro_definition.cj
// file macro_definition.cj
macro package macro_definition
import std.ast.*
public macro ModifyFunc(input: Tokens): Tokens {
println("I'm in macro body")
let funcDecl = FuncDecl(input)
return quote(
func $(funcDecl.identifier)(id: Int64) {
println("start ${id}")
$(funcDecl.block.nodes)
println("end")
})
}
宏调用文件 main.cj
package macro_calling
import macro_definition.*
var counter = 0
@ModifyFunc
func MyFunc() {
counter++
}
func exModifyFunc() {
println("I'm in function body")
MyFunc(123)
println("MyFunc called: ${counter} times")
return 0
}
同样的,上述两段代码分别位于不同文件中,先编译宏定义文件 macro_definition.cj,再编译宏调用 macro_call.cj 生成可执行文件。
这个例子中,ModifyFunc 宏的输入是一个函数声明,因此可以省略括号:
@ModifyFunc
func MyFunc() {
counter++
}
经过宏展开后,得到如下代码:
func MyFunc(id: Int64) {
println("start ${id}")
counter++
println("end")
}
MyFunc 会在 main 中调用,它接受的实参也是在 main 中定义的,从而形成了一段合法的仓颉程序。运行时打印如下:
4.2 属性宏
和非属性宏相比,属性宏
的定义会增加一个 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 []
- 宏的定义和调用的类型要保持一致:如果宏定义有两个入参,即为属性宏定义,调用时必须加上
[]
,且内容可以为空;如果宏定义有一个入参,即为非属性宏定义,调用时不能使用[]
。
4.3 嵌套宏
仓颉语言不支持宏定义的嵌套
;有条件地支持在宏定义和宏调用中嵌套宏调用
。
4.3.1 宏定义中嵌套宏调用
下面是一个宏定义中包含其他宏调用的例子。
宏包 pkg1 中定义 getIdent
宏:
macro package pkg1
import std.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])
)
}
宏包 pkg2 中定义 Prop
宏,其中嵌套了 getIdent
宏的调用:
macro package pkg2
import std.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)
}
}
)
}
宏调用包 pkg3 中调用 Prop
宏:
package pkg3
import pkg2.*
class A {
@Prop
private let a_: Int64 = 1
}
main() {
let b = A()
println("${b.a}")
}
注意,按照宏定义必须比宏调用点先编译的约束,上述 3 个文件的编译顺序必须是:pkg1 -> pkg2 -> pkg3。pkg2 中的 Prop 宏定义:
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)
}
}
)
}
4.3.2 宏调用中嵌套宏调用
嵌套宏的常见场景,是宏修饰的代码块中,出现了宏调用。一个具体的例子如下:
pkg1 包中定义 Foo 和 Bar 宏:
macro package pkg1
import std.ast.*
public macro Foo(input: Tokens): Tokens {
return input
}
public macro Bar(input: Tokens): Tokens {
return input
}
pkg2 包中定义 addToMul 宏:
macro package pkg2
import std.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)))
}
pkg3 包中使用上面定义的三个宏:
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)
}
}
4.3.3 嵌套宏之间的消息传递
这里指的是宏调用的嵌套
。
内层宏可以调用库函数 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 接收每一个内层宏发送的信息
(一组键/值对映射)。下面是一个简单的例子。
宏定义如下:
macro package define
import std.ast.*
public macro Outer(input: Tokens): Tokens {
let messages = getChildMessages("Inner")
let getTotalFunc = quote(public func getCnt() {
)
for (m in messages) {
let identName = m.getString("identifierName")
// let value = m.getString("key") // 接收多组消息
getTotalFunc.append(Token(TokenKind.IDENTIFIER, identName))
getTotalFunc.append(quote(+))
}
getTotalFunc.append(quote(0))
getTotalFunc.append(quote(}))
let funcDecl = parseDecl(getTotalFunc)
let decl = (parseDecl(input) as ClassDecl).getOrThrow()
decl.body.decls.append(funcDecl)
return decl.toTokens()
}
public macro Inner(input: Tokens): Tokens {
assertParentContext("Outer")
let decl = parseDecl(input)
setItem("identifierName", decl.identifier.value)
// setItem("key", "value") // 可以通过不同的key值传递多组消息
return input
}
宏调用如下:
import define.*
@Outer
class Demo {
@Inner var state = 1
@Inner var cnt = 42
}
main(): Int64 {
let d = Demo()
println("${d.getCnt()}")
return 0
}
在上面的代码中,Outer 接收两个 Inner 宏发送来的变量名,自动为类添加如下内容:
public func getCnt() {
state + cnt + 0
}
具体流程为:内层宏 Inner 通过 setItem 向外层宏发送信息;Outer 宏通过 getChildMessages 函数接收到 Inner 发送的一组信息对象(Outer 中可以调用多次 Inner);最后通过该信息对象的 getString 函数接收对应的值。
5、编译、报错与调试
5.1 宏的编译和使用
当前编译器约束宏的定义与宏的调用不允许在同一包里
。宏包必须首先被编译
,然后再编译宏调用的包
。在宏调用的包中,不允许出现宏的定义。由于宏需在包中导出给另一个包使用,因此编译器约束宏定义必须使用 public 修饰
。
下面介绍一个简单的例子。
源码目录结构如下:
// Directory layout.
src
`-- macros |-- m.cj `-- demo.cj
宏定义放在 _macros_
子目录下:
// macros/m.cj
// In this file, we define the macro Inner, Outer.
macro package define
import std.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
@Inner var cnt = 42
}
main() {
println("test macro")
0
}
以下为 Linux 平台的编译命令(具体编译选项会随着 cjc 更新而演进,以最新 cjc 的编译选项为准):
# 当前目录: src
# 先编译宏定义文件在当前目录产生默认的动态库文件(允许指定动态库的路径,但不能指定动态库的名字)
cjc macros/m.cj --compile-macro
# 编译使用宏的文件,宏替换完成,产生可执行文件
cjc demo.cj -o demo
# 运行可执行文件
./demo
在 Linux 平台上,将生成用于包管理的 macro_define.cjo 和实际的动态库文件。
若在 Windows 平台:
# 当前目录: src
# 先编译宏定义文件在当前目录产生默认的动态库文件(允许指定动态库的路径,但不能指定动态库的名字)
cjc macros/m.cj --compile-macro
# 编译使用宏的文件,宏替换完成,产生可执行文件
cjc demo.cj -o demo.exe
5.2 并行宏展开
可以在编译宏调用文件
时添加 --parallel-macro-expansion
选项,启用并行宏展开的能力。编译器会自动分析宏调用之间的依赖关系,无依赖关系的宏调用可以并行执行,如上述例子中的两个 @Inner
就可以并行展开,如此可以缩短整体编译时间。
如果
宏函数依赖一些全局变量
,使用并行宏展开会存在风险。
macro package define
import std.ast.*
import std.collection.*
var Counts = HashMap<String, Int64>()
public macro Inner(input: Tokens) {
for (t in input) {
if (t.value.size == 0) {
continue
}
// 统计所有有效token value的出现次数
if (!Counts.contains(t.value)) {
Counts[t.value] = 0
}
Counts[t.value] = Counts[t.value] + 1
}
return input
}
public macro B(input: Tokens) {
return input
}
参考上述代码,如果 @Inner 的宏调用出现在多处,并且启用了并行宏展开选项,则访问全局变量 Counts 就可能存在冲突,导致最后获取的结果不正确。
建议不要在宏函数中使用全局变量,如果必须使用,要么关闭并行宏展开选项,或者可以通过仓颉线程锁对全局变量进行保护。
5.3 diagReport 报错机制
仓颉 ast 包提供了自定义报错接口 diagReport
。方便定义宏的用户,在解析传入 tokens 时,对错误 tokens 内容进行自定义报错。
自定义报错接口提供同原生编译器报错一样的输出格式,允许用户报 warning 和 error 两类错误提示信息
。
diagReport 的函数原型如下:
public func diagReport(level: DiagReportLevel, tokens: Tokens, message: String, hint: String): Unit
其参数含义如下:
- level: 报错信息等级
- tokens: 报错信息中所引用源码内容对应的 tokens
- message: 报错的主信息
- hint: 辅助提示信息
参考如下使用示例。
宏定义文件:
// macro_definition.cj
macro package macro_definition
import std.ast.*
public macro testDef(input: Tokens): Tokens {
for (i in 0..input.size) {
if (input[i].kind == IDENTIFIER) {
diagReport(DiagReportLevel.ERROR, input[i..(i + 1)],
"This expression is not allowed to contain identifier",
"Here is the illegal identifier")
}
}
return input
}
宏调用文件:
// macro_call.cj
package macro_calling
import std.ast.*
import macro_definition.*
main(): Int64 {
let a = @testDef(1)
let b = @testDef(a)
let c = @testDef(1 + a)
return 0
}
编译宏调用文件过程中,会出现如下报错信息:
5.4 使用 --debug-macro 输出宏展开结果
借助宏在编译期做代码生成时,如果发生错误,处理起来十分棘手,这是开发者经常遇到但一般很难定位的问题。这是因为,开发者写的源码,经过宏的变换后变成了不同的代码片段。编译器抛出的错误信息是基于宏最终生成的代码
进行提示的,但这些代码在开发者的源码中没有体现。
为了解决这个问题,仓颉宏提供 debug 模式
,在这个模式下,开发者可以从编译器为宏生成的 debug 文件中看到完整的宏展开后的代码
,如下所示。
宏定义文件:
macro package define
import std.ast.*
public macro Outer(input: Tokens): Tokens {
let messages = getChildMessages("Inner")
let getTotalFunc = quote(public func getCnt() {
)
for (m in messages) {
let identName = m.getString("identifierName")
getTotalFunc.append(Token(TokenKind.IDENTIFIER, identName))
getTotalFunc.append(quote(+))
}
getTotalFunc.append(quote(0))
getTotalFunc.append(quote(}))
let funcDecl = parseDecl(getTotalFunc)
let decl = (parseDecl(input) as ClassDecl).getOrThrow()
decl.body.decls.append(funcDecl)
return decl.toTokens()
}
public macro Inner(input: Tokens): Tokens {
assertParentContext("Outer")
let decl = parseDecl(input)
setItem("identifierName", decl.identifier.value)
return input
}
宏调用文件 macro_calling.cj
:
import define.*
@Outer
class Demo {
@Inner var state = 1
@Inner var cnt = 42
}
main(): Int64 {
let d = Demo()
println("${d.getCnt()}")
return 0
}
在编译宏调用的文件
时,在选项中,增加 --debug-macro
,即使用仓颉宏的 debug 模式。
cjc --debug-macro macro_calling.cj
在 debug 模式下,会生成临时文件 macro_calling.cj.macrocall_
,对应宏展开如下:
/** * Created on 2024/7/30 */
package ohos_app_cangjie_entry.study
import std.ast.*
import ohos_app_cangjie_entry.study.macro_definition.*
/* ===== Emitted by MacroCall @Outer in macro_calling.cj:9:1 ===== */
/* 9.1 */class Demo {
/* 9.2 */ var state = 1
/* 9.3 */ var cnt = 42
/* 9.4 */ public func getCnt() {
/* 9.5 */ state + cnt + 0
/* 9.6 */ }
/* 9.7 */}
/* 9.8 */
/* ===== End of the Emit ===== */
main(): Int64 {
let d = Demo()
println("${d.getCnt()}")
return 0
}
如果宏展开后的代码有语义错误,则编译器的错误信息会溯源到宏展开后代码的具体行列号。仓颉宏的 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
}
6、宏包定义和导入
仓颉宏的定义
需要放在由 macro package 声明的包
中,被 macro package 限定的包仅允许宏定义对外可见
,其他声明包内可见。
重导出的声明也允许对外可见,关于包管理和重导出的相关概念,请参见包的导入章节。
// file define.cj
macro package define // 编译 define.cjo 携带 macro 属性
import std.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 中定义宏 M1
macro package A
import std.ast.*
public macro M1(input: Tokens): Tokens {
return input
}
编译命令如下:
cjc A.cj --compile-macro
- 在非宏包 B 中定义一个 public 函数 f1。注意在非 macro package 中无法重导出 macro package 的符号
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
}
编译命令如下,这里选择使用 --output-type 选项将 B 包编译成到动态库,关于 cjc 编译选项介绍可以参考cjc 编译选项章节。
cjc B.cj --output-type=dylib -o libB.so
- 在宏包 C 中定义宏 M2,依赖了 A 包和 B 包的内容。可以看到 macro package 中可以重导出 macro package 和非 macro package 的符号
macro package C
public import A.* // correct: macro package is allowed to re-export in a macro package.
public import B.* // correct: non-macro package is also allowed to re-export in a macro package.
import std.ast.*
public macro M2(input: Tokens): Tokens {
return @M1(input) + Token(TokenKind.NL) + quote(f1(1))
}
编译命令如下,注意这里需要显式链接 B 包动态库:
cjc C.cj --compile-macro -L. -lB
- 在 main.cj 中使用 M2 宏
import C.*
main() {
@M2(let a = 1)
}
编译命令如下:
cjc main.cj -o main -L. -lB
main.cj中 M2 宏展开后的结果如下:
import C.*
main() {
let a = 1
f1(1)
}
可以看到 main.cj 中出现了来自于 B 包的符号 f1。宏的编写者可以在 C 包中重导出 B 包里的符号,这样宏的使用者仅需导入宏包,就可以正确的编译宏展开后的代码。如果在 main.cj 中仅使用 import C.M2 导入宏符号,则会报 undeclared identifier ‘f1’ 的错误信息。
7、内置编译标记
仓颉语言提供了一些预定义的编译标记
,可以通过这些编译标记控制仓颉编译器的编译行为
。
7.1 源码位置
仓颉提供了几个内置编译标记,用于在编译时获取源代码的位置。
@sourcePackage()
展开后是一个 String 类型的字面量,内容为当前宏所在的源码的包名@sourceFile()
展开后是一个 String 类型的字面量,内容为当前宏所在的源码的文件名@sourceLine()
展开后是一个 Int64 类型的字面量,内容为当前宏所在的源码的代码行
这几个编译标记可以在任意表达式内部使用
,只要能符合类型检查规则即可。示例如下:
func test1() {
let s: String = @sourceFile() // The value of `s` is the current source file name
}
func test2(n!: Int64 = @sourceLine()) {
/* at line 5 */
// The default value of `n` is the source file line number of the definition of `test2`
println(n) // print 5
}
7.2 条件编译
条件编译使用 @When
标记,是一种在程序代码中根据特定条件选择性地编译不同代码段的技术。条件编译的作用主要体现在以下几个方面:
- 平台适应:支持根据当前的编译环境选择性地编译代码,用于实现跨平台的兼容性。
- 功能选择:支持根据不同的需求选择性地启用或禁用某些功能,用于实现功能的灵活配置。例如,选择性地编译包含或排除某些功能的代码。
- 调试支持:支持调试模式下编译相关代码,用于提高程序的性能和安全性。例如,在调试模式下编译调试信息或记录日志相关的代码,而在发布版本中将其排除。
- 性能优化:支持根据预定义的条件选择性地编译代码,用于提高程序的性能。
关于条件编译的具体内容,可以参考条件编译章节,这里不再额外展开。
7.3 @FastNative
为了提升与 C 语言互操作的性能
,仓颉提供 @FastNative
标记用于优化对 C 函数的调用。值得注意的是 @FastNative 只能用于 foreign 声明的函数
。
使用示例如下
@FastNative
foreign func strlen(str: CPointer<UInt8>): UIntNative
开发者在使用 @FastNative
修饰 foreign
函数时,应确保对应的 C 函数满足以下两点要求:
- 函数的整体执行时间不宜太长。例如:不允许函数内部存在很大的循环;不允许函数内部产生阻塞行为,如,调用 sleep、wait 等函数。
- 函数内部不能调用仓颉方法。
8、实用案例(重要)
8.1 快速幂的计算
我们通过一个简单的例子展示使用宏进行编译期求值
,生成优化代码
的应用。在计算幂 n ^ e 的时候,如果 e 是一个(比较大的)整数,可以通过重复取平方(而不是迭代相乘)的方式加速计算。这个算法可以直接使用 while 循环实现,例如:
func power(n: Int64, e: Int64) {
var result = 1
var vn = n
var ve = e
while (ve > 0) {
if (ve % 2 == 1) {
result *= vn
}
ve /= 2
if (ve > 0) {
vn *= vn
}
}
result
}
然而,这个实现需要每次对 e 的值进行分析,在循环和条件判断中多次对 ve 进行判断和更新。此外,实现只支持 n 的类型为Int64的情况,如果要支持其他类型的 n,还要处理如何表达 result = 1 的问题。如果我们预先知道 e 的具体值,可以将这个代码写的更简单。例如,如果知道 e 的值为 10,我们可以展开整个循环如下:
func power_10(n: Int64) {
var vn = n
vn *= vn // vn = n ^ 2
var result = vn // result = n ^ 2
vn *= vn // vn = n ^ 4
vn *= vn // vn = n ^ 8
result *= vn // result = n ^ 10
result
}
当然,手动编写这些代码非常繁琐,我们希望在给定 e 的值之后,自动将这些代码生成出来。宏允许我们做到这一点
。我们先看使用案例:
public func power_10(n: Int64) {
@power[10](n)
}
这个宏展开的代码是(根据.macrocall文件):
public func power_10(n: Int64) {
/* ===== Emitted by MacroCall @power in main.cj:20:5 ===== */
/* 20.1 */var _power_vn = n
/* 20.2 */_power_vn *= _power_vn
/* 20.3 */var _power_result = _power_vn
/* 20.4 */_power_vn *= _power_vn
/* 20.5 */_power_vn *= _power_vn
/* 20.6 */_power_result *= _power_vn
/* 20.7 */_power_result
/* ===== End of the Emit ===== */
}
下面,我们看宏 @power 的实现。
macro package define
import std.ast.*
import std.convert.*
public macro power(attrib: Tokens, input: Tokens) {
let attribExpr = parseExpr(attrib)
if (let Some(litExpr) <- attribExpr as LitConstExpr) {
let lit = litExpr.literal
if (lit.kind != TokenKind.INTEGER_LITERAL) {
diagReport(DiagReportLevel.ERROR, attrib,
"Attribute must be integer literal",
"Expected integer literal")
}
var n = Int64.parse(lit.value)
var result = quote(var _power_vn = $(input)
)
var flag = false
while (n > 0) {
if (n % 2 == 1) {
if (!flag) {
result += quote(var _power_result = _power_vn
)
flag = true
} else {
result += quote(_power_result *= _power_vn
)
}
}
n /= 2
if (n > 0) {
result += quote(_power_vn *= _power_vn
)
}
}
result += quote(_power_result)
return result
} else {
diagReport(DiagReportLevel.ERROR, attrib,
"Attribute must be integer literal",
"Expected integer literal")
}
return input
}
这段代码的解释如下:
- 首先,确认输入的属性 attrib 是一个整数字面量,否则通过 diagReport 报错。将这个字面量解析为整数 n。
- 设 result 为当前积累的输出代码,首先添加 var _power_vn 的声明。这里为了避免变量名冲突,我们使用不易造成冲突的名字 _power_vn。
- 下面进入 while 循环,布尔变量 flag 表示 var _power_result 是否已经被初始化。其余的代码结构和之前展示的 power 函数的实现类似,但区别是我们使用 while 循环和 if 判断在编译时决定生成的代码是什么,而不是在运行时做这些判断。最后生成由 _power_result *= _power_vn 和 _power_vn *= _power_vn 适当组合的代码。
- 最后添加返回 _power_result 的代码。
将这段代码放到 macros/power.cj 文件中,并在 main.cj 添加如下测试:
public func power_10(n: Int64) {
@power[10](n)
}
main() {
let a = 3
println(power_10(a)) // 59049
}
8.2 Memoize 宏
Memoize(记忆化)是动态规划算法的常用手段
。它将已经计算过的子问题的结果存储起来
,当同一个子问题再次出现时,可以直接查询表来获取结果,从而避免重复的计算,提高算法的效率。
通常 Memoize 的使用需要开发者手动实现存储和提取的功能。通过宏,我们可以自动化这一过程。首先,让我们先看一下宏使用的效果:
@Memoize[true]
func fib(n: Int64): Int64 {
if (n == 0 || n == 1) {
return n
}
return fib(n - 1) + fib(n - 2)
}
main() {
let start = DateTime.now()
let f35 = fib(35)
let end = DateTime.now()
println("fib(35): ${f35}")
println("execution time: ${(end - start).toMicroseconds()} us")
}
在以上代码中,fib 函数采用简单的递归方式实现。如果没有 @Memoize[true] 标注,这个函数的运行时间将随着 n 指数增长。例如,如果在前面的代码中去掉 @Memoize[true] 这一行,或者把 true 改为 false,则 main 函数的运行结果为:
恢复 @Memoize[true],运行结果为:
相同的答案和大幅缩短的计算时间表明,@Memoize 的使用确实实现了记忆化。
现在让我们理解 @Memoize 的原理。首先,展示对以上 fib 函数进行宏展开的结果(来自 .macrocall 文件,但是为了提高可读性整理了格式)。
import std.collection.*
var _memoize_fib_map = HashMap<Int64, Int64>()
func fib(n: Int64): Int64 {
if (_memoize_fib_map.contains(n)) {
return _memoize_fib_map.get(n).getOrThrow()
}
let _memoize_eval_result = {
=>
if (n == 0 || n == 1) {
return n
}
return fib(n - 1) + fib(n - 2)
}()
_memoize_fib_map.put(n, _memoize_eval_result)
return _memoize_eval_result
}
上述代码的执行流程如下:
- 首先,定义 _memoize_fib_map 为一个从 Int64 到 Int64 的哈希表,这里第一个 Int64 对应 fib 的唯一参数的类型,第二个 Int64 对应 fib 返回值的类型。
- 其次,在函数体中,检查入参是否在 _memoize_fib_map 中,如果是则立即返回哈希表中存储的值。否则,使用 fib 原来的函数体得到计算结果。这里使用了(不带参数的)匿名函数使 fib 的函数体不需要任何改变,并且能够处理任何从 fib 函数退出的方式(包括中间的 return,返回最后一个表达式等)。
- 最后,把计算结果存储到 _memoize_fib_map 中,然后将计算结果返回。
有了这样一个“模版”之后,下面宏的实现就不难理解了。我们给出完整的代码如下。
public macro Memoize(attrib: Tokens, input: Tokens) {
if (attrib.size != 1 || attrib[0].kind != TokenKind.BOOL_LITERAL) {
diagReport(DiagReportLevel.ERROR, attrib,
"Attribute must be a boolean literal (true or false)",
"Expected boolean literal (true or false) here")
}
let memoized = (attrib[0].value == "true")
if (!memoized) {
return input
}
let fd = FuncDecl(input)
if (fd.funcParams.size != 1) {
diagReport(DiagReportLevel.ERROR, fd.lParen + fd.funcParams.toTokens() + fd.rParen,
"Input function to memoize should take exactly one argument",
"Expect only one argument here")
}
let memoMap = Token(TokenKind.IDENTIFIER, "_memoize_" + fd.identifier.value + "_map")
let arg1 = fd.funcParams[0]
return quote(
var $(memoMap) = HashMap<$(arg1.paramType), $(fd.declType)>()
func $(fd.identifier)($(arg1)): $(fd.declType) {
if ($(memoMap).contains($(arg1.identifier))) {
return $(memoMap).get($(arg1.identifier)).getOrThrow()
}
let _memoize_eval_result = {
=> $(fd.block.nodes) }()
$(memoMap).put($(arg1.identifier), _memoize_eval_result)
return _memoize_eval_result
}
)
}
首先,对属性和输入做合法性检查。属性必须是布尔字面量,如果为 false 则直接返回输入。否则,检查输入必须能够解析为函数声明(FuncDecl),并且必须包含正好一个参数。下面,产生哈希表的变量,取不容易造成冲突的变量名。最后,通过 quote 模版生成返回的代码,其中用到哈希表的变量名,以及唯一参数的名称、类型和输入函数的返回类型。
8.3 一个 dprint 宏的扩展
本节一开始使用了一个打印表达式的宏作为案例,但这个宏一次只能接受一个表达式。我们希望扩展这个宏,使其能够接受多个表达式,由逗号分开。我们展示如何使用 parseExprFragment 来实现这个功能
。
宏的实现如下:
public macro dprint2(input: Tokens) {
let exprs = ArrayList<Expr>()
var index: Int64 = 0
while (true) {
let (expr, nextIndex) = parseExprFragment(input, startFrom: index)
exprs.append(expr)
if (nextIndex == input.size) {
break
}
if (input[nextIndex].kind != TokenKind.COMMA) {
diagReport(DiagReportLevel.ERROR, input[nextIndex..nextIndex+1],
"Input must be a comma-separated list of expressions",
"Expected comma")
}
index = nextIndex + 1 // 跳过逗号
}
let result = quote()
for (expr in exprs) {
result.append(quote(
print($(expr.toTokens().toString()) + " = ")
println($(expr))
))
}
return result
}
使用案例:
let x = 3
let y = 2
@dprint2(x, y, x + y)
在宏的实现中,使用 while 循环从索引 0 开始依次解析每个表达式。变量 index 保存当前解析的位置。每次调用 parseExprFragment 时,从当前位置开始,并返回解析后的位置(以及解析得到的表达式)。如果解析后的位置到达了输入的结尾,则退出循环。否则检查到达的位置是否是一个逗号,如果不是逗号,报错并退出,如果是逗号,跳过这个逗号并开始下一轮的解析。在得到表达式的列表后,依次输出每个表达式。
8.4 一个简单的 DSL
在这个案例中,我们展示如何使用宏实现一个简单的 DSL(Domain Specific Language,领域特定语言)。LINQ(Language Integrated Query,语言集成查询)是微软 .NET 框架的一个组成部分,它提供了一种统一的数据查询语法,允许开发者使用类似 SQL 的查询语句来操作各种数据源。在这里,我们仅展示一个最简单的 LINQ 语法的支持。
我们希望支持的语法为:
from <variable> in <list> where <condition> select <expression>
其中,variable 是一个标识符,list、condition 和 expression 都是表达式。因此,实现宏的策略是先后提取标识符和表达式,同时检查中间的关键字是正确的。最后,生成由提取部分组成的查询结果。
宏的实现如下:
public macro linq(input: Tokens) {
let syntaxMsg = "Syntax is \"from <attrib> in <table> where <cond> select <expr>\""
if (input.size == 0 || input[0].value != "from") {
diagReport(DiagReportLevel.ERROR, input[0..1], syntaxMsg,
"Expected keyword \"from\" here.")
}
if (input.size <= 1 || input[1].kind != TokenKind.IDENTIFIER) {
diagReport(DiagReportLevel.ERROR, input[1..2], syntaxMsg,
"Expected identifier here.")
}
let attribute = input[1]
if (input.size <= 2 || input[2].value != "in") {
diagReport(DiagReportLevel.ERROR, input[2..3], syntaxMsg,
"Expected keyword \"in\" here.")
}
var index: Int64 = 3
let (table, nextIndex) = parseExprFragment(input, startFrom: index)
if (nextIndex == input.size || input[nextIndex].value != "where") {
diagReport(DiagReportLevel.ERROR, input[nextIndex..nextIndex+1], syntaxMsg,
"Expected keyword \"where\" here.")
}
index = nextIndex + 1 // 跳过where
let (cond, nextIndex2) = parseExprFragment(input, startFrom: index)
if (nextIndex2 == input.size || input[nextIndex2].value != "select") {
diagReport(DiagReportLevel.ERROR, input[nextIndex2..nextIndex2+1], syntaxMsg,
"Expected keyword \"select\" here.")
}
index = nextIndex2 + 1 // 跳过select
let (expr, nextIndex3) = parseExprFragment(input, startFrom: index)
return quote(
for ($(attribute) in $(table)) {
if ($(cond)) {
println($(expr))
}
}
)
}
使用案例:
@linq(from x in 1..=10 where x % 2 == 1 select x * x)
这个例子从 1, 2, … 10 列表中筛选出奇数,然后返回所有奇数的平方。输出结果为:
可以看到,宏的实现的很大部分用于解析并校验输入的 tokens
,这对宏的可用性至关重要
。实际的 LINQ 语言(以及大多数 DSL)的语法更加复杂,需要一整套解析的机制,通过识别不同的关键字或连接符来决定下一步解析的内容。
文章评论