跳到主要内容
约 18 分钟9 张卡

Go 反射

反射是在运行时检查类型和操作值的能力。Go 的 reflect 包可以读取结构体字段、标签和方法,也可以在满足类型与可设置条件时修改值或调用函数。序列化、配置绑定等通用代码因此可以处理事先没有写死的具体类型。

反射没有改变 Go 的值传递和类型规则,只是把一部分检查放到了运行时。读懂反射代码,需要同时回答:当前拿到的是什么类型,操作的是哪个值,这个值是否允许执行相应操作。本文以 Go 1.22 为基线。

1. 面试核心​

先记住这几条,再读正文
  • reflect.Type 描述类型,reflect.Value 表示具体值及可执行操作。反射可以从接口中的动态类型和值进入,再通过 Interface 取回普通值;它依赖运行时类型信息,不是把源代码重新解析一遍。卡 · 类型、种类与值
  • Type 表示完整类型,Kind 只表示种类。type Count int 与 int 类型不同,但种类都是 reflect.Int;TypeOf 看到的是接口中的动态类型。卡 · 类型、种类与值
  • 可寻址不等于可设置。通过非 nil 指针的 Elem 可以取得所指变量,但未导出字段本身仍不能直接 Set;CanInterface 则检查能否调用 Interface。卡 · 指针与 Elem
  • 无效的 reflect.Value、某类型的零值和 nil 不是同一件事。先检查 IsValid,再按种类选择 IsNil、Elem 等操作,避免对缺失值执行不适用的方法。卡 · 无效值、零值与 nil
  • 结构体标签是类型上的字符串元数据,标签的行为由使用它的库解释。Get 不能区分键缺失与显式空字符串,需要这个区别时使用 Lookup。卡 · 结构体字段与标签
  • 反射查找方法仍受方法集约束。Value.MethodByName 得到的方法值已经绑定接收者,Call 只传普通参数,并检查数量和类型是否匹配。卡 · 动态调用与方法集
  • 反射适合运行时才确定结构的通用边界;类型明确时优先使用普通代码、接口或泛型。性能要区分元数据查找、值操作与动态调用,并以具体测量判断,不能给 Go 与 Java 反射排一个固定快慢顺序。卡 · 使用场景与性能判断
  • CanSet 只说明当前 Value 能否被直接设置,不代表它关联的数据都不可修改。切片元素、map 内容需要分别按自己的操作规则判断。卡 · CanSet 为 false,为什么仍能修改切片元素和 map 内容?
  • 可以转换不等于转换没有数值损失。Set 要求可赋值,Convert 执行显式转换规则;写入窄整数前,可用 OverflowInt 等方法检查范围。卡 · 反射会自动完成数值转换和溢出检查吗?

2. 类型、种类与值​

reflect.Type 是类型信息,可以查询名称、种类、字段、方法和元素类型。reflect.Value 表示一个具体值,除了查询类型,还提供读取、修改和调用等操作。值能够执行哪些操作,取决于它的类型及取得方式。

package main

import (
"fmt"
"reflect"
)

type Count int

func main() {
count := Count(3)
typ := reflect.TypeOf(count)
value := reflect.ValueOf(count)

fmt.Println(typ, typ.Name(), typ.Kind()) // main.Count Count int
fmt.Println(typ == reflect.TypeOf(int(3))) // false
fmt.Println(value.Int()) // 3
restored := value.Interface().(Count)
fmt.Printf("%T %v\n", restored, restored) // main.Count 3
}

Kind 是类型所属的种类,例如整数、结构体、切片和指针。Count 的完整类型是 main.Count,种类却是 reflect.Int。所以可以用 Int() 读取它;Int() 返回 int64,并不表示原值的类型变成了 int64。

Interface() 把当前值作为 any 返回。随后断言为 Count,能取回具体类型的值;断言为 int 则会失败,因为命名类型的身份仍然存在。类型断言的规则见 Go 接口。

需要判断两个类型是否相同时,可以直接比较两个 reflect.Type。不要仅比较 Name()、String() 或 Kind():不同包可能声明同名类型,种类相同也不代表类型相同。

