两部分改动。
一、更正 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 性能章节开头加一行指向该文档, 正文只留结论。
5.6 KiB
内部流程:路径是怎么被分派的
Get 和 Set 都不直接操作数据,而是把路径拆成段,逐段按当前值的 Kind 分派。
分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect,
也决定了它要不要分配内存。
图例:
| 标记 | 含义 |
|---|---|
| 青色 | unsafe 快路径 —— 偏移量寻址,零分配 |
| 琥珀色 | reflect 回退 —— 有堆拷贝 |
| 红色 | 拒绝 —— 返回无效值或 panic |
一、取值:Get(path...)
主干是 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)
写入比取值多两道关:目标必须可写,类型必须对得上。
两者任何一个不满足,就把整个操作交回基于 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× |
三点说明:
-
纯 map 路径是负收益。 根就是 map、或路径深处全是 map 时,当前实现比旧版慢约 28%。 原因在第一张图里直接看得到:每跳一次 map 都要
boxCopy。 收益全部集中在 struct 字段访问上 —— 如果你的数据以map[string]any为主, 这次重写对你没有收益。 -
那个 1.25× 容易看错。 它的第一跳
Meta是 struct 字段,走了快路径, 把第二跳 map 的损失盖了过去 —— 不能当成"map 变快了"来读。 -
剩下的 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,数字取多轮中位数。