两处之前没说清楚的地方。
一、值传递的代价只发生在构造那一次, 不影响之后的操作
原来只列了 New(值) 3227ns vs New(&v) 25ns, 容易被读成"传值之后一直慢"。
实际上构造完成后两者的内部表示完全相同(都是类型描述符 + 地址), 后续
Get/Set 走同一条代码路径:
构造后 Get 构造后 Set
New(v) 传值构造 49.7ns / 2 allocs 27.2ns / 0 allocs
New(&v) 传指针构造 50.3ns / 2 allocs 27.1ns / 0 allocs
差异在噪声范围内。所以要看的是**构造频次**而不是访问频次: 构造一次访问很多次
的话那点开销会被摊薄; 每个请求都构造、对象又含 map/slice 时才值得计较。
二、流程图补上值分支内部的二次分岔
图是在 needsClone 那次优化之前画的, 一直写着"传值 = DeepClone", 已经不准:
不含引用成分(纯值 struct/数组/字符串) -> 逐字节浅拷贝, 约 85 ns
字符串虽然内部有指针, 但底层数组不可变, 共享是安全的
含指针/切片/map/interface(递归包含字段与数组元素) -> DeepClone
这也是"能不能 copy 一份再取指针"的答案: 纯值类型可以(库里已经这么做了),
含引用的类型不行 —— 浅拷贝只复制 slice/map 的头部, 底层数据仍与调用方共享,
写入会穿透, 那就不再是隔离副本了。
顺带把代价数字更正为实测值(原来写的 1518ns/44allocs 是另一个更小的样本)。
217 lines
10 KiB
Markdown
217 lines
10 KiB
Markdown
# 内部流程:路径是怎么被分派的
|
||
|
||
`New` 把各种输入收敛成统一的内部表示;`Get` 和 `Set` 则不直接操作数据,
|
||
而是把路径拆成段,逐段按当前值的 `Kind` 分派。
|
||
**分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect,
|
||
也决定了它要不要分配内存。**
|
||
|
||
三张图分别对应这三个入口,配色编码一致。
|
||
|
||
图例:
|
||
|
||
| 标记 | 含义 |
|
||
|---|---|
|
||
| 青色 | unsafe 快路径 —— 偏移量寻址,零分配 |
|
||
| 琥珀色 | reflect 回退 —— 有堆拷贝 |
|
||
| 红色 | 拒绝 —— 返回无效值或 panic |
|
||
|
||
---
|
||
|
||
## 〇、构造:`New(v any)`
|
||
|
||

