Go 是怎么做到的:运行时反射 · znzryb
## 名字确实被存进了可执行文件
先做个小实验。写一个字段名很好认的结构体,编译成可执行文件,再用 `strings` 命令把文件里所有可读的字符串翻出来:
```go
package main
import "fmt"
type Secret struct {
ZebraField int
GiraffeField int
}
func main() { fmt.Printf("%+v\n", Secret{1, 2}) }
```
```bash
go build -o names . && strings names | grep -E 'ZebraField|GiraffeField'
```
```text
ZebraField
GiraffeField
```
字段名就躺在编译好的程序里。Go 编译器会给程序里用到的每个类型生成一份**类型描述**:类型叫什么、占几个字节、有哪些字段、每个字段叫什么、是什么类型、离结构体开头多少字节。这份描述跟着程序一起被装进内存,运行的时候随时可以查。
## 类型描述是怎么跟到 `fmt` 手里的
`fmt.Printf` 的参数类型是 `any`(也就是 `interface{}`)。把 `p` 传进去的那一刻,Go 会把它装进一个**接口值**,这个接口值其实是两个指针:
| 接口值里的东西 | 指向哪里 | 举例 |
|---|---|---|
| 类型指针 | 上面说的那份类型描述 | 「`main.P`,3 个字段:`A int`、`B string`、`C float64`」 |
| 数据指针 | 这个值本身的那块内存 | `1`、`"hi"`、`2.5` 挨着放 |
所以 `fmt` 拿到的从来不是一个光秃秃的值,而是「值 + 它的说明书」。`reflect` 包就是读这份说明书的工具:
- `reflect.TypeOf(x)`:拿到说明书(类型信息);
- `reflect.ValueOf(x)`:拿到值,同时也能顺着它查说明书。
## 自己写一个迷你 `%+v`
用到的就这几个函数:
| 函数 | 作用 |
|---|---|
| `v.Kind()` | 这是个什么东西:结构体?整数?字符串? |
| `v.NumField()` | 结构体有几个字段 |
| `v.Type().Field(i).Name` | 第 `i` 个字段叫什么 |
| `v.Field(i)` | 第 `i` 个字段的值(还是一个 `reflect.Value`,可以继续往下查) |
```go
package main
import (
"fmt"
"reflect"
"strings"
)
type Point struct {
X, Y int
}
type P struct {
A int
B string
C float64
Where Point
note string // 小写:未导出
}
// show 是一个迷你版的 %+v
func show(v reflect.Value) string {
switch v.Kind() {
case reflect.Struct:
t := v.Type()
parts := make([]string, 0, v.NumField())
for i := 0; i < v.NumField(); i++ {
name := t.Field(i).Name // 字段名:从类型信息里读出来
parts = append(parts, name+":"+show(v.Field(i)))
}
return "{" + strings.Join(parts, " ") + "}"
case reflect.Int:
return fmt.Sprint(v.Int())
case reflect.String:
return v.String()
case reflect.Float64:
return fmt.Sprint(v.Float())
default:
return "?"
}
}
func main() {
p := P{A: 1, B: "hi", C: 2.5, Where: Point{3, 4}, note: "secret"}
fmt.Println(show(reflect.ValueOf(p)))
fmt.Printf("%+v\n", p)
}
```
```text
{A:1 B:hi C:2.5 Where:{X:3 Y:4} note:secret}
{A:1 B:hi C:2.5 Where:{X:3 Y:4} note:secret}
```
二十几行,跟 `fmt` 打的一模一样。嵌套的 `Where` 也打出来了,因为遇到结构体就递归下去。**真正的 `fmt` 干的也就是这件事**,只是它处理的类型更全(切片、map、指针、实现了 `String()` 方法的类型……)。
## 把说明书整份翻出来看看
```go
t := reflect.TypeOf(p)
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%-6s %-12s offset=%-3d exported=%v\n", f.Name, f.Type, f.Offset, f.IsExported())
}
```
```text
A int offset=0 exported=true
B string offset=8 exported=true
C float64 offset=24 exported=true
Where main.Point offset=32 exported=true
note string offset=48 exported=false
```
`offset` 是这个字段离结构体开头多少字节:`string` 在 Go 里占 16 字节(一个指针 + 一个长度),所以 `B` 从 8 开始、`C` 从 24 开始。`fmt` 就是靠「数据指针 + offset」找到每个字段的值的。
## 小写字段:能看,不能拿走
上面的 `note` 是小写开头,在 Go 里表示**未导出**(别的包不能用)。可 `fmt` 明明是另一个包,它还是把 `note:secret` 打出来了。
反射对未导出字段的规矩是:**可以读出来看,但不能把它当成一个普通值拿走。** 试着拿走:
```go
_ = reflect.ValueOf(p).Field(4).Interface()
```
```text
panic: reflect.Value.Interface: cannot return value obtained from unexported field or method
```
`Interface()` 的意思是「把这个值交还给我,当普通变量用」。要是这一步也放行,任何包都能借反射绕开「小写就是私有」这条规矩。`fmt` 不调用 `Interface()`,而是直接 `v.String()` / `v.Int()` 这样读,所以只是「看」,没有破坏封装。
## 这一节记住三件事
1. Go 编译器把**每个类型的说明书**(字段名、类型、偏移)写进了程序;
2. 值装进 `any` 时,说明书的指针跟着一起走;
3. `reflect` 在**运行时**读说明书,`fmt` 就是用它打出字段名的。
所以 Go 的反射叫**运行时反射**。代价也很明显:说明书要占程序体积,而且每次打印都要现场查表、分情况处理,比直接写死的代码慢。