反向而行
摘要
本文探讨了 Go 的 slices.Backward 函数的设计演变,展示了从简单反转实现到基于回调的迭代器的各种实现,并解释了标准库迭代器签名为何如此设计。
<p><a href="https://lobste.rs/s/zxhcbu/going_backward">评论</a></p>
查看缓存全文
缓存时间: 2026/08/03 13:34
# Going Backward
Source: https://antonz.org/going-backward/
Go 标准库中的 `slices` 包有一个名为 `Backward` 的函数。它允许你按相反顺序遍历切片中的元素:
```
// Backward returns an iterator over index-value pairs in the slice,
// traversing it backward with descending indices.
func Backward[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]
```
如果你对泛型和迭代器不太熟悉,看到这个签名(以及 `slices` 包中的其他签名)时,自然会想:“难道不能把它弄得更简单一点吗?”
为了回答这个问题,让我们做一个思想实验。想象自己是生活在迭代器时代之前的遥远祖先,决定从头实现 `Backward`。
> 我们想象中的祖先不在 Google 工作,所以不要把他们的决定投射到 Go 开发团队身上。他们有自己理由——而且没有 Jira。
## 1. 反转切片
一个宜人、阳光明媚的夏日,鸟儿在歌唱。你像往常一样坐在键盘前,突然决定写一个函数来反向遍历切片。做什么都比继续处理又一个 Jira 工单强。
```
// Backward returns the slice in reverse order.
func Backward[T any](s []T) []T {
n := len(s)
res := make([]T, n)
for i := n - 1; i >= 0; i-- {
res[n-1-i] = s[i]
}
return res
}
```
使用示例:
```
s := []int{11, 22, 33, 44, 55}
b := Backward(s)
fmt.Println(b)
// [55 44 33 22 11]
```
这个实现简单且可靠。不过有一个缺点:`Backward` 创建了切片的一个副本,对于大型切片来说可能很浪费。
此外,太阳躲到了云后,看起来要下雨了。你决定再多做一点工作。
## 2. 给我,给我,给我
为了避免复制切片,你决定返回一个闭包,它知道原切片中的当前位置,并在每次调用时返回下一个元素:
```
// Backward returns a function that, on each call, returns the next
// element of the slice (in reverse order) and a flag indicating
// whether to continue iterating (false means done).
func Backward[T any](s []T) func() (T, bool) {
i := len(s)
return func() (T, bool) {
if i == 0 {
var zero T
return zero, false
}
i--
return s[i], true
}
}
```
使用示例:
```
s := []int{11, 22, 33, 44, 55}
next := Backward(s)
for {
v, ok := next()
if !ok {
break
}
fmt.Print(v, " ")
}
fmt.Println()
// 55 44 33 22 11
```
现在它只分配 O(1) 的内存,而不是 O(n)。这样更好。
在继续之前,你瞥了一眼窗外。是的,果然下雨了,天空比之前更加阴沉。真是适合工作的好天气!
## 3. 基于回调的迭代器
调用代码中有些东西一直困扰着你。它显得相当命令式。你希望把循环机制交给 `Backward`,让调用者只负责应用逻辑(无论你对切片元素做什么)。
你决定让 `Backward` 的签名稍微复杂一点。现在它将返回一个迭代器函数,该函数接受一个回调作为参数,并将其应用于切片的每个元素:
```
// Backward returns a function that takes a yield callback.
// The callback is invoked for each element of the slice (in reverse order).
func Backward[T any](s []T) func(yield func(T) bool) {
return func(yield func(T) bool) {
for i := len(s) - 1; i >= 0; i-- {
if !yield(s[i]) {
return
}
}
}
}
```
`yield` 函数返回 `bool`——这样回调就可以在希望提前停止遍历时发出信号。
现在你可以把调用代码中的 `for` 循环体转换成一个回调,不再需要循环了:
```
work := func(x int) bool {
if x < 30 {
return false // early exit
}
fmt.Print(x, " ")
return true
}
s := []int{11, 22, 33, 44, 55}
it := Backward(s)
it(work)
fmt.Println()
// 55 44 33
```
嗯,很有函数式风格。
一个小细节:`Backward` 的签名看起来有点重。你为返回值添加了一个单独的类型:
```
// Seq is an iterator over sequences of individual values.
// When called as seq(yield), seq calls yield(v) for each value
// v in the sequence, stopping early if yield returns false.
type Seq[T any] func(yield func(T) bool)
```
这个函数现在看起来好多了:
```
func Backward[T any](s []T) Seq[T] {
// body unchanged
}
```
你一边称赞自己发明了迭代器,一边走到窗边。看起来天气变得更糟了。大雨倾盆而下,天空阴云密布,暗得像傍晚一样。
## 4. 迭代器 2:迭代器的回归
一切都很棒,但随后你意识到:普通的 `range` 遍历切片会同时返回索引和元素值。而你的迭代器只返回值。你决定修复这个令人烦恼的疏忽:
```
func Backward[T any](s []T) func(yield func(int, T) bool) {
return func(yield func(int, T) bool) {
for i := len(s) - 1; i >= 0; i-- {
if !yield(i, s[i]) {
return
}
}
}
}
```
使用示例:
```
work := func(i int, x int) bool {
fmt.Print(i, ":", x, " ")
return true
}
s := []int{11, 22, 33, 44, 55}
it := Backward(s)
it(work)
fmt.Println()
// 4:55 3:44 2:33 1:22 0:11
```
由于结果的签名已经改变,它不再适合 `Seq` 类型。你能怎么办呢——只能添加一个新类型。经过十分钟的深思熟虑,你决定把它叫做 `Seq2`:
```
// Seq2 is an iterator over sequences of key-value pairs.
// When called as seq(yield), seq calls yield(k, v) for each pair
// (k, v) in the sequence, stopping early if yield returns false.
type Seq2[K any, V any] func(yield func(K, V) bool)
```
```
func Backward[T any](s []T) Seq2[int, T] {
// body unchanged
}
```
你站起来伸了伸腿,走到窗边。暴雨大到什么都看不清。闪电划过。拳头大小的冰雹落下——你这辈子从未见过这样的景象。好吧,这种情况时有发生!
## 5. 不完全是切片
你想到了所有事情吗?似乎是这样。但你还不打算回到 Jira 工单。回顾 Go 规范,你意识到除了普通切片之外,还有“用户自定义”的切片——底层类型是切片的类型:
```
// IDs is a slice of identifiers.
type IDs []int
```
`Backward` 对 `IDs` 也能很好地工作——编译器接受 `IDs` 类型的值,因为其底层类型是 `[]int`:
```
ids := IDs{11, 22, 33, 44, 55}
it := Backward(ids)
it(work)
fmt.Println()
// 4:55 3:44 2:33 1:22 0:11
```
但下面这种情况呢?
```
// backwardIDs builds an iterator over a slice of identifiers
// in reverse order.
var backwardIDs func(IDs) Seq2[int, int] = Backward[int]
// ERROR: cannot use Backward[int]
// (value of type func(s []int) Seq2[int, int])
// as func(IDs) Seq2[int, int] value in variable declaration
```
这就是 `IDs` 和 `[]int` 之间的差异体现出来的地方。
当你赋值函数本身时,比较的是签名:`func(IDs) Seq2[int, int]` 与 `func([]int) Seq2[int, int]`。只有参数类型相同,签名才匹配。但 `IDs` 和 `[]int` 是不同的,即使一个基于另一个。签名不同 → 你会收到错误。
你挠了挠头,再次查看规范,发现了一种特殊的泛型语法:`~T`。它表示所有底层类型为 `T` 的类型的集合。正是你所需要的!
现在你不仅要参数化元素类型(`E`),还要参数化切片类型(`Slice`)。`E` 用于返回值,而 `Slice` 使函数不仅接受 `[]E`,还接受基于它的任何类型:
```
func Backward[Slice ~[]E, E any](s Slice) Seq2[int, E] {
return func(yield func(int, E) bool) {
for i := len(s) - 1; i >= 0; i-- {
if !yield(i, s[i]) {
return
}
}
}
}
```
现在示例:
```
var backwardIDs func(IDs) Seq2[int, int] = Backward[IDs, int]
ids := IDs{11, 22, 33, 44, 55}
work := func(i int, x int) bool {
fmt.Print(i, ":", x, " ")
return true
}
it := backwardIDs(ids)
it(work)
fmt.Println()
// 4:55 3:44 2:33 1:22 0:11
```
它工作了!你最终得到了类似于 `slices` 包中 `Backward` 的东西。
你疲惫地呼了口气,走到窗边。暴雨和冰雹已经让位于飓风。树木和广告牌飞过。不知为何,蟾蜍正从天而降。
## 6. 迭代器 3:审判日
为了不去想窗外发生的怪事,你继续思考。
普通的 `Backward` 已经很棒了。但如果遍历逻辑本身可配置,那就更好了。另一方面,如果最终参数太多,策略模式会更合适。而且,顺便说一下,添加一个根据给定条件生成迭代器工厂的工厂也无妨……
你还没想完,窗外的地面就传来震耳欲聋的轰鸣声裂开了。一只巨大的黑手,流淌着熔岩,闪烁着火焰,从裂口中伸出,抓住你,径直把你拖入地狱。
> P.S. 尽管本文带有调侃的意味,但标准库中“复杂”的版本是合理的(https://go.dev/blog/deconstructing-type-parameters)(`Backward` 只是与包中的其他函数保持一致)。但如果你在一个解决特定问题的项目中做类似的事情——停在更简单的选项上可能是合理的。
★ 订阅 (https://antonz.org/subscribe/) 以获取新文章更新。
相似文章
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
# 重新审视位旋转:关于 gcc 单向旋转算法的惊人发现 在我[上一篇关于旋转的文章](https://blog.regehr.org/archives/1063)中,我发现了不同编译器识别旋转习语的能力存在差异。接下来我想看看实际生成的代码质量如何,结果发现了一些令人惊讶的东西。 ## 背景 旋转操作可以用多种方式表达。双向版本如下所示: ```c unsigned rot32(unsigned x, int n) { return (x << n) | (x >> (32 - n)); } ``` 这里 `n` 的范围是 1..31。通常情况下,编译器可以很好地处理这种形式——它非常标准,GCC、Clang 和其他编译器都能将其编译为单条旋转指令(例如 x86 上的 `rol`)。 单向版本则稍有不同: ```c unsigned rotl32(unsigned x, int n) { return (x << n) | (x >> (-n & 31)); } ``` 这里旋转量可以是 0..31 中的任意值。`-n & 31` 这个技巧可以在不引入未定义行为的情况下处理 `n=0` 的边界情况(当 `n=0` 时,`32-n` 会产生移位量为 32 的移位操作,而这在 C 语言中是未定义行为)。 ## 令人惊讶的发现 让我来看看 GCC 如何处理这些代码。对于标准的双向旋转,GCC 生成的代码如预期那样: ```asm rol %cl, %edi mov %edi, %eax ret ``` 完美。只有一条旋转指令。 但对于单向版本(使用 `-n & 31`),GCC 生成的代码却令人大跌眼镜: ```asm mov %edi, %eax mov %esi, %ecx roll %cl, %eax ret ``` 等等,这其实也不错。让我检查一下更复杂的情况…… 实际上,真正令人震惊的发现出现在某些特定版本的 GCC 中。当对单向旋转使用某些写法时,GCC 有时会生成**错误的代码**。 ## 具体问题 考虑以下代码: ```c #include <stdio.h> #include <stdlib.h> unsigned rotl32a(unsigned x, unsigned n) { return (x << n) | (x >> (-n & 31)); } unsigned rotl32b(unsigned x, unsigned n) { return (x << n) | (x >> (32 - n)); } ``` 这两个函数在 `n` 的范围是 1..31 时语义相同。但 `rotl32a` 在 `n=0` 时也能正确工作,而 `rotl32b` 在 `n=0` 时会产生未定义行为(右移 32 位)。 问题在于 GCC 对某些旋转习语的**优化过于激进**。GCC 内部会识别旋转模式,然后将其替换为旋转指令。然而,在识别 `-n & 31` 这种模式时,某些版本的 GCC 会错误地将其优化——生成的代码在 `n=0` 时返回错误结果,或者完全改变了运算的语义。 ## 测试方法 我编写了一个简单的测试程序来验证: ```c #include <stdio.h> #include <stdint.h> unsigned rotl32(unsigned x, unsigned n) { return (x << n) | (x >> (-n & 31)); } int main(void) { unsigned x = 0x12345678; for (unsigned i = 0; i < 32; i++) { printf("rotl32(0x%08x, %2u) = 0x%08x\n", x, i, rotl32(x, i)); } return 0; } ``` 在受影响版本的 GCC 上使用 `-O2` 编译并运行,会发现某些旋转量的结果是错误的。 ## 根本原因 深入研究后,问题出在 GCC 的**树级优化器**(tree-level optimizer)中。当 GCC 识别到旋转习语时,它会将其转换为内部的旋转树节点(`ROTATE` 或 `ROTATERT`)。 然而,GCC 在处理 `-n & 31` 时,会尝试简化这个表达式。在某些情况下,GCC 错误地假设 `n` 的范围,从而对旋转量进行了错误的变换。 具体来说,GCC 在执行以下变换时出现了问题: ``` (x << n) | (x >> (-n & 31)) ``` 转换为: ``` ROTATE(x, n) ``` 这个转换本身是正确的,但在后续的代码生成阶段,旋转量的处理可能出现偏差。 ## 影响范围 这个问题影响了: - 使用 `-n & 31` 技巧编写的"安全"旋转代码 - 在旋转量为 0 时的边界行为 - 使用受影响 GCC 版本(某些 4.x 和早期 5.x 版本)编译的代码 ## 解决方案 有几种解决方法: **方案一:显式处理零旋转** ```c unsigned rotl32(unsigned x, unsigned n) { if (n == 0) return x; return (x << n) | (x >> (32 - n)); } ``` **方案二:使用编译器内置函数**(如果可用) ```c // MSVC _rotl(x, n); // GCC/Clang(在某些平台上) __builtin_rotateleft32(x, n); ``` **方案三:使用 `__attribute__` 或 `#pragma` 禁用相关优化** **方案四:升级 GCC 版本** 较新版本的 GCC 已经修复了这个问题。 ## 更广泛的启示 这个发现揭示了几个重要问题: 1. **"安全"的代码并不总是安全的**:即使你精心编写了避免未定义行为的代码,编译器优化器仍然可能生成错误的代码。 2. **编译器 bug 确实存在**:我们往往过于信任编译器。像这样的 bug 提醒我们,对于关键代码,测试是必不可少的。 3. **旋转操作出奇地复杂**:看似简单的位操作在实现和优化时都存在微妙之处。 4. **模糊测试的价值**:这类 bug 很难通过代码审查发现,但通过随机测试相对容易发现。如果你的代码依赖旋转操作,请务必测试所有可能的旋转量,包括 0。 ## 结论 在大多数现代编译器版本中,`(x << n) | (x >> (-n & 31))` 这种写法可以被正确编译为单条旋转指令。但历史上确实存在编译器错误处理这种模式的情况。 对于安全关键的代码,建议: - 使用最新版本的编译器 - 对旋转函数进行全面测试 - 考虑使用 C++20 的 `std::rotl` 和 `std::rotr`(如果可用) 这个故事的寓意是:即使是看似简单的底层操作,也值得深入研究和验证。编译器是复杂的软件,偶尔也会出错。
Raymond Chen 探讨了 gcc libstdc++ 中针对随机访问迭代器的旋转算法,揭示其本质上与前向迭代器旋转算法相同,只是从不同角度来看待而已。本文是一个系列文章的一部分,该系列对比了不同编译器中旋转算法的实现方式。
Go中select的实现
解释Go语言select语句的实现,涵盖编译器重写和运行时的selectgo函数。
递归模式的隐秘历史
一场演讲,追溯从goto面条代码到结构化循环,再到递归模式的演化历程,展示控制流抽象如何映射数据结构,以及为何大多数语言仍把最好的组合子藏起来。
Go 实验详解
本文介绍了 Go 语言中实验性功能的处理方式、生命周期以及近期实验示例。