|
||
|
||
`New` 要把五花八门的输入收敛成统一的内部表示 `rfx{td, ptr, writable, ptrRoot}`。
|
||
过程分四步:**快速分支 → 解包 → 解引用与校验 → 按 isPtr 分岔**。
|
||
|
||
### 快速分支(命中即返回,不走后面的流程)
|
||
|
||
| 输入 | 结果 | 为什么特殊 |
|
||
|---|---|---|
|
||
| `nil` / `valuex.Nil` | 返回 `Nil` 单例 | 不分配 |
|
||
| 已经是 `R` | **原样返回** | 避免重复包装 |
|
||
| `[]R` | 直接包装,**不深拷贝** | 保住 `Raw()` 是 Slice kind、`Array()` 能取到里面的 R |
|
||
|
||
### 解包与校验
|
||
|
||
`normalizeInputValue` 负责把输入收敛成 `reflect.Value`:`valuex.Accessor` 取 `Raw()`,
|
||
`[]valuex.Accessor` / `[]R` 合并成一个切片,`reflect.Value` 直接用。
|
||
同时算出 `isPtr` —— 这个标记决定后面走哪条路。
|
||
|
||
然后递归解引用 `Ptr`/`Interface`(中途遇到指针会把 `isPtr` 置真),
|
||
最后校验目标类型:只接受 **map / struct / slice / array / string / bool / float**。
|
||
`int`、`chan`、`func` 这些不是容器的类型会 panic。
|
||
|
||
### 关键分岔:指针模式 vs 值模式
|
||
|
||
这是对外承诺的语义,也是 `New` 里唯一有显著性能差异的地方:
|
||
|
||
| | 做法 | 代价 | 语义 |
|
||
|---|---|---|---|
|
||
| **传指针** `New(&v)` | 直接借用该指针 | 约 25 ns,零拷贝 | 写入**直接反映到原数据** |
|
||
| **传值 + 纯值类型** | 逐字节浅拷贝 | 约 85 ns | 写入**不影响原数据** |
|
||
| **传值 + 含引用成分** | `DeepClone` 递归复制整个对象图 | 10 键嵌套 map 约 **3227 ns / 119 allocs** | 写入**不影响原数据** |
|
||
|
||
传值这一支还要再分一次,依据是类型描述符里预先算好的 `needsClone`
|
||
(构建期算一次,不在热路径):
|
||
|
||
- **不含引用成分**(纯值 struct、数组、字符串)→ 逐字节复制就已经完全独立了。
|
||
字符串虽然内部有指针,但底层数组不可变,共享是安全的。
|
||
- **含指针 / 切片 / map / interface**(递归包含字段与数组元素)→ 必须 `DeepClone`。
|
||
只做浅拷贝的话,slice 和 map 复制的只是**头部**,底层数据仍与调用方共享,
|
||
写入会穿透过去 —— 这正是"能不能只 copy 一份再取指针"这个问题的答案:
|
||
对纯值类型可以(库里已经这么做了),对含引用的类型不行。
|
||
|
||
**这笔开销只发生在 `New` 这一次,不影响之后的任何操作。**
|
||
构造完成后两者的内部表示完全相同(都是"类型描述符 + 地址"),
|
||
后续 `Get`/`Set` 走的是同一条代码路径:
|
||
|
||
| | 构造后 `Get` | 构造后 `Set` |
|
||
|---|---|---|
|
||
| `New(v)` 传值构造 | 49.7 ns / 2 allocs | 27.2 ns / 0 allocs |
|
||
| `New(&v)` 传指针构造 | 50.3 ns / 2 allocs | 27.1 ns / 0 allocs |
|
||
|
||
所以判断标准是**构造频次**,不是访问频次:
|
||
|
||
- 构造一次、访问很多次 → 传值的那点开销会被摊薄,可以忽略
|
||
- 每个请求都构造(比如把查询结果包成 R)、而且对象含 map/slice → 这时候
|
||
那 3227 ns 是实打实的,值得改成传指针
|
||
|
||
不需要副本语义就传指针;取不到地址的表达式先落一个局部变量。
|
||
|
||
`DeepClone` 还有一个已知限制:**遇到循环引用会栈溢出**(`CloneValue` 没有已访问集合)。
|
||
这是旧实现就有的行为,当前实现直接复用了它,所以两边表现一致 ——
|
||
`New(&循环对象)` 本身没问题,`New(循环值)` 和 `Scope()` 会 fatal。
|
||
|
||
### 为什么最后要标 `ptrRoot`
|
||
|
||
两条路最终都持有一个指针(`DeepClone` 返回的也是指针),所以统一标 `ptrRoot = true`,
|
||
让 `Raw()` 返回 `reflect.Ptr` —— 与旧实现保持一致。
|
||
|
||
这个标记看着不起眼,但漏掉会连锁出问题:`Raw()` 返回值类型而不是指针时,
|
||
`normalizeAccessorSlice` 会把 `[]R` 解成 `[]map[string]any` 而不是 `[]*map[string]any`。
|
||
这个 bug 只有在 `[]R` 相关的用例上才暴露得出来。
|
||
|
||
---
|
||
|
||
## 一、取值:`Get(path...)`
|
||
|
||

