perf: 用类型布局缓存 + 指针偏移寻址重写热路径

将实验目录 experiment/fastx 的实现合入主实现,替换原来逐次反射的做法。

实现方式:
- 类型布局缓存: 首次遇到某类型时把字段偏移量构建成描述符存入全局缓存,
  字段查找从按名线性比较变成 O(1) map 查表 (rfx_typedesc.go)
- 指针偏移寻址: 取字段时用 基址+偏移量 直接算地址, 不再构造中间 reflect.Value
  (unsafeptr.go, 全部 unsafe 代码集中在这一个文件)
- 零分配路径解析: 路径按需切片遍历, 不再为每次 Get 分配临时切片 (path.go)
- 标量直读: String/Int/Bool/Float64 在类型匹配时直接按机器类型读内存,
  绕开 interface 装箱和 cast 转换

语义复杂、调用频次低的操作(复合类型赋值、Append、Delete、容器转换)仍走原
reflect 实现, 保留为冷路径 refx (rfx_reflect.go), 避免重写引入行为偏差。

新增包级泛型函数 Get[T](r, path...), 按路径直接取出目标类型, 零内存分配。
语义与 r.Get(path...).Xxx() 严格等价, 不改动任何现有接口。

性能对比(同进程同数据, -benchmem -count=6 中位数):
- Get 嵌套 + String   150.6ns/7allocs -> 50.7ns/2allocs   2.97x
- Get 4 层深路径      234.1ns/10allocs -> 68.9ns/2allocs  3.40x
- Set 单层            65.8ns/2allocs  -> 18.1ns/0allocs   3.64x
- Set 嵌套           122.1ns/3allocs  -> 35.5ns/0allocs   3.44x
- Exists             127.4ns/5allocs  -> 37.2ns/1alloc    3.42x
- 访问器 String        15.0ns/1alloc   -> 1.3ns/0allocs   11.8x
- Get[string] 泛型直取                 -> 29.9ns/0allocs   5.04x
两处不快: map 键访问 1.25x(map 无稳定布局, 仍走 reflect);
New 构造 0.67x(多一次描述符缓存查找, 一次性成本)。

内存安全: 未导出字段在读和写两处显式拦截 —— reflect.NewAt 构造的 Value
不带只读标记, 语言层面的导出规则保护在 unsafe 路径上失效, 必须自己拦。
不变式与评审要点见 unsafeptr.go 顶部注释。