2.1 接口的动态类型与接口变量本身​

TypeOf 和 ValueOf 接收 any。传入接口变量时,反射看到的是其中保存的具体动态类型和值,不会在外面再套一层原接口类型。

package main

import (
"fmt"
"io"
"reflect"
"strings"
)

func main() {
var reader io.Reader = strings.NewReader("Go")
fmt.Println(reflect.TypeOf(reader)) // *strings.Reader

slot := reflect.ValueOf(&reader).Elem()
fmt.Println(slot.Kind() == reflect.Interface) // true
fmt.Println(slot.Type() == reflect.TypeFor[io.Reader]()) // true
fmt.Println(slot.Elem().Type() == reflect.TypeOf(reader)) // true
}

reflect.ValueOf(reader) 查看 *strings.Reader 这个具体值。reflect.ValueOf(&reader).Elem() 则表示 reader 这个接口变量本身,所以 slot.Type() 是 io.Reader;再调用一次 slot.Elem(),才取得接口当前保存的动态值。

reflect.TypeFor[T]() 从 Go 1.22 起提供,用来取得类型实参 T 的类型信息。这里也可以写 reflect.TypeOf((*io.Reader)(nil)).Elem():先取得接口指针类型,再取得其元素类型。它不需要构造一个实际的读取器。

运行时会保留类型相关的元数据,反射按这些元数据解释值。理解这条链路不需要依赖接口内部的具体内存布局,也不能把某个版本的布局当成通用 API 契约。

3. 可寻址、可设置与导出限制​

可寻址表示可以通过 Addr 取得该值的指针,由 CanAddr 判断。可设置表示允许通过 Set 或相应的类型设置方法修改这个 Value 所表示的值,由 CanSet 判断。可设置首先要求可寻址,还受字段导出状态等限制,需要检查实际要修改的 Value。

3.1 指针与 Elem​

把普通变量传给 ValueOf,遵守值传递规则。ValueOf 得到的值不能直接作为调用方原变量来修改;要修改变量,可以传入指针,再用 Elem 取得所指变量。

package main

import (
"fmt"
"reflect"
)

func main() {
number := 10
copied := reflect.ValueOf(number)
pointer := reflect.ValueOf(&number)
target := pointer.Elem()

fmt.Println(copied.CanAddr(), copied.CanSet()) // false false
fmt.Println(pointer.CanAddr(), pointer.CanSet()) // false false
fmt.Println(target.CanAddr(), target.CanSet()) // true true
target.SetInt(20)
fmt.Println(number) // 20
// copied.SetInt(20) // 运行时 panic:copied 不可设置
}

pointer 本身保存的是传入的指针值,不能直接把它当作调用方的某个指针变量来重新赋值。pointer.Elem() 才表示 number 所在的位置,所以 target.SetInt 可以改到 number。

Elem 不是通用的“去掉任意外壳”操作。Value.Elem 用于指针或接口值;指针是 nil、接口没有动态值时,它返回无效 Value。若直接对整数调用 Elem,会在运行时 panic。

3.2 未导出字段与 Interface​

结构体本身可寻址,不表示每个字段都可设置。普通反射 API 会保留未导出字段的限制,即使反射调用与结构体声明位于同一个包,也不能借 Set 修改该字段。

package main

import (
"fmt"
"reflect"
)

type User struct {
Name string
age int
}

func main() {
user := User{Name: "Ada", age: 20}
value := reflect.ValueOf(&user).Elem()
name := value.FieldByName("Name")
age := value.FieldByName("age")

fmt.Println(name.CanAddr(), name.CanSet(), name.CanInterface()) // true true true
fmt.Println(age.CanAddr(), age.CanSet(), age.CanInterface()) // true false false
name.SetString("Lin")
fmt.Println(user.Name) // Lin
// age.SetInt(21) // 运行时 panic:未导出字段不可设置
// age.Interface() // 运行时 panic:不能把该字段作为接口取出
}

CanInterface 与 CanSet 回答不同的问题。一个通过 ValueOf(3) 得到的值不能设置,却可以调用 Interface。相反,未导出字段即使可寻址,也不能通过 Interface 取出。

