【仓颉语言入门 · 第二篇 第 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 进阶、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:认识 std.database.sql,连上 MySQL
  8. 建表:用代码定义表结构
  9. 查询:SELECT 与结果集遍历
  10. 插入:INSERT、占位符与自增主键
  11. 更新与删除:UPDATE / DELETE
  12. 预编译与防 SQL 注入
  13. 事务:批量操作的成败一致
  14. 手写 DAO 数据访问层
  15. 数据库综合小实战
  16. 反射:在运行时打量类型
  17. 宏基础:std.ast 与 quote(本文)
  18. deriving 宏实战
  19. 与 C 互操作基础:FFI 调用 C 函数
  20. FFI 进阶:结构体、指针与编译配置
  21. 并发容器:并发安全的集合
  22. 对象池与资源复用
  23. 实战三:HTTP 服务端基础
  24. 实战四:HTTP 进阶与 JSON 接口
  25. 实战五:多线程日志分析器
  26. 实战六:简易爬虫

一、宏:让编译器替你写代码

普通函数的输入是值,输出也是值。你调 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

逐行拆开看:

  1. macro package cjmacro.macros:声明这是宏包,不是普通包。包名要和目录对应(src/macros → 工程名.macros),否则 cjpm 找不到。
  2. import std.ast.*:Tokens、Token、quote 都在 std.ast 里,写宏必须先导入。
  3. public macro dprint(input: Tokens): Tokens:定义一个宏,参数和返回值都是 Tokens。宏必须写 public,因为它要被别的包调用。
  4. input.toString():把输入的程序片段还原成字符串。传 x + y 就得到 "x + y"。
  5. quote(...):用代码模板构造新的 Tokens。模板里的 $(...) 是插值,把外面的值塞进模板。
  6. 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 的正常行为:

  1. 前后各有一个空行。多行模板会把模板开头的换行和结尾的换行一起保留,println 再补一个换行,看起来就多了空行;写成单行 quote(arr = $(intList)) 就没有这个问题,实测输出 arr =[1, 2, 3]。

  2. arr =[1, 2, 3] 的等号后面没有空格。quote 保留的是 Token 序列,输出空格由 Token 拼接规则决定,不保证和源码排版一模一样。功能不受影响,别为了空格较劲。

插值 $(...) 能插入的类型必须实现 ToTokens。标准库里这些类型都可以:

  • 基础类型(整数、浮点数、Bool 等)和 String
  • Token 和 Tokens
  • Array<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

两个坑要记住:

  1. if-let 里的 as 转换要加括号:写 if (let Some(b) <- (expr as BinaryExpr))。少写这对括号会报 unclosed delimiter: '(',编译器把 as 当成别的结构了。
  2. 解析失败会抛 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
posted @ 2026-10-27 00:07  upala  阅读(8)  评论(0)    收藏  举报