你能在C++中制作一个计算的goto吗?

Can you make a computed goto in C++?

本文关键字:计算 一个 goto C++      更新时间:2023-10-16

Fortran有一种计算效率高的方法,称为"计算goto"。该构造使用分支表中的索引来执行直接 goto。如果我没记错的话,语法是:

go to index (label1, label2, ...)

其中索引用于引用括号列表中的代码指针(标签)。

我有一个案例,计算的 goto 是比 switch 语句更好的解决方案,并且想构造一个,但我无法弄清楚如何。

现在,在 jibes 和吊索到来之前,编译器可以优化计算的 goto,但我不能保证它会。


始终可以使用开关语句。在某些情况下,switch 语句可以优化为跳转表(计算 goto 的实现)。

但是,仅当大小写值的范围几乎是密集的覆盖时,这才有可能(从低值到高值的范围中,每个整数几乎都有一个 case 语句)。如果不是这种情况,则实现可能是二叉树。编译器编写器可以选择在适当或不适当时优化跳转表。二叉树总是满足 switch 语句的语义,有时跳转表就足够了,让我问我是否可以在适当的时候保证跳转表。我无法控制编译器编写器。

举一个简单的例子,我经常编写词法分析器(FSM),我使用三个数据结构,一个用于将输入映射到可接受的字母表中,一个用于执行节点转换,另一个用于根据当前状态和输入值执行一些代码。FSM 的实现是 Mealy 机器,而不是摩尔机器,因此操作是在弧(过渡)上而不是在节点上执行的。

执行的操作通常很小,通常不超过一行源代码。我认识到可以使用函数,并且它们的使用消除了对跳转表的需求。但我相信我不能"指向"内联函数,因此函数是闭式可调用过程。

在大多数情况下,这比具有或不具有跳转表优化的 switch 语句效率低。如果我可以使用跳转表,那么我就可以避免编译器编写者对优化的看法,并且能够编写有效的代码。

至于下面提出的关于与Fortran计算goto相关的问题的一般情况:这不是对那个/那些评论的批评。但是,质的问题,即使它们是真实的,也不能回答这个问题。

下面有一个使用void* &&label;的答案,我要为此感谢您。但是,唉,正如您指出的那样,这是非标准的C/C++,将来可能不会出现。所以,最好不要这样做。

我希望我已经回答了"获得更好的编译器"评论。我希望我至少已经解决了使用函数指针的问题。最后,这对我来说是一个好奇的时刻。我认为我不应该提供为什么我认为这个问题具有某种承载力的杀菌历史。但现在我知道了。无论何时,我的意思是无论何时,我写信给这群人,我最好告诉你我所有的鸭子是什么,这样它们就可以被击落

如果你使用最近的GCC编译器(例如,GCC 7或GCC 6)进行编译 - 甚至对于C代码,旧版本的GCC,你可以使用其标签作为值语言扩展(因此C++11或C++14标准之外),它适用于C和C++。前缀&&运算符提供标签的地址,如果后跟间接寻址运算符*则计算goto。您最好让目标标签开始一些块。

例如:

#include <map>
int foo (std::map<int,int>& m, int d, int x) {
static const void* array[] = {&&lab1, &&lab2, &&lab3 };
goto *array[d%3];
lab1: {
m[d]= 1;
return 0;
};
lab2: {
m[2*d]=x;
return 1;
}
lab3: {
m[d+1]= 4*x;
return 2;
}
}

(当然,对于上面的代码,普通switch会更好,并且可能同样有效)

顺便说一句,最近的Clang(例如,clang++-5.0)也接受该扩展。