未导出的嵌入字段与普通未导出字段还有一点区别。例如,type inner struct { X int } 嵌入 type Outer struct { inner } 后,从可寻址的 Outer 取得 inner 时不能直接设置这个嵌入字段,但继续取得导出字段 X 后可以设置。普通未导出命名字段的限制则会传递到它的子字段。因此,不能只根据父字段的 CanSet 推断子字段是否可设置。

字段查找也可能失败。FieldByName 返回无效 Value 时,应先识别缺失,再决定报错或采用默认值;不能直接接着调用 SetString。

4. 无效值、零值与 nil​

无效 Value表示没有可操作的值,例如 reflect.Value{}、ValueOf(nil) 或不存在的字段查询结果。某类型的零值仍有确定的类型,例如 int(0);nil 指针也有指针类型。

package main

import (
"fmt"
"reflect"
)

func main() {
missing := reflect.ValueOf(nil)
fmt.Println(reflect.TypeOf(nil) == nil) // true
fmt.Println(missing.IsValid(), missing.Kind()) // false invalid

var pointer *int
value := reflect.ValueOf(pointer)
fmt.Println(value.IsValid(), value.IsNil()) // true true
fmt.Println(value.Elem().IsValid()) // false

zero := reflect.Zero(reflect.TypeOf(0))
fmt.Println(zero.IsValid(), zero.IsZero(), zero.CanSet()) // true true false
// missing.IsNil() // 运行时 panic:无效 Value 不适用 IsNil
// zero.IsNil() // 运行时 panic:int 不适用 IsNil
}

IsValid 先回答是否存在值,IsZero 再回答它是否为所属类型的零值。reflect.Zero 创建对应类型的零值,但该结果本身不可设置;如果需要新建可修改的存储,可以使用 reflect.New(typ) 取得指针,再操作它的 Elem。

IsNil 用于允许 nil 的相应种类,例如指针、接口、切片、map、函数和 channel,不能对所有输入无条件调用。Type、Interface、IsNil、IsZero 等操作对无效 Value 会 panic,因此处理任意输入时,检查顺序本身就是接口契约的一部分。

nil 接口与带类型 nil 的接口仍遵守普通 Go 规则。var value any = (*int)(nil) 中的 value 不等于 nil,但 ValueOf(value) 得到一个有效的 nil 指针 Value。相关区别见 Go 接口。

5. 结构体字段与标签​

结构体标签是附在字段声明上的字符串元数据。reflect.Type 可以取得字段的名称、类型、索引和标签;具体库再按照自己的规则解释标签。标签不会自己执行序列化,也不会让未导出字段变成导出字段。

package main

import (
"encoding/json"
"fmt"
"reflect"
)

type User struct {
Name string `json:"name"`
Alias string `json:""`
Hidden string `json:"-"`
secret string `json:"secret"`
}

func main() {
typ := reflect.TypeOf(User{})
for _, name := range []string{"Name", "Alias", "Hidden", "secret"} {
field, _ := typ.FieldByName(name)
tag, present := field.Tag.Lookup("json")
fmt.Printf("%s %q %t %t\n", name, tag, present, field.IsExported())
}
field, _ := typ.FieldByName("Alias")
_, present := field.Tag.Lookup("db")
fmt.Println(present) // false

data, err := json.Marshal(User{Name: "Lin", Alias: "L", Hidden: "h", secret: "s"})
if err != nil {
panic(err)
}
fmt.Println(string(data)) // {"name":"Lin","Alias":"L"}
}

前四行分别输出字段名、json 标签值、该标签键是否存在,以及字段是否导出。Alias 的标签值是空字符串,但 Lookup("json") 返回的 present 为 true;Lookup("db") 则为 false。Get 对这两种情况都返回空字符串,无法区分。

encoding/json 把 Name 输出为 name,对空名称的 Alias 使用字段名,跳过标为 - 的 Hidden。secret 虽然写了标签,仍然是未导出字段,所以不会因此进入这个对象的 JSON 输出。

