ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

【仓颉语言入门 · 第二篇 第 16 课】反射:在运行时打量类型(std.reflect)

数据库篇收尾时,TodoDao里的每一行都靠手写:查出来的列,要手动get出来、手动塞进Todo对象;存回去时再手动set一遍。一张表两张表还能忍,二十张表就是体力活。有没有办法让程序在运行时自己知道"这个类有哪些字段、每个字段叫什么、类型是什么",然后自动完成搬运?有,这就是本课的主角——反射(reflection)。std.reflect让程序在运行中"照镜子",看见自己的类型结构;ORM、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. 实战六:简易爬虫

一、为什么需要反射

平时写代码,类型是编译期就定死的:User有哪些字段、Todo有哪些方法,编译器一清二楚。你写u.name,编译器知道u是User、name是String;写错了直接编译报错。

但有些需求,写代码时根本不知道要操作哪个类:

  • 写一个通用调试工具dump(obj),要能打印任意对象的全部字段
  • 写一个 JSON 序列化器,要把任意对象的字段名和值变成键值对
  • 写一个依赖注入容器,要根据配置里的类名创建对象、按名字给字段赋值
  • 写一个插件系统,要在运行时按字符串名字调用某个方法

这些场景的共同点是:类名、字段名、方法名都是运行时的字符串。你不能为每个类各写一份dump,只能让程序在运行时"问"类型系统:你有哪些字段?叫什么?什么类型?——这套能力就是反射。

反射很强,但也有代价:

  • 慢:正常字段访问是几条机器指令,反射要走查找、装箱、类型检查
  • 错误推迟到运行期:字段名写错,编译期发现不了,跑起来才抛异常
  • 能力有边界:仓颉反射只暴露public成员,非public成员看不见(后面会实测)

所以工程上的态度很明确:框架和工具用反射,普通业务代码少用。


二、三个入口:TypeInfo、ClassTypeInfo、StructTypeInfo

std.reflect的信息按类型种类分了层:

入口拿到什么额外提供
TypeInfo任意类型的信息名称、方法、父接口
ClassTypeInfo类的信息字段、构造器
StructTypeInfo结构体的信息字段、构造器

TypeInfo是基类,管"类型"层面的通用信息;字段和构造器是类/结构体特有的,所以放在子类里。先定义一个用来演示的类:

import std.reflect.* class User { public let id: Int64 public var name: String public init(id: Int64, name: String) { this.id = id this.name = name } public func greet(prefix: String): String { return "${prefix}, ${this.name}" } public func add(a: Int64, b: Int64): Int64 { return a + b } public static func version(): String { return "1.0" } }

注意字段和方法都写了public。仓颉反射只认public成员,漏写public的字段在反射里会"消失",这是初学者最常踩的坑。

拿类型信息有两种方式——按类型拿,或按对象拿:

main(): Int64 { let ti = TypeInfo.of<User>() println("类型名: ${ti.name}") let u = User(1, "zhangsan") let ti2 = TypeInfo.of(u) println("实例类型: ${ti2.name}") let cti = ClassTypeInfo.of(u) println("类名: ${cti.name}") println("字段数: ${cti.instanceVariables.size}") println("方法数: ${cti.instanceFunctions.size}") 0 }
类型名: User 实例类型: User 类名: User 字段数: 2 方法数: 2

几个要点:

  • TypeInfo.of<User>()是泛型入口,编译期就把类型写进代码
  • TypeInfo.of(u)是对象入口,拿的是运行时真实类型
  • ClassTypeInfo.of(u)比TypeInfo多出instanceVariables(字段)和constructors(构造器)
  • qualifiedName会带上包名(形如你的包名.User),name只有类名
  • 方法数只算实例方法,静态方法version不算在内;instanceVariables也只算public字段

三、读字段:instanceVariables + getValue

ClassTypeInfo.instanceVariables是一个集合,里面每个元素是InstanceVariableInfo,它带着字段的全部元信息:

成员作用
name字段名
typeInfo.name字段类型名
isMutable()是不是var(let返回false)
getValue(obj)从对象里读出字段值,返回Any

遍历并打印User的全部字段:

let cti = ClassTypeInfo.of<User>() let u = User(1, "zhangsan") for (v in cti.instanceVariables) { let value = (v.getValue(u) as ToString).getOrThrow().toString() println("${v.name} (${v.typeInfo.name}) = ${value}, 可修改: ${v.isMutable()}") }
id (Int64) = 1, 可修改: false name (String) = zhangsan, 可修改: true

这里有两层转换,必须讲清楚:

  1. getValue返回Any。Any是所有类型的公共父接口,但它没有toString,不能直接println。要打印,先转成ToString接口:raw as ToString。
  2. as的结果是Option<T>。仓颉 1.2.0 里Any as Int64得到的是Option<Int64>(成功是Some,失败是None),所以还要.getOrThrow()解包。写成(raw as ToString).getOrThrow().toString()才能拿到字符串。

