From 1cc43562baa2b623f83db587076697abc57407ad Mon Sep 17 00:00:00 2001 From: what Date: Mon, 31 Aug 2026 14:40:37 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=8A=8A"=E8=AF=A5=E4=BC=A0=E5=80=BC?= =?UTF-8?q?=E8=BF=98=E6=98=AF=E4=BC=A0=E6=8C=87=E9=92=88"=E8=AE=B2?= =?UTF-8?q?=E6=B8=85=E6=A5=9A,=20=E4=BB=A3=E6=9B=BF=E6=96=B0=E5=A2=9E=20Re?= =?UTF-8?q?f=20=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因是想给 New 加一个跳过深拷贝的开关。做出来之后对比发现,现有的 "传指针"已经在三个维度上都更好, 那个开关是多余的 API: New(m, WithRef()) New(&m) 速度 47.5ns / 3 allocs 24.8ns / 1 alloc map/slice 共享底层 是 是 struct 写回调用方 否 是 新增 API 一个选项 + 一套语义 零 WithRef 对 struct 还有个说不清的半吊子语义 —— 共享 map/slice 字段的底层数据, 但写不回 struct 本身。这种"部分穿透"很难讲明白也容易踩坑, 而 & 没有这个问题。 所以撤掉选项, 改为把这件事在文档里讲透。 README 新增"该传值还是传指针"一节, 给出实测对比: 10 键嵌套 map 传值 3227ns/119allocs 传指针 24.8ns/1alloc 130x 50 元素 slice 传值 594ns/ 6allocs 传指针 25ns/1alloc 24x 8 字段纯值 struct 传值 85.5ns/ 2allocs 传指针 25.5ns/1alloc 3.4x 判断标准就一句: 这份数据我会不会通过 R 去写? 写了穿透出去有没有问题? 只读或本来就想改原数据就传指针; 需要互不干扰的副本就传值(或传指针后 Scope)。 另附取不到地址的表达式该怎么落局部变量。 代码改动只剩一处无关的重构: 把 cloneForValueInput 里的浅拷贝抽成 shallowBox。 新增 TestPointerInputSharesAndSkipsClone 把 README 的承诺钉住: 传指针必须共享底层 map/slice、能写回 struct 变量、不触发深拷贝, 且 Scope 仍能在此基础上取到隔离副本。 --- README.md | 29 +++++++++++++++++++++++++++ reflux.go | 21 +++++++++++++++----- rfx_clone_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f84d52c..207d96a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,35 @@ rfx.Set("Name", "Bob") // rfx 内部是独立的深度克隆 ``` +#### 该传值还是传指针 + +**默认传值是安全的,但深度克隆的代价随数据规模增长**: + +| 输入 | `New(v)` 传值 | `New(&v)` 传指针 | 差距 | +|---|---|---|---:| +| 10 键嵌套 map | 3227 ns / 119 allocs | 24.8 ns / 1 alloc | **130x** | +| 50 元素 slice | 594 ns / 6 allocs | 25 ns / 1 alloc | **24x** | +| 8 字段纯值 struct | 85.5 ns / 2 allocs | 25.5 ns / 1 alloc | 3.4x | + +判断标准就一句:**这份数据我会不会通过 R 去写?写了穿透出去有没有问题?** + +- 只读,或者本来就想改调用方的数据 → **传指针** +- 需要一份互不干扰的副本 → 传值(或者传指针之后用 `Scope()` 取局部副本) + +取不到地址的表达式,先落一个局部变量: + +```go +// 取不到地址 +return reflux.New(lo.Assign(a, b)) + +// 落一个变量, 顺带让"共享数据"这件事在调用点就看得见 +m := lo.Assign(a, b) +return reflux.New(&m) +``` + +纯值类型(不含指针/slice/map/interface)的传值成本已经优化过 —— 逐字节复制 +就已经完全独立,不走深拷贝的递归。所以上表里 struct 那一行差距最小。 + #### Interface 类型支持 Reflux 支持 `interface{}` (或 `any`) 类型,会自动解析到实际类型: diff --git a/reflux.go b/reflux.go index c3d1b4a..136a468 100644 --- a/reflux.go +++ b/reflux.go @@ -52,8 +52,13 @@ type R interface { // New 创建一个新的 R 实例 // 参数 v 可以是指针或非指针类型 -// - 如果传入指针: 将直接使用该指针,可以修改原始数据 -// - 如果传入值: 会自动创建一个深度克隆的指针副本,修改不影响原始数据 +// - 如果传入指针: 将直接使用该指针,可以修改原始数据,且不产生任何复制 +// - 如果传入值: 会自动创建一个深度克隆的指针副本,修改不影响原始数据 +// +// 深度克隆的代价随数据规模增长(10 键嵌套 map 约 3200 ns / 119 allocs, +// 而传指针只要 25 ns / 1 alloc)。数据量大、或者本来就想改调用方的数据时, +// **传指针**。取不到地址的表达式先落一个局部变量即可。 +// // 支持的类型: map、struct、slice、array // 也支持 interface 类型以及部分基础类型(string/bool/float),会自动解析到实际类型 // 返回一个 R 接口实例,可用于访问和操作嵌套的字段、元素和键值对 @@ -122,6 +127,14 @@ func New(v any) R { return newRfx(rv) } +// shallowBox 把一个不可寻址的值装到新分配的内存里,只做逐字节复制。 +// map/slice 复制的是头部,底层数据仍与调用方共享。 +func shallowBox(v reflect.Value) reflect.Value { + box := reflect.New(v.Type()) + box.Elem().Set(v) + return box +} + // cloneForValueInput 为 New(值) 复制一份独立的数据,返回指向副本的指针。 // // 只有类型含引用成分(指针/切片/map/interface 等)时才需要 DeepClone 的递归; @@ -132,9 +145,7 @@ func New(v any) R { // 判断本身不花钱: needsClone 在类型描述符构建时算好,这里只读一个 bool。 func cloneForValueInput(v reflect.Value) reflect.Value { if td := rfxDescriptorOf(v.Type()); !td.needsClone { - box := reflect.New(v.Type()) - box.Elem().Set(v) - return box + return shallowBox(v) } return DeepClone(v) } diff --git a/rfx_clone_test.go b/rfx_clone_test.go index 530cb8f..55ec4ca 100644 --- a/rfx_clone_test.go +++ b/rfx_clone_test.go @@ -60,6 +60,13 @@ type cArrRef struct { Arrs [2]cInner } +// 同时含切片和 map, 用于验证传指针不触发深拷贝 +type cBoth struct { + A string + Tags []string + Meta map[string]string +} + // 自引用 type cNode struct { Name string @@ -212,3 +219,46 @@ func errStr(e error) string { } return e.Error() } + +// README 建议"数据量大或想改原数据时传指针"。这一组把那个承诺钉住: +// 传指针必须共享底层数据、且不做任何复制。 +func TestPointerInputSharesAndSkipsClone(t *testing.T) { + t.Run("map 写入穿透", func(t *testing.T) { + m := map[string]any{"k": "v"} + New(&m).Set("k", "changed") + if m["k"] != "changed" { + t.Fatalf("传指针应当共享底层 map, 调用方看到 %v", m["k"]) + } + }) + t.Run("slice 元素写入穿透", func(t *testing.T) { + s := []string{"a"} + New(&s).Set("0", "changed") + if s[0] != "changed" { + t.Fatalf("传指针应当共享底层数组, 调用方看到 %v", s) + } + }) + t.Run("struct 字段写回调用方变量", func(t *testing.T) { + d := cPure{A: "a"} + New(&d).Set("A", "changed") + if d.A != "changed" { + t.Fatal("传指针应当写回调用方的 struct 变量") + } + }) + t.Run("含引用的大对象传指针不触发深拷贝", func(t *testing.T) { + v := cBoth{A: "a", Tags: []string{"x"}, Meta: map[string]string{"k": "v"}} + // 传指针路径不该调用 DeepClone —— 用"写入穿透"反证没有复制 + New(&v).Set("Tags.0", "changed") + New(&v).Set("Meta.k", "changed") + if v.Tags[0] != "changed" || v.Meta["k"] != "changed" { + t.Fatalf("传指针不该复制, 实际 %+v", v) + } + }) + t.Run("Scope 仍可在传指针的基础上取隔离副本", func(t *testing.T) { + v := cBoth{A: "a", Tags: []string{"x"}} + s := New(&v).Scope() + s.Set("Tags.0", "changed") + if v.Tags[0] != "x" { + t.Fatalf("Scope 应当隔离, 原数据被改成 %v", v.Tags) + } + }) +}