news 2026/10/30 5:02:55

【仓颉语言入门 · 第二篇 第 18 课】

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【仓颉语言入门 · 第二篇 第 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~26HTTP 基础、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
空字段类标@DeriveEqualsconds为空,生成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()
字符串转代码 TokenscangjieLex(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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 5:00:45

Redis高并发库存扣减方案设计与实战

1. 热点库存扣减的典型业务场景与核心挑战在电商大促、秒杀活动、直播带货等高并发场景中&#xff0c;热点商品的库存扣减一直是系统设计的难点。我曾参与过某头部直播平台618大促的库存系统改造&#xff0c;当时峰值QPS达到12万&#xff0c;传统方案根本无法应对。这类场景通常…

作者头像 李华
网站建设 2026/10/30 4:58:19

5000 个请求堵在 10 条连接上,jstack 一个都看不见

一个开了虚拟线程的 Spring Boot 服务&#xff0c;HikariCP 连接池 10 条&#xff0c;大约 5000 个请求在途&#xff0c;延迟在往上爬。你抓起线程栈&#xff0c;看到 43 个平台线程&#xff0c;大部分是调度器的 worker&#xff0c;停在 ForkJoinPool.awaitWork&#xff0c;CP…

作者头像 李华
网站建设 2026/10/30 4:57:46

07|把 DeepSeek Harness 会话导出成 Markdown,怎样避免覆盖另一段对话?

把 DeepSeek Harness 的会话导出为 Markdown,便于用普通编辑器搜索和查阅。但如果用“创建时间+首句话”命名,两段会话可能得到同一个文件名。第二次导出看似成功,却把第一段对话覆盖了。 我检查过的一份导出代码就存在这个问题:它取创建时间和首条消息的前 20 个字符作为…

作者头像 李华
网站建设 2026/10/30 4:55:40

C语言——数组

一、数组的概念&#xff1a;数组其实就是一类相同的元素的集合&#xff1a;从这个概念我们就可以发现两个有价值的信息&#xff1a;a、数组中存放的是一个或者多个元素&#xff0c;但是数组元素的个数不可以是0在标准的C语言中这个不是合法的普通数组的定义&#xff0c;因为这里…

作者头像 李华
网站建设 2026/10/30 4:52:52

Windows server 2019 AD备域搭建

在Windows server 2019 AD备域搭建 设置备域服务器。 设置固定IP地址 修改AD备域服务器的名称 重复&#xff1a;Windows server 2019 AD域控搭建&#xff0c;的《打开服务器管理器》部分Windows server 2019 AD域控搭建_域控服务器安装步骤-CSDN博客 点击图标&#xff0c…

作者头像 李华