如果明确知道字段类型,也可以直接转具体类型:

let cti = ClassTypeInfo.of<User>() let u = User(1, "zhangsan") let raw = cti.getInstanceVariable("id").getValue(u) let id = (raw as Int64).getOrThrow() println(id + 1)
2

非public字段看不见:把name前的public去掉,instanceVariables里就只剩id;再调用getInstanceVariable("name")会抛InfoNotFoundException,提示instance variable 'name' in class 你的包名.User not found。反射不是"万能后门",它遵守仓颉的可见性规则。


四、改字段:setValue 与两类异常

读出字段只是"看",反射还能"改"。InstanceVariableInfo.setValue(obj, value)把对象的某个字段改成新值:

let cti = ClassTypeInfo.of<User>() let u = User(1, "zhangsan") cti.getInstanceVariable("name").setValue(u, "lisi") println(u.name)
lisi

name是var,改完立刻生效。但id是let——编译期就不允许改,反射也不能破例:

let cti = ClassTypeInfo.of<User>() let u = User(1, "zhangsan") cti.getInstanceVariable("id").setValue(u, 99)
An exception has occurred: IllegalSetException: Attempt to modify immutable variable 'id'.

所以改字段之前,最好先用isMutable()判断一下,避免程序跑一半崩掉。

另一类异常是类型不匹配。name是String,硬塞一个Int64进去,会抛IllegalTypeException(它是ReflectException的子类)。下面用一个最小的类单独演示:

import std.reflect.* class Person { public var name: String public init(name: String) { this.name = name } } main(): Int64 { let cti = ClassTypeInfo.of<Person>() let p = Person("zhangsan") try { cti.getInstanceVariable("name").setValue(p, 123) } catch (e: ReflectException) { println("字段类型不匹配,反射操作失败") } 0 }
字段类型不匹配,反射操作失败

实战里,setValue前用field.typeInfo.name判断类型、按类型转换,比事后 catch 更清晰——第七节的配置注入就是这么做的。

结构体的坑:StructTypeInfo也能读字段、也能setValue,但结构体是值类型。传给反射的Any是装箱后的副本,setValue改的是副本,原变量纹丝不动(实测Point(5, 6)改x后仍是5)。结构体场景建议只读不写;要改,直接改原变量。


五、方法:查签名、动态调用

字段靠名字找,方法也靠名字找,但多了一步:同名方法可能有多个重载,必须给参数类型列表。

ClassTypeInfo.getInstanceFunction(name, [TypeInfo...])按"方法名 + 参数类型数组"精确定位一个方法;返回InstanceFunctionInfo,再调apply(obj, [实参...])执行:

let cti = ClassTypeInfo.of<User>() let u = User(2, "lisi") let greet = cti.getInstanceFunction("greet", [TypeInfo.of<String>()]) println((greet.apply(u, ["hello"]) as String).getOrThrow()) let add = cti.getInstanceFunction("add", [TypeInfo.of<Int64>(), TypeInfo.of<Int64>()]) println((add.apply(u, [3, 4]) as Int64).getOrThrow())
hello, lisi 7

要点:

  • 第二个参数是参数类型,不是实参值。greet要一个String,就传[TypeInfo.of<String>()]
  • 没有参数的方法传空数组:apply(u, [])
  • apply的返回值同样是Any,用as T+getOrThrow()解包
  • 找不到方法(名字错、参数类型对不上)抛InfoNotFoundException,消息形如Instance func 'greet' not found.

静态方法用getStaticFunction,第一个参数传类信息而不是对象:

let cti = ClassTypeInfo.of<User>() let version = cti.getStaticFunction("version", []) println((version.apply(cti, []) as String).getOrThrow())
1.0

想"浏览"一个类有哪些方法,可以遍历instanceFunctions。每个InstanceFunctionInfo都带name、returnType.name、parameters(ParameterInfo里有name、index、typeInfo):

for (f in cti.instanceFunctions) { println("${f.name}: ${f.parameters.size} 个参数, 返回 ${f.returnType.name}") }
greet: 1 个参数, 返回 String add: 2 个参数, 返回 Int64

六、构造对象:getConstructor 与 construct

反射还能在运行时"凭空造对象"。两条路:

let cti = ClassTypeInfo.of<User>() let ctor = cti.getConstructor([TypeInfo.of<Int64>(), TypeInfo.of<String>()]) let u1 = (ctor.apply([3, "wangwu"]) as User).getOrThrow() println("${u1.id} ${u1.name}") let u2 = (cti.construct([4, "zhaoliu"]) as User).getOrThrow() println("${u2.id} ${u2.name}")
3 wangwu 4 zhaoliu
  • getConstructor同样要精确的参数类型列表;construct直接传实参,由内部按实参类型匹配构造器
  • 返回的都是Any,转成User再解包
  • 构造器必须是public init。写成不带public的init,getConstructor会直接抛InfoNotFoundException: Can not find the specified constructor

