From 9cb3ea857ec461085fa2f89e84346fbc053b9682 Mon Sep 17 00:00:00 2001 From: what Date: Fri, 28 Aug 2026 16:50:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20Get[T]=20=E7=9A=84?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=88=86=E6=94=AF,=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E8=BF=94=E5=9B=9E=E9=9B=B6=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- README.md | 28 ++++++++++++-- rfx_generic.go | 77 +++++++++++++++++++++++++++++++++---- rfx_generic_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a83668b..3f18f25 100644 --- a/README.md +++ b/README.md @@ -1099,14 +1099,34 @@ r.Get("Address", "City").String() // 2 次分配 reflux.Get[string](r, "Address", "City") // 0 次分配, 快约 1.7 倍 ``` -**覆盖类型**: `string` / `int` / `int64` / `bool` / `float64` 走零分配快路径; -其余类型走通用路径,行为等同 `r.Get(path...).Any().(T)`。 +**覆盖类型**: `T` 支持 `valuex.Accessor` 全部转换方法对应的类型,每个分支都落到 +同名访问器上,因此**转换语义完全一致**: + +| 类别 | 支持的 T | +|---|---| +| 零分配快路径 | `string` `int` `int64` `bool` `float64` | +| 其余标量 | `int8` `int16` `int32` `uint` `uint8` `uint16` `uint32` `uint64` `float32` | +| 切片 | `[]any` `[]string` `[]int` `[]bool` | +| map | `map[string]any` `map[string]string` `map[string]int` `map[string]int64` `map[string]bool` `map[string][]string` | + +因为落到访问器上,所以**会做转换**而不是类型断言: + +```go +type Doc struct{ Tags []any; Age int } +d := &Doc{Tags: []any{"a", "b"}, Age: 42} +r := reflux.New(d) + +reflux.Get[[]string](r, "Tags") // ["a" "b"] —— 逐元素转换, 不是断言失败返回 nil +reflux.Get[int32](r, "Age") // 42 —— 与 r.Get("Age").Int32() 一致 +``` **两点注意**: 1. `T` 只出现在返回值里,Go 无法类型推导,必须显式写出 `Get[string](...)`。 -2. 快路径只认原生标量类型。`Get[MyStr](r, "Name")` 在字段是原生 `string` 时, - 会走通用路径并因类型断言失败返回零值 —— 这种场景请用 `r.Get("Name").String()`。 +2. **表格之外的类型**走 `Any().(T)` 断言,**不做转换** —— 这对 + `Get[SomeStruct](r, "Field")` 这种"取出原样的值"是有用的,但具名标量类型 + (`type MyStr string`)在字段是原生 `string` 时会断言失败返回零值, + 这种场景请改用 `r.Get("Name").String()`。 ### Reflux 接口 diff --git a/rfx_generic.go b/rfx_generic.go index 5f28e0b..7af1915 100644 --- a/rfx_generic.go +++ b/rfx_generic.go @@ -4,23 +4,35 @@ package reflux // // name := reflux.Get[string](r, "Address", "City") // age := reflux.Get[int](r, "Age") +// tags := reflux.Get[[]string](r, "Tags") // // 语义与 r.Get(path...).Xxx() 严格等价,是可以放心替换的写法: // - 路径不存在 / 未导出字段 / 下标越界 -> 返回 T 的零值 // - 类型转换失败 -> panic,错误信息与访问器方法一致 // -// 与链式写法的区别只在开销: 链式的 r.Get(...).String() 每次都要在堆上新建一个 -// R 包装对象,而这里直接把结果写进调用方的变量,常见标量类型下**零分配**。 +// T 覆盖 valuex.Accessor 全部转换方法对应的类型(见下面的 switch), +// 每一个分支都落到与之同名的访问器上,因此转换语义完全一致 —— +// 比如 Get[[]string] 读一个 []any 字段时会逐元素转换,而不是类型断言失败返回 nil。 // -// 覆盖 string / int / int64 / bool / float64 五种类型;其余类型走通用路径, -// 行为与 r.Get(path...).Any().(T) 一致。 +// 其中 string / int / int64 / bool / float64 五种走零分配快路径: +// 不构造中间的 R 包装对象,直接把结果写进调用方的变量。其余类型的结果本身 +// 就要分配(切片、map),没有额外快路径可言,但语义一样正确。 // -// 注意: T 只出现在返回值里,无法类型推导,必须显式写出 Get[string](...)。 +// 两点注意: +// +// 1. T 只出现在返回值里,Go 无法类型推导,必须显式写出 Get[string](...)。 +// 2. 列表之外的类型走 default 分支的 Any().(T) 断言 —— 这对 +// Get[SomeStruct](r, "Field") 这种"取出原样的值"是有用的,但**不做转换**: +// 具名标量类型(type MyStr string)在字段是原生 string 时会断言失败返回零值, +// 这种场景请改用 r.Get("Name").String()。 func Get[T any](r R, path ...string) T { var out T + // 用 any(&out) 而不是 any(out) 做类型分发: 前者装箱的是指针, // 指针本身就是接口的数据字段,不需要额外堆分配;后者会把值拷进堆。 switch p := any(&out).(type) { + + // ---- 零分配快路径: 命中 *rfx 时直接读内存,不建包装对象 ---- case *string: if g, ok := r.(*rfx); ok { *p = g.getString(path...) @@ -51,8 +63,57 @@ func Get[T any](r R, path ...string) T { } else { *p = r.Get(detachPath(path)...).Float64() } + + // ---- 其余标量: 没有零分配快路径,但必须走对应访问器以保证转换语义 ---- + // + // 这些分支不能省。少了它们就会掉进 default 的 Any().(T) 断言, + // 而断言是不做转换的 —— Get[int32] 读一个 int 字段会**静默返回 0**, + // 而 r.Get(...).Int32() 返回的是正确的值。静默给错数据比 panic 更难查。 + case *int8: + *p = r.Get(detachPath(path)...).Int8() + case *int16: + *p = r.Get(detachPath(path)...).Int16() + case *int32: + *p = r.Get(detachPath(path)...).Int32() + case *uint: + *p = r.Get(detachPath(path)...).Uint() + case *uint8: + *p = r.Get(detachPath(path)...).Uint8() + case *uint16: + *p = r.Get(detachPath(path)...).Uint16() + case *uint32: + *p = r.Get(detachPath(path)...).Uint32() + case *uint64: + *p = r.Get(detachPath(path)...).Uint64() + case *float32: + *p = r.Get(detachPath(path)...).Float32() + + // ---- 切片 ---- + case *[]any: + *p = r.Get(detachPath(path)...).Slice() + case *[]string: + *p = r.Get(detachPath(path)...).StringSlice() + case *[]int: + *p = r.Get(detachPath(path)...).IntSlice() + case *[]bool: + *p = r.Get(detachPath(path)...).BoolSlice() + + // ---- map ---- + case *map[string]any: + *p = r.Get(detachPath(path)...).StringMap() + case *map[string]string: + *p = r.Get(detachPath(path)...).StringMapString() + case *map[string]int: + *p = r.Get(detachPath(path)...).StringMapInt() + case *map[string]int64: + *p = r.Get(detachPath(path)...).StringMapInt64() + case *map[string]bool: + *p = r.Get(detachPath(path)...).StringMapBool() + case *map[string][]string: + *p = r.Get(detachPath(path)...).StringMapStringSlice() + default: - // 非标量类型: 退回通用路径 + // 不在上面列表里的类型: 取出原样的值做断言,不做转换。 if x, ok := r.Get(detachPath(path)...).Any().(T); ok { out = x } @@ -62,8 +123,8 @@ func Get[T any](r R, path ...string) T { // detachPath 复制一份路径切片。 // -// 这一步看着多余,实则必要: 下面那些 r.Get(...) 是**接口动态调用**,逃逸分析 -// 看不穿,于是判定 path 整体逃逸 —— 哪怕运行时走的是上面的快分支。 +// 这一步看着多余,实则必要: 上面那些 r.Get(...) 是**接口动态调用**,逃逸分析 +// 看不穿,于是判定 path 整体逃逸 —— 哪怕运行时走的是零分配快分支。 // 在慢分支里复制一份切断数据流,快分支的可变参数才能留在栈上。 // 少了这一步,Get[string] 会从 0 分配退化成 1 次 48 字节分配。 func detachPath(p []string) []string { diff --git a/rfx_generic_test.go b/rfx_generic_test.go index b8e37b1..b654590 100644 --- a/rfx_generic_test.go +++ b/rfx_generic_test.go @@ -6,6 +6,7 @@ package reflux // 丢弃结果的写法会被编译器优化掉, 测出假的 0 分配。 import ( + "fmt" "testing" ) @@ -160,3 +161,96 @@ func TestGenericAllocs(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// 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) + } + }) + } +}