Files
what 50f5531d3b docs: 讲清"值传递的开销只在 New 这一次", 并补上浅拷贝/深拷贝的分岔
两处之前没说清楚的地方。

一、值传递的代价只发生在构造那一次, 不影响之后的操作

原来只列了 New(值) 3227ns vs New(&v) 25ns, 容易被读成"传值之后一直慢"。
实际上构造完成后两者的内部表示完全相同(都是类型描述符 + 地址), 后续
Get/Set 走同一条代码路径:

                     构造后 Get          构造后 Set
  New(v)  传值构造    49.7ns / 2 allocs   27.2ns / 0 allocs
  New(&v) 传指针构造  50.3ns / 2 allocs   27.1ns / 0 allocs

差异在噪声范围内。所以要看的是**构造频次**而不是访问频次: 构造一次访问很多次
的话那点开销会被摊薄; 每个请求都构造、对象又含 map/slice 时才值得计较。

二、流程图补上值分支内部的二次分岔

图是在 needsClone 那次优化之前画的, 一直写着"传值 = DeepClone", 已经不准:

  不含引用成分(纯值 struct/数组/字符串) -> 逐字节浅拷贝, 约 85 ns
    字符串虽然内部有指针, 但底层数组不可变, 共享是安全的
  含指针/切片/map/interface(递归包含字段与数组元素) -> DeepClone

这也是"能不能 copy 一份再取指针"的答案: 纯值类型可以(库里已经这么做了),
含引用的类型不行 —— 浅拷贝只复制 slice/map 的头部, 底层数据仍与调用方共享,
写入会穿透, 那就不再是隔离副本了。

顺带把代价数字更正为实测值(原来写的 1518ns/44allocs 是另一个更小的样本)。
2026-08-31 14:52:23 +08:00

10 KiB
Raw Permalink Blame History

内部流程:路径是怎么被分派的

New 把各种输入收敛成统一的内部表示;GetSet 则不直接操作数据, 而是把路径拆成段,逐段按当前值的 Kind 分派。 分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect, 也决定了它要不要分配内存。

三张图分别对应这三个入口,配色编码一致。

图例:

标记 含义
青色 unsafe 快路径 —— 偏移量寻址,零分配
琥珀色 reflect 回退 —— 有堆拷贝
红色 拒绝 —— 返回无效值或 panic

〇、构造:New(v any)

New 的解析流程

New 要把五花八门的输入收敛成统一的内部表示 rfx{td, ptr, writable, ptrRoot}。 过程分四步:快速分支 → 解包 → 解引用与校验 → 按 isPtr 分岔

快速分支(命中即返回,不走后面的流程)

输入 结果 为什么特殊
nil / valuex.Nil 返回 Nil 单例 不分配
已经是 R 原样返回 避免重复包装
[]R 直接包装,不深拷贝 保住 Raw() 是 Slice kind、Array() 能取到里面的 R

解包与校验

normalizeInputValue 负责把输入收敛成 reflect.Value:valuex.AccessorRaw(), []valuex.Accessor / []R 合并成一个切片,reflect.Value 直接用。 同时算出 isPtr —— 这个标记决定后面走哪条路。

然后递归解引用 Ptr/Interface(中途遇到指针会把 isPtr 置真), 最后校验目标类型:只接受 map / struct / slice / array / string / bool / floatintchanfunc 这些不是容器的类型会 panic。

关键分岔:指针模式 vs 值模式

这是对外承诺的语义,也是 New 里唯一有显著性能差异的地方:

做法 代价 语义
传指针 New(&v) 直接借用该指针 约 25 ns,零拷贝 写入直接反映到原数据
传值 + 纯值类型 逐字节浅拷贝 约 85 ns 写入不影响原数据
传值 + 含引用成分 DeepClone 递归复制整个对象图 10 键嵌套 map 约 3227 ns / 119 allocs 写入不影响原数据

传值这一支还要再分一次,依据是类型描述符里预先算好的 needsClone (构建期算一次,不在热路径):

  • 不含引用成分(纯值 struct、数组、字符串)→ 逐字节复制就已经完全独立了。 字符串虽然内部有指针,但底层数组不可变,共享是安全的。
  • 含指针 / 切片 / map / interface(递归包含字段与数组元素)→ 必须 DeepClone。 只做浅拷贝的话,slice 和 map 复制的只是头部,底层数据仍与调用方共享, 写入会穿透过去 —— 这正是"能不能只 copy 一份再取指针"这个问题的答案: 对纯值类型可以(库里已经这么做了),对含引用的类型不行。

这笔开销只发生在 New 这一次,不影响之后的任何操作。 构造完成后两者的内部表示完全相同(都是"类型描述符 + 地址"), 后续 Get/Set 走的是同一条代码路径:

构造后 Get 构造后 Set
New(v) 传值构造 49.7 ns / 2 allocs 27.2 ns / 0 allocs
New(&v) 传指针构造 50.3 ns / 2 allocs 27.1 ns / 0 allocs

所以判断标准是构造频次,不是访问频次:

  • 构造一次、访问很多次 → 传值的那点开销会被摊薄,可以忽略
  • 每个请求都构造(比如把查询结果包成 R)、而且对象含 map/slice → 这时候 那 3227 ns 是实打实的,值得改成传指针

不需要副本语义就传指针;取不到地址的表达式先落一个局部变量。

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 的分派流程

主干是 walk 的循环:每取出一段路径,先 normalize 把指针和 interface 剥掉, 再由 stepKind 分派。整个循环在栈上进行,只有最后返回给调用方时才装箱一次。

各步骤

步骤 函数 说明
构造 New(&v) 见上一节,得到 rfx{td, ptr, writable, ptrRoot},24 字节
取段 pathIter.next() . 切子串,不新建切片 —— 旧实现的 expandPath 每次要分配 4 次
解引用 normalize() PtrloadPtr 逐层解;Interface 退回 reflect 拆包
分派 step(seg) td.Kind 进入下面五个分支之一
装箱 boxed() 唯一一次堆分配,24 字节

五个分支

分支 做法 代价
Struct lookupField 查缓存好的偏移量 → fieldAt(ptr, off) 零分配
Slice parseIndexsliceElemAt 零分配
Array parseIndexarrayElemAt 零分配
Map tryMapFieldValueboxCopy 每跳一次堆拷贝
其它 / 未导出字段 拒绝,ok = false 返回无效值

map 是唯一在循环内部就要堆拷贝的分支。 map 元素不可寻址,而 rfx 的内部表示 需要一个地址,所以每经过一跳 map 都要 reflect.New 复制一份。层级越深,拷贝越多 —— 这是纯 map 路径反而比旧实现慢的直接原因(见下面的实测)。

未导出字段必须显式拒绝:reflect.NewAt 造出来的 Value 不带只读标记, 放行会让调用方绕过 Go 的导出规则直接读写私有字段。这是 unsafe 路径上最关键的一条防线。


二、写入:Set(key, val)

Set 的分派流程

写入比取值多两道关:目标必须可写,类型必须对得上。 两者任何一个不满足,就把整个操作交回基于 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,数字取多轮中位数。