Files
what 50f5531d3b docs: 讲清"值传递的开销只在 New 这一次", 并补上浅拷贝/深拷贝的分岔
两处之前没说清楚的地方。

一、值传递的代价只发生在构造那一次, 不影响之后的操作

原来只列了 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 是另一个更小的样本)。
2026-08-31 14:52:23 +08:00

217 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 内部流程:路径是怎么被分派的
`New` 把各种输入收敛成统一的内部表示;`Get``Set` 则不直接操作数据,
而是把路径拆成段,逐段按当前值的 `Kind` 分派。
**分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect,
也决定了它要不要分配内存。**
三张图分别对应这三个入口,配色编码一致。
图例:
| 标记 | 含义 |
|---|---|
| 青色 | unsafe 快路径 —— 偏移量寻址,零分配 |
| 琥珀色 | reflect 回退 —— 有堆拷贝 |
| 红色 | 拒绝 —— 返回无效值或 panic |
---
## 〇、构造:`New(v any)`
![New 的解析流程](flow-new.svg)
`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...)`
![Get 的分派流程](flow-get.svg)
主干是 `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)`
![Set 的分派流程](flow-set.svg)
写入比取值多两道关:**目标必须可写**,**类型必须对得上**。
两者任何一个不满足,就把整个操作交回基于 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,数字取多轮中位数。