【仓颉语言入门 · 第二篇 第 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.cjsrc/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