想一想:如果字段、方法、构造器都能按字符串操作,那"把数据库一行变成一个对象"就可以自动化了——遍历字段、按列名取值、调用setValue填进去。这正是 ORM 框架的雏形。第七节我们先做一个更小、更完整的版本。


七、实战:反射版通用打印器 + 配置注入

目标:写一个dump函数,打印任意对象的字段;再写一个applyConfig函数,按HashMap里的键值对自动给对象字段赋值。整个过程不碰具体字段名,全靠反射。

import std.reflect.* import std.collection.* import std.convert.* class Config { public var host: String public var port: Int64 public var debug: Bool public init(host: String, port: Int64, debug: Bool) { this.host = host this.port = port this.debug = debug } } func dump(obj: Any, cti: ClassTypeInfo): String { var out = "${cti.name} {" for (v in cti.instanceVariables) { let raw = v.getValue(obj) let text = (raw as ToString).getOrThrow().toString() out = out + "\n ${v.name}: ${v.typeInfo.name} = ${text}" } return out + "\n}" } func applyConfig(obj: Any, cti: ClassTypeInfo, data: HashMap<String, String>): Int64 { var applied = 0 for ((key, text) in data) { let field = cti.getInstanceVariable(key) if (field.typeInfo.name == "String") { field.setValue(obj, text) } else if (field.typeInfo.name == "Int64") { field.setValue(obj, Int64.parse(text)) } else if (field.typeInfo.name == "Bool") { field.setValue(obj, text == "true") } else { continue } applied++ } return applied } main(): Int64 { let cti = ClassTypeInfo.of<Config>() let cfg = Config("localhost", 3306, false) println(dump(cfg, cti)) let data = HashMap<String, String>([("host", "192.168.1.10"), ("port", "8080"), ("debug", "true")]) let n = applyConfig(cfg, cti, data) println("应用了 ${n} 项配置") println(dump(cfg, cti)) 0 }
Config { host: String = localhost port: Int64 = 3306 debug: Bool = false } 应用了 3 项配置 Config { host: String = 192.168.1.10 port: Int64 = 8080 debug: Bool = true }

拆开看三件事:

  1. dump不认识Config的任何字段,它只依赖instanceVariables和getValue。换一个类,dump一行都不用改。
  2. raw as ToString是通用打印的关键:Int64、String、Bool这些内建类型都实现了ToString,所以统一转ToString就能拿到文本。自定义类如果也要被dump,记得实现ToString。
  3. applyConfig按类型名分支:field.typeInfo.name返回"Int64"、"String"这类名字,再选对应的解析方式。真实框架会更进一步,用field.typeInfo.qualifiedName和泛型信息做完整映射——但核心思路就是这三步:查字段 → 判类型 → setValue。

注意:applyConfig里的键必须真实存在,否则getInstanceVariable会抛InfoNotFoundException。生产代码里通常try/catch包一层,跳过未知键并记录日志;本课为了主线清晰没有再套一层。


八、常用 API 速查

需求API
类型入口(泛型)TypeInfo.of<T>()/ClassTypeInfo.of<T>()
类型入口(对象)TypeInfo.of(obj)/ClassTypeInfo.of(obj)
类型名ti.name/ti.qualifiedName
字段列表cti.instanceVariables
取字段cti.getInstanceVariable("name")
读字段field.getValue(obj)→Any
写字段field.setValue(obj, value)
字段元信息field.name/field.typeInfo.name/field.isMutable()
方法列表cti.instanceFunctions/cti.getInstanceFunctions("name")
取方法cti.getInstanceFunction("name", [TypeInfo...])
调实例方法fn.apply(obj, [args])→Any
调静态方法cti.getStaticFunction("name", []).apply(cti, [args])
构造对象cti.getConstructor([TypeInfo...]).apply([args])/cti.construct([args])
解包结果(value as T).getOrThrow()
结构体信息StructTypeInfo.of<T>()/StructTypeInfo.of(obj)

九、常见问题 FAQ

Q1:getValue拿到的值为什么不能直接println?

因为返回类型是Any,Any只保证"是个值",不保证实现ToString。正确姿势是转接口再解包:(value as ToString).getOrThrow().toString()。如果明确知道类型,也可以(value as Int64).getOrThrow()后再参与运算。

Q2:为什么我的字段在instanceVariables里找不到?

