【仓颉语言入门 · 第二篇 第 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~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 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、为什么需要反射
平时写代码,类型是编译期就定死的: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这里有两层转换,必须讲清楚:
getValue返回Any。Any是所有类型的公共父接口,但它没有toString,不能直接println。要打印,先转成ToString接口:raw as ToString。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)lisiname是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 zhaoliugetConstructor同样要精确的参数类型列表;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 }拆开看三件事:
dump不认识Config的任何字段,它只依赖instanceVariables和getValue。换一个类,dump一行都不用改。raw as ToString是通用打印的关键:Int64、String、Bool这些内建类型都实现了ToString,所以统一转ToString就能拿到文本。自定义类如果也要被dump,记得实现ToString。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