通用字段处理还需要决定如何对待嵌入字段、同名冲突和 nil 的嵌入指针。Type.FieldByName 查询的是字段元数据,Value.FieldByName 访问的是当前对象的值;后者在经过 nil 嵌入指针时可能失败。不能只确认“类型上存在字段”,就假设所有实例都能直接访问到它。

标签应符合约定的 key:"value" 格式。格式不符合约定时,Get、Lookup 的结果没有保证;可以使用 go vet 检查常见标签问题,但仍需按所用库的规则处理字段。

6. 动态调用与方法集​

Value.Call 可以在运行时调用函数,输入与输出都用 reflect.Value 表示。函数种类、参数数量和可赋值关系不匹配时,会 panic;函数内部发生的 panic 也不会因为使用反射而自动变成错误返回值。

package main

import (
"fmt"
"reflect"
)

type User struct{ Name string }

func (u *User) Rename(name string) { u.Name = name }
func (u User) Hello(prefix string) string { return prefix + u.Name }

func main() {
user := User{Name: "Ada"}
value := reflect.ValueOf(&user).Elem()
fmt.Println(value.CanAddr(), value.MethodByName("Rename").IsValid()) // true false

pointer := reflect.ValueOf(&user)
rename := pointer.MethodByName("Rename")
rename.Call([]reflect.Value{reflect.ValueOf("Lin")})
result := pointer.MethodByName("Hello").Call([]reflect.Value{reflect.ValueOf("hello ")})
fmt.Println(result[0].String()) // hello Lin
}

User 的方法集没有指针接收者的 Rename。即使 value.CanAddr() 为 true,value.MethodByName("Rename") 也不会自动替我们取地址。使用指针 Value,或者在可寻址时显式使用 value.Addr(),才能按 *User 的方法集查找。方法集与普通调用简写的区别见 Go 结构体与方法集。

Value.MethodByName 返回的是已绑定接收者的方法值,所以调用 rename.Call 时只传 name,不再传 user。Type.MethodByName 返回的则是方法元数据;对非接口类型,其 Method.Func 对应的函数把接收者作为第一个参数,不能混用这两种签名。

方法存在也不表示任意输入都能安全执行。还需要确认参数可赋值、指针接收者是否允许 nil,以及方法本身的业务前置条件。能静态确定接口时,直接调用接口方法通常更容易让编译器检查这些关系。

7. 使用场景与性能判断​

反射适合编写需要在运行时处理不同数据形状的通用边界。例如,配置绑定器读取结构体字段和标签,把字符串转换后写入目标字段;序列化器根据值的类型选择输出方式。这些场景依赖的是结构信息,不只是一组统一方法。

需求可以采用的方式
固定业务类型的字段读取与更新普通字段访问和函数调用
依赖一组稳定行为,例如读取、关闭接口
同一算法适用于多种类型,并保留输入输出类型关系泛型
运行时才知道字段结构、标签或完整调用签名反射,并明确输入与失败契约
类型集合在构建时已知,希望生成重复的映射代码代码生成

反射的成本来自具体操作:按名称查找字段或方法、检查类型、构造调用参数与结果,以及可能产生的分配。TypeOf、缓存后的字段访问和 Call 做的工作不同,不能把它们都归为同一种固定开销。

需要优化时,可以先用性能分析确定热点,再把重复的字段元数据解析移到初始化阶段,按 reflect.Type 缓存结果。缓存的是类型对应的字段规则,不应把某一个对象的可变 Value 当作所有实例的目标。缓存命中后的路径仍要保留实际值所需的 nil、范围和可设置检查。

Go 和 Java 的反射性能也不能脱离任务直接比较。应固定相同的输入、操作和结果消费方式,分别说明 Go、JDK 的版本与运行条件;Java 还要考虑预热后的运行状态。比较查找与调用、是否缓存、耗时和分配,才有可解释的结果。仅凭“Go 编译成机器码”或“Java 有运行时优化”,不能推出某种反射始终更快。

8. 常见问题​

8.1 CanSet 为 false,为什么仍能修改切片元素和 map 内容?​

