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 个对拍用例。
8.5 KiB
内部流程:路径是怎么被分派的
New 把各种输入收敛成统一的内部表示;Get 和 Set 则不直接操作数据,
而是把路径拆成段,逐段按当前值的 Kind 分派。
分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect,
也决定了它要不要分配内存。
三张图分别对应这三个入口,配色编码一致。
图例:
| 标记 | 含义 |
|---|---|
| 青色 | unsafe 快路径 —— 偏移量寻址,零分配 |
| 琥珀色 | reflect 回退 —— 有堆拷贝 |
| 红色 | 拒绝 —— 返回无效值或 panic |
〇、构造:New(v any)
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...)
主干是 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)
写入比取值多两道关:目标必须可写,类型必须对得上。
两者任何一个不满足,就把整个操作交回基于 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× |
两点说明:
-
map 路径分段处理,只有单跳 map 略负。 命中 map 后用 reflect 连续走完 这一段 map,离开时装箱一次再切回偏移量快路径。两头都要顾:逐跳装箱的话 每跳 map 都要
boxCopy(元素不可寻址),连续多层会累积;但一路 reflect 走到底 又会让 map 后面的 struct 跳丢掉快路径、退回FieldByName。分段之后三种混合 形态都是最优。只有"单跳 map"仍慢约 5% —— 没有boxCopy可省,而reflect.NewAt那一次省不掉。 -
剩下的 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,数字取多轮中位数。