Files
reflux/docs/flow.md
T
what 6c6159f342 fix: Scope() 返回快实现, 不再把后续操作丢回逐次反射
Scope() 原来是 tv.slow().Scope(), 返回的是 *refx —— 基于 reflect 的慢实现。
功能完全正常, 只是它之后的每一次 Get/Set 都退回逐次反射, 这次重写的收益
在 Scope 之后全部消失。

  Scope("A") 之后再 Get   144.6ns / 7 allocs -> 48.7ns / 2 allocs   2.97x
  对照: 不经 Scope 直接 Get                     59.0ns / 2 allocs

改为自己走 walk 定位、克隆、再包成 *rfx。克隆复用 cloneForValueInput,
与 New(值) 同一套逻辑: 含引用成分才递归深拷贝, 纯值类型逐字节复制。

这类问题很容易在重构里悄悄失守 —— 交回慢实现不会有任何功能异常, 只是慢 3 倍,
测试不看动态类型就发现不了。所以 TestScopeReturnsFastImpl 直接断言返回的是
*rfx, 并验证过有效: 改回 &refx{} 后该用例失败。

顺带确认了其余 slow() 调用点都不会把慢实现泄漏给调用方:
Append/Delete 返回 v 自身, Keys/StringMap*/Slice* 返回的是具体 Go 类型。

另加 Scope 的语义测试(含引用子树 / 纯值子树 / map 子树 / 无参数整体克隆,
都断言原数据未被改写)与 78 个对拍用例。
2026-08-31 14:29:06 +08:00

191 lines
8.5 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 键 | 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,数字取多轮中位数。