CanSet 判断的是能否直接替换这个 Value 所表示的值,不是在判断所有能通过它访问的数据是否只读。切片元素有自己的可寻址性,map 则通过专门的操作更新内容。

package main

import (
"fmt"
"reflect"
)

func main() {
values := []int{1}
slice := reflect.ValueOf(values)
fmt.Println(slice.CanSet(), slice.Index(0).CanSet()) // false true
slice.Index(0).SetInt(9)
fmt.Println(values) // [9]

counts := map[string]int{"a": 1}
mapping := reflect.ValueOf(counts)
key := reflect.ValueOf("a")
entry := mapping.MapIndex(key)
fmt.Println(mapping.CanSet(), entry.CanAddr(), entry.CanSet()) // false false false
mapping.SetMapIndex(key, reflect.ValueOf(2))
fmt.Println(counts["a"]) // 2
mapping.SetMapIndex(key, reflect.Value{})
fmt.Println(mapping.MapIndex(key).IsValid()) // false
}

slice 不能直接替换调用方的切片变量,但 slice.Index(0) 对应底层数组中的元素,可以修改。MapIndex 取出的元素本身不可寻址,更新 map 要使用 SetMapIndex。

SetMapIndex 的第二个参数为无效 reflect.Value{} 时表示删除键。传入 reflect.Zero(mapping.Type().Elem()) 则表示把键对应的值设为元素类型的零值,两者不同。向 nil map 写入值仍会 panic,反射不会自动初始化 map。

8.2 反射会自动完成数值转换和溢出检查吗?​

8.3 可以用 unsafe 代替反射检查来提升性能吗?​

9 · 面试题卡片​

先想答案,再翻面先在心里答一遍,再点开卡片对照参考回答。卡片之间可以左右切换,看完会回到这里。
逐张翻看 9 张 →
未标记的卡读完后明天进入复习;已标记的按状态安排
本页题目与参考回答

9.1 Go 反射的基本机制是什么,哪些场景适合使用?​

考察重点:接口动态信息、类型元数据、运行时操作与适用边界。

相关内容:类型、种类与值、接口的动态类型与接口变量本身、使用场景与性能判断

参考回答

Go 反射通过运行时类型信息检查类型和操作值,常用入口是 reflect.TypeOf 和 reflect.ValueOf。 它们接收接口值,取得具体动态类型和值,再按类型、导出状态和可设置条件执行操作;允许时可以用 Interface 取回普通值。

序列化、配置绑定等需要处理不同结构和字段标签的通用代码适合考虑反射。类型明确的业务逻辑可以直接访问字段;统一行为用接口,同类算法与类型关系用泛型。反射中的类型或调用错误可能在运行时才暴露,需要明确检查与失败处理。

9.2 reflect.Type、reflect.Value 和 Kind 有什么区别,TypeOf 接口变量会得到什么?​

考察重点:完整类型、种类、动态类型与接口变量。

相关内容:类型、种类与值、接口的动态类型与接口变量本身

参考回答

Type 描述完整类型,Value 表示具体值及其操作状态,Kind 只表示类型种类。 type Count int 得到的类型不同于 int,但二者的 Kind 都是 reflect.Int。

TypeOf(reader) 得到接口中具体值的动态类型,例如 *strings.Reader。若要取得接口变量本身,可以反射 &reader 后调用 Elem;若只需要接口类型的信息,Go 1.22 可以使用 TypeFor[io.Reader]()。

9.3 为什么 ValueOf(x) 不能直接修改 x,CanAddr 和 CanSet 是否相同?​

考察重点:值传递、指针解引用、可设置性与导出限制。

相关内容:指针与 Elem、未导出字段与 Interface

参考回答

ValueOf(x) 得到传入的值,不能把它当作调用方变量直接设置;修改原变量通常需要传入指针并操作 Elem。

CanAddr 表示可以取得指针,CanSet 的要求更严格,还要满足导出限制。例如可寻址结构体的未导出字段可以 CanAddr() == true,却 CanSet() == false。嵌入字段中的导出子字段需要单独判断。CanInterface 则判断能否调用 Interface,不是可设置性的同义词。

