Get[T] 原本只处理 string/int/int64/bool/float64 五种,其余类型掉进 default
分支的 Any().(T) 断言。而断言**不做转换**,导致:
Get[int32](r, "Age") // 字段是 int(42) -> 静默返回 0
Get[[]string](r, "Tags") // 字段是 []any -> 静默返回 nil
Get[map[string]string](r, "M") // 字段是 map[string]any -> 静默返回 nil
而对应的访问器 r.Get("Age").Int32() 等返回的是正确的转换结果。
实测 8 个场景里 7 个静默给错数据 —— 不报错不 panic,是最难排查的一类问题。
补齐后 T 覆盖 valuex.Accessor 全部转换方法对应的类型(9 个标量 + 4 种切片
+ 6 种 map),每个分支都落到同名访问器上,转换语义完全一致。
五种快路径类型的零分配特性不受影响(新分支同样用 detachPath 切断逃逸)。
新增 TestGenericMirrorsAccessors: 23 个用例逐个比对 Get[T] 与同名访问器,
并额外断言结果不是零值,防止将来有人删掉分支又掉回 default。
257 lines
10 KiB
Go
257 lines
10 KiB
Go
package reflux
|
|
|
|
// 泛型 Get[T] 的等价性与分配情况。
|
|
//
|
|
// 分配这块用 testing.AllocsPerRun 精确统计, 并且必须写进包级 sink 变量 ——
|
|
// 丢弃结果的写法会被编译器优化掉, 测出假的 0 分配。
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
// 泛型 Get[T] 必须与 r.Get(path...).Xxx() 等价, 且在"非 *rfx 实现的 R"上能正确回退。
|
|
func TestGenericGetEquivalent(t *testing.T) {
|
|
box := newConvBox()
|
|
fast := New(box) // 走快路径
|
|
slow := newLegacy(box) // 走回退路径
|
|
|
|
for _, field := range convFields {
|
|
t.Run(field, func(t *testing.T) {
|
|
// string
|
|
want, wp := callSafe(slow.Get(field), func(r R) any { return r.String() })
|
|
gotFast, gpF := callSafe(fast, func(R) any { return Get[string](fast, field) })
|
|
gotSlow, gpS := callSafe(slow, func(R) any { return Get[string](slow, field) })
|
|
if wp != gpF || wp != gpS {
|
|
t.Fatalf("panic 行为不一致: reflux=%v 快路径=%v 回退=%v", wp, gpF, gpS)
|
|
}
|
|
if !wp && (want != gotFast || want != gotSlow) {
|
|
t.Fatalf("string: reflux=%#v 快路径=%#v 回退=%#v", want, gotFast, gotSlow)
|
|
}
|
|
|
|
// int
|
|
wantI, wpI := callSafe(slow.Get(field), func(r R) any { return r.Int() })
|
|
gotIF, gpIF := callSafe(fast, func(R) any { return Get[int](fast, field) })
|
|
gotIS, gpIS := callSafe(slow, func(R) any { return Get[int](slow, field) })
|
|
if wpI != gpIF || wpI != gpIS {
|
|
t.Fatalf("int panic 行为不一致: reflux=%v 快=%v 回退=%v", wpI, gpIF, gpIS)
|
|
}
|
|
if !wpI && (wantI != gotIF || wantI != gotIS) {
|
|
t.Fatalf("int: reflux=%#v 快=%#v 回退=%#v", wantI, gotIF, gotIS)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenericGetTypes(t *testing.T) {
|
|
o := newUxOuter()
|
|
r := New(o)
|
|
|
|
if got := Get[string](r, "Mid", "Inner", "City"); got != "Beijing" {
|
|
t.Fatalf("string = %q", got)
|
|
}
|
|
if got := Get[string](r, "Mid.Inner.City"); got != "Beijing" {
|
|
t.Fatalf("点号路径 = %q", got)
|
|
}
|
|
if got := Get[int](r, "Mid.Inner.Zip"); got != 100000 {
|
|
t.Fatalf("int = %d", got)
|
|
}
|
|
if got := Get[int64](r, "Mid.Inner.Zip"); got != 100000 {
|
|
t.Fatalf("int64 = %d", got)
|
|
}
|
|
if got := Get[bool](r, "Mid.Inner.Active"); !got {
|
|
t.Fatal("bool = false")
|
|
}
|
|
if got := Get[float64](r, "Mid.Inner.Ratio"); got != 1.5 {
|
|
t.Fatalf("float64 = %v", got)
|
|
}
|
|
// 路径不存在 -> 零值
|
|
if got := Get[string](r, "NoSuch"); got != "" {
|
|
t.Fatalf("不存在的路径应该返回零值, 得到 %q", got)
|
|
}
|
|
if got := Get[int](r, "NoSuch"); got != 0 {
|
|
t.Fatalf("不存在的路径应该返回 0, 得到 %d", got)
|
|
}
|
|
// 未导出字段 -> 零值
|
|
if got := Get[string](r, "secret"); got != "" {
|
|
t.Fatalf("未导出字段应该返回零值, 得到 %q", got)
|
|
}
|
|
}
|
|
|
|
// 泛型方案的已知缺口: 具名标量类型不会命中 type switch 的 case,
|
|
// 会掉进 default 分支走 Any() 断言 —— 行为与 Get(...).String() 不同。
|
|
// 这里把这个差异**显式钉住**, 免得以后当成 bug 排查。
|
|
func TestGenericNamedTypeGap(t *testing.T) {
|
|
box := newConvBox()
|
|
r := New(box)
|
|
|
|
// NamedS 字段本身是 myStr 类型
|
|
// 1) 取成 string: 命中 case *string 快路径, 正常转换
|
|
if got := Get[string](r, "NamedS"); got != "named" {
|
|
t.Fatalf("GetT[string] 取具名字段 = %q", got)
|
|
}
|
|
// 2) Str 字段是原生 string, 取成具名类型 myStr:
|
|
// 走 default 分支的 Any().(myStr) 断言, 会失败并返回零值,
|
|
// 而 Get("Str").String() 是能拿到 "hello" 的。
|
|
if got := Get[myStr](r, "Str"); got != "" {
|
|
t.Logf("具名类型缺口已消失(实现改进了): GetT[myStr] = %q", got)
|
|
} else {
|
|
t.Log("已知缺口: GetT[具名类型] 在字段为原生类型时返回零值, 需在文档中说明")
|
|
}
|
|
}
|
|
|
|
// 全局 sink: 防止编译器把"结果被丢弃"的调用优化掉, 导致测出假的 0 分配。
|
|
var (
|
|
sinkS string
|
|
sinkI int
|
|
sinkI64 int64
|
|
sinkB bool
|
|
sinkF float64
|
|
sinkSS []string
|
|
)
|
|
|
|
// 精确统计 Get[T] 各条分支的分配次数。
|
|
// testing.AllocsPerRun 比 -benchmem 更直接: 它给出的是每次调用的确切分配数。
|
|
func TestGenericAllocs(t *testing.T) {
|
|
o := newUxOuter()
|
|
box := newConvBox()
|
|
fast := New(o)
|
|
fastBox := New(box)
|
|
slow := newLegacy(o)
|
|
|
|
cases := []struct {
|
|
name string
|
|
want float64 // 期望的分配次数
|
|
f func()
|
|
}{
|
|
// ---- 快路径: 应该全部 0 分配 ----
|
|
{"Get[string] 3层路径", 0, func() { sinkS = Get[string](fast, "Mid", "Inner", "City") }},
|
|
{"Get[string] 点号路径", 0, func() { sinkS = Get[string](fast, "Mid.Inner.City") }},
|
|
{"Get[string] 单层路径", 0, func() { sinkS = Get[string](fast, "Name") }},
|
|
{"Get[int]", 0, func() { sinkI = Get[int](fast, "Mid.Inner.Zip") }},
|
|
{"Get[int64]", 0, func() { sinkI64 = Get[int64](fast, "Mid.Inner.Zip") }},
|
|
{"Get[bool]", 0, func() { sinkB = Get[bool](fast, "Mid.Inner.Active") }},
|
|
{"Get[float64]", 0, func() { sinkF = Get[float64](fast, "Mid.Inner.Ratio") }},
|
|
{"Get[string] slice下标", 0, func() { sinkS = Get[string](fast, "Tags", "1") }},
|
|
{"Get[string] 路径不存在", 0, func() { sinkS = Get[string](fast, "NoSuch") }},
|
|
{"Get[string] 未导出字段", 0, func() { sinkS = Get[string](fast, "secret") }},
|
|
{"Get[int] 路径不存在", 0, func() { sinkI = Get[int](fast, "NoSuch") }},
|
|
|
|
// ---- 会分配的分支(预期之内, 但要说清楚) ----
|
|
{"Get[string] 跨类型(int=42, 命中strconv小整数缓存)", -1, func() { sinkS = Get[string](fastBox, "I") }},
|
|
{"Get[string] 跨类型(int=100000, 真实格式化)", -1, func() { sinkS = Get[string](fast, "Mid.Inner.Zip") }},
|
|
{"Get[string] 跨类型(float64)", -1, func() { sinkS = Get[string](fast, "Mid.Inner.Ratio") }},
|
|
{"Get[int] 跨类型(string字段)", -1, func() { sinkI = Get[int](fastBox, "NumStr") }},
|
|
{"Get[string] map键", -1, func() { sinkS = Get[string](fast, "Meta", "k1") }},
|
|
{"Get[[]string] default分支", -1, func() { sinkSS = Get[[]string](fast, "Tags") }},
|
|
{"Get[string] 回退到reflux", -1, func() { sinkS = Get[string](slow, "Mid.Inner.City") }},
|
|
|
|
// ---- 对照 ----
|
|
{"对照: Get(...).String()", -1, func() { sinkS = fast.Get("Mid", "Inner", "City").String() }},
|
|
{"对照: 内部 getString", -1, func() { sinkS = fast.(*rfx).getString("Mid", "Inner", "City") }},
|
|
{"对照: 参照实现 refx Get().String()", -1, func() { sinkS = slow.Get("Mid", "Inner", "City").String() }},
|
|
}
|
|
|
|
t.Log("每次调用的分配次数:")
|
|
for _, c := range cases {
|
|
got := testing.AllocsPerRun(200, c.f)
|
|
t.Logf(" %-32s %.0f allocs", c.name, got)
|
|
if c.want >= 0 && got != c.want {
|
|
t.Errorf("%s: 分配 %.0f 次, 期望 %.0f 次", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Get[T] 必须是 R 访问器集合的完整镜像
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type genAllDoc struct {
|
|
Age int
|
|
Ratio float64
|
|
Tags []any
|
|
Names []string
|
|
Nums []int
|
|
Flags []bool
|
|
Meta map[string]any
|
|
SMeta map[string]string
|
|
IMeta map[string]int
|
|
I64Map map[string]int64
|
|
BMeta map[string]bool
|
|
SSMeta map[string][]string
|
|
}
|
|
|
|
func newGenAllDoc() *genAllDoc {
|
|
return &genAllDoc{
|
|
Age: 42, Ratio: 2.5,
|
|
Tags: []any{"a", "b"},
|
|
Names: []string{"x", "y"},
|
|
Nums: []int{1, 2},
|
|
Flags: []bool{true, false},
|
|
Meta: map[string]any{"k": "v"},
|
|
SMeta: map[string]string{"k": "v"},
|
|
IMeta: map[string]int{"k": 1},
|
|
I64Map: map[string]int64{"k": 2},
|
|
BMeta: map[string]bool{"k": true},
|
|
SSMeta: map[string][]string{"k": {"a"}},
|
|
}
|
|
}
|
|
|
|
// Get[T] 与同名访问器必须给出相同结果 —— 尤其在"类型不完全匹配、需要转换"时。
|
|
//
|
|
// 这是补齐类型分支的原因: 少一个分支就会掉进 default 的 Any().(T) 断言,
|
|
// 断言不做转换, 于是 Get[int32] 读 int 字段会静默返回 0。
|
|
func TestGenericMirrorsAccessors(t *testing.T) {
|
|
d := newGenAllDoc()
|
|
r := New(d)
|
|
|
|
cases := []struct {
|
|
name string
|
|
generic any
|
|
accessor any
|
|
}{
|
|
// 需要转换的标量(字段类型与 T 不一致)
|
|
{"int8 <- int", Get[int8](r, "Age"), r.Get("Age").Int8()},
|
|
{"int16 <- int", Get[int16](r, "Age"), r.Get("Age").Int16()},
|
|
{"int32 <- int", Get[int32](r, "Age"), r.Get("Age").Int32()},
|
|
{"uint <- int", Get[uint](r, "Age"), r.Get("Age").Uint()},
|
|
{"uint8 <- int", Get[uint8](r, "Age"), r.Get("Age").Uint8()},
|
|
{"uint16 <- int", Get[uint16](r, "Age"), r.Get("Age").Uint16()},
|
|
{"uint32 <- int", Get[uint32](r, "Age"), r.Get("Age").Uint32()},
|
|
{"uint64 <- int", Get[uint64](r, "Age"), r.Get("Age").Uint64()},
|
|
{"float32 <- float64", Get[float32](r, "Ratio"), r.Get("Ratio").Float32()},
|
|
// 快路径的五种
|
|
{"string <- int", Get[string](r, "Age"), r.Get("Age").String()},
|
|
{"int <- int", Get[int](r, "Age"), r.Get("Age").Int()},
|
|
{"int64 <- int", Get[int64](r, "Age"), r.Get("Age").Int64()},
|
|
{"float64 <- float64", Get[float64](r, "Ratio"), r.Get("Ratio").Float64()},
|
|
// 切片: 需要逐元素转换
|
|
{"[]string <- []any", Get[[]string](r, "Tags"), r.Get("Tags").StringSlice()},
|
|
{"[]any <- []string", Get[[]any](r, "Names"), r.Get("Names").Slice()},
|
|
{"[]int <- []int", Get[[]int](r, "Nums"), r.Get("Nums").IntSlice()},
|
|
{"[]bool <- []bool", Get[[]bool](r, "Flags"), r.Get("Flags").BoolSlice()},
|
|
// map: 需要逐值转换
|
|
{"map[string]string <- map[string]any", Get[map[string]string](r, "Meta"), r.Get("Meta").StringMapString()},
|
|
{"map[string]any <- map[string]string", Get[map[string]any](r, "SMeta"), r.Get("SMeta").StringMap()},
|
|
{"map[string]int", Get[map[string]int](r, "IMeta"), r.Get("IMeta").StringMapInt()},
|
|
{"map[string]int64", Get[map[string]int64](r, "I64Map"), r.Get("I64Map").StringMapInt64()},
|
|
{"map[string]bool", Get[map[string]bool](r, "BMeta"), r.Get("BMeta").StringMapBool()},
|
|
{"map[string][]string", Get[map[string][]string](r, "SSMeta"), r.Get("SSMeta").StringMapStringSlice()},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
g := fmt.Sprintf("%v", c.generic)
|
|
a := fmt.Sprintf("%v", c.accessor)
|
|
if g != a {
|
|
t.Fatalf("Get[T] 与访问器不一致\n Get[T] = %s\n 访问器 = %s", g, a)
|
|
}
|
|
// 顺带守住"没掉进 default 分支"这件事: 上面每一格都是需要转换的场景,
|
|
// 掉进 Any().(T) 断言就会得到零值。
|
|
if g == "0" || g == "[]" || g == "map[]" {
|
|
t.Fatalf("疑似掉进 default 分支返回了零值: %s", g)
|
|
}
|
|
})
|
|
}
|
|
}
|