以 map[string]any 转结构体为例,梳理 Go 反射中的类型与值、指针解引用、字段赋值,以及嵌套结构体和指针字段的递归处理。
假设我们已经把一份配置整理成了 map[string]any:
data := map[string]any{
"Mode": "debug",
"Http": map[string]any{
"Host": "localhost",
"Port": 8080,
},
}
程序使用的配置结构体是这样的:
type HttpConfig struct {
Host string
Port int
}
type Config struct {
Mode string
Http HttpConfig
}
我们希望写一个函数,把 map 中的值填进去:
var conf Config
ConvertMapToStruct(data, &conf)
调用之后,conf.Mode 是 "debug",conf.Http.Port 是 8080。
如果只需要处理 Config,手动赋值就够了:
conf.Mode = data["Mode"].(string)
但如果同一个函数还要处理其他结构体,参数就不能写死为 *Config:
func ConvertMapToStruct(data map[string]any, obj any)
这时,函数内部无法直接写 obj.Mode。编译器只知道 obj 是 any,不知道调用者会传来什么结构体,也无法通过一个运行时才拿到的字符串直接访问字段。
反射可以在程序运行时取得具体类型的信息,找到对应字段,再按类型规则赋值。
any 里面还保留着具体类型
先看一个接口变量:
obj 的静态类型是 any,但接口值仍保留着动态类型和动态值。这里的动态类型是 *Config,动态值是指向某个 Config 对象的指针。any 是 interface{} 的别名。
反射会利用这些运行时信息,以及编译器保留的类型元数据。
reflect 包提供了两个常用入口:
t := reflect.TypeOf(obj)
v := reflect.ValueOf(obj)
t 是 reflect.Type,用来查看类型信息,比如字段名、字段类型和 tag。v 是 reflect.Value,用来读取具体值,也可以在满足条件时修改值。
传入的是指针,Elem 取得它指向的值
调用转换函数时,传进去的是 &conf:
var conf Config
ConvertMapToStruct(data, &conf)
进入函数后,obj 的静态类型是 any,里面装着的具体值却是一个 *Config 指针。因此,函数开头需要先解引用:
v := reflect.ValueOf(obj) // 表示 *Config 指针
v = v.Elem() // 表示这个指针指向的原结构体 conf
t := v.Type() // 此时取得的类型是 Config
调用 Elem() 后,v 自身仍是 reflect.Value。它表示的内容从指针变成了指针指向的结构体,obj 本身没有改变。
这里的 Elem() 类似普通 Go 代码中的 *p。指针和它指向的结构体是两种不同的值,NumField()、Field(i) 要求当前反射值表示结构体,不能直接在指针上调用。
用一个整数对照会更清楚:
x := 10
p := reflect.ValueOf(&x) // Kind 是 reflect.Ptr
v := p.Elem() // Kind 是 reflect.Int,表示原来的 x
v.SetInt(20)
fmt.Println(x) // 20
与它对应的普通赋值是:
ValueOf 不会自动取得参数的地址。传入 &x,得到的反射值就表示指针;传入 10,得到的反射值就表示整数。是否需要 Elem(),取决于当前表示的值和接下来要做的操作。
为什么直接传结构体值不能修改原变量
试着把结构体值直接交给反射:
var conf Config
v := reflect.ValueOf(conf)
fmt.Println(v.CanSet()) // false
CanSet() 表示这个反射值能否被修改。这里得到的值无法回写原变量 conf,所以返回 false。如果继续调用下面的代码,就会 panic:
v.FieldByName("Mode").SetString("release")
传入指针,再取得它指向的值:
p := reflect.ValueOf(&conf)
fmt.Println(p.CanSet()) // false
fmt.Println(p.Elem().CanSet()) // true
p 表示传进来的指针值,本身不可设置。调用 p.Elem() 会取得它所指向的原结构体,这个反射值可以修改。在这里,Elem() 做的是指针解引用。
因此,修改 conf.Mode 的写法是:
p.Elem().FieldByName("Mode").SetString("release")
这段代码依次取得原结构体、找到 Mode 字段,再把字符串写进去。
Go 的参数传递仍然是值传递。传指针时,复制的是指针值,复制后的指针依然指向同一个 conf。下一层函数通过它找到原对象,修改才会生效。
v 自身的类型,与 v 表示的类型
下面几个表达式查询的对象不同:
v := reflect.ValueOf(obj).Elem()
fmt.Printf("%T\n", v) // reflect.Value
fmt.Println(reflect.TypeOf(v)) // reflect.Value
fmt.Println(v.Type()) // main.Config
fmt.Println(reflect.TypeOf(obj)) // *main.Config
reflect.TypeOf(v) 查询 v 这个 Go 变量自身的类型;v.Type() 查询它表示的内容的类型。这里 v 表示一个 Config 结构体,所以 v.Type() 得到 Config。
reflect.TypeOf(obj) 则检查原来的入参。入参依然装着 *Config 指针,得到的也依然是指针类型。
Type.Elem 和 Value.Elem 取得的东西不同
对于同一个指针:
reflect.TypeOf(&conf).Elem() // 取得指向的类型:Config
reflect.ValueOf(&conf).Elem() // 取得指向的值:原来的 conf
Type.Elem() 在这里用来查询类型;Value.Elem() 用来沿着指针访问实际的值。它们都不会替一个 nil 指针创建对象。
本文讨论的是指针场景。reflect.Value.Elem() 也可以用于接口值,但不能对普通结构体、整数或字符串值调用。它只解开一层;调用本文的转换函数时,应传入非 nil 的结构体指针。
t 和 v 要保持在同一层
如果把入参记为 b,下面这段代码只给值解了引用:
t := reflect.TypeOf(b) // *Config
v := reflect.ValueOf(b).Elem() // Config 结构体
此时 v.NumField() 可以执行,t.Field(i) 却会报 Field of non-struct type *main.Config,因为 t 仍然描述指针。v.Elem() 不会顺带改变另一个变量 t。
可以从解引用后的值取得类型:
v := reflect.ValueOf(b).Elem()
t := v.Type()
也可以让类型和值分别解引用:
t := reflect.TypeOf(b).Elem()
v := reflect.ValueOf(b).Elem()
在入参为非 nil 结构体指针的前提下,这两种写法都正确。后面的练习会使用第二种写法。
t.Field(i) 与 v.Field(i) 分别取得什么
“字段描述”指的是结构体定义里的一行声明。例如 Mode string 告诉我们:字段名叫 Mode,类型是 string。这些信息属于类型定义,不代表某个对象中存着的 "debug" 或 "release"。
先创建一个对象,再分别读取字段描述和字段值:
conf := Config{Mode: "debug"}
v := reflect.ValueOf(&conf).Elem()
t := v.Type()
tf := t.Field(0) // 字段描述,类型为 reflect.StructField
f := v.Field(0) // 表示当前对象 conf.Mode 的 reflect.Value
fmt.Println(tf.Name) // Mode
fmt.Println(tf.Type) // string
fmt.Println(f.Interface()) // debug
f.SetString("release")
fmt.Println(conf.Mode) // release
这里的下标 0 表示结构体的第一个字段。在本文的 Config 中,它就是 Mode。t.Field(0) 查看的是 Mode string 这条声明的信息,v.Field(0) 则访问当前这个对象的 conf.Mode。
| 表达式 |
返回的内容 |
t.Field(0) |
reflect.StructField,描述 Mode 这个字段 |
tf.Name |
字段名 "Mode" |
tf.Type |
字段的类型 string,它是一个 reflect.Type |
tf.Tag |
字段上的 tag,本文没有设置 |
v.Field(0) |
当前对象中 conf.Mode 的反射值,类型是 reflect.Value |
t.Field(i) 返回的是 reflect.StructField,字段类型保存在它的 Type 属性中。v.Field(i) 返回的仍是 reflect.Value,可以通过 Interface() 读取其中表示的值,也可以在允许修改时通过 Set 等方法写回原字段。
当 conf.Mode 从 "debug" 改成 "release" 时,tf.Name 仍是 "Mode",tf.Type 仍是 string,改变的是当前对象里的值。
tf 和 f 都是自定义变量名,不是 Go 关键字,也不是反射库提供的函数。后面的练习分别把它们命名为 tt 和 vv,用途相同。
字段名怎样用来查找 map
反射不会自动把字段与 map 关联起来。转换函数自己规定:取得字段名,再用这个名字查询 map。
继续以上面的 Mode 字段为例,把查询和赋值拆开写:
data := map[string]any{"Mode": "debug"}
key := tf.Name // 字符串 "Mode"
val, ok := data[key] // 相当于 data["Mode"],得到 "debug"、true
if ok {
f.Set(reflect.ValueOf(val)) // 把查询结果写进 conf.Mode
}
所以 t.Field(i) 本身不是 map 的 key。i 是结构体字段的下标,tf.Name 才是我们拿来当 key 的字符串。
如果 map 只提供 "mode",字段仍叫 Mode,tf.Name 不会随之改变,查询也会失败,ok 为 false。是否按字段名、tag 或其他规则匹配,需要转换函数自己决定。
循环可以使用 v.NumField()
可以把循环条件写成下面这样:
for i := 0; i < v.NumField(); i++ {
tf := t.Field(i)
f := v.Field(i)
fmt.Printf("字段名=%s,类型=%v,值=%v\n", tf.Name, tf.Type, f.Interface())
}
只要 t 是 v.Type(),且 v 表示结构体,t.NumField() 和 v.NumField() 得到的数量就相同。对于本文的 Config,结果是 2:只统计这一层的 Mode 和 Http,不会把 Http 内部的 Host、Port 展开计算。
switch tf.Type.Kind() 也可以
Type 描述具体类型,Kind() 表示类型所属的类别:
t := reflect.TypeOf(Config{})
fmt.Println(t) // main.Config
fmt.Println(t.Kind()) // struct
Config 和 HttpConfig 的具体类型不同,但 Kind() 都是 reflect.Struct。在当前循环中,tf.Type.Kind() 与 f.Kind() 得到的类别相同,所以可以写:
switch tf.Type.Kind() {
case reflect.Struct:
// 处理嵌套结构体
case reflect.Ptr:
// 处理指针字段
default:
// 处理普通字段
}
注意,tf 是 reflect.StructField,它本身没有 Kind() 方法,需要通过 tf.Type.Kind() 查询。
写出转换函数
下面是一份完整的教学代码。它只处理普通字段和嵌套结构体,并假设:传入的是非 nil 的结构体指针,字段已导出,输入值的类型与字段匹配。
package main
import (
"fmt"
"reflect"
)
type HttpConfig struct {
Host string
Port int
}
type Config struct {
Mode string
Http HttpConfig
}
func ConvertMapToStruct(data map[string]any, obj any) {
// 取得指针指向的结构体
v := reflect.ValueOf(obj).Elem()
t := v.Type()
for i := 0; i < t.NumField(); i++ {
tf := t.Field(i) // 字段信息
f := v.Field(i) // 字段的具体值
val, ok := data[tf.Name]
if !ok {
continue
}
switch f.Kind() {
case reflect.Struct:
// 嵌套结构体:取得原字段的地址,递归填充
ConvertMapToStruct(
val.(map[string]any),
f.Addr().Interface(),
)
default:
// 普通字段:直接赋值
f.Set(reflect.ValueOf(val))
}
}
}
func main() {
data := map[string]any{
"Mode": "debug",
"Http": map[string]any{
"Host": "localhost",
"Port": 8080,
},
}
var conf Config
ConvertMapToStruct(data, &conf)
fmt.Printf("%+v\n", conf)
}
保存为 main.go,运行:
输出:
{Mode:debug Http:{Host:localhost Port:8080}}
函数取得原结构体后,用字段名从 map 中查找数据。处理 Mode 时,tf.Name 是 "Mode",f 对应原来的 conf.Mode,val 则是字符串 "debug"。赋值发生在这一行:
f.Set(reflect.ValueOf(val))
完成了与 conf.Mode = "debug" 相同的赋值。Set 接收的参数也是 reflect.Value,所以先用 ValueOf 包装 map 中取出的值。
为什么赋值时不用 ValueOf(val).Elem()
入口处的 obj 与这里的 val 虽然都是 any,里面装着的具体值却不同:
| 位置 |
具体值 |
ValueOf 得到的 Kind |
下一步要做的事 |
函数入口的 obj |
&conf,类型为 *Config |
reflect.Ptr |
通过 Elem() 找到结构体,再访问字段 |
Mode 对应的 val |
"debug",类型为 string |
reflect.String |
直接赋给 string 字段 |
Port 对应的 val |
8080,类型为 int |
reflect.Int |
直接赋给 int 字段 |
Elem() 不负责把值从 reflect.Value 中取出来。它在这里做的是指针解引用;对字符串或整数调用它会 panic。
是否需要解引用,还要看目标字段需要什么类型。假设 f 表示一个 int 字段,而来源是一个 *int 指针,可以先取得指针指向的值:
port := 8080
f.Set(reflect.ValueOf(&port).Elem()) // 来源解引用后为 int,与目标字段匹配
如果目标字段本来就是 *int,来源也为 *int,就应保留指针,直接 Set(reflect.ValueOf(&port))。Set 要求来源类型可赋值给目标类型,不能把 Elem() 当作每次赋值前的固定步骤。
遇到 Http 时,它的 Kind() 是 reflect.Struct。函数取得这个字段的地址,再把内层 map 传下去,继续处理 Host 和 Port。
嵌套结构体里的 Interface 和 Addr
递归时,我们通过下面的表达式传入嵌套字段:
此时 f 表示原来的 conf.Http。
f.Addr() 取得这个字段的地址,相当于普通代码里的 &conf.Http,返回结果仍是一个 reflect.Value。
Interface() 再把其中表示的指针取出来,返回一个 any。这个接口值的动态类型是 *HttpConfig,可以交给下一层 ConvertMapToStruct。
两步的区别也可以直接打印出来:
fmt.Printf("%T\n", f.Addr()) // reflect.Value
fmt.Printf("%T\n", f.Addr().Interface()) // *main.HttpConfig
如果递归时只传 f.Addr(),下一层入参里面装着的就是一个 reflect.Value 对象。再调用 reflect.ValueOf(obj),取得的是表示这个 reflect.Value 结构体的反射值,随后调用 Elem() 就会 panic。因此,在本文约定接收普通结构体指针的函数中,不能省略 Interface()。
下一层调用 ValueOf(obj).Elem() 时,就能沿着这个指针找到原来的 conf.Http。
如果省掉 Addr(),直接这样递归:
ConvertMapToStruct(val.(map[string]any), f.Interface())
传进去的就是结构体值,而不是字段的地址。在当前这份实现中,下一层对结构体值调用 Elem() 就会 panic。
即使把函数入口改为先判断是不是指针,仅在指针上调用 Elem(),问题也没有解决。重新通过 ValueOf 取得的结构体值依然不可设置,后面的 Set 仍会失败:
f.CanSet() // true
reflect.ValueOf(f.Interface()).CanSet() // false
Interface() 没有保留这个结构体字段可回写原对象的访问方式。把字段地址传下去,才能继续修改同一个对象:
reflect.ValueOf(f.Addr().Interface()).Elem().CanSet() // true
Interface() 返回的是 any。它保留动态类型,但不会让接收变量的静态类型自动变成 HttpConfig 或 *HttpConfig。如果普通代码需要按具体类型使用它,仍然需要类型断言。
新建对象再赋回,也能解决
本文参考的视频使用了另一种结构体递归写法:
fobj := reflect.New(tf.Type).Interface()
ConvertMapToStruct(val.(map[string]any), fobj)
f.Set(reflect.ValueOf(fobj).Elem())
reflect.New(tf.Type) 根据字段类型创建一个新对象。假设字段类型是 HttpConfig,创建结果表示的就是一个 *HttpConfig。
递归函数先填充新对象,再通过最后一行把它的值赋回原字段。在本文的例子中,原字段已经可以取地址,用 f.Addr().Interface() 就能直接填充它。
如果字段本身就是指针
如果把 Http 字段的类型改为 *HttpConfig,当字段非 nil 时,f.Interface() 取出来的已经是指针,可以直接交给下一层函数。这里不用再调用 Addr(),否则取得的会是指向该指针字段的 **HttpConfig。
它的 Kind() 是 reflect.Ptr,不会进入 reflect.Struct 分支。字段指向的类型则可以通过 tf.Type.Elem() 查询,在这里得到 HttpConfig,其 Kind() 才是 reflect.Struct。
上面的精简实现没有加入指针字段分支。如果要支持这种指向结构体的字段,可以在 switch 中补上下面的分支。这里仍假设对应的输入是 map[string]any,并且目标字段可设置:
case reflect.Ptr:
if f.IsNil() {
f.Set(reflect.New(f.Type().Elem()))
}
ConvertMapToStruct(val.(map[string]any), f.Interface())
f.Type().Elem() 取得 HttpConfig 类型,reflect.New 再创建一个指向新对象的 *HttpConfig。目标字段也需要 *HttpConfig,所以这一行 Set 不需要再调用 Elem()。
这与函数入口处不同:初始化字段时,需要把指针放进指针字段;递归函数进入后,要访问指针指向的结构体,才调用 ValueOf(obj).Elem()。这个分支只用于当前讨论的结构体指针,不能直接当作所有指针类型的通用处理。
再练一次:填充带有 *Log 字段的配置
下面是另一份独立程序,可以单独保存为 practice.go 运行。它在前面精简实现的基础上,加上结构体指针字段的初始化,保留 v.NumField() 和 switch tt.Type.Kind() 的写法。不要把两份程序的 main 函数放在同一个文件中。
package main
import (
"fmt"
"reflect"
)
type Conf struct {
Port int
Host string
Log *Log
}
type Log struct {
Path string
}
func mapToC(a map[string]any, b any) {
t := reflect.TypeOf(b).Elem()
v := reflect.ValueOf(b).Elem()
for i := 0; i < v.NumField(); i++ {
tt := t.Field(i)
vv := v.Field(i)
val, ok := a[tt.Name]
if !ok {
continue
}
switch tt.Type.Kind() {
case reflect.Struct:
mapToC(val.(map[string]any), vv.Addr().Interface())
case reflect.Ptr:
if vv.IsNil() {
vv.Set(reflect.New(tt.Type.Elem()))
}
mapToC(val.(map[string]any), vv.Interface())
default:
vv.Set(reflect.ValueOf(val))
}
}
}
func main() {
a := map[string]any{
"Port": 8080,
"Host": "127.0.0.1",
"Log": map[string]any{
"Path": "/log.log",
},
}
c := &Conf{}
mapToC(a, c)
fmt.Println(c.Port, c.Host, c.Log.Path)
}
运行 go run practice.go,输出:
这份练习第一轮的 i 为 0:tt 描述的是 Port int,tt.Name 为 "Port",vv 表示当前对象的 c.Port。a[tt.Name] 用名字查到 8080,再由 vv.Set 把这个整数写进字段。
处理到 Log 时,tt.Type 是 *Log,tt.Type.Kind() 是 reflect.Ptr,所以会进入指针分支。初始的 c.Log 为 nil,需要先创建对象:tt.Type.Elem() 得到 Log 类型,reflect.New 创建一个 *Log,再由 vv.Set 把它放进字段。
递归时,vv.Interface() 返回的接口值装着 *Log。下一层函数取得的 t 描述 Log 类型,v 表示原来的 Log 对象,随后就能给 Path 赋值。
如果改成只打印 fmt.Println(*c),本例中的 Log 会显示为指针地址。可以单独打印 c.Log.Path 检查内容,不要把地址输出当作填充失败。
对照报错检查代码
| 写法或输入 |
在本文示例中的后果 |
调整方式 |
t := reflect.TypeOf(b),但 v 已经调用 Elem() |
t.Field(i) 报指针不是结构体 |
对 t 也调用 Elem(),或使用 v.Type() |
只有 reflect.Struct 和 default 分支 |
*Log 进入 default,map 不能直接赋给 *Log |
增加结构体指针分支,必要时先初始化 |
给递归函数传 vv.Addr() |
入参装着 reflect.Value,下一层 Elem() 作用在了结构体上 |
传 vv.Addr().Interface() |
忽略 map 查询的 ok |
缺少字段时可能拿到 nil,后续赋值或断言会 panic |
没有对应键时跳过该字段 |
这份练习仍假设指针字段指向结构体、嵌套输入为 map,而且普通字段类型匹配。Port 对应的 8080 是 int;若实际输入为 "8080",需要先用 strconv.Atoi 等方式解析并处理错误。Elem() 不会把字符串转成整数。
从教学代码到实际使用
这份函数省略了输入校验,不能直接当作通用配置解析器使用。
首先,修改字段要满足可设置的条件。可寻址不等于一定可设置:未导出字段即使可以取地址,也不能通过普通的反射赋值操作修改。实际代码应检查 CanSet()。
类型也必须匹配。Set 要求来源值的类型能够赋给目标类型,不会自动把 float64(8080) 转成 int。使用标准库把 JSON 解码到 map[string]any 时,数字默认是 float64,因此不能直接套用本文的 Port 赋值。示例中的 8080 是在 Go 代码里定义的 int。
入口和递归参数同样需要检查。传入 nil、非指针、指向非结构体的指针,或者让 Http 对应的值不是 map[string]any,都可能触发 panic。实际使用时应返回明确的错误。
当前函数按字段名精确匹配,所以 "Mode" 能找到 Mode,"mode" 不行。要支持 json:"mode" 这样的名称,需要从字段描述信息中读取 tag,再决定使用哪个键。
这个例子与配置绑定、JSON 序列化、ORM 中常见的反射用法有相似之处:库接收业务代码传来的值,通过类型信息了解结构,再处理具体字段。对于只有一种确定结构的业务逻辑,直接赋值通常更容易阅读;需要让同一段代码适配多种结构体时,再考虑使用反射。
示例来源
本文的配置转换例子参考了老周聊 golang 的视频:京东 Go 一面:反射原理以及反射的应用场景。文中使用了精简后的结构,并将嵌套结构体的处理写成直接取地址递归。