九成是忘了写public。仓颉成员不写访问修饰符时不是public,而反射只暴露public成员。public var name能看见,var name看不见;方法同理,非public方法不会出现在instanceFunctions里。

Q3:getConstructor明明参数对得上,为什么还抛InfoNotFoundException?

检查构造器有没有写public init。不带public的init,反射找不到,报错文案是Can not find the specified constructor。本课User、Config的构造器都显式写了public。

Q4:反射能改let字段吗?

不能。let在语言层面就是不可变的,反射也不开后门,setValue会抛IllegalSetException: Attempt to modify immutable variable 'id'.。改之前可以先看isMutable()。

Q5:反射慢多少?要不要缓存?

没有固定倍数,但肯定比直接字段访问慢得多——每次都要按字符串查找、装箱/拆箱、类型检查。做法是把InstanceVariableInfo、InstanceFunctionInfo查出来缓存住,不要每次循环都getInstanceVariable。真正的高频路径(比如每秒百万次的业务字段访问)不该用反射。

Q6:结构体和枚举也能反射吗?

能。结构体用StructTypeInfo,枚举用EnumTypeInfo(还能拿到构造器和关联值)。但结构体是值类型,setValue改的是装箱副本,原变量不变;枚举的构造器/关联值属于进阶内容,本课不展开。日常使用记住一句话:类反射最常用,结构体反射以读为主。

Q7:反射和宏有什么区别?

反射在运行时干活:程序已经跑起来了,按字符串查字段、调方法。宏在编译期干活:编译器帮你生成代码,运行时没有反射开销。反射灵活但慢,宏快但要求类型在编译期已知。下一课就进入宏的世界。


十、课后练习

规矩不变:前 4 题必做,第 5 题选做。每题只考本课 1~2 个知识点,题面给全函数签名和 API 提示,先看懂题再动笔。

第 1 题(必做)遍历字段并打印

用ClassTypeInfo.of<Book>()遍历Book的字段,按字段名=值的格式打印。

提示:for (v in cti.instanceVariables);v.getValue(book)返回Any,用(raw as ToString).getOrThrow().toString()转字符串。

import std.reflect.* class Book { public let title: String public let price: Int64 public init(title: String, price: Int64) { this.title = title this.price = price } } main(): Int64 { let cti = ClassTypeInfo.of<Book>() let book = Book("仓颉入门", 59) // 补全:遍历字段并打印 0 }

期望输出:

title=仓颉入门 price=59

第 2 题(必做)按名字改字段

用反射把Counter的count字段改成10,然后打印c.count。

提示:cti.getInstanceVariable("count").setValue(c, 10);count是var才能改。

import std.reflect.* class Counter { public var count: Int64 public init(count: Int64) { this.count = count } } main(): Int64 { let cti = ClassTypeInfo.of<Counter>() let c = Counter(0) // 补全:用反射把 count 改成 10 0 }

期望输出:

10

第 3 题(必做)动态调用无参方法

用反射调用Greeter的hello(),打印返回值。

提示:参数类型列表传空数组[];apply(g, [])返回Any,转String再getOrThrow()。

import std.reflect.* class Greeter { public init() {} public func hello(): String { return "hello" } } main(): Int64 { let g = Greeter() let cti = ClassTypeInfo.of<Greeter>() // 补全:取 hello 方法并调用 0 }

期望输出:

hello

第 4 题(必做)反射创建对象

用getConstructor创建一个Point(3, 4),打印x,y。

提示:构造器参数类型[TypeInfo.of<Int64>(), TypeInfo.of<Int64>()];apply([3, 4])返回Any,转Point再解包。

import std.reflect.* class Point { public let x: Int64 public let y: Int64 public init(x: Int64, y: Int64) { this.x = x this.y = y } } main(): Int64 { let cti = ClassTypeInfo.of<Point>() // 补全:通过构造器创建 Point(3, 4) 并打印 0 }

期望输出:

3,4

第 5 题(选做)调用静态方法

用反射调用MathUtil.square(7),打印结果。

提示:getStaticFunction("square", [TypeInfo.of<Int64>()]);静态方法调用时第一个参数传类信息cti,不是对象。

import std.reflect.* class MathUtil { public init() {} public static func square(n: Int64): Int64 { return n * n } } main(): Int64 { let cti = ClassTypeInfo.of<MathUtil>() // 补全:取静态方法 square 并调用 0 }

期望输出:

49

下节预告

反射是"运行时打量类型",代价是慢和类型不安全。如果类型在编译期就已知,有没有办法让编译器把样板代码自动写出来——比如给一个类自动生成toString、equals、JSON 序列化?有,下一课进入宏基础:用std.ast和quote在编译期操作语法树,让代码生成代码。从"运行时照镜子"到"编译期造代码",这是元编程的两条路。


系列说明:本系列基于 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
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进