Files
reflux/docs/flow.md
T
what cb84176381 docs: 补上 New 的解析流程,并用测试钉住 rfx 的大小
原来的流程文档只画了 Get 和 Set, New 在图里就是一个方框。但 New 的分支其实不少,
而且"指针模式 vs 值模式"是对外承诺的语义, 值得单独说清楚。

新增 docs/flow-new.svg 与对应章节, 覆盖四步: 快速分支 -> 解包 -> 解引用与校验
-> 按 isPtr 分岔。其中三处是使用者真正需要知道的:

1. 快速分支里 []R 不深拷贝也不包指针。这样 Raw() 才是 Slice kind、
   Array() 才能取到里面的 R。

2. 传值比传指针贵约 60 倍(约 1518ns/44allocs vs 约 25ns), 因为要 DeepClone
   递归复制整个对象图, 代价随对象规模增长。不需要副本语义就一律传指针。
   顺带记下 DeepClone 的已知限制: 循环引用会栈溢出(CloneValue 没有已访问
   集合), 这是旧实现就有的行为, 当前实现复用了它所以两边一致。

3. 为什么两条路最后都标 ptrRoot。漏掉它会连锁出问题: Raw() 返回值类型而不是
   指针时, normalizeAccessorSlice 会把 []R 解成 []map 而不是 []*map ——
   这个 bug 只在 []R 相关用例上才暴露得出来。

另外补 rfx_size_test.go: 文档里"24 字节"这个数字此前没有测试守着, 而 rfx 在
walk 循环里全程按值传递, 加字段会直接拖慢热路径。加了 ptrRoot 之后仍是 24 字节
(落在原有的对齐填充里), 用测试钉住, 以后加字段时会先失败。

同时修正: 源码对照表补上 New/newRfx 与 normalizeInputValue/DeepClone 的位置,
Get 章节的 rfx 字段列表补上 ptrRoot, 开头改成三个入口。
2026-08-31 11:02:00 +08:00

188 lines
8.2 KiB
Markdown
Raw 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,零拷贝 | 写入**直接反映到原数据** |
| **传值** `New(v)` | `DeepClone` 递归复制整个对象图 | **约 1518 ns / 44 allocs**(10 元素 + map 的对象) | 写入**不影响原数据** |
**传值比传指针贵约 60 倍**,而且随对象规模增长。如果不需要副本语义,一律传指针。
`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 键 | 175.6 ns / 10 allocs | 140.9 ns / 7 allocs | 1.25× |
| Map | **纯 map,三层嵌套** | 314.4 ns / 20 allocs | 439.5 ns / 20 allocs | **0.72×** |
三点说明:
1. **纯 map 路径是负收益。** 根就是 map、或路径深处全是 map 时,当前实现比旧版慢约 28%。
原因在第一张图里直接看得到:每跳一次 map 都要 `boxCopy`
**收益全部集中在 struct 字段访问上** —— 如果你的数据以 `map[string]any` 为主,
这次重写对你没有收益。
2. **那个 1.25× 容易看错。** 它的第一跳 `Meta`**struct 字段**,走了快路径,
把第二跳 map 的损失盖了过去 —— 不能当成"map 变快了"来读。
3. **剩下的 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,数字取多轮中位数。