docs: 更正 map 性能数据, 并增加内部流程文档

两部分改动。

一、更正 README 里关于 map 的性能结论(这是修正一个错误的说法)

原来写的是 "map 只快 1.25 倍", 但那个数字来自 Get("Meta","k") ——
第一跳 Meta 是 struct 字段, 走了快路径, 把第二跳 map 的损失盖了过去。
标成 "map 键" 会误导人。

补测纯 map 路径(根就是 map, 全程没有 struct 跳), 复测 10 轮结果稳定:

  1 层  旧 120.7ns/8allocs -> 新 161.7ns/8allocs   0.75x
  3 层  旧 314.4ns/20allocs -> 新 439.5ns/20allocs  0.72x

也就是说纯 map 场景当前实现比旧版慢约 28%, 不是变快。原因是内部表示为
"地址 + 类型描述符", 而 map 元素不可寻址, 每跳一次 map 都要 reflect.New
拷贝一份才能拿到地址; 旧实现直接持有 reflect.Value, 没这次拷贝。

表格改成分行列出两种场景, 并加了选型建议: 数据以 map[string]any 为主的
调用方, 这次重写没有收益, 反而略有退化 —— 收益全部集中在 struct 字段访问。

二、新增 docs/flow.md 与两张手写 SVG 流程图

说明 Get/Set 如何把路径逐段分派: 每段先 normalize 剥掉指针和 interface,
再按 Kind 进入 struct/slice/array 三条 unsafe 快路径、map 的 reflect 回退,
或直接拒绝。三色区分快路径 / 回退 / 拒绝, 把两件容易被忽略的事画了出来:

  1. map 是唯一在 walk 循环内部就要堆拷贝的分支 —— 正是上面那个负收益的
     直接原因, 图里一眼能看到。
  2. Set 的 assignField 是四级递降, 第 ④ 级把复合类型整个交回 refx。
     原因写在文档里: 指针字段要设置指向的值而不是替换指针、[]any 要逐元素
     转换、map 可填充进 struct —— 重新实现必然出偏差, 是实际踩过的坑。

SVG 通过 img 引用时是隔离渲染(拿不到宿主页面的 CSS 变量和 currentColor),
所以配色和 prefers-color-scheme 明暗适配都烘在文件内部, 背景留透明。

README 性能章节开头加一行指向该文档, 正文只留结论。
This commit is contained in:
2026-08-31 10:29:52 +08:00
parent d3216cb3b4
commit ac011cd5c5
4 changed files with 397 additions and 3 deletions
+131
View File
@@ -0,0 +1,131 @@
# 内部流程:路径是怎么被分派的
`Get``Set` 都不直接操作数据,而是把路径拆成段,逐段按当前值的 `Kind` 分派。
**分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect,
也决定了它要不要分配内存。**
图例:
| 标记 | 含义 |
|---|---|
| 青色 | unsafe 快路径 —— 偏移量寻址,零分配 |
| 琥珀色 | reflect 回退 —— 有堆拷贝 |
| 红色 | 拒绝 —— 返回无效值或 panic |
---
## 一、取值:`Get(path...)`
![Get 的分派流程](flow-get.svg)
主干是 `walk` 的循环:每取出一段路径,先 `normalize` 把指针和 interface 剥掉,
再由 `step``Kind` 分派。整个循环在栈上进行,只有最后返回给调用方时才装箱一次。
### 各步骤
| 步骤 | 函数 | 说明 |
|---|---|---|
| 构造 | `New(&v)` | 得到 `rfx{td, ptr, writable}`,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]`
---
## 对应源码
| 图中节点 | 位置 |
|---|---|
| `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,数字取多轮中位数。