C 循环条件中的函数

function inside C loop condition

提问人:Sasha 提问时间:9/21/2023 更新时间:9/21/2023 访问量:89

问:

我可以有这样的东西:

for (unsigned i = 0; i < get_length(object); ++i) {
   ...
}

我确定 get_length(object) 在循环生命周期内不会改变,或者我可以将其转换为:

const unsigned length = get_length(object);
for (unsigned i = 0; i < length; ++i) {
   ...
}

也许编译器经常无法确定是否真的不会改变,所以它会在每次循环中评估它,所以可能第二种形式更好,尽管它对眼睛来说更麻烦?get_length(object)

C 循环 for-loop 编译器优化

评论

3赞 nielsen 9/21/2023
对我来说,第二种形式更容易阅读,因为对于第一种形式,我问了完全相同的问题:这个函数的结果真的是恒定的吗?
2赞 wohlstad 9/21/2023
我不觉得第二个“对眼睛更累赘”。由于它也更有效率,我认为没有理由使用第一个选项。
1赞 Some programmer dude 9/21/2023
如果函数在同一个转换单元中定义(实现),编译器可能能够推断出其返回的值是否可以缓存,或者至少调用可以内联。但除此之外,每次迭代都会调用它,这可能有点浪费。
0赞 Sasha 9/21/2023
好的,谢谢你!
0赞 Lundin 9/21/2023
“虽然对眼睛来说比较麻烦” 将表情分成几行,对眼睛来说就不那么麻烦了。当心那些坚持把所有东西都写在一行上的人。

答:

3赞 nielsen 9/21/2023 #1

第一种形式还是第二种形式最容易看是有争议的。

无论如何,对于第二种形式,我们确保编译器只会调用一次。我猜大多数有经验的程序员会更喜欢这种形式,如果函数确实打算在循环期间返回一个常量,因为第二种形式本身就传达了这个假设。get_length()

在第一种形式中,编译器可能在简单情况下能够推断出它将是常量,因此只调用一次。然而,似乎至少 GCC 还没有那么聪明。具有优化级别的 Godbolt 链接get_length()-O3

链接的汇编程序代码可能有点难以阅读,但在 中,请注意第 37 行中的指令跳转到第 21 行,这是与我的示例实现相对应的内联代码的开头。因此,在这种情况下,对应于 的代码将在循环的每次迭代中执行。f1jne .L15get_length()get_length()

也许编译器可以更好地优化更简单的示例,或者编译器的未来版本或其他编译器可以优化此示例。无论如何,通过使用第二种形式,我们可以避免这种猜测,并且必须查看编译的汇编代码。

这个例子和分析应该说明第二种形式的好处。

评论

