docs: 把"该传值还是传指针"讲清楚, 代替新增 Ref 选项

起因是想给 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 仍能在此基础上取到隔离副本。
This commit is contained in:
2026-08-31 14:40:37 +08:00
parent 6c6159f342
commit 1cc43562ba
3 changed files with 95 additions and 5 deletions
+29
View File
@@ -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`) 类型,会自动解析到实际类型:
+16 -5
View File
@@ -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)
}
+50
View File
@@ -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)
}
})
}