与 C 相比,C++ 中无限循环没有副作用的好处是未定义行为?

wim*_*aan 43 c c++ optimization loops undefined-behavior

在 C++ 循环中为

for(;;) {}
Run Code Online (Sandbox Code Playgroud)

是未定义的行为,但它们不在 C(?) 中。

在 P2809R0上。平凡的无限循环不是未定义的行为,它表示这样做有充分的理由。有没有一些简单的例子可以清楚地说明这一点?

Qui*_*mby 60

原因只是优化。

如果编译器可以假设所有没有副作用的循环都终止,则不必证明这一点。

如果允许非终止循环,则只有在能够证明终止的情况下才允许编译器执行某些优化,这通常是不可能的,因此它将变成模式识别游戏。又有什么好处呢?

根本问题是,不终止是其自身的一种副作用。当且仅当循环终止时,才会观察到循环终止后肯定会发生的任何可观察到的效果,即使循环没有任何效果。

当然,可以对 进行完全相同的推理if(expr) return;。编译器不允许移动之前之后的内容ifif除非它可以证明expr是错误的。但这if是基本的控制流机制,而非终止循环则不是(恕我直言)。

采取以下代码。

int collatz_conjecture(int i){
    while(i!=1){
        if ((i%2)==0)
            i/=2;
        else
            i=i*3+1;
    }
    return i;
}

int main(){
    collatz_conjecture(10);

    return 5;
}
Run Code Online (Sandbox Code Playgroud)

使用-O3,gcc 将其编译为:

int collatz_conjecture(int i){
    while(i!=1){
        if ((i%2)==0)
            i/=2;
        else
            i=i*3+1;
    }
    return i;
}

int main(){
    collatz_conjecture(10);

    return 5;
}
Run Code Online (Sandbox Code Playgroud)

那么,编译器是否证明了Collat​​z 猜想以确定它应该返回1所有数字?当然不是,这只是终止假设允许的优化之一(也是UB可能发生的地方)。循环终止的唯一方法是如果i==1它可以i==1在循环之后假设并使用它进行进一步优化 -> 函数总是返回 1,因此可以减少到它。

更有用的例子可以是交错复制。如果你有

collatz_conjecture(int):
        mov     eax, 1
        ret
main:
        mov     eax, 5
        ret
Run Code Online (Sandbox Code Playgroud)

即使编译器不知道终止,也可以将它们交错A。许多矢量化操作都依赖于这个假设。

类似地,在循环之前对一些独立的循环后操作进行重新排序会假定循环将终止。

  • @彼得什么?NP 难题可以在有限的时间内解决。这几乎就是“NP-hard”的定义。循环终止是不可判定的,不是 NP 困难或任何其他类型的困难。可以证明给定的循环是否会终止,但不可能设计一种算法来确定_any_循环的这一点。一个多世纪前,计算机科学的一篇基础论文就证明了这一点,证明我们在过去一个世纪左右的时间里犯了错误可能要付出代价,但我不会把钱押在这件事的实际发生上。 (20认同)
  • 还值得指出的是,“NP 难”并不意味着“不切实际”。这是一个常见的神话。许多 NP 难题非常适合实际问题输入的随机方法、启发式方法和近似方法。编译器中出现 NP 困难甚至不可判定的问题并不罕见,通常分别通过启发式和“尽力而为,如果不行就报告错误”来解决。 (7认同)
  • 我确实注意到,在 Rust 中,循环允许不终止——事实上,在小型嵌入式平台上使用“loop {}”作为“abort”的实现是很常见的。因此,虽然优化可能是导致 UB 缺乏进展的原因,但这是否是一个充分的理由还有待商榷,因为 C/C++/Rust 程序往往表现不佳。 (4认同)
  • 这是否意味着在 C 语言中这种优化是不可能的? (2认同)
  • @wimalopaan:请参阅我之前的评论:C 与 C++。在 ISO C 中,仅允许使用常数作为循环控制的循环是无限的。Collat​​z 循环仍然可以假设终止。但这无助于真正的编译器矢量化。 (2认同)

sup*_*cat 5