0赞 Peter Cordes 9/21/2023
你的例子当然不会让 GCC 变得容易!你使你的字符串全局变量成为非-,在循环中你调用了一个非内联函数,你甚至没有原型()。即使 GCC 将 printf 视为 ,它也没有特别的支持来知道它可以假设您自己的全局变量没有被它修改,尽管事实并非如此。(因为事实并非如此;您可以调用其他函数来使用自己的 stdout 缓冲区等。constprintf__builtin_printf__attribute__((pure))
0赞 Peter Cordes 9/21/2023
所以无论如何,在您的示例中并没有真正遗漏优化,这是 GCC 没有的充分理由!但是,如果您使用 ,那么 GCC 应该知道即使在调用任意函数时也无法在迭代中更改。但这仍然无济于事,GCC 仍然在循环内扫描它,而不是弄清楚这是.但是,对于clang来说,在编译时通过它不断传播就足够了:godbolt.org/z/dzEz3jr9hconst char *const object = "Hello";objectget_lengthpureconst object
0赞 nielsen 9/21/2023
@PeterCordes 你是对的,这个例子对于编译器来说并不像看起来那么简单。这仅仅强调了要点:在这种情况下,我们可以在源代码中做更多的输入,以避免所有这些考虑和调查。这是一个以正确的方式保持简单的情况。我的回答不应被视为对 GCC 编译器的批评,我对此非常赞赏。
0赞 Peter Cordes 9/21/2023
(是否存在一个中间地带,您可以告诉编译器足够多,让它认为某些指向是循环不变的,但它在编译时看不到要计算为常量的实际定义?理论上是的,但在这种情况下,GCC 和 clang 错过了优化:它们扫描 printf 循环中的数组,而不是之前的数组。godbolt.org/z/84x4P1Mehconst char *get_lengthextern const char object[];)
0赞 Peter Cordes 9/21/2023
100%的人同意,如果人们关心效率,他们应该写第二种形式;这在 C 语言中是惯用的,因为我们不能指望编译器来提升这样的东西。我主要是在评论建议纠正措辞,因为您实际上是在展示一个根本无法安全/正确优化的案例。(我很确定编译器会更容易地提升一个而不是一个搜索循环;那么循环中一些和/或缺乏非内联函数调用就可以了。return p->lengthconst
2赞 Sneftel 9/21/2023 #2

我通常将其呈现为:

for (unsigned i = 0, length = get_length(); i < length; ++i) {
   ...
}

...这稍微多了一点,可以自我记录,并保持外部示波器的清洁。但在大多数情况下,这确实是一个不必要的微优化,在许多情况下,编译器已经可以发现它可以将调用从循环中提升出来,特别是如果你已经正确地完成了指针限制,并且定义了它可以内联的某个地方。get_length

评论

1赞 Jabberwocky 9/21/2023
get_length可以是任何内容,从编译器知道的内联函数到编译器不知道的外部库调用。因此,微观优化可能不是其中之一。
0赞 Sneftel 9/21/2023
是的,因此是“最”。要点是要知道你的代码在做什么。
0赞 Peter Cordes 9/21/2023
请参阅尼尔森答案下的评论中的讨论--阻止编译器看到某些东西是循环不变的并不需要太多。一个非内联函数调用可以做到这一点,即使 is 或其他东西。或者,如果它实际上是隐式长度数组的搜索循环,GCC/Clang 不会提升搜索循环。(就像尼尔森的回答一样,现在我想起来它实际上是可怕的,而且不是很合理;你应该在数组上迭代检查终止符,而不是把它当作显式长度。get_length(const foo *p)return p->length
2赞 Lundin 9/21/2023 #3

第二种形式可能确实更好 - 我将尝试解释为什么,同时我们更接近这里可能发生的各种形式的优化。

假设我们有 where 不修改任何内容,并且结果将始终是相同的整数常量,那么:for (unsigned i = 0; i < get_length(object); ++i)get_length

  • 这里需要优化,将函数调用替换为硬编码数字。
  • 或者,如果由于数字是在运行时计算而无法完成的,我们希望程序只调用该函数一次。
  • 我们不希望程序毫无意义地一遍又一遍地调用该函数,因为这会非常慢。

从历史上看,编译器在这里非常糟糕,因此过去的传统良好做法总是将函数调用移到循环上方。较旧的 C 编程书籍肯定会告诉你很多。但它在今天仍然是很好的做法,因为我们可能并不总是能够依赖优化。

让我们从一个产生最需要的“硬编码数字”优化的示例开始:

#include <stdint.h>
#include <stdio.h>

typedef struct
{
  size_t length;
} obj_t;

size_t get_length (const obj_t* obj)
{
  return obj->length;
}

int main (void)
{
  obj_t object = { 5 };
  for (size_t i = 0; i<get_length(&object); ++i)
  {
    puts("hello world");
  }
}

我们可以在这里查看 Godbolt 中具有最大优化 () 的生成汇编器:https://godbolt.org/z/9GnrEGvxc。相关部分是 .编译器完全忽略了结构、函数、函数调用等一切。它只是将值 5 存储在 CPU 寄存器中,然后调用与该数字一样多的次数。这是非常高效的代码。gcc -O3mov ebx, 5puts

现在,让我们通过在运行时将数字 5 传递给程序来稍微阻止优化器:

int main (int argc, char* argv[]) // I'll pass "5" in argv
{
// quick and dirty string-to-int conversion:
  obj_t object = { argv[1][0]-'0' };

https://godbolt.org/z/4Mcjsha5d。现在,编译器必须从命令行参数中实际加载值。但它仍然很自大,因为它注意到它可以将读取的数字保存在寄存器中以备后用 - 仍然没有任何地方。当这种优化发生时,我们说函数是内联的。因为从本质上讲,函数所做的整个事情(在 Godbolt 反汇编中)入到调用者端(保存“rax”以备后用)。call get_lengthmov rax, QWORD PTR [rdi]mov rax...

正如我们目前所看到的,我们把函数调用放在哪里并不重要,因为编译器仍然表现得很聪明。我们必须编写一个效率低得多的程序来产生实际的函数调用。例如,我们可以将函数和结构变量移动到另一个 .c 文件中。

在这种情况下,我将作弊,只是通过 gcc 非标准函数属性禁用函数内联,同时仍然保留所有其他优化。https://godbolt.org/z/j3WPscxWq最终被击败,编译器被迫使用实际的函数调用生成错误代码。for 循环被翻译为:-O3

.L5:
        mov     edi, OFFSET FLAT:.LC0
        add     rbx, 1
        call    puts
.L4:
        lea     rdi, [rsp+8]
        call    get_length
        cmp     rbx, rax
        jb      .L5

现在,随着内联的消失,我们注意到它确实调用了循环的每一圈。每当编译器既不能在编译时确定函数的结果,也不能通过抓取函数的主体并将其插入调用方代码来执行内联时,就会发生这种情况。当函数位于另一个单独编译的 .c 文件中时,通常会发生这种情况。get_length

我们可能期望它只调用函数一次,但这种优化也没有发生。正如下面另一个答案的评论中所指出的,无论出于何种原因,gcc 编译器都未能对其进行优化,而 clang 编译器将其优化为仅调用一次函数。显然,我们不能在这里依赖编译器优化,因为不同的编译器和编译器端口的结果会有所不同。

在这一点上,移出循环将产生更好的结果。https://godbolt.org/z/f6dEGMfrn。该函数仅调用一次,调用结果存储在一个寄存器中,存储在另一个寄存器中,并且对循环的每一圈进行比较寄存器。get_lengthmov rbp, raxicmp rbp, rbx

现在我们已经证明,第二种形式确实更有效率,但前提是编译器不能内联函数或提前确定结果。

然而,我们应该注意,像 or etc 这样的通用标准库函数很可能会被编译器有效地内联。所以他们旧的 C 书告诉你专门离开循环条件已经过时了。如有疑问,请拆卸并查找说明 - 您甚至不需要了解汇编程序。strlenmemcpystrlencall strlen

评论

0赞 Peter Cordes 9/22/2023
clang 优化但 GCC 没有优化的情况是完全优化函数并将其替换为编译时常量,而不是循环不变的运行时变量。godbolt.org/z/63x1qTGcM - 是字符串上的循环,是手写的,因此要获得编译时常量需要编译时对足够的循环迭代进行评估才能到达末尾。可能调整一些 GCC 启发式方法可以让它看起来更远。使字符串更短,GCC 使用两字节字符串(包括终止字符串)成功,如 godbolt.org/z/voY1vWdhnget_lengthstrlen0"H"
0赞 Peter Cordes 9/22/2023
因此,他们旧的 C 书告诉你将 strlen 专门移出循环条件已经过时了——如果你在循环中调用任何可能修改你调用的缓冲区内容的函数,编译器一定不能优化,因为存储在任何地方可能会改变结果。因此,这仍然是一个不错的建议,除非您正在使用一个地址永远不会转义此函数的本地人(这不太可能),或者您的循环不调用任何函数并且不写入缓冲区。strlen0char buf[]
1赞 Lundin 9/22/2023
@PeterCordes 不仅如此,在我这个答案中非常简单的“getter”函数中,只是返回结构的 int 成员,当我关闭内联时,最新的 gcc 未能对其进行优化。而最新的 clang 将其优化为单个调用。旧版本的 clang 也无法将其优化为单个调用。我怀疑这与关于内部/外部联系的假设有关。无论如何,为了这个答案,它失败了是件好事,因为这表明我们不应该盲目依赖编译器的优化器。
0赞 Peter Cordes 9/22/2023
这是令人惊讶的。在 GCC 中,是分开的(这将禁用所有过程间分析,使其成为一个黑盒,就像它在另一个没有 LTO 的编译单元中一样)。但是我所知道的 Clang 没有单独的属性。所以我猜到这可能会禁用 clang 而不是 GCC。但它有点人为:通常它可以看到那个微小的函数并内联它,或者它只能看到一个原型。 使用原型或 noinline:godbolt.org/z/n5sGeGabv__attribute__((noinline))__attribute__((noipa))noinline__attribute__((const))
0赞 Peter Cordes 9/22/2023
gcc -O3 -fno-inline甚至对微小的函数也禁用内联,而不需要属性。godbolt.org/z/5bYGeKEvT 显示 GCC 仍在循环内调用,而不是推断,但 clang 确实如此。如果我们更改为 ,则相同,以防 GCC 对 main 的隐式影响优化。所以,是的,GCC 在不内联的情况下进行优化确实更差。__attribute__((const))mainfoo__attribute__((cold))