【仓颉语言入门 · 第二篇 第 17 课】
【仓颉语言入门 · 第二篇 第 17 课】宏基础:std.ast 与 quote
上一课我们用反射在运行时打量类型:程序跑起来之后,按字符串查字段、调方法。灵活是真灵活,但代价也真实——每次都要查找、装箱、类型检查,字段名写错了还要等运行到那一行才炸。可有些活儿明明在编译期就能干完:比如把一段重复的样板代码自动写出来,把
println("x = " + x)这种调试语句自动生成。编译器能不能替我们写代码?能,这就是本课的主角——宏(macro)。std.ast把源码变成Tokens,quote把代码模板变回Tokens,宏在编译期完成这场"代码换代码"的交易。
本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。
目录(第二篇导航)
第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、补遗篇 | 01~06 | 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理 |
| 二、数据库篇 | 07~15 | 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战 |
| 三、进阶硬核篇 | 16~22 | 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池 |
| 四、实战项目篇 | 23~26 | HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令行参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(std.binary)
- 数据库入门:认识 std.database.sql,连上 MySQL
- 建表:用代码定义表结构
- 查询:SELECT 与结果集遍历
- 插入:INSERT、占位符与自增主键
- 更新与删除:UPDATE / DELETE
- 预编译与防 SQL 注入
- 事务:批量操作的成败一致
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏基础:std.ast 与 quote(本文)
- deriving 宏实战
- 与 C 互操作基础:FFI 调用 C 函数
- FFI 进阶:结构体、指针与编译配置
- 并发容器:并发安全的集合
- 对象池与资源复用
- 实战三:HTTP 服务端基础
- 实战四:HTTP 进阶与 JSON 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、宏:让编译器替你写代码
普通函数的输入是值,输出也是值。你调 add(3, 4),函数只看见 3 和 4,看不见"我来自表达式 2 + 1 + 1"这件事。
可调试时我们往往想同时打印表达式本身和它的值:
let x = 3
let y = 2
@dprint(x) // 希望输出 x = 3
@dprint(x + y) // 希望输出 x + y = 5
普通函数做不到,因为 x + y 传进去时已经被算成 5 了。宏可以:它的输入不是值,而是一段程序片段(Tokens);输出也不是值,而是另一段程序片段。编译器在编译期把宏调用替换成宏返回的代码,再继续编译。
| 机制 | 工作时机 | 输入 | 输出 | 运行时代价 |
|---|---|---|---|---|
| 普通函数 | 运行时 | 值 | 值 | 一次函数调用 |
| 反射 | 运行时 | 对象 / 类型 | 字段、方法信息 | 查找、装箱、类型检查 |
| 宏 | 编译期 | Tokens(代码片段) |
Tokens(新代码) |
零(宏已经展开完) |
调用宏要加 @,和函数调用区分开。宏展开发生在类型检查之前,所以宏生成的代码仍然要合法、能通过编译——宏不是"绕过编译器",而是"帮编译器多写几行"。
二、最小宏工程:macro package + cjpm
先建一个工程。宏必须放在独立的宏包里,不能和普通 public 函数挤在同一个包;宏定义与宏调用也不能在同一个包。用 cjpm 建工程最省事:
cjpm init --name cjmacro
目录结构如下(macros 是 src 下的子目录,cjpm 会把它当作子包):
cjmacro/
├── cjpm.toml
└── src/
├── main.cj
└── macros/
└── dprint.cj
宏包文件 src/macros/dprint.cj:
// src/macros/dprint.cj
macro package cjmacro.macros
import std.ast.*
public macro dprint(input: Tokens): Tokens {
let inputStr = input.toString()
let result = quote(
print($(inputStr) + " = ")
println($(input))
)
return result
}
主程序 src/main.cj:
// src/main.cj
package cjmacro
import cjmacro.macros.*
main() {
let x = 3
let y = 2
@dprint(x)
@dprint(x + y)
}
运行:
x = 3
x + y = 5
逐行拆开看:
macro package cjmacro.macros:声明这是宏包,不是普通包。包名要和目录对应(src/macros→工程名.macros),否则 cjpm 找不到。import std.ast.*:Tokens、Token、quote都在std.ast里,写宏必须先导入。public macro dprint(input: Tokens): Tokens:定义一个宏,参数和返回值都是Tokens。宏必须写public,因为它要被别的包调用。input.toString():把输入的程序片段还原成字符串。传x + y就得到"x + y"。quote(...):用代码模板构造新的Tokens。模板里的$(...)是插值,把外面的值塞进模板。return result:把构造好的代码还给编译器,编译器用它替换@dprint(x)这次调用。
用 cjpm 时不需要手动执行 cjc --compile-macro,cjpm run 会先编译宏包、再编译调用方。宏定义和调用分居两个包,是硬性约束;把宏写进 main.cj 所在的包里,编译会直接拒绝:
error: macro declaration must be defined in macro package
三、Tokens 与 Token:程序片段的零件
Token 是一个词法单元:一个标识符、一个字面量、一个关键字或一个运算符。Tokens 是 Token 的序列,也就是"一段程序"。
Token 用 TokenKind + 可选的字符串构造:Token(TokenKind.ADD) 是 +,Token(TokenKind.FUNC) 是关键字 func,Token(TokenKind.IDENTIFIER, "x") 是标识符 x,Token(TokenKind.INTEGER_LITERAL, "3") 是整数字面量 3。Token 没有实现 ToString,打印内容用 .value,不要直接 println(token)。
Tokens 可以直接用 Token 数组构造,支持 size、下标 []、+ 拼接和 toString():
import std.ast.*
main() {
let tks = Tokens([
Token(TokenKind.INTEGER_LITERAL, "1"),
Token(TokenKind.ADD),
Token(TokenKind.INTEGER_LITERAL, "2")
])
println(tks)
println(tks.size)
println(tks[0].value)
println(tks[2].value)
println(tks + tks)
}
1 + 2
3
1
2
1 + 2 1 + 2
几个要点:
println(tks)会调用Tokens.toString(),把 Token 序列拼回源码片段1 + 2。tks[0]的类型是Token,不是String;打印内容写tks[0].value。直接println(tks[0])会报unable to infer generic argument of this function,因为Token没实现ToString。tks + tks把两个Tokens首尾相接,输出1 + 2 1 + 2。tks.dump()可以打印每个 Token 的种类、值和位置,调试时很有用;位置里的行号列号会随源码变化,不必逐字对齐。- 构造
Tokens时直接传数组字面量即可。不要写Tokens(Array<Token>([...])),1.2.0 里Array没有"从数组字面量构造"的重载,会报missing argument for parameter list。
四、quote:用模板拼出 Tokens
手动 Token(...) 拼代码太啰嗦。quote 让你直接写代码模板,编译器把它变成 Tokens:
import std.ast.*
main() {
let intList: Array<Int64> = [1, 2, 3]
let f: Float64 = 1.0
let s: String = "Hello"
let q = quote(
arr = $(intList)
x = $(f)
s = $(s)
)
println(q)
}
arr =[1, 2, 3]
x = 1.0
s = "Hello"
这段输出有两个"怪"地方,都是 quote 的正常行为:
-
前后各有一个空行。多行模板会把模板开头的换行和结尾的换行一起保留,
println再补一个换行,看起来就多了空行;写成单行quote(arr = $(intList))就没有这个问题,实测输出arr =[1, 2, 3]。 -
arr =[1, 2, 3]的等号后面没有空格。quote保留的是 Token 序列,输出空格由 Token 拼接规则决定,不保证和源码排版一模一样。功能不受影响,别为了空格较劲。
插值 $(...) 能插入的类型必须实现 ToTokens。标准库里这些类型都可以:
- 基础类型(整数、浮点数、
Bool等)和String Token和TokensArray<T>- 语法节点(
Expr、Decl等),比如把parseExpr的结果再插回quote
实测几个细节:Float64 的 1.0 插进去输出 x = 1.0;String 会带上双引号,输出 s = "Hello";Array<Int64> 会带方括号,输出 [1, 2, 3];语法节点插值后输出它对应的源码片段。
版本坑:官方文档列了
ArrayList<T>,但 1.2.0 实测插值会抛IllegalArgumentException: Unsupported ArrayList<T> in function toTokens().。先转成Array,或逐项拼。
$ 是插值标记,想输出字面量 $ 要转义:quote(\$(1)) 的输出是 $(1)。
quote 不仅能写在宏里,也能写在普通函数里——它只是构造 Tokens 的表达式。所以本课的 Tokens、quote、parseExpr 示例都可以直接在 main 里跑,方便观察。
五、宏里的 quote:生成表达式、语句与声明
5.1 表达式宏:@addOne
最简单的宏:把输入表达式加一。
public macro addOne(input: Tokens): Tokens {
return quote(($(input)) + 1)
}
$(input) 把调用时传进来的表达式原样插入,外面再包一层括号,保证优先级不被破坏。调用:
println(@addOne(2 + 3) * 10)
60
展开后的代码是 ((2 + 3) + 1) * 10。如果模板里不写括号,2 + 3 + 1 * 10 的结果就完全变了——宏作者必须自己保证生成代码的优先级和语义。
5.2 声明宏:用输入当函数名
宏还能生成顶层声明。下面这个宏把输入当作函数名,生成一个求两个整数最大值的函数:
public macro defMax(input: Tokens): Tokens {
return quote(
func $(input)(a: Int64, b: Int64): Int64 {
if (a > b) { a } else { b }
}
)
}
调用时把函数名交给宏,宏展开后 maxOf2 就真的被定义了:
package cjmacro
import cjmacro.macros.*
@defMax(maxOf2)
main() {
println(maxOf2(3, 5))
println(maxOf2(8, 2))
}
5
8
@defMax(maxOf2) 看起来像函数调用,实际是"把 maxOf2 这段 Token 交给宏,让宏生成一个同名函数"。生成出来的函数和手写的没有区别,后续调用、类型检查都照常进行。
5.3 属性宏:@repeat3
上面的宏都只有一个 Tokens 参数,叫非属性宏,调用形式是 @宏名(输入)。如果宏需要额外参数,就成了属性宏,额外参数写在方括号里:
public macro repeat(count: Tokens, input: Tokens): Tokens {
return quote(
for (_ in 0..$(count)) {
$(input)
}
)
}
调用:
@repeat[3](println("hi"))
hi
hi
hi
声明里 count 是属性参数,input 是宏的输入;调用时 [3] 对应 count,(println("hi")) 对应 input。两类宏的区别:
| 类型 | 声明参数 | 调用形式 |
|---|---|---|
| 非属性宏 | 恰好一个 Tokens |
@m(输入) |
| 属性宏 | 两个及以上 Tokens |
@m[属性](输入) |
把属性宏当非属性宏调用,编译器会明确报 expect a plain macro 'repeat', but find an attributed one。
六、语法树:parseExpr 与 parseDecl
Tokens 只是"扁平的词法单元序列",它不知道 1 + 2 * 3 里谁先算。要理解结构,得把它解析成语法树节点。std.ast 提供了一组解析函数:
| 函数 | 作用 | 返回 |
|---|---|---|
cangjieLex(str) |
把字符串变成 Tokens |
Tokens |
parseExpr(tokens) |
解析一个表达式 | Expr |
parseDecl(tokens) |
解析一个声明 | Decl |
先看表达式。parseExpr 返回 Expr,用 as 转成具体节点;BinaryExpr 是二元运算节点,带 op、leftExpr、rightExpr:
import std.ast.*
main() {
let expr = parseExpr(quote(1 + 2 * 3))
if (let Some(b) <- (expr as BinaryExpr)) {
println("op = ${b.op.value}")
println("left = ${b.leftExpr.toTokens()}")
println("right = ${b.rightExpr.toTokens()}")
}
}
op = +
left = 1
right = 2 * 3
这证明了优先级:顶层是 +,右边才是 2 * 3。toTokens() 把节点再变回 Tokens,方便打印或继续拼接。
再看声明。parseDecl 返回 Decl,函数声明对应 FuncDecl,能拿到函数名和参数列表:
import std.ast.*
main() {
let decl = parseDecl(quote(func add(a: Int64, b: Int64): Int64 { a + b }))
if (let Some(f) <- (decl as FuncDecl)) {
println(f.identifier.value)
println(f.funcParams.size)
}
}
add
2
两个坑要记住:
if-let里的as转换要加括号:写if (let Some(b) <- (expr as BinaryExpr))。少写这对括号会报unclosed delimiter: '(',编译器把as当成别的结构了。- 解析失败会抛
ParseASTException。传给parseExpr的必须是合法表达式,传给parseDecl的必须是合法声明;不要拿半截代码去试。
这些解析函数在普通程序里能用,在宏体里也能用——宏体本身就是编译期执行的普通代码。下一课的 deriving 宏,就是先用 parseDecl 把类声明拆开,读取字段名和类型,再 quote 出 toString、equals 这些方法。
七、常见错误与避坑
| 错误写法 | 编译器提示 |
|---|---|
| 宏定义写在普通包里 | macro declaration must be defined in macro package |
宏包写成 package xxx 而不是 macro package xxx |
package name with macro should be consistent in the same package. |
宏没写 public |
macro declaration must be modified with 'public' |
宏参数不是 Tokens |
macro declaration's parameter type must be 'Tokens' |
| 属性宏用普通形式调用 | expect a plain macro 'repeat', but find an attributed one |
忘了 import std.ast.* 就用 quote |
import 'std.ast' to use the 'quote' expression |
生成代码要合法:宏只负责"拼",拼出来的代码仍要过类型检查。括号、变量名、分号位置错了,报错会落在展开后的代码上,看起来可能有点陌生。
八、常用 API 速查
| 需求 | API |
|---|---|
| 构造 Token | Token(TokenKind.ADD) / Token(TokenKind.IDENTIFIER, "x") |
| 构造 Tokens | Tokens([...]) / Tokens() |
| Token 内容 | token.value |
| Tokens 长度 | tokens.size |
| 取第 i 个 Token | tokens[i] |
| 拼接 | tokens1 + tokens2 |
| 变回字符串 | tokens.toString() |
| 调试打印 | tokens.dump() |
| 代码模板 | quote(...) |
| 模板插值 | $(expr) |
| 字符串转 Tokens | cangjieLex("1 + 2") |
| 解析表达式 | parseExpr(tokens) → Expr |
| 解析声明 | parseDecl(tokens) → Decl |
| 节点转 Tokens | node.toTokens() |
| 二元表达式节点 | BinaryExpr:op、leftExpr、rightExpr |
| 函数声明节点 | FuncDecl:identifier、funcParams |
| 定义宏包 | macro package 包名 |
| 定义非属性宏 | public macro m(input: Tokens): Tokens |
| 定义属性宏 | public macro m(attr: Tokens, input: Tokens): Tokens |
| 调用非属性宏 | @m(输入) |
| 调用属性宏 | @m[属性](输入) |
九、课后练习
规矩不变:前 4 题必做,第 5 题选做。每题只考本课 1~2 个知识点,题面给全函数签名和 API 提示,先看懂题再动笔。
第 1 题(必做)构造并打印 Tokens
用 Token 和 Tokens 构造出 1 * 2 + 3,打印这段代码和 Token 个数。
提示:Token(TokenKind.INTEGER_LITERAL, "1")、Token(TokenKind.MUL)、Token(TokenKind.ADD);Tokens([...]) 接收数组字面量;tokens.size 取长度。
import std.ast.*
main() {
// 补全:构造 1 * 2 + 3 的 Tokens
// 补全:打印 Tokens 和 size
}
期望输出:
1 * 2 + 3
5
第 2 题(必做)用 quote 插值
用 quote 生成 x = 42 和 name = "Cangjie" 两段代码并打印。
提示:println(quote(x = $(n)));n 是 Int64,s 是 String。单行模板不会多出空行。
import std.ast.*
main() {
let n: Int64 = 42
let s: String = "Cangjie"
// 补全:用 quote 插值打印两段代码
}
期望输出:
x = 42
name = "Cangjie"
第 3 题(必做)解析表达式
用 parseExpr 解析 (a + b) * c,转成 BinaryExpr,打印运算符、左表达式、右表达式。
提示:parseExpr(quote((a + b) * c)) 返回 Expr;用 if (let Some(b) <- (expr as BinaryExpr)) 转换,注意 as 外面要加括号;b.op.value、b.leftExpr.toTokens()、b.rightExpr.toTokens()。
import std.ast.*
main() {
let expr = parseExpr(quote((a + b) * c))
// 补全:转成 BinaryExpr 并打印 op / left / right
}
期望输出:
op = *
left = (a + b)
right = c
第 4 题(必做)写一个表达式宏
在宏包文件末尾追加 twice 宏,把输入表达式乘 2;然后在主程序里调用 @twice(3 + 4) 并打印结果。
提示:宏声明 public macro twice(input: Tokens): Tokens;模板写 quote(($(input)) * 2);括号不能省,否则优先级会变。宏包文件是 src/macros/dprint.cj,主程序要 import cjmacro.macros.*。
// 追加到 src/macros/dprint.cj 末尾
public macro twice(input: Tokens): Tokens {
// 补全
}
// src/main.cj
package cjmacro
import cjmacro.macros.*
main() {
// 补全:调用 @twice(3 + 4) 并打印
}
期望输出:
14
第 5 题(选做)解析函数声明
用 parseDecl 解析 func mul(a: Int64, b: Int64): Int64 { a * b },转成 FuncDecl,打印函数名和参数个数。
提示:parseDecl(quote(func mul(a: Int64, b: Int64): Int64 { a * b }));FuncDecl 的 identifier.value 是函数名,funcParams.size 是参数个数。
import std.ast.*
main() {
let decl = parseDecl(quote(func mul(a: Int64, b: Int64): Int64 { a * b }))
// 补全:转成 FuncDecl 并打印函数名、参数个数
}
期望输出:
mul
2
下节预告
这一课我们把宏的地基打好了:宏包怎么建、Tokens 怎么拼、quote 怎么插值、parseExpr / parseDecl 怎么读语法树。但真正让宏"值钱"的,是它能替一类类型批量生成样板代码。
下一课进入 deriving 宏实战:写一个属性宏,用 parseDecl 读出类的全部字段名和类型,自动生成 toString、equals,甚至 JSON 序列化方法。到那时你会发现,反射那套"运行时按名字找字段"的活儿,宏在编译期就能干完,而且运行时零开销。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn

浙公网安备 33010602011771号