【仓颉语言入门 · 第二篇 第 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 进阶、多线程日志分析器、简易爬虫
  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. 实战六:简易爬虫

一、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
posted @ 2026-10-28 22:31  upala  阅读(4)  评论(0)    收藏  举报