9.4 ValueOf(nil)、nil 指针的 Value 和 reflect.Zero 有什么区别?​

考察重点:无效值、类型零值、nil 与检查顺序。

相关内容:无效值、零值与 nil

参考回答

ValueOf(nil) 是无效 Value;nil 指针的 Value 有指针类型,仍是有效值;reflect.Zero(typ) 创建的是 typ 对应的有效零值。

无效 Value 的 IsValid 为 false,对它调用 IsNil、Type 等方法会 panic。nil 指针可以 IsNil,但 Elem 返回无效 Value。reflect.Zero 的结果不可设置;需要新建可修改存储时,可以使用 reflect.New(typ).Elem()。

9.5 结构体标签由谁解释,Get 与 Lookup 有什么区别?​

考察重点:元数据与库行为、标签键是否存在、字段可见性。

相关内容:结构体字段与标签

参考回答

标签本身是字符串元数据,行为由读取它的库解释。 例如 encoding/json 解释 json 标签中的名称和选项;标签不会自动修改字段,也不会让普通未导出字段参与 JSON 输出。

Get 在键不存在或值明确为空时都返回空字符串。Lookup 还返回键是否存在,可以区分两种情况。处理嵌入字段时,还要区分类型上存在的字段与当前实例能否经过非 nil 路径访问到该字段。

9.6 反射调用方法时要传接收者吗,可寻址的值会自动找到指针方法吗?​

考察重点:绑定的方法值、方法元数据、方法集和运行时签名检查。

相关内容:动态调用与方法集

参考回答

Value.MethodByName 得到的方法值已经绑定接收者,Call 不再传接收者。 对非接口类型,Type.MethodByName 得到的 Method.Func 则把接收者作为第一个参数。

反射查找遵守当前类型的方法集,不因为 Value 可寻址就自动查找指针类型的方法。需要指针方法时,显式取得指针 Value;调用前还要检查方法存在、参数数量与可赋值关系。

9.7 Go 和 Java 的反射性能如何比较,反射代码可以怎样优化?​

考察重点:具体操作、缓存、运行条件、测量与类型化替代方案。

相关内容:使用场景与性能判断、unsafe 与反射

参考回答

不能给出脱离操作、版本和测量条件的固定快慢顺序。 需要区分类型查询、按名查找字段、读取或写入值、动态调用,以及是否已经缓存元数据;跨语言还要统一输入与结果消费方式,并说明各自编译、运行和预热条件。

先确定热点,再考虑按类型缓存字段规则、减少重复查找和参数构造。类型与行为能够在编译期明确时,也可以使用普通代码、接口、泛型或代码生成。使用 unsafe 还会引入额外正确性条件,不能单凭绕过检查就认定是有效优化。

9.8 CanSet 为 false 是否表示完全不能修改数据,map 元素怎样更新或删除?​

考察重点:变量与关联数据、切片元素、map 更新和删除。

相关内容:集合内容与可设置性

参考回答

不是,CanSet 只约束当前 Value 的直接设置。 ValueOf(slice) 本身不可设置,但其元素通常可以设置;ValueOf(map) 也可通过 SetMapIndex 更新内容。

MapIndex 返回的元素不可寻址,不能直接对它 Set。SetMapIndex(key, reflect.Value{}) 删除键,传入元素类型的有效零值则是把值设为零。向 nil map 写入值仍会 panic。

9.9 CanConvert 为 true 是否说明转换无损,SetInt 会拒绝超出范围的整数吗?​

考察重点:可赋值与可转换、值相关条件、整数截断与范围验证。

相关内容:数值转换与范围检查

参考回答

都不能这样判断。CanConvert 检查转换能否执行,不保证数值无损;SetInt 也不会自动拒绝整数截断。 例如把 128 写入 int8,结果可以变成 -128。

Set 要求类型可赋值,需要转换时显式调用 Convert。处理外部数值应先按目标类型检查范围,例如使用 OverflowInt;切片转数组等操作还依赖实际长度,不能只检查类型上的 ConvertibleTo。

10. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明