|
||
|
||
主干是 `walk` 的循环:每取出一段路径,先 `normalize` 把指针和 interface 剥掉,
|
||
再由 `step` 按 `Kind` 分派。整个循环在栈上进行,只有最后返回给调用方时才装箱一次。
|
||
|
||
### 各步骤
|
||
|
||
| 步骤 | 函数 | 说明 |
|
||
|---|---|---|
|
||
| 构造 | `New(&v)` | 见上一节,得到 `rfx{td, ptr, writable, ptrRoot}`,24 字节 |
|
||
| 取段 | `pathIter.next()` | 按 `.` 切子串,**不新建切片** —— 旧实现的 `expandPath` 每次要分配 4 次 |
|
||
| 解引用 | `normalize()` | `Ptr` 走 `loadPtr` 逐层解;`Interface` 退回 reflect 拆包 |
|
||
| 分派 | `step(seg)` | 按 `td.Kind` 进入下面五个分支之一 |
|
||
| 装箱 | `boxed()` | 唯一一次堆分配,24 字节 |
|
||
|
||
### 五个分支
|
||
|
||
| 分支 | 做法 | 代价 |
|
||
|---|---|---|
|
||
| **Struct** | `lookupField` 查缓存好的偏移量 → `fieldAt(ptr, off)` | 零分配 |
|
||
| **Slice** | `parseIndex` → `sliceElemAt` | 零分配 |
|
||
| **Array** | `parseIndex` → `arrayElemAt` | 零分配 |
|
||
| **Map** | `tryMapFieldValue` → `boxCopy` | **每跳一次堆拷贝** |
|
||
| 其它 / 未导出字段 | 拒绝,`ok = false` | 返回无效值 |
|
||
|
||
**map 是唯一在循环内部就要堆拷贝的分支。** map 元素不可寻址,而 `rfx` 的内部表示
|
||
需要一个地址,所以每经过一跳 map 都要 `reflect.New` 复制一份。层级越深,拷贝越多 ——
|
||
这是纯 map 路径反而比旧实现慢的直接原因(见下面的实测)。
|
||
|
||
未导出字段必须显式拒绝:`reflect.NewAt` 造出来的 Value **不带只读标记**,
|
||
放行会让调用方绕过 Go 的导出规则直接读写私有字段。这是 unsafe 路径上最关键的一条防线。
|
||
|
||
---
|
||
|
||
## 二、写入:`Set(key, val)`
|
||
|
||