主要好处是规范简单。假设规则不能真正适应这样的概念:程序可以具有已定义的行为,但可能与顺序程序执行明显不一致。此外,C 和 C++ 标准的作者使用“未定义行为”这一短语来概括他们认为没有必要行使管辖权的情况,在许多情况下,因为他们期望编译器编写者比编译器编写者处于更好的位置。委员会了解客户的需求。

大多数有用的优化都是通过指定如果循环中没有单独的操作相对于后面的代码片段进行排序来实现的,则循环的执行作为一个整体也不需要被视为已排序。对于无限循环“之后”出现的代码来说,这有点“手动”,但它清楚地表明了它将允许编译器做什么。除此之外,如果在程序终止之前不会对循环内的单独操作进行排序,则可以完全省略整个循环的执行

这种规则所体现的一个重要原则是,在循环内的代码和循环外的代码之间引入依赖性的变换也会引入排序关系,而当前的规则中缺少该原则。如果某个条件为真时循环将退出,并且循环后的代码检查该条件,则编译器可以使用先前检查的结果来避免重复测试,但这意味着循环后的代码依赖于根据循环内计算的值。

下面显示了一个具体示例,说明了应用该规则的有用和鲁莽的方式:

char arr[65537];
unsigned test(unsigned x, unsigned y)
{
    unsigned i=1;
    while((i & y) != x)
        i*=17;
    return i;
}
void test2(unsigned x)
{
    test(x, 65535);
    if (x < 65536)
        arr[x] = 2;
}
Run Code Online (Sandbox Code Playgroud)

编译器在内联到 时可以应用两种单独有用的优化:testtest2

  1. 编译器可以认识到,查看是否x == (i & 65535)只能在x小于 65536 的情况下报告“true”的测试,从而使该if测试变得test2()多余。

  2. 编译器可以认识到,由于循环的唯一作用是计算,并且当从 内部调用时, willi的值i最终会被忽略,因此循环的代码是多余的。test()test2()

在保持隔离的同时消除循环if可能比相反的做法更好,但是代码能否满足基本要求(即不写入arr过去的元素 65535)取决于循环或if保留的测试。任何一个在另一个存在的情况下都是多余的,但任何一个的缺席都会使另一个变得重要。

请注意,如果在某些情况下需要使用外部手段终止应用程序,那么在保留数据依赖性的同时,给予编译器重新排序代码的自由并不能排除正确的应用程序在输入一些可能的输入时可能陷入无限循环的可能性。可以接受。然而,允许它们重新排序代码而不考虑结果数据依赖性,将会产生具有讽刺意义的效果,即加速主要是“错误”的程序,这些程序不能依赖于满足应用程序的要求,并且对大多数添加额外检查或操作的程序没有任何好处。虚拟副作用(否则不需要满足应用程序要求)以防止无限循环。

PS——作为两段代码在另一段代码存在的情况下各自冗余的一般概念的进一步示例,请考虑以下函数:

double x,y,z;
void f1(void) {
  x=sin(z);
}
void f2(void)
{
  f1();
  y=sin(z);
  x=0;
}
Run Code Online (Sandbox Code Playgroud)

该赋值x=sin(z);在编写的代码中是多余的,并且可以进行优化,因为没有任何内容使用存储在x. sin(z)在编写的代码中, within的计算f2是多余的,可以替换为,y=x;因为x它已经保存了需要存储到 中的值y。然而,显而易见的是,任一变换本身可以合法地应用这一事实并不意味着两者可以合法地一起应用。这个原则应该适用于第一个示例片段中使用的优化类型,这一想法对于编写标准的人来说可能是显而易见的,但不幸的是对于编写 clang 和 gcc 的人来说却不是。

  • @kc9jud:这个神话从何而来?实现定义的行为和未定义的行为之间的区别在于“所有”实现是否“需要”指定它们的行为方式。该标准承认 UB 可能出现在不可移植但正确的程序中,甚至当正确且可移植的程序接收到错误数据时。请参阅 https://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf 第 11 页从第 23 行开始,并告诉我是否有任何迹象表明 UB 旨在邀请无端荒谬的行为。 (2认同)