From cb8417638109cff1c03522e5585872b8978aa3fc Mon Sep 17 00:00:00 2001 From: what Date: Mon, 31 Aug 2026 11:02:00 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E4=B8=8A=20New=20=E7=9A=84?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=B5=81=E7=A8=8B,=E5=B9=B6=E7=94=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=92=89=E4=BD=8F=20rfx=20=E7=9A=84=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的流程文档只画了 Get 和 Set, New 在图里就是一个方框。但 New 的分支其实不少, 而且"指针模式 vs 值模式"是对外承诺的语义, 值得单独说清楚。 新增 docs/flow-new.svg 与对应章节, 覆盖四步: 快速分支 -> 解包 -> 解引用与校验 -> 按 isPtr 分岔。其中三处是使用者真正需要知道的: 1. 快速分支里 []R 不深拷贝也不包指针。这样 Raw() 才是 Slice kind、 Array() 才能取到里面的 R。 2. 传值比传指针贵约 60 倍(约 1518ns/44allocs vs 约 25ns), 因为要 DeepClone 递归复制整个对象图, 代价随对象规模增长。不需要副本语义就一律传指针。 顺带记下 DeepClone 的已知限制: 循环引用会栈溢出(CloneValue 没有已访问 集合), 这是旧实现就有的行为, 当前实现复用了它所以两边一致。 3. 为什么两条路最后都标 ptrRoot。漏掉它会连锁出问题: Raw() 返回值类型而不是 指针时, normalizeAccessorSlice 会把 []R 解成 []map 而不是 []*map —— 这个 bug 只在 []R 相关用例上才暴露得出来。 另外补 rfx_size_test.go: 文档里"24 字节"这个数字此前没有测试守着, 而 rfx 在 walk 循环里全程按值传递, 加字段会直接拖慢热路径。加了 ptrRoot 之后仍是 24 字节 (落在原有的对齐填充里), 用测试钉住, 以后加字段时会先失败。 同时修正: 源码对照表补上 New/newRfx 与 normalizeInputValue/DeepClone 的位置, Get 章节的 rfx 字段列表补上 ptrRoot, 开头改成三个入口。 --- docs/flow-new.svg | 135 ++++++++++++++++++++++++++++++++++++++++++++++ docs/flow.md | 62 +++++++++++++++++++-- rfx_size_test.go | 16 ++++++ 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 docs/flow-new.svg create mode 100644 rfx_size_test.go diff --git a/docs/flow-new.svg b/docs/flow-new.svg new file mode 100644 index 0000000..db4ce13 --- /dev/null +++ b/docs/flow-new.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + New(v any) + + + + + 按具体类型快速分支 + switch v.(type) · 命中即返回 + + + + 命中 + + + nil / valuex.Nil → 返回 Nil 单例 + 已经是 R → 原样返回,不重新包装 + []R → 直接包装,不深拷贝 + (保住 Raw() 是 Slice、Array() 能取到 R) + + + 其它 + + + + normalizeInputValue + Accessor → 取 Raw() · []Accessor → 合并成切片 + reflect.Value → 直接用 · 同时算出 isPtr + + + + + + 递归解引用 Ptr / Interface + 中途遇到指针会把 isPtr 置真 + + + 遇到 nil + + panic ErrTargetNilPointer + + + + + + 校验目标类型 + map/struct/slice/array/string/bool/float + + + int / chan / func … + + panic 不支持的目标类型 + + + + + + isPtr ? + + + 是 · 传的是指针 + + + 否 · 传的是值 + + + + 直接借用该指针 + 零拷贝 · 约 25 ns + 写入直接反映到原数据 + 指针模式 + + + + DeepClone 深拷贝 + 递归复制整个对象图,返回新指针 + 写入不影响原数据 · 值模式 + 比借用贵得多,且循环引用会栈溢出 + + + + + + + + + rfx{ td, ptr, writable, ptrRoot } + 两条路最终都持有一个指针,ptrRoot = true + + + + + ptrRoot 让 Raw() 返回 reflect.Ptr, + 与旧实现一致。少了它,[]R 场景会 + 解成 []map 而不是 []*map。 + diff --git a/docs/flow.md b/docs/flow.md index 139b84f..174fdc8 100644 --- a/docs/flow.md +++ b/docs/flow.md @@ -1,9 +1,12 @@ # 内部流程:路径是怎么被分派的 -`Get` 和 `Set` 都不直接操作数据,而是把路径拆成段,逐段按当前值的 `Kind` 分派。 +`New` 把各种输入收敛成统一的内部表示;`Get` 和 `Set` 则不直接操作数据, +而是把路径拆成段,逐段按当前值的 `Kind` 分派。 **分派到哪个分支,决定了这一跳走 unsafe 偏移量寻址还是退回 reflect, 也决定了它要不要分配内存。** +三张图分别对应这三个入口,配色编码一致。 + 图例: | 标记 | 含义 | @@ -14,6 +17,57 @@ --- +## 〇、构造:`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) @@ -25,7 +79,7 @@ | 步骤 | 函数 | 说明 | |---|---|---| -| 构造 | `New(&v)` | 得到 `rfx{td, ptr, writable}`,24 字节,栈上传递 | +| 构造 | `New(&v)` | 见上一节,得到 `rfx{td, ptr, writable, ptrRoot}`,24 字节 | | 取段 | `pathIter.next()` | 按 `.` 切子串,**不新建切片** —— 旧实现的 `expandPath` 每次要分配 4 次 | | 解引用 | `normalize()` | `Ptr` 走 `loadPtr` 逐层解;`Interface` 退回 reflect 拆包 | | 分派 | `step(seg)` | 按 `td.Kind` 进入下面五个分支之一 | @@ -118,6 +172,8 @@ | 图中节点 | 位置 | |---|---| +| `New` / `newRfx` | `reflux.go` | +| `normalizeInputValue` / `DeepClone` | `util.go` | | `walk` / `step` / `normalize` / `fromReflect` | `rfx.go` | | `Set` / `setField` / `assignField` / `storeFast` | `rfx.go` | | `pathIter` | `path.go` | @@ -125,7 +181,7 @@ | `fieldAt` / `sliceElemAt` / `boxCopy` 等全部 unsafe 操作 | `unsafeptr.go` | | `refx` 慢路径实现 | `rfx_reflect.go` | -改动这几个函数时记得同步这两张图。SVG 源文件就在本目录下, +改动这几个函数时记得同步这三张图。SVG 源文件就在本目录下, 是手写的(没有用绘图工具),直接编辑坐标即可。 实测环境:Apple M4 Pro / darwin-arm64 / go1.25.5,数字取多轮中位数。 diff --git a/rfx_size_test.go b/rfx_size_test.go new file mode 100644 index 0000000..5e9a227 --- /dev/null +++ b/rfx_size_test.go @@ -0,0 +1,16 @@ +package reflux + +import ( + "testing" + "unsafe" +) + +// rfx 必须保持在 24 字节: 它在 walk 循环里全程按值传递, 变大会直接拖慢热路径, +// 也会让 boxed() 的那次堆分配跨到更大的 size class。 +// docs/flow.md 里的说明依赖这个数字。 +func TestRfxStaysSmall(t *testing.T) { + const want = 24 + if got := unsafe.Sizeof(rfx{}); got != want { + t.Fatalf("rfx 大小 = %d 字节, 期望 %d —— 加字段前请确认是否真的值得", got, want) + } +}