(计算的 gotos 不是异常友好的,因此它们可能会在未来的 GCC 版本中消失C++。

使用线程代码编程技术,您可以使用它编写一些非常有效的(字节码)解释器,并且在这种特殊情况下,代码保持非常可读(因为它非常线性)并且非常高效。顺便说一句,您可以使用宏和条件编译隐藏此类计算的 gotos——例如,#if-s-(例如,在不支持该扩展的编译器上使用代替switch);那么你的代码将是相当可移植的。有关 C 语言的示例,请查看 OCaml 的 runtime/interp.c。


参考Eli Bendersky, 计算的goto版本更快,原因有两个:

  1. 由于边界检查,开关每次迭代执行更多操作。
  2. 硬件分支预测的效果。

编译器可以实现switch的许多变体。

  1. 使用if构造对交换机空间进行二进制搜索。
  2. "案例"位置表(计算的类似goto)。
  3. 一个计算分支,要求所有事例具有相同的代码大小,形成一个"代码数组"。

对于 OPs 状态机调度,第 2 项是最佳情况。 它是唯一不需要返回到主switch调度位置的结构。 因此,break;可以将控制权转移到下一个case。 这就是为什么机制对分支预测更有效的原因。

是(不是直接),通过使用switch或创建函数指针或函数对象的表。

大多数编译器会将switch转换为计算goto(也称为跳转表)。

函数指针数组大致相同。 取消引用数组槽以执行函数。

还可以将std::map或其他容器与函数指针一起使用。

编辑 1:使用数组计算goto示例

typedef (void) (*Pointer_To_Function)();
void Hello()
{
cout << "Hellon";
}
void Bye()
{
cout << "Byen";
}
static const Pointer_To_Function function_table[] =
{
Hello,
Bye,
}
int main()
{
(*function_table[0])();
(*function_table[1])();
// A "computed goto" based on a variable
unsigned int i = 0;
(*function_table[i])();
return 0;
}

编辑 2:使用switch计算goto

int main()
{
int i = 1;
switch (i)
{
case 0: Hello(); break;
case 1: Bye(); break;
}
return 0;
}

告诉编译器为上述每个示例生成程序集语言列表。

最有可能的是,它们看起来像一个计算goto转表。 如果没有,请提高优化级别。

若要对switch或数组进行良好的优化,大小写值应在一定范围内连续。 对于选择带孔的范围,std::map可能更有效(或使用表格来选择较小的数量)。

using jump_func_t = void(*)(void const*);
template<class F>
jump_func_t jump_func() {
return [](void const*ptr){ (*static_cast<F const*>(ptr))(); };
}
template<class...Fs>
void jump_table( std::size_t i, Fs const&...fs ) {
struct entry {
jump_func_t f;
void const* data;
void operator()()const { f(data); }
};
const entry table[] = {
{jump_func<Fs>(), std::addressof(fs)}...
};
table[i]();
}

测试代码:

int x = 0, y = 0, z = 0;
jump_table( 3,
[&]{ ++x; },
[&]{ ++y; },
[&]{ ++z; },
[&]{ ++x; ++z; }
);
std::cout << x << y << z << "n";

产出 101.

现场示例

如果你想要大量的差距,就必须做额外的工作。 短"间隙"可以使用无效的跳转目标进行处理:

using action = void();
static action*const invalid_jump = 0;

如果实际调用,这应该分段错误。

对于一个非常稀疏的表,您需要传入每个目标的表大小和编译时索引的编译时常量,然后从中构建表。 根据你想要的效率,这可能需要相当花哨的编译时编程。

这里已经有一些有用的答案,但我有点惊讶没有人提到尾调用优化,因为根据你如何构建你的实现,编译器可能已经在做你所希望的!

从本质上讲,如果您将每条指令编写为单独的函数,则可以以安全和结构化的方式获取计算的 goto,该函数将"下一条指令"函数作为它所做的最后一件事。只要调用后不需要处理,并且实现操作的函数都具有相同的签名(以及与"下一个指令"函数相同的签名),调用应该自动优化为计算的 goto。

当然,必须启用优化 -- -GCC 的 O2 或 MSVC 的/O2 - 否则函数调用将递归并消耗堆栈。这至少在功能上是正确的,如果您的执行跟踪很短,例如少于 10k 个顺序操作,您应该能够在现代机器和操作系统上禁用优化的情况下进行调试。

至少从 LISP 时代开始,消除尾叫就已经得到了很好的理解。据我了解,至少从 2000 年左右开始,大多数 C/C++ 编译器都可以使用它,而且肯定可以在 LLVM、GCC、MSVC 和 ICC 上使用。如果你使用 LLVM 构建,你可以使用 __attribute__((musttail)) 来请求此优化,即使优化通常被禁用 - GCC 似乎还没有赶上,但如果你必须这样做,即使你禁用了其他优化,你也可以传递"-foptimize-sibling-calls"。

(这篇与该属性在 GCC 中的实现状态相关的评论与讨论替换计算 goto 的特定用例的尾调用的优点特别相关:https://gcc.gnu.org/pipermail/gcc/2021-April/235891.html)

强制性的具体例子:

#include <stdio.h>
#include <stdint.h>
using operation_t = void (*)();
static void op_add();
static void op_print();
static void op_halt();
// Test program
operation_t program[] = { op_add, op_print, op_halt };
// VM registers
int32_t ra = 5, rb = 3;
operation_t const * rpp = program;
static void exec_next_op() {
// Call the next operation function -- since nothing is
// returned from our stack frame and no other code needs to
// run after the function call returns, the compiler can
// destroy (or repurpose) this stack frame and then jump
// directly to the code for the next operation.
// If you need to stop execution prematurely or do debug
// stuff, this is probably where you'd hook that up.
(*(rpp++))();
}
static void op_add() {
ra += rb;
// EVERY operation besides halt must call exec_next_op as
// the very last thing it does.
exec_next_op();
}
static void op_print() {
printf("%dn", ra);
// EVERY operation besides halt must call exec_next_op as
// the very last thing it does.
exec_next_op();
}
static void op_halt() {
// DON'T exec_next_op(), and the notional call chain unwinds
// -- notional because there is no call chain on the stack
// if the compiler has done its job properly.
}
int main(int argc, char const * argv[]) {
// Kick off execution
exec_next_op();
return 0;
}

在编译器资源管理器上:https://godbolt.org/z/q8M1cq7W1

请注意,在 x86-64 上使用 -O2 时,GCC 内联 exec_next_op() 调用,op_*(停止除外)以间接的"jmp rax"指令结尾。

为了完整起见,我浏览了相当多的架构,对主要架构的支持很好 - x86/x86-64,ARM/ARM64和RISC-V上的MSVC,GCC和Clang,但是一些较旧,更晦涩的架构确实无法优化。

针对 ESP32 的 GCC 是当前开发人员可能唯一担心的问题,但我注意到针对 SPARC 的 GCC 也无法做到这一点。我怀疑他们会处理静态尾递归,但调用函数指针确实意味着需要额外级别的特殊情况处理,这真的很不寻常。

如果你有兴趣阅读这里的人们关于兼容性的评价,你可以看看哪个(如果有的话)C++编译器进行尾递归优化?

GCC 还能够优化C++指向成员的指针版本:

#include <stdint.h>
#include <stdio.h>
class Interpreter {
public:
using operation_t = void (Interpreter::*)();
operation_t program[3];
// VM registers
int32_t ra = 5, rb = 3;
operation_t const* rpp = program;
Interpreter()
: program{&Interpreter::op_add, &Interpreter::op_print,
&Interpreter::op_halt} {}
void exec_next_op() { (this->**(rpp++))(); }
void op_add() {
ra += rb;
exec_next_op();
}
void op_print() {
printf("%dn", ra);
exec_next_op();
}
void op_halt() {}
};
int main(int argc, char const* argv[]) {
// Kick off execution
Interpreter interp;
interp.exec_next_op();
return 0;
}

https://godbolt.org/z/n43rYP81r

...我是否可以在适当的时候保证跳转表。我无法控制编译器编写器。

不,你不能。 事实上,鉴于这是 C,即使您巧妙地实现自己的跳转表,您也不能保证编译器不会撤消您的工作。 如果你想要保证,你必须自己写汇编。 编译器仅遵循"假设"标准。 他们必须做一些事情,就好像他们做了你告诉他们做的事情一样。

大多数编译器都非常擅长这种优化。 您不是第一个开发解析器的开发人员。 事实上,我希望他们在效率低时使用跳转表,而在效率低下时不使用跳转表。 您可能会意外地让它做一些效率较低的事情。 编译器通常有一个大型的 CPU 时序数据库,它们利用这些数据库来决定如何最好地编写操作码。

现在,如果您无法将代码制作成花园品种开关语句,则可以使用自定义开关语句来模拟它。

要替换go to index (label1, label2, ...),请尝试

switch(index) {
case trampoline1:
goto label1;
case trampoline2:
goto label2;
...
}

看起来这些蹦床是"真实的",但我希望任何值得一提的编译器都能很好地为您优化这一点。 因此,此解决方案是特定于编译器的,但它应该在各种合理的编译器中工作。 静态可计算的控制流是编译器已经吃掉了 30+ 年的东西。 例如,我知道我使用过的每个编译器都可以采用

bool found = false;
for(int i = 0; i < N; i++)
{
if (matches(i))
{
found = true;
break;
}
}
if (!found)
return false;
doSomething();

并将其转化为有效的

for(int i = 0; i < N; i++)
{
if (match(i))
goto label1;
}
return false;
label1:
doSomething();

但最后,不,没有任何结构可以毫无疑问地保证在 C 语言中使用了特定的 Fortran 启发的方法。 您将始终必须针对编译器进行测试。 但是,请信任编译器。 和个人资料。 在你选择死在上面的柴堆上之前,请确保这是一个热点。

使用现代编译器,您不需要计算的 goto。

我创建了以下代码:

void __attribute__((noinline)) computed(uint8_t x)
{
switch (x)
{
case 0:
goto lab0;
case 1:
goto lab1;
case 2:
goto lab2;
case 3:
goto lab3;
case 4:
goto lab4;
case 5:
goto lab5;
default:
abort();
}
lab0:
printf("An");
return;
lab1:
printf("Bn");
return;
lab2:
printf("Cn");
return;
lab3:
printf("Dn");
return;
lab4:
printf("En");
return;
lab5:
printf("Fn");
return;
}
int main(int argc, char **argv)
{
computed(3);
return 0;
}

对于 gcc (9.4.0) 和 clang (10.0.0),在优化级别 0 不低于 0 时,我检查了汇编代码,它确实在已经优化级别 0 中使用了计算的 goto(随着更高的优化级别,clang 发现 printfs 非常相似,并简单地用变量作为参数调用其中一个)。我还检查了C++模式,它正在做同样的事情。

你只需要创建一个足够长的开关案例表,该表从 0 开始,具有连续索引,并且每个案例都有一个 goto(默认情况除外),它将自动转换为计算的 goto。

显式使用计算 goto 的问题在于,它是一种非标准语言扩展,并且使您的代码仅在非标准编译器上编译。在标准编译器上编译也会好得多,即使一个非常糟糕的编译器无法弄清楚计算的 goto 是最好的方法,并根据一长串常量反复检查x以找出正确的分支(这将只包含一个跳转指令)。

我可以确认@juhist已经提到的内容。您可以通过将索引声明到跳转表中(最好是枚举)并随时将其分配给变量来隐式生成计算的 goto。下面证明这甚至适用于您在 case 语句中动态分支的情况(这是计算 goto 的唯一原因)。

演示

#include <cstdio>
#include <cstdint>
volatile uint8_t number = 2;
int main() {
enum class Label {
label1,
label2,
label3,
label4,
done
};
Label next = Label::label1;
again:
switch(next) {
case Label::label1:
printf("label1n");
next = Label::label3;
goto again;
case Label::label2:
printf("label2n");
if (number == 2) {
next = Label::done;
} else {
next = Label::label4;
}
goto again;
case Label::label3:
printf("label3n");
next = Label::label2;
goto again;
case Label::label4:
printf("label4n");
next = Label::done;
goto again;
case Label::done:
printf("donen");
break;
}
}

指纹:

label1
label3
label2
done

生成的程序集确实与 gcc 中计算的 goto 相同:

.LC0:
.string "label1"
.LC1:
.string "label3"
.LC2:
.string "label2"
.LC3:
.string "label4"
.LC4:
.string "done"
main:
push    rcx
mov     edi, OFFSET FLAT:.LC0
call    puts
mov     edi, OFFSET FLAT:.LC1
call    puts
mov     edi, OFFSET FLAT:.LC2
call    puts
mov     al, BYTE PTR number[rip]
cmp     al, 2
je      .L2
mov     edi, OFFSET FLAT:.LC3
call    puts
.L2:
mov     edi, OFFSET FLAT:.LC4
call    puts
xor     eax, eax
pop     rdx
ret
number:
.byte   2

Gcc 检测到下一个变量在跳转到跳表顶部时实际上不会更改,只需将其替换为直接跳转指令即可。完善!但是叮当和msvc呢?

clang x64 trunk 似乎有点挣扎,因为使用相同的优化 (-Os) 仍然跳转到 switch 语句的顶部,但它至少构建了跳跃表。

MSVC x86 最新版还优化了跳转到跳表顶部并直接跳转到预先计算的标签 - 一切都很好。但是MSVC x64似乎喝醉了...不仅跳到跳台的顶部,而且它甚至做了一个可怕的如果/否则超过开关值 - 令人作呕!谁能解释一下?

所以综上所述,gcc x86 和 x64 好,MSVC x86 好,x64 可怕,clang x64 可以更好。问题仍然是编译器在优化更多涉及的示例方面有多好。