ChatGPT解决这个技术问题 Extra ChatGPT

函数中过早返回的效率

这是我作为一个没有经验的程序员经常遇到的情况,我特别想知道我正在尝试优化的一个雄心勃勃、速度密集的项目。对于主要的类 C 语言(C、objC、C++、Java、C# 等)及其常用的编译器,这两个函数会同样高效地运行吗?编译后的代码有什么不同吗?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

基本上,当breakreturn提早时,是否有直接的效率奖励/惩罚?堆栈帧是如何参与的?有优化的特殊情况吗?是否有任何因素(如内联或“Do stuff”的大小)可能会显着影响这一点?

我一直支持通过次要优化提高易读性(我在参数验证中经常看到 foo1),但这经常出现,以至于我想一劳永逸地抛开所有担忧。

而且我知道过早优化的陷阱......呃,那些是一些痛苦的记忆。

编辑:我接受了一个答案,但 EJP 的答案非常简洁地解释了为什么使用 return 几乎可以忽略不计(在汇编中,return 在函数末尾创建了一个“分支”,这非常快。分支改变了 PC 寄存器,也可能影响缓存和管道,这是非常微不足道的。)特别是对于这种情况,它实际上没有区别,因为 if/elsereturn 都创建相同的分支到功能。

我认为这类事情不会对性能产生显着影响。只需写一个小测试,看看自己。 Imo,第一个变体更好,因为您不会获得不需要的嵌套,从而提高了可读性
@SirVaulterScott,除非这两种情况在某种程度上是对称的,在这种情况下,您希望通过将它们置于相同的缩进级别来呈现对称性。
SirVaulterScoff:+1 减少不必要的嵌套
可读性 >>> 微优化。对于将要维护它的湿件来说,无论哪种方式都更有意义。在机器代码级别,这两个结构在输入到一个相当愚蠢的编译器时也是相同的。优化编译器将消除两者之间的任何速度优势。
不要担心这样的事情来优化你的“速度密集型”项目。分析您的应用程序以找出它实际上慢的地方 - 如果它在您完成工作后实际上太慢了。你几乎肯定猜不出是什么真正减慢了它的速度。

D
Daniel

根本没有区别:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

这意味着即使没有在两个编译器中进行优化,生成的代码也没有任何区别


或者更好:至少有一个特定编译器的版本可以为两个版本生成相同的代码。
@UncleZeiv - 大多数(如果不是全部)编译器会将源代码转换为执行流图模型。很难想象一个理智的实现会为这两个示例提供有意义的不同流程图。您可能会看到的唯一区别是两个不同的做某事被交换了 - 甚至在许多实现中也可能被撤销以优化分支预测或平台确定首选顺序的其他问题。
@Steve314,当然,我只是在吹毛求疵 :)
@UncleZeiv:也在 clang 上进行了测试,结果相同
我不明白。很明显,something() 将始终被执行。在原始问题中,OP 有 Do stuffDo diffferent stuff 取决于标志。我不确定生成的代码是否相同。
b
blueshift

简短的回答是,没有区别。帮自己一个忙,不要再为此担心了。优化编译器几乎总是比你聪明。

专注于可读性和可维护性。

如果您想看看会发生什么,请使用优化构建这些并查看汇编器输出。


