【仓颉语言入门 · 第二篇 第 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~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. 实战六:简易爬虫

一、为什么需要反射

平时写代码,类型是编译期就定死的: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
posted @ 2026-10-26 00:18  upala  阅读(6)  评论(0)    收藏  举报