Go 从 0 到精通 · 第 35 课:反射入门
Go 从 0 到精通 · 第 35 课:反射入门
学习定位:这是整套 Go 教程的第 35 课,也是阶段六(高级进阶阶段)的第二课。
前置要求:已经完成第 34 课,理解 Go 的类型系统、接口、结构体、方法和泛型基础。
本课目标:理解 Go 反射的核心作用,掌握reflect.Type、reflect.Value、Kind、Elem、CanSet等关键概念,学会在运行时读取类型信息、遍历结构体字段、读取标签、修改可设置值,并知道反射的代价、适用场景和常见坑。
1. 本课你要解决的核心问题
前面的 Go 代码,大多数都是“编译期就知道类型”的。
例如:
var n int = 10user.Nametasks[0].Title
编译器在你写代码的时候,就已经知道:
- 变量是什么类型
- 有哪些字段
- 有哪些方法
但真实项目里,有一类问题在编译期并不知道全部信息:
- 传进来的到底是不是结构体
- 这个结构体有哪些字段
- 字段上有没有
json:"name"这种标签 - 我想根据字符串
"Name"动态找到字段并赋值 - 我写的是通用框架代码,不知道用户会传什么类型
这时候,普通静态写法就不够了。
反射解决的问题就是:让程序在运行时查看和操作类型信息。
这听起来很强,但也很危险,因为它会带来:
- 代码更绕
- 性能更差
- 出错更隐蔽
所以这节课要讲清楚两件事:
- 反射到底能做什么
- 为什么它必须谨慎使用
2. 先建立直觉:什么叫“运行时看类型”
看一个最简单的例子:
1 | package main |
这里做了什么?
reflect.TypeOf(x):拿到变量的类型描述reflect.ValueOf(x):拿到变量当前的值描述
也就是说,反射把“类型”和“值”都包装成了可在运行时分析的对象。
你可以先把它记成:
Type关注“它是什么”Value关注“它现在的值是什么”
3. reflect.Type 和 reflect.Value 是两块核心地基
3.1 reflect.Type
reflect.Type 用来描述类型信息。
常见操作:
- 看类型名
- 看种类
- 看字段
- 看方法
例如:
1 | type User struct { |
3.2 reflect.Value
reflect.Value 用来描述值。
常见操作:
- 读取值
- 判断值类型
- 取字段值
- 在满足条件时修改值
例如:
1 | u := User{Name: "张三", Age: 18} |
3.3 一句话区分
你可以先这样记:
Type更像“说明书”Value更像“当前实例”
很多反射代码,本质上就是在 Type 和 Value 之间来回切换。
4. Type 不等于 Kind
这是反射里第一个很重要、也很容易混的点。
4.1 Type 是具体类型
例如:
1 | type MyInt int |
它们的 Type 分别是:
MyIntUser
4.2 Kind 是底层类别
例如:
intstringstructslicemapptr
看例子:
1 | package main |
这里:
Name()是MyIntKind()是int
4.3 为什么要区分这两个
因为很多反射逻辑判断的是“它属于哪一类”。
比如:
- 如果
Kind() == reflect.Struct,那就可以遍历字段 - 如果
Kind() == reflect.Slice,那就可以遍历元素 - 如果
Kind() == reflect.Ptr,那就应该先Elem()
所以:
- 想知道具体类型是谁,看
Type - 想知道应该怎么处理它,看
Kind
5. 读取基本类型信息
下面先把最常见的几个 API 过一遍。
5.1 读取类型名和种类
1 | package main |
5.2 判断是不是某种类别
1 | func PrintKind(x any) { |
5.3 读取值
1 | func PrintValue(x any) { |
不过要注意:
reflect.Value 不是普通值本身,而是一个“反射包装对象”。你通常还要调用它的方法拿到具体内容。
例如:
1 | v := reflect.ValueOf(123) |
6. 不同 Kind 取值方式不同
这是反射里最容易踩坑的一类问题。
6.1 整数用 Int()
1 | v := reflect.ValueOf(123) |
6.2 字符串用 String()
1 | v := reflect.ValueOf("hello") |
6.3 布尔值用 Bool()
1 | v := reflect.ValueOf(true) |
6.4 浮点数用 Float()
1 | v := reflect.ValueOf(3.14) |
6.5 错误示例
1 | v := reflect.ValueOf("hello") |
为什么?
因为 v 的底层种类是 string,你却按整数去取。
所以反射里一个非常重要的习惯是:
先看
Kind(),再决定调用哪个取值方法。
7. 指针和 Elem():反射里最关键的入口之一
很多反射代码看着复杂,本质都绕不开 Elem()。
7.1 什么是 Elem()
如果一个值是:
- 指针
- 接口
- 切片元素
- 数组元素
那你经常需要“进入下一层”。
对于指针来说,Elem() 表示“指针指向的那个值”。
7.2 先看一个例子
1 | package main |
这里:
&x的Kind是ptrvp.Elem()才是它指向的那个int
7.3 为什么 Elem() 这么重要
因为很多时候你想修改原值,必须拿到它指向的真实对象,而不是那个指针包装。
例如修改变量:
1 | x := 10 |
如果你不传指针,而是直接:
1 | v := reflect.ValueOf(x) |
会直接 panic,因为它不是可设置的值。
8. 修改值:CanSet 是关键判断
反射不仅能看,还能改,但不是所有值都能改。
8.1 先看失败例子
1 | package main |
为什么不能改?
因为 reflect.ValueOf(x) 拿到的是 x 的一个副本信息,不是可回写的原对象。
8.2 正确写法:传指针,再 Elem()
1 | func main() { |
8.3 一个通用示例
1 | func SetIntValue(ptr any, newValue int64) error { |
调用:
1 | var x int = 10 |
8.4 一个经验口诀
想通过反射修改值,通常要满足三件事:
- 传的是指针
- 找到了正确的目标值
CanSet()为true
9. 结构体反射:最常见、最实用
反射在实际项目里最常见的对象不是基本类型,而是结构体。
因为很多框架都需要:
- 看结构体字段
- 看字段标签
- 按字段名读写
9.1 遍历结构体字段
1 | package main |
输出大致会是:
1 | 字段名: Name |
9.2 按字段名查找
1 | u := User{Name: "王五", Age: 20} |
如果字段不存在:
1 | f := v.FieldByName("Email") |
所以按名字找字段时,最好先判断 IsValid()。
10. 读取结构体标签:这就是很多框架的基础
很多标准库和框架都依赖标签,例如:
1 | type User struct { |
反射可以读这些标签。
10.1 读取标签示例
1 | package main |
10.2 为什么这很重要
像这些能力背后大多依赖反射:
encoding/json根据json标签做编解码- ORM 根据
db标签做字段映射 - 参数校验库根据
validate标签做规则验证
所以学反射,不只是为了自己手写反射代码,更重要的是:
你开始能读懂这些库为什么能“自动工作”。
11. 修改结构体字段:必须满足条件
修改结构体字段是反射里第二个常见操作,但限制也更多。
11.1 正确示例
1 | package main |
11.2 为什么一定要 &u
因为:
reflect.ValueOf(u)拿到的是值副本reflect.ValueOf(&u).Elem()才是原结构体本身
11.3 必须是导出字段
如果结构体字段是小写:
1 | type User struct { |
那么即使你拿到了结构体值,反射也不能随便改这个未导出字段。
这也是 Go 可见性规则在反射层面的延续。
12. 一个完整实战:打印结构体信息
下面写一个很典型的小工具函数:接收任意结构体,打印字段名、类型、值和标签。
1 | package main |
12.1 这个例子体现了哪些关键点
它把这节课的几个主干串起来了:
- 用
TypeOf/ValueOf拿类型和值 - 用
Kind()判断是不是指针、是不是结构体 - 用
Elem()解引用 - 用
NumField()和Field(i)遍历字段 - 用
Tag.Get()读取标签 - 用
Interface()拿到可打印的普通值
如果你能把这个函数读顺,说明你已经真正入门反射了。
13. Interface() 是把反射值还原回普通值
这个方法经常出现在反射代码里。
例如:
1 | v := reflect.ValueOf(123) |
它的作用可以理解为:
把
reflect.Value里的内容重新取出来,作为一个普通的interface{}值返回
这样你就能:
- 交给
fmt.Println - 做类型断言
- 传给其他普通函数
例如:
1 | v := reflect.ValueOf("hello") |
所以很多反射流程最后都会回到:
1 | fieldValue.Interface() |
14. 反射和接口的关系
这部分必须讲清楚,否则很多反射代码会看得一头雾水。
14.1 反射常常从 any 开始
很多通用函数写成:
1 | func Inspect(x any) { ... } |
为什么?
因为调用者可能传:
intstringUser*User
而 any 可以接住所有类型。
14.2 反射的本质,是把接口里的动态类型拿出来
当一个值被放进接口后,运行时其实仍然记得:
- 它的真实类型是什么
- 它的真实值是什么
reflect.TypeOf(x) 和 reflect.ValueOf(x) 就是在把这些信息重新拿出来。
所以你可以把反射理解成:
“对接口中真实类型信息的运行时访问能力”
这个理解非常重要。
15. 反射能做什么,真实项目里为什么常见
很多人第一次学反射会觉得:“这玩意我平时好像不用啊。”
其实你经常在“间接使用”它。
15.1 编解码
例如 encoding/json:
- 看结构体字段
- 读
json标签 - 把 JSON 数据填进对应字段
15.2 ORM / 数据库映射
例如很多 ORM 会:
- 看结构体字段名
- 看数据库标签
- 自动构造扫描和映射逻辑
15.3 配置加载
有些配置库会:
- 读取环境变量或配置文件
- 根据标签把值填充到结构体
15.4 参数校验
有些校验库会:
- 遍历结构体字段
- 读取
validate:"required"之类的标签 - 按规则做校验
15.5 框架能力
很多框架的“自动注册”“自动绑定”“自动注入”背后,本质都是反射。
所以反射是一个“底层基础设施工具”。
业务代码里你可能不常直接写它,但你会大量遇到它的结果。
16. 为什么说反射要慎用
这节是整课里最重要的价值判断部分。
16.1 可读性差
普通代码一眼就知道在干什么:
1 | user.Name = "张三" |
反射代码则是:
1 | v := reflect.ValueOf(&user).Elem() |
显然更绕。
16.2 容易 panic
例如:
- 对
string调Int() - 对不可设置值调用
Set - 对非指针调用
Elem() - 字段不存在却继续操作
这些都很容易在运行时直接 panic。
16.3 性能更差
反射需要:
- 做动态类型检查
- 做额外包装
- 走运行时分派逻辑
这通常比直接访问字段、直接调用函数更慢。
16.4 编译器帮不了太多
普通代码很多错误能在编译期发现。
反射代码则有一大类错误只能等到运行时暴露。
所以反射的原则很简单:
能不用就不用,必须用时把范围收窄。
17. 泛型出现后,哪些场景不必再用反射
这是你现在这个学习阶段很值得建立的判断力。
在 Go 引入泛型之前,很多“通用处理”只能在:
- 重复代码
- 反射
interface{}+ 类型断言
之间选。
现在多了一个更好的选项:泛型。
17.1 例如切片工具函数
过去有人会写各种“反射版通用切片处理”。
现在像这种场景:
1 | func Contains[T comparable](items []T, target T) bool |
显然泛型比反射更合适。
17.2 那反射还剩什么价值
反射仍然适合:
- 处理结构体标签
- 处理运行时未知结构
- 写通用框架和基础库
- 编解码、绑定、注入、校验这类动态机制
所以一个实用判断是:
- “同一算法适用于多种已知类型” -> 更像泛型
- “运行时才知道结构和字段” -> 更像反射
18. 常见坑总结
18.1 忘了判断 Kind
例如:
1 | func PrintInt(x any) { |
如果传进来不是整数,就会 panic。
正确思路:
- 先判断
v.Kind() - 再做对应操作
18.2 对非指针调用 Elem()
1 | v := reflect.ValueOf(10) |
Elem() 不是随便都能调,至少得先确认它是指针或接口等可解开的值。
18.3 以为拿到 Value 就一定能改
1 | v := reflect.ValueOf(10) |
只有可寻址、可设置的值才能改,通常需要:
1 | reflect.ValueOf(&x).Elem() |
18.4 忘了字段可能不存在
1 | f := v.FieldByName("Email") |
应该先判断:
1 | if !f.IsValid() { |
18.5 忘了字段可能不可设置
即使字段存在,也不代表可以改。
要先看:
1 | f.CanSet() |
18.6 在普通业务逻辑里滥用反射
例如你明明知道是:
1 | user.Name |
却还写成:
1 | reflect.ValueOf(&user).Elem().FieldByName("Name") |
这基本只会让代码更差。
18.7 反射代码不做错误保护
反射比普通代码更应该:
- 提前判断类型
- 提前判断字段是否存在
- 提前判断是否可设置
- 在必要处返回错误而不是直接 panic
19. 一个更实用的安全版本:按字段名设置字符串
下面写一个更接近真实工具函数的例子。
1 | package main |
19.1 这个例子为什么比直接 panic 更好
因为它在每一步都做了边界检查:
- 是不是指针
- 指向的是不是结构体
- 字段存不存在
- 字段类型对不对
- 字段能不能改
这就是反射代码和普通代码最大的写法差别:
你必须更防御式。
20. 本课练习
练习 1:打印变量类型与值
写一个函数:
1 | func Describe(x any) |
要求打印:
- 具体类型
Kind- 当前值
并分别测试:
intstring[]int- 结构体
练习 2:遍历结构体字段
定义一个 Book 结构体,包含:
TitleAuthorPrice
写一个函数,使用反射打印每个字段的:
- 字段名
- 字段类型
- 字段值
练习 3:读取标签
给 Book 增加标签:
1 | type Book struct { |
写一个函数读取并打印每个字段的 json 和 db 标签。
练习 4:修改字段值
写一个函数:
1 | func SetIntField(ptr any, fieldName string, value int64) error |
要求:
- 只允许修改
int类型字段 - 如果字段不存在、不是
int、不可设置,都返回错误
练习 5:对比反射与普通写法
分别实现下面两种逻辑:
- 直接把
user.Name改成"李四" - 用反射把字段
"Name"改成"李四"
然后比较:
- 哪个更简单
- 哪个更安全
- 哪个更适合普通业务代码
21. 自测题
21.1 概念题
- Go 反射主要解决什么问题?
reflect.Type和reflect.Value分别表示什么?Type和Kind有什么区别?- 为什么很多反射代码都要先判断
Kind()? Elem()在什么场景下最常见?它的作用是什么?- 为什么通过反射修改值时通常要传指针?
CanSet()的意义是什么?- 为什么说反射更适合框架和基础库,而不适合滥用在普通业务代码里?
21.2 代码阅读题
下面这段代码有两个明显问题,试着找出来:
1 | func SetName(x any) { |
点击查看答案
问题 1:没有传指针,也没有 Elem()。
reflect.ValueOf(x)如果拿到的是结构体值副本,字段通常不可设置- 正确做法通常是传入结构体指针,然后
Elem()取到原结构体
问题 2:没有做任何安全检查。
- 没判断
x是不是结构体或结构体指针 - 没判断字段
"Name"是否存在 - 没判断字段是不是
string - 没判断字段是否可设置
更稳妥的写法应该像这样:
1 | func SetName(x any) error { |
22. 本课总结
这一课你已经进入 Go 里最“动态”的能力之一。
| 知识点 | 要点 |
|---|---|
| 反射目标 | 在运行时查看和操作类型与值 |
| 两大核心 | reflect.Type 看类型,reflect.Value 看值 |
| 关键判断 | Kind() 决定当前值该怎么处理 |
| 关键入口 | Elem() 用来进入指针或接口内部值 |
| 修改前提 | 指针、正确目标、CanSet() 为真 |
| 典型用途 | 标签读取、编解码、框架能力、动态绑定 |
最重要的四件事:
- 反射解决的是“运行时才知道结构”的问题
- 反射代码一定要先做类型和边界检查
- 很多框架大量使用反射,但普通业务代码不该为了炫技滥用它
- 泛型解决的是通用类型复用,反射解决的是运行时动态检查,两者不是一回事
23. 下一课预告
你已经学完泛型和反射,接下来我们要转向另一个非常重要的高级主题:Go 的内存行为。
下一课:内存与逃逸分析基础
会重点讲:
- 栈和堆的基本区别
- 什么是逃逸分析
- 哪些写法容易导致变量逃逸到堆上
- 值传递、指针传递和内存分配之间的关系
- 怎么建立对性能和内存行为的初步判断
学完下一课,你会开始真正理解“为什么这段 Go 代码会有这样的性能表现”。





