起因是想给 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 仍能在此基础上取到隔离副本。
265 lines
7.3 KiB
Go
265 lines
7.3 KiB
Go
package reflux
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// New(值) 的语义是"改副本不影响调用方的原数据"。为了省掉深拷贝,现在会先看
|
|
// 类型描述符里的 needsClone —— 不含引用成分的类型走逐字节浅拷贝。
|
|
//
|
|
// 这一组测试守两件事:
|
|
// 1. needsClone 的判定不能漏(漏了就会浅拷贝一个含引用的类型,写入穿透到原数据)
|
|
// 2. 无论走哪条路,隔离语义都必须成立
|
|
//
|
|
// 第 1 条是这次改动唯一的风险点:判错的后果是静默的数据串改,不报错也不 panic。
|
|
|
|
// ---- needsClone 判定 ----
|
|
|
|
type cPure struct {
|
|
A, B string
|
|
N int
|
|
F float64
|
|
Ok bool
|
|
Arr [3]int
|
|
Sub struct{ X, Y string }
|
|
}
|
|
|
|
type cSlice struct {
|
|
A string
|
|
Tags []string
|
|
}
|
|
|
|
type cMap struct {
|
|
A string
|
|
Meta map[string]string
|
|
}
|
|
|
|
type cPtr struct {
|
|
A string
|
|
P *string
|
|
}
|
|
|
|
type cIface struct {
|
|
A string
|
|
V any
|
|
}
|
|
|
|
// 引用成分藏在两层嵌套之下
|
|
type cInner struct{ Tags []string }
|
|
type cMid struct{ Inner cInner }
|
|
type cDeepRef struct {
|
|
A string
|
|
Mid cMid
|
|
}
|
|
|
|
// 引用成分藏在数组元素里
|
|
type cArrRef struct {
|
|
A string
|
|
Arrs [2]cInner
|
|
}
|
|
|
|
// 同时含切片和 map, 用于验证传指针不触发深拷贝
|
|
type cBoth struct {
|
|
A string
|
|
Tags []string
|
|
Meta map[string]string
|
|
}
|
|
|
|
// 自引用
|
|
type cNode struct {
|
|
Name string
|
|
Next *cNode
|
|
}
|
|
|
|
func TestNeedsCloneDetection(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
typ reflect.Type
|
|
want bool
|
|
}{
|
|
{"纯值 struct", reflect.TypeOf(cPure{}), false},
|
|
{"含切片", reflect.TypeOf(cSlice{}), true},
|
|
{"含 map", reflect.TypeOf(cMap{}), true},
|
|
{"含指针", reflect.TypeOf(cPtr{}), true},
|
|
{"含 interface", reflect.TypeOf(cIface{}), true},
|
|
{"引用藏在两层嵌套下", reflect.TypeOf(cDeepRef{}), true},
|
|
{"引用藏在数组元素里", reflect.TypeOf(cArrRef{}), true},
|
|
{"自引用类型", reflect.TypeOf(cNode{}), true},
|
|
{"time.Time(内含 *Location)", reflect.TypeOf(time.Time{}), true},
|
|
{"裸切片", reflect.TypeOf([]string{}), true},
|
|
{"裸 map", reflect.TypeOf(map[string]string{}), true},
|
|
{"纯值数组", reflect.TypeOf([3]int{}), false},
|
|
{"字符串不算引用", reflect.TypeOf(""), false},
|
|
{"纯值嵌套 struct", reflect.TypeOf(struct{ S struct{ A string } }{}), false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := rfxDescriptorOf(c.typ).needsClone; got != c.want {
|
|
t.Fatalf("needsClone = %v, 期望 %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- 隔离语义:无论走哪条路都必须成立 ----
|
|
|
|
func TestNewByValueIsolation(t *testing.T) {
|
|
t.Run("纯值 struct(走浅拷贝)", func(t *testing.T) {
|
|
orig := cPure{A: "a", N: 1, Arr: [3]int{1, 2, 3}}
|
|
orig.Sub.X = "x"
|
|
r := New(orig)
|
|
r.Set("A", "changed")
|
|
r.Set("N", 99)
|
|
r.Set("Arr.0", 100)
|
|
r.Set("Sub.X", "changed")
|
|
if orig.A != "a" || orig.N != 1 || orig.Arr[0] != 1 || orig.Sub.X != "x" {
|
|
t.Fatalf("原数据被改写了: %+v", orig)
|
|
}
|
|
if r.Get("A").String() != "changed" || r.Get("Arr.0").Int() != 100 {
|
|
t.Fatal("副本上的写入没生效")
|
|
}
|
|
})
|
|
|
|
t.Run("含切片(走深拷贝)", func(t *testing.T) {
|
|
orig := cSlice{A: "a", Tags: []string{"x", "y"}}
|
|
r := New(orig)
|
|
r.Set("Tags.0", "changed")
|
|
if orig.Tags[0] != "x" {
|
|
t.Fatalf("原切片被改写了: %v", orig.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("含 map(走深拷贝)", func(t *testing.T) {
|
|
orig := cMap{A: "a", Meta: map[string]string{"k": "v"}}
|
|
r := New(orig)
|
|
r.Set("Meta.k", "changed")
|
|
if orig.Meta["k"] != "v" {
|
|
t.Fatalf("原 map 被改写了: %v", orig.Meta)
|
|
}
|
|
})
|
|
|
|
t.Run("引用藏在两层嵌套下", func(t *testing.T) {
|
|
orig := cDeepRef{A: "a", Mid: cMid{Inner: cInner{Tags: []string{"x"}}}}
|
|
r := New(orig)
|
|
r.Set("Mid.Inner.Tags.0", "changed")
|
|
if orig.Mid.Inner.Tags[0] != "x" {
|
|
t.Fatalf("深层的原切片被改写了: %v", orig.Mid.Inner.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("引用藏在数组元素里", func(t *testing.T) {
|
|
orig := cArrRef{A: "a"}
|
|
orig.Arrs[0] = cInner{Tags: []string{"x"}}
|
|
r := New(orig)
|
|
r.Set("Arrs.0.Tags.0", "changed")
|
|
if orig.Arrs[0].Tags[0] != "x" {
|
|
t.Fatalf("数组元素里的原切片被改写了: %v", orig.Arrs[0].Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("裸 map", func(t *testing.T) {
|
|
orig := map[string]any{"k": "v"}
|
|
r := New(orig)
|
|
r.Set("k", "changed")
|
|
if orig["k"] != "v" {
|
|
t.Fatalf("原 map 被改写了: %v", orig)
|
|
}
|
|
})
|
|
|
|
t.Run("裸切片", func(t *testing.T) {
|
|
orig := []string{"x", "y"}
|
|
r := New(orig)
|
|
r.Set("0", "changed")
|
|
if orig[0] != "x" {
|
|
t.Fatalf("原切片被改写了: %v", orig)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 与旧实现逐格对拍:浅拷贝路径的可观察行为必须与 DeepClone 完全一致。
|
|
func TestNewByValueMatchesLegacy(t *testing.T) {
|
|
mk := []struct {
|
|
name string
|
|
val func() any
|
|
}{
|
|
{"纯值 struct", func() any { return cPure{A: "a", N: 7, F: 1.5, Ok: true, Arr: [3]int{1, 2, 3}} }},
|
|
{"纯值数组", func() any { return [3]int{1, 2, 3} }},
|
|
{"含切片", func() any { return cSlice{A: "a", Tags: []string{"x"}} }},
|
|
{"含 map", func() any { return cMap{A: "a", Meta: map[string]string{"k": "v"}} }},
|
|
{"纯值嵌套", func() any { return struct{ S struct{ A string } }{} }},
|
|
}
|
|
ops := []struct {
|
|
name string
|
|
f func(R) any
|
|
}{
|
|
{"RawKind", func(r R) any { return r.Raw().Kind().String() }},
|
|
{"Keys", func(r R) any { return r.Keys() }},
|
|
{"JSON", func(r R) any { b, e := r.MarshalJSON(); return string(b) + "/" + errStr(e) }},
|
|
{"Any", func(r R) any { return r.Any() }},
|
|
{"PtrType", func(r R) any { return reflect.TypeOf(r.Ptr()).String() }},
|
|
}
|
|
for _, m := range mk {
|
|
for _, op := range ops {
|
|
t.Run(m.name+"/"+op.name, func(t *testing.T) {
|
|
want := call(newLegacy(m.val()), op.f)
|
|
got := call(New(m.val()), op.f)
|
|
if want != got {
|
|
t.Fatalf("行为不一致\n 旧 = %s\n 新 = %s", want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func errStr(e error) string {
|
|
if e == nil {
|
|
return "<nil>"
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|