|
||
|
||
写入比取值多两道关:**目标必须可写**,**类型必须对得上**。
|
||
两者任何一个不满足,就把整个操作交回基于 reflect 的旧实现 `refx`。
|
||
|
||
### 为什么不自己实现复合类型的赋值
|
||
|
||
`assignField` 的四级递降是有意的,越往下越通用、也越慢:
|
||
|
||
| 级别 | 条件 | 做法 |
|
||
|---|---|---|
|
||
| ① | 类型完全一致的 `string`/`int`/`bool`/`float64` | 按 `*T` 直接写内存,**0 分配** |
|
||
| ② | `rv.Type() == td.rtype` | `valueAt().Set(rv)`,不绕道 |
|
||
| ③ | 标量目标 | `assignReflect` + `spf13/cast` 转换 |
|
||
| ④ | 复合目标(指针/切片/结构体/map/interface) | **整个交回 `refx`** |
|
||
|
||
第 ④ 级不能自己实现。`refx.setValue` 对这些类型有一整套规则:
|
||
|
||
- 指针字段是"设置指针**指向的值**"而不是替换指针
|
||
- `[]any` 会逐元素转成目标切片的元素类型
|
||
- `map` 可以按字段名填充进 `struct`
|
||
|
||
重新实现一遍必然出偏差 —— 这不是假设,是实际踩过的坑:
|
||
第一版自己实现时,`Set("Email", "x")`(`Email` 是 `*string`)会 panic,
|
||
而旧实现是正常的。
|
||
|
||
### 父级不可写的典型情况
|
||
|
||
`walk` 定位到父级后如果 `!parent.writable`,整个 `Set` 交回 `refx`。
|
||
最常见的触发是**路径穿过了 map**:map 元素不可寻址,取到的是副本,
|
||
直接写副本不会反映到原 map 上。`refx` 有完整的"取出-修改-写回 `SetMapIndex`"逻辑,
|
||
复用它而不是重写。
|
||
|
||
---
|
||
|
||
## 三、每条分支的实际代价
|
||
|
||
同样一次 `Get`,走哪条分支决定了它是 63 ns 还是 440 ns。
|
||
|
||
| 分支 | 路径 | 旧实现 | 当前 | 变化 |
|
||
|---|---|---|---|---|
|
||
| Struct | 三层嵌套字段 | 199.1 ns / 9 allocs | 63.4 ns / 2 allocs | **3.14×** |
|
||
| Slice | `Get("Tags","1")` | 132.6 ns / 7 allocs | 47.5 ns / 2 allocs | **2.79×** |
|
||
| `Get[T]` | 泛型直取,跳过装箱 | — | 29.8 ns / **0 allocs** | **5.08×** |
|
||
| Map | struct 字段 → map 键 | 270.3 ns / 16 allocs | 210.2 ns / 11 allocs | 1.29× |
|
||
| Map | 纯 map,三层嵌套 | 300.9 ns / 20 allocs | 260.8 ns / 15 allocs | 1.15× |
|
||
| Map | 纯 map,单跳 | 120.0 ns / 8 allocs | 126.8 ns / 7 allocs | **0.95×** |
|
||
| Map | map → struct → struct | 224.6 ns / 11 allocs | 140.3 ns / 6 allocs | 1.60× |
|
||
| Map | map → map → struct → struct | 303.2 ns / 16 allocs | 205.6 ns / 10 allocs | 1.47× |
|
||
| Scope | `Scope("A")` 之后再取值 | 144.6 ns / 7 allocs | 48.7 ns / 2 allocs | 2.97× |
|
||
|
||
两点说明:
|
||
|
||
1. **map 路径分段处理,只有单跳 map 略负。** 命中 map 后用 reflect 连续走完
|
||
**这一段** map,离开时装箱一次再切回偏移量快路径。两头都要顾:逐跳装箱的话
|
||
每跳 map 都要 `boxCopy`(元素不可寻址),连续多层会累积;但一路 reflect 走到底
|
||
又会让 map 后面的 struct 跳丢掉快路径、退回 `FieldByName`。分段之后三种混合
|
||
形态都是最优。只有"单跳 map"仍慢约 5% —— 没有 `boxCopy` 可省,而
|
||
`reflect.NewAt` 那一次省不掉。
|
||
|
||
2. **剩下的 2 次分配是 API 形状的下限。** 一次是返回的 `R` 包装对象(24 字节),
|
||
一次是可变参数切片经接口调用时的逃逸。想彻底避开,用 `Get[T]`。
|
||
|
||
---
|
||
|
||
## 对应源码
|
||
|
||
| 图中节点 | 位置 |
|
||
|---|---|
|
||
| `New` / `newRfx` | `reflux.go` |
|
||
| `normalizeInputValue` / `DeepClone` | `util.go` |
|
||
| `walk` / `step` / `normalize` / `fromReflect` | `rfx.go` |
|
||
| `Set` / `setField` / `assignField` / `storeFast` | `rfx.go` |
|
||
| `pathIter` | `path.go` |
|
||
| 类型布局缓存 `rfxDescriptorOf` | `rfx_typedesc.go` |
|
||
| `fieldAt` / `sliceElemAt` / `boxCopy` 等全部 unsafe 操作 | `unsafeptr.go` |
|
||
| `refx` 慢路径实现 | `rfx_reflect.go` |
|
||
|
||
改动这几个函数时记得同步这三张图。SVG 源文件就在本目录下,
|
||
是手写的(没有用绘图工具),直接编辑坐标即可。
|
||
|
||
实测环境:Apple M4 Pro / darwin-arm64 / go1.25.5,数字取多轮中位数。
|