测试: 新增 703 个用例全部通过, 含 go test -race。
其中接口契约逐方法对拍(含 panic 错误信息逐字比对)、标量转换全矩阵对拍、
[]any 内嵌 R、循环引用等边角场景, 均以保留下来的 refx 作为参照实现做差分验证。
合并过程中据此发现并修复 11 处行为偏差。
This commit is contained in:
2026-08-28 16:37:04 +08:00
parent 1f48635da3
commit 5c9f8bd6e7
16 changed files with 4360 additions and 851 deletions
+115 -1
View File
@@ -19,7 +19,8 @@ Reflux 是一个 Go 语言包,提供了统一的接口用于访问和操作嵌
- 🎯 **类型安全**: 使用反射但保证类型安全
- 🔥 **增强类型转换**: 支持切片和结构体之间的智能转换(如 []any -> []T, map -> struct)
- 🌀 **R 接口集成**: 支持直接传入 R 接口或 []R 切片,无缝集成反射值
- 🚀 **高性能**: 优化的反射操作,低内存开销
- **泛型直取**: `reflux.Get[string](r, "Address", "City")` 直接返回目标类型,**零内存分配**
- 🚀 **高性能**: 类型布局缓存 + 指针偏移寻址,Get/Set 比逐次反射快 3~4 倍,访问器快 8~12 倍
- 📦 **零依赖**: 仅依赖 Go 标准库和 spf13/cast
## 安装
@@ -1063,6 +1064,50 @@ value := rfx.Get("Config.Database.Host").String()
## API 文档
### 包级函数 Get[T] (泛型直取)
```go
func Get[T any](r R, path ...string) T
```
按路径取值并直接返回目标类型,**不产生中间的 R 包装对象**。
```go
p := &Person{Name: "Alice", Address: Address{City: "Beijing"}}
r := reflux.New(p)
city := reflux.Get[string](r, "Address", "City") // "Beijing"
city2 := reflux.Get[string](r, "Address.City") // 点号路径同样可用
age := reflux.Get[int](r, "Age")
ok := reflux.Get[bool](r, "Active")
ratio := reflux.Get[float64](r, "Ratio")
```
语义与 `r.Get(path...).Xxx()` **严格等价**,可以放心替换:
| 情况 | 行为 |
|---|---|
| 路径不存在 / 未导出字段 / 下标越界 | 返回 `T` 的零值 |
| 类型转换失败 | panic,错误信息与访问器方法完全一致 |
| 传入非本包实现的 `R` | 自动回退到 `r.Get(path...).Xxx()`,结果一致 |
差别只在开销: 链式写法每次都要在堆上新建一个 `R` 包装对象,
`Get[T]` 直接把结果写进调用方的变量。
```go
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)`
**两点注意**:
1. `T` 只出现在返回值里,Go 无法类型推导,必须显式写出 `Get[string](...)`
2. 快路径只认原生标量类型。`Get[MyStr](r, "Name")` 在字段是原生 `string` 时,
会走通用路径并因类型断言失败返回零值 —— 这种场景请用 `r.Get("Name").String()`
### Reflux 接口
```go
@@ -1117,6 +1162,75 @@ type Reflux interface {
}
```
## 性能
### 实现方式
热路径不再逐次走 `reflect` 的按名字段查找,而是:
1. **类型布局缓存** —— 第一次遇到某个类型时,把它每个字段的**字节偏移量**、
元素大小等信息构建成描述符,存进全局缓存(`sync.Map`)。之后同类型直接命中,
字段查找从"按名字线性比较"变成 O(1) 的 map 查表。
2. **指针偏移寻址** —— 取字段时用 `基址 + 偏移量` 直接算出地址,不再构造中间的
`reflect.Value`
3. **零分配路径解析** —— 路径字符串按需切片遍历,不再为每次 `Get` 分配临时切片。
4. **标量直读** —— `String()`/`Int()`/`Bool()`/`Float64()` 等在类型匹配时直接按
机器类型读内存,绕开 `interface{}` 装箱和 `cast` 转换。
语义复杂、调用频次低的操作(复合类型赋值、`Append``Delete`、容器转换)
仍然走原来的 reflect 实现 —— 这些操作的语义琐碎,重写必然引入偏差,
而它们本来就不在性能热点上。
### 与旧版本(纯 reflect 实现)的对比
同一进程、同一数据结构、同一路径,`-benchmem -count=6` 取中位数
(Apple M4 Pro / darwin-arm64 / go1.25.5):
| 场景 | 旧版本 | 新版本 | 提速 |
|---|---|---|---:|
| `Get("Address","City").String()` | 150.6 ns / 152 B / 7 allocs | **50.7 ns / 56 B / 2 allocs** | **2.97x** |
| `Get("Address.City").String()` | 143.2 ns / 136 B / 6 allocs | **52.4 ns / 40 B / 2 allocs** | **2.73x** |
| `Get` 4 层深路径 | 234.1 ns / 280 B / 10 allocs | **68.9 ns / 88 B / 2 allocs** | **3.40x** |
| `Get("Tags","1")` slice 下标 | 134.9 ns / 152 B / 7 allocs | **47.9 ns / 56 B / 2 allocs** | **2.81x** |
| `Set("Name", ...)` | 65.8 ns / 32 B / 2 allocs | **18.1 ns / 0 B / 0 allocs** | **3.64x** |
| `Set("Address.City", ...)` | 122.1 ns / 80 B / 3 allocs | **35.5 ns / 0 B / 0 allocs** | **3.44x** |
| `Exists("Address","City")` | 127.4 ns / 112 B / 5 allocs | **37.2 ns / 32 B / 1 alloc** | **3.42x** |
| 访问器 `String()` (纯转换) | 15.0 ns / 16 B / 1 alloc | **1.3 ns / 0 B / 0 allocs** | **11.8x** |
| 访问器 `Int()` (纯转换) | 12.9 ns / 8 B / 1 alloc | **1.5 ns / 0 B / 0 allocs** | **8.5x** |
泛型直取(新增 API,旧版本没有对应写法):
| 场景 | 新版本 | 相对旧版链式 |
|---|---|---:|
| `Get[string](r, "Address", "City")` | **29.9 ns / 0 B / 0 allocs** | **5.04x** |
| `Get[string](r, "B","C","D","Leaf")` | **45.0 ns / 0 B / 0 allocs** | **5.20x** |
参照基准线: 纯 Go 字段访问 `p.Address.City` 是 0.34 ns / 0 allocs。
### 两处不快的地方(如实说明)
| 场景 | 旧版本 | 新版本 | 变化 |
|---|---|---|---:|
| `Get("Meta","k")` map 键 | 175.6 ns / 10 allocs | 140.9 ns / 7 allocs | 1.25x |
| `New(指针)` 构造 | 16.9 ns / 1 alloc | 25.4 ns / 1 alloc | **0.67x** |
- **map 只快 1.25 倍**: map 没有稳定的内存布局可以做偏移量运算,这条路径完全走
reflect,而且取出来的值必须拷一份(map 元素不可寻址)。这是设计上的取舍。
- **`New` 慢了约 8 ns**: 构造时要查一次类型描述符缓存。这是一次性成本,
换来之后每次 `Get`/`Set` 省下 50~100 ns —— 只要构造后至少访问一次就是净赚。
### 内存分配
分配次数的下降往往比 CPU 时间更有意义(GC 压力):
- `Set`: **2 → 0**
- `Get` + 访问器: **7 → 2**
- `Exists`: **5 → 1**
- `Get[T]` 泛型直取: **0**
`Get` 剩下的 2 次分配是 API 形状决定的下限: 一次是返回的 `R` 包装对象(24 字节),
一次是可变参数切片(通过接口调用时逃逸分析穿不透)。想完全避免就用 `Get[T]`
## 注意事项
1. **指针 vs 值传递**: