【仓颉语言入门 · 第二篇 第 18 课】
【仓颉语言入门 · 第二篇 第 18 课】deriving 宏实战:自动生成 toString、equals 与 JSON
上一课我们打下了宏的地基:宏包怎么建、
Tokens怎么拼、quote怎么插值、parseDecl怎么读语法树。这一课把这些零件组装成一件真正有用的工具——deriving 宏。写个class User,标上一行@DeriveToString,toString就自动长出来了;再标@DeriveEquals、@DeriveJson,比较运算符和 JSON 序列化也有了。全程在编译期完成,运行时零开销。学完这一课,你就拥有了一个能给自己类"批量发方法"的小框架。
本文所有代码、输出与报错文案均在仓颉 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 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、deriving:一次写宏,处处生成
给类写 toString 是件枯燥活。每个类的写法都一样,区别只在字段列表:
class User {
let id: Int64
var name: String
init(id: Int64, name: String) {
this.id = id
this.name = name
}
public func toString(): String {
return "User(id = ${id}, name = ${name})"
}
}
字段加几个,这串模板就重抄一遍;equals、toJson 同理。写一两个类能忍,写二十个就是体力活——和第 16 课开头的问题一模一样。
别的语言早有答案:Rust 用 #[derive(Debug)],Java 生态用 Lombok 的 @Data,套路都是在类上打一个标注,编译器照着字段列表自动生成样板方法。这类标注有个专门的名字:deriving(派生)。
仓颉没有内置 derive,但有宏。上一课的属性宏正好能干这事:把 @DeriveToString 标在类上,宏拿到的 input 就是整个类声明的 Tokens,用 parseDecl 拆开它、读出字段名和类型,再把"原类声明 + 生成的方法"一起还给编译器。一次写好,所有类都能用。
二、工程准备
沿用上一课的宏工程模式:宏必须放在独立的宏包里,宏定义与调用不能同包。新建工程:
cjpm init --name cjderive
目录结构(macros 是 src 的子目录,对应宏包 cjderive.macros):
cjderive/
├── cjpm.toml
└── src/
├── main.cj
└── macros/
└── derive.cj
src/macros/derive.cj:宏包,第一行写macro package cjderive.macros,里面放三个 deriving 宏src/main.cj:普通包package cjderive,import cjderive.macros.*后用@宏名标注类
三、第一步:读出类名和字段
写宏之前,先在普通程序里把"拆类声明"这件事练熟(parseDecl 在普通 main 里也能跑)。把下面代码贴进 src/main.cj:
package cjderive
import std.ast.*
main() {
let decl = parseDecl(quote(
class User {
let id: Int64
var name: String
}
))
if (let Some(c) <- (decl as ClassDecl)) {
println("类名: ${c.identifier.value}")
println("字段数: ${c.body.decls.size}")
for (d in c.body.decls) {
if (let Some(v) <- (d as VarDecl)) {
println("${v.identifier.value}: ${v.declType.toTokens()}")
}
}
}
}
运行输出:
类名: User
字段数: 2
id: Int64
name: String
这条链路上的四个节点,就是 deriving 宏的全部原料:
| 节点 | 拿到什么 |
|---|---|
parseDecl(tokens) |
把类声明的 Tokens 解析成 Decl |
decl as ClassDecl |
转成类声明节点(as 外面要加括号) |
c.body.decls |
类体内的成员声明列表(字段、构造器、方法都在里面) |
d as VarDecl |
筛出字段;identifier.value 是字段名,declType.toTokens() 是字段类型 |
init、成员函数也是 body.decls 里的元素,只是它们不是 VarDecl,as 转换得到 None,自然被过滤掉——所以遍历出来的恰好全是字段。
四、DeriveToString:自动生成 toString
思路:宏返回 input + quote(...)。input 是原类声明,原样保留;quote(...) 里生成一段 extend,给类补上 toString。
仓颉的类不能在声明外直接加成员函数,但 extend 可以——扩展写在类外,效果和写在类里一样。extend User <: ToString 还能顺带实现 ToString 接口,这样 println(u) 就能直接用。打开 src/macros/derive.cj:
// src/macros/derive.cj
macro package cjderive.macros
import std.ast.*
public macro DeriveToString(input: Tokens): Tokens {
let c = (parseDecl(input) as ClassDecl).getOrThrow()
let className = c.identifier.value
var parts = ""
var first = true
for (d in c.body.decls) {
if (let Some(v) <- (d as VarDecl)) {
let f = v.identifier.value
if (!first) {
parts += ", "
}
parts += "${f} = \${${f}}"
first = false
}
}
let template = "${className}(${parts})"
return input + quote(
extend $(c.identifier) <: ToString {
public func toString(): String {
return $(template)
}
}
)
}
src/main.cj:
// src/main.cj
package cjderive
import cjderive.macros.*
@DeriveToString
class User {
let id: Int64
var name: String
init(id: Int64, name: String) {
this.id = id
this.name = name
}
}
main() {
let u = User(1, "zhangsan")
println(u)
}
运行:
User(id = 1, name = zhangsan)
宏体里最绕的一行是 parts += "${f} = \${${f}}"。它在宏里拼一个字符串,这个字符串将来是生成代码里的字符串模板,所以有两层转义:
\"是字符串里普通的引号转义(本例没用到)\${输出字面量${,不触发宏里的插值${f}正常插值,取宏里变量f的值
字段是 id 时,拼出来的是 id = ${id} 这串文字。攒齐所有字段后,template 的值是 User(id = ${id}, name = ${name})。再经 quote 的 $(template) 插进模板,String 类型的插值会变成带引号的字符串字面量,于是生成代码里的 return "User(id = ${id}, name = ${name})"——${id} 在生成代码运行时才求值,正好是我们想要的。
五、DeriveEquals:自动生成 == 和 !=
toString 拼的是字符串,equals 拼的是代码:this.id == rhs.id && this.name == rhs.name。这段条件表达式不能再用 String 插值——$(字符串) 插进 quote 只会变成字符串字面量,不会变成可执行代码。
解决办法是上一课见过的 cangjieLex:先把字符串词法分析回 Tokens,再插进 quote,就是真正的代码了。在 derive.cj 末尾追加:
public macro DeriveEquals(input: Tokens): Tokens {
let c = (parseDecl(input) as ClassDecl).getOrThrow()
var conds = ""
var first = true
for (d in c.body.decls) {
if (let Some(v) <- (d as VarDecl)) {
let f = v.identifier.value
if (!first) {
conds += " && "
}
conds += "this.${f} == rhs.${f}"
first = false
}
}
return input + quote(
extend $(c.identifier) {
public operator func ==(rhs: $(c.identifier)): Bool {
return $(cangjieLex(conds))
}
public operator func !=(rhs: $(c.identifier)): Bool {
return !(this == rhs)
}
}
)
}
注意 != 必须自己写。仓颉不会从 == 自动推导 !=,漏掉它,用 u1 != u3 时编译器直接报:
error: invalid binary operator '!=' on type 'Class-User' and 'Class-User'
# note: you may want to implement 'operator func !=(right: Class-User)' for type 'Class-User'
给类标上 @DeriveEquals(堆在 @DeriveToString 下面即可,属性宏可以叠多个)。第五节把 main 换成三个对象、四次打印:
main() {
let u1 = User(1, "zhangsan")
let u2 = User(1, "zhangsan")
let u3 = User(2, "lisi")
println(u1)
println(u1 == u2)
println(u1 == u3)
println(u1 != u3)
}
实测输出:
User(id = 1, name = zhangsan)
true
false
true
对应关系:println(u1) 走 toString;u1 == u2 字段全同;u1 == u3 字段不同;u1 != u3 走 !=。
如果照搬第四节的
main、只留println(u1),编译时会提示note: unused function:'!=':!=生成了却没有被调用。这是编译期警告,不是错误,程序照常运行,补上一行u1 != u3就会消失。
六、DeriveJson:按类型生成 JSON
再进一步:生成 toJson(): String,输出 {"id": 1, "name": "zhangsan"}。难点只有一个——JSON 里字符串值要带引号,数字不带。所以宏要读字段类型:String 字段的值两侧补 \",其余直接插值。继续追加:
public macro DeriveJson(input: Tokens): Tokens {
let c = (parseDecl(input) as ClassDecl).getOrThrow()
var parts = ""
var first = true
for (d in c.body.decls) {
if (let Some(v) <- (d as VarDecl)) {
let f = v.identifier.value
let t = v.declType.toTokens().toString()
if (!first) {
parts += ", "
}
if (t == "String") {
parts += "\"${f}\": \"\${${f}}\""
} else {
parts += "\"${f}\": \${${f}}"
}
first = false
}
}
let template = "{${parts}}"
return input + quote(
extend $(c.identifier) {
public func toJson(): String {
return $(template)
}
}
)
}
v.declType.toTokens().toString() 把字段类型变成字符串 "Int64"、"String",宏在编译期按它分流。给 User 再叠一个 @DeriveJson,调用 println(u1.toJson()):
{"id": 1, "name": "zhangsan"}
这是教学版的最小实现:只管"字段是字符串还是其他",不处理嵌套对象、数组和转义字符。真上生产,要么在宏里继续完善这些分支,要么换成成熟的 JSON 库——但"读字段、按类型拼模板"的骨架思路完全相同。
七、让报错更友好:diagReport
上面的宏有个粗糙处:第一行 (parseDecl(input) as ClassDecl).getOrThrow() 假定使用者只会标在类上。万一有人标到函数上:
@DeriveToString
func add(a: Int64, b: Int64): Int64 {
a + b
}
编译期宏直接炸出异常堆栈:
NoneValueException
at cjderive.macros::DeriveToString(std.ast::Tokens)(...)
error: macro evaluation has failed for macro call 'DeriveToString'
使用者根本看不懂自己错在哪。std.ast 提供了 diagReport,让宏主动报告一条规范的编译错误。把开头的转型抽成一个公共函数,放进 derive.cj:
func requireClass(input: Tokens): Option<ClassDecl> {
match (parseDecl(input) as ClassDecl) {
case Some(c) => Some(c)
case None =>
diagReport(DiagReportLevel.ERROR, input, "this macro can only be applied to class", "apply it to a class declaration")
None
}
}
每个宏开头改成:
public macro DeriveToString(input: Tokens): Tokens {
if (let Some(c) <- requireClass(input)) {
// ……原来的生成逻辑……
return input + quote(...)
}
return input
}
注意:if 里的 c 已经由 requireClass 绑定,原来那行 let c = (parseDecl(input) as ClassDecl).getOrThrow() 必须删掉。保留它会报:
error: redefinition of declaration 'c'
note: 'c' is previously declared here
三个宏都要按这个方式改:删掉开头那行 let c = ...,直接用条件里绑定出来的 c,末尾补 return input。
diagReport 四个参数:级别(DiagReportLevel.ERROR)、出错的 Tokens、错误消息、修复建议。报完 ERROR 编译本就失败,宏把 input 原样返回即可。再编译那段错误用法:
error: this macro can only be applied to class
==> src\main.cj:7:1:
7 | func add(a: Int64, b: Int64): Int64 {
| _^
8 | | a + b
9 | | }
| |_^ apply it to a class declaration
错误位置精确指到函数声明,消息和建议都是人话。三个宏共用一个 requireClass,这就是完整版 derive.cj 的最终形态。
八、常见错误与避坑
| 错误写法 | 现象 |
|---|---|
| 宏标在函数 / 结构体上(无校验版) | 宏内 getOrThrow() 抛 NoneValueException,报 macro evaluation has failed |
只生成 == 没生成 != |
invalid binary operator '!=' on type ...,!= 不会自动推导 |
用 $(字符串) 往 quote 里插代码 |
得到的是字符串字面量,不是可执行代码;代码要先 cangjieLex 成 Tokens |
空字段类标 @DeriveEquals |
conds 为空,生成 return 后面没表达式,报 mismatched types: expected 'Bool', found 'Unit' |
| 生成代码本身不合法 | 报错落在宏展开处,编译器会附注 the error originates in the macro,并提示可用 --debug-macro 查看更多 |
最后一条值得展开:宏生成的代码仍然要过类型检查。报错位置显示在 @宏名 那一行,指向的是展开后的代码,第一眼可能陌生,按消息找生成逻辑里的问题即可。
九、常用 API 速查
| 需求 | API |
|---|---|
| 解析类声明 | parseDecl(tokens) as ClassDecl |
| 类名 | classDecl.identifier.value |
| 类成员列表 | classDecl.body.decls |
| 筛字段 | decl as VarDecl |
| 字段名 | varDecl.identifier.value |
| 字段类型 | varDecl.declType.toTokens().toString() |
| 字符串转代码 Tokens | cangjieLex(str) |
| 生成扩展 | quote(extend $(c.identifier) { ... }) |
| 扩展中实现接口 | extend X <: ToString { ... } |
| 宏内报编译错误 | diagReport(DiagReportLevel.ERROR, tokens, 消息, 建议) |
模板里输出字面量 ${ |
\${ |
十、课后练习
规矩不变:前 4 题必做,第 5 题选做。每题只考本课 1~2 个知识点,题面给全函数签名和 API 提示,先看懂题再动笔。练习 2~4 依赖本课写好的三个宏,请先在
cjderive工程里把derive.cj写完。
第 1 题(必做)拆出类声明
在普通 main 里用 parseDecl 解析下面的类,打印类名和每个字段的"名字: 类型"。
提示:parseDecl(quote(...)) 得到 Decl;if (let Some(c) <- (decl as ClassDecl)) 转换;字段是 VarDecl,v.identifier.value 是名,v.declType.toTokens() 是类型。
package cjderive
import std.ast.*
main() {
let decl = parseDecl(quote(
class Point {
let x: Int64
let y: Int64
}
))
// 补全:转成 ClassDecl,打印类名、逐字段打印
}
期望输出:
类名 = Point
x: Int64
y: Int64
(打印格式按 类名 = ${...} 和 ${名}: ${类型} 自行对齐即可。)
第 2 题(必做)给 Book 生成 toString
定义 Book 类(字段 title: String、price: Int64,构造器两参数),标上 @DeriveToString,创建 Book("仓颉语言入门", 59) 并直接 println。
提示:import cjderive.macros.*;extend 已帮你实现 ToString,所以不需要手写 toString。
期望输出:
Book(title = 仓颉语言入门, price = 59)
第 3 题(必做)比较两个 Book
给第 2 题的 Book 再叠 @DeriveEquals,创建三本书:b1 与 b2 内容相同,b3 不同。打印 b1 == b2 和 b1 != b3。
提示:属性宏直接往下叠一行即可;!= 由宏一并生成,不用自己写。
期望输出:
true
true
第 4 题(必做)Book 转 JSON
给第 2 题的 Book 再叠 @DeriveJson,打印 b1.toJson()。
提示:String 字段的值会自动带引号,Int64 不会;这就是宏里按 declType 分流的意义。
期望输出:
{"title": "仓颉语言入门", "price": 59}
第 5 题(选做)自己动手写一个 DeriveIntro
在 derive.cj 末尾追加宏 DeriveIntro:标在类上后,生成 public func intro(): String,返回固定格式的自我介绍。然后在 User(id: Int64、name: String)上使用并打印 u.intro()。
提示:骨架照抄 DeriveToString——用 requireClass(或 (parseDecl(input) as ClassDecl).getOrThrow())拿到 ClassDecl,遍历 body.decls 筛出 VarDecl,边遍历边拼字段名串、边计数,最后 quote 里 extend 出 intro 方法。
public macro DeriveIntro(input: Tokens): Tokens {
// 补全
}
期望输出:
I am User, I have 2 fields: id, name
下节预告
这一课我们把宏用到了实处:三个 deriving 宏,让任何类标一行就拥有 toString、==/!= 和 toJson。回头看第 16 课的反射——同样是"按字段列表批量处理",反射在运行时查找、有开销、错误暴露晚;宏在编译期一次生成、运行时零开销、字段写错直接编译报错。两者各有适用场景,但样板代码这件事,宏是更优解。
下一课进入 FFI 基础:与 C 互操作。仓颉可以用 foreign func 直接调用 C 函数,我们会从环境配置讲到第一个跨语言调用——这也是 CIDE 的 csrc 目录约定在背后支撑的能力。
系列说明:本系列基于 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号