@Philip:也帮其他人一个忙,不要再担心这个了。您编写的代码也将被其他人阅读和维护(即使您编写的代码永远不会被其他人阅读,您仍然会养成影响您编写的其他代码将被其他人阅读的习惯)。始终编写尽可能易于理解的代码。
优化器不比你聪明!!!他们只是更快地决定影响无关紧要的地方。在真正重要的地方,您肯定会通过一些经验进行比编译器更好的优化。
@johannes 让我不同意。编译器不会将您的算法更改为更好的算法,但它在重新排序指令以实现最大管道效率和其他不那么琐碎的循环(裂变、融合等)方面做得非常出色,即使是有经验的程序员也无法决定除非他对 CPU 架构有深入的了解,否则先验的更好。
@johannes - 对于这个问题,您可以假设它确实如此。此外,一般来说,在某些特殊情况下,您偶尔可能会比编译器优化得更好,但这需要相当多的专业知识——通常情况下,优化器会应用您能想到的大多数优化并这样做系统地,而不仅仅是在少数特殊情况下。 WRT这个问题,编译器可能会为两种形式构造完全相同的执行流程图。选择更好的算法是一项人工工作,但代码级优化几乎总是浪费时间。
我同意和不同意这一点。在某些情况下,编译器无法知道某物与其他物等价。您是否知道执行 x = <some number> 通常比执行 if(<would've changed>) x = <some number> 快得多 不需要的分支确实会造成伤害。另一方面,除非这是在极其密集的操作的主循环中,否则我也不会担心。
C
Community

有趣的答案:虽然我确实同意所有这些(到目前为止),但到目前为止,这个问题可能有一些含义被完全忽视。

如果上面的简单示例通过资源分配进行扩展,然后通过潜在的资源释放进行错误检查,则情况可能会发生变化。

考虑一下初学者可能会采取的幼稚方法:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

以上将代表过早返回风格的极端版本。请注意,随着时间的推移,随着复杂性的增加,代码变得非常重复和不可维护。现在人们可能会使用 exception handling 来捕捉这些。

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

在查看下面的 goto 示例后,Philip 建议在上面的 catch 块中使用 无中断开关/案例。可以 switch(typeof(e)) 然后通过 free_resourcex() 调用,但这是 not trivial and needs design consideration。请记住,没有中断的开关/外壳与下面带有菊花链标签的 goto 完全一样...

正如 Mark B 所指出的,在 C++ 中,遵循 Resource Aquisition is Initialization 原则(简而言之RAII)被认为是一种很好的风格。该概念的要点是使用对象实例化来获取资源。一旦对象超出范围并调用它们的析构函数,资源就会自动释放。对于相互依赖的资源,必须特别注意确保正确的释放顺序并设计对象类型,以便所有析构函数都可以使用所需的数据。

或者在例外前的日子里可能会这样做:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

但是这个过于简化的例子有几个缺点:它只能在分配的资源不相互依赖的情况下使用(例如它不能用于分配内存,然后打开文件句柄,然后从句柄中读取数据到内存中) ),并且它不提供单独的、可区分的错误代码作为返回值。

为了使代码保持快速(!)、紧凑、易于阅读和可扩展Linus Torvalds enforced a different style for kernel code that deals with resources, even using the infamous goto in a way that makes absolutely sense

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

内核邮件列表讨论的要点是,大多数比 goto 语句“首选”的语言特性都是隐式 goto,例如巨大的树状 if/else、异常处理程序、循环/中断/继续语句等. 上面例子中的 goto 被认为是没问题的,因为它们只跳了一小段距离,有清晰的标签,并且释放了其他杂乱的代码来跟踪错误情况。 This question has also been discussed here on stackoverflow

但是,最后一个示例中缺少的是返回错误代码的好方法。我正在考虑在每个 free_resource_x() 调用之后添加一个 result_code++ 并返回该代码,但这抵消了上述编码风格的一些速度增益。并且在成功的情况下很难返回 0。也许我只是缺乏想象力;-)

所以,是的,我确实认为是否编码过早返回的问题存在很大差异。但我也认为只有在更复杂的代码中才明显,这些代码更难或不可能为编译器重构和优化。一旦资源分配发挥作用,通常就是这种情况。


哇,真的很有趣。我绝对可以理解这种天真的方法的不可维护性。但是,该特定情况下的异常处理将如何改进?就像在错误代码中包含无中断 switch 语句的 catch
@Philip 添加了基本的异常处理示例。请注意,只有 goto 有失败的可能性。您建议的 switch(typeof(e)) 会有所帮助,但它是 not trivial and needs design consideration。请记住,没有中断的开关/外壳与带有菊花链标签的 goto 完全一样;-)
+1 这是 C/C++(或任何需要手动释放内存的语言)的正确答案。就个人而言,我不喜欢多标签版本。在我以前的公司,它总是“goto fin”(这是一家法国公司)。在 fin 中,我们将取消分配任何内存,这是 goto 唯一可以通过代码审查的用法。
请注意,在 C++ 中,您不会执行任何这些方法,而是会使用 RAII 来确保正确清理资源。
L
Lou

尽管这不是一个答案,但生产编译器在优化方面会比你好得多。我更倾向于这些优化的可读性和可维护性。


u
user207421

具体来说,return 将被编译到方法末尾的分支中,其中将有 RET 指令或任何可能的指令。如果将其省略,则 else 之前的块末尾将被编译为 else 块末尾的分支。因此,您可以看到在这种特定情况下它没有任何区别。


明白了。我实际上认为这非常简洁地回答了我的问题;我想这实际上只是一个寄存器添加,这是可以忽略不计的(除非你正在做系统编程,即使那样......)我会给予这个荣誉。
@Philip 什么注册添加?路径中根本没有额外的指令。
那么两者都会有注册添加。这就是装配分支的全部内容,不是吗?程序计数器的补充?我在这里可能是错的。
@Philip 不,装配分支是装配分支。当然,它确实会影响 PC,但它可能是通过完全重新加载它来实现的,而且它对处理器的管道、缓存等也有副作用。
M
Mark B

如果您真的想知道特定编译器和系统的编译代码是否存在差异,您必须自己编译并查看程序集。

然而,在大的计划中,几乎可以肯定编译器可以比你的微调更好地优化,即使它不能,它也不太可能对你的程序性能产生影响。

相反,以最清晰的方式编写代码以供人类阅读和维护,并让编译器做它最擅长的事情:从您的源代码生成最好的程序集。


P
PCPGMR

在您的示例中,回报是显而易见的。当返回是上面/下面的一两个页面时,调试人员会发生什么//做不同的事情?当有更多代码时,更难找到/查看。

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

当然,一个函数的长度不应超过一页(甚至两页)。但是调试方面尚未包含在任何其他答案中。点了!
C
Community

我非常同意 blueshift:首先是可读性和可维护性!但是如果你真的很担心(或者只是想了解你的编译器在做什么,从长远来看这绝对是一个好主意),你应该自己寻找。

这将意味着使用反编译器或查看低级编译器输出(例如汇编语言)。在 C# 或任何 .Net 语言中,tools documented here 将为您提供所需的内容。

但正如您自己所观察到的,这可能是过早的优化。


Y
Yuan

来自Clean Code: A Handbook of Agile Software Craftsmanship

标志参数很丑陋。将布尔值传递给函数是一种非常糟糕的做法。它立即使方法的签名复杂化,大声宣称该函数不止一件事。如果标志是真的,它会做一件事,如果标志是假的,它会做另一件事!

foo(true);

在代码中只会让读者导航到函数并浪费时间阅读 foo(boolean flag)

更好的结构化代码库将为您提供更好的优化代码的机会。


我只是以此为例。传递给函数的可能是一个 int、double、一个类,你可以命名它,它并不是问题的核心。
你问的问题是关于在你的函数内部做一个开关,大多数情况下,这是一个代码味道。它可以通过多种方式实现,读者不必阅读整个函数,说说 foo(28) 是什么意思?
M
MartyTPS

一种思想流派(现在不记得提出它的蛋头了)是,从结构的角度来看,所有函数都应该只有一个返回点,以使代码更易于阅读和调试。我想,这更多是为了编写宗教辩论。

您可能想要控制违反此规则的函数何时以及如何退出的一个技术原因是,当您编写实时应用程序时,您希望确保通过该函数的所有控制路径花费相同数量的时钟周期来完成。


呃,我认为这与清理有关(尤其是在用 C 编码时)。
不,无论您将方法留在哪里,只要您返回堆栈就会被撞回(这就是“清理”的全部内容)。
T
Thomas Eding

很高兴你提出这个问题。您应该始终使用分支而不是提前返回。为什么停在那里?如果可以(至少尽可能多),将所有功能合并为一个。如果没有递归,这是可行的。最后,您将拥有一个庞大的主要功能,但这就是您对这类事情的需要/想要的。之后,将您的标识符重命名为尽可能短。这样,当你的代码被执行时,花在读取名字上的时间就更少了。接下来做...


我可以说你在开玩笑,但可怕的是有些人可能会认真对待你的建议!
同意丹尼尔。尽管我很喜欢玩世不恭——它不应该用在技术文档、白皮书和像 SO 这样的问答网站中。
-1 表示愤世嫉俗的答案,初学者不一定能认出。