编写依赖于编译器优化的代码是不好的做法吗?


99

我一直在学习一些C ++,并且经常不得不从该函数内创建的函数中返回大型对象。我知道有按引用传递,返回指针和返回引用类型的解决方案,但我还读到C ++编译器(和C ++标准)允许返回值优化,从而避免了通过内存复制这些大对象,从而节省所有时间和内存。

现在,当以值显式返回对象时,我感到语法更加清晰,并且编译器通常将使用RVO并使过程更高效。依靠这种优化是不好的做法吗?这对于用户来说使代码更清晰,更易读,这是非常重要的,但是我应该谨慎地假设编译器会抓住RVO的机会吗?

这是微优化,还是在设计代码时应牢记的一点?


7
为了回答您的编辑,这是一个微优化,因为即使您尝试基准测试纳秒级的收入,也几乎看不到它。对于其余的内容,我对于C ++太烂了,无法为您提供严格的答案,说明为什么它不起作用。其中之一是在可能需要动态分配并因此使用new / pointer / references的情况下。
Walfrat

4
即使对象很大,@ Walfrat的大小也要达到兆字节?由于我要解决的问题的性质,我的阵列可能会变得很多。
马特

6
@马特,我不会。引用/指针正是为此目的而存在。编译器优化被认为超出了程序员在构建程序时应考虑的范围,尽管是的,但通常这两个世界是重叠的。
尼尔

5
@Matt除非您要做的非常具体的事情要求开发人员具有超过10年的C /内核经验,否则硬件交互作用低就不需要。如果您认为自己属于某个非常具体的东西,请编辑您的文章并添加准确的描述您的应用程序应该做的事情(实时?繁重的数学计算?...)
Walfrat

37
是的,在C ++(N)RVO 的特殊情况下,依靠这种优化是完全有效的。这是因为C ++ 17个标准明确责成其发生,在现代的编译器已经做了的情况。
卡雷斯(Caleth)'17

Answers:


130

采用最小惊讶原则

是您并且只有您将要使用此代码,并且您确定三年之内不会因为所做的事情而感到惊讶吗?

然后继续。

在所有其他情况下,请使用标准方式;否则,您和您的同事将很难找到错误。

例如,我的同事抱怨我的代码导致错误。事实证明,他已在编译器设置中关闭了短路布尔评估。我差点打了他一巴掌。


88
@Neil是我的意思,每个人都依赖于短路评估。而且您不必三思而后行,应该将其打开。这是事实上的标准。是的,您可以更改它,但是不可以。
Pieter B

49
“我改变了语言的工作方式,您肮脏的烂代码坏了!啊!” 哇。打耳光是适当的,将您的同事送去接受Zen培训,那里有很多。

109
@PieterB我很确定C C ++语言规范可以保证短路评估。因此,这不仅是事实上的标准,而且标准。没有它,您甚至都不再使用C / C ++,而是像这样可疑的东西:P
marcelm

47
仅供参考,此处的标准方法是按值返回。
DeadMG '17

28
@ dan04是的,它在Delphi中。伙计们,不要被这个例子所困扰。不要做别人没有做的令人惊讶的事情。
Pieter B

81

对于这种特殊情况,绝对只是按值返回。

  • RVO和NRVO是众所周知的健壮的优化,即使在C ++ 03模式下,任何体面的编译器也都应该真正进行优化。

  • 如果(N)RVO没有发生,则移动语义可确保将对象移出函数。这仅在对象内部使用动态数据时才有用(就像std::vector这样),但是如果它很大,那确实应该是这样 -堆栈溢出对于大型自动对象是有风险的。

  • C ++ 17 强制执行 RVO。所以不用担心,它不会消失在您身上,只有在最新的编译器之后,它才能完全完成自身的建立。

最后,强迫额外的动态分配返回指针,或者强迫结果类型是默认可构造的,以便您可以将其作为输出参数传递,这对于您可能永远不会遇到的问题都是丑陋且非惯常的解决方案有。

只需编写有意义的代码,并感谢编译器作者正确优化了有意义的代码。


9
只是为了好玩,看看1990年代的Borland Turbo C ++ 3.0是如何处理RVO的。剧透:它基本上可以正常工作。
nwp

9
这里的关键是它不是特定于编译器的随机优化或“未记录的功能”,而是一些在C ++标准版本中从技术上讲是可选的,但却受到业界的大力推动,并且几乎每个主要的编译器都为之而努力。很长一段时间。

7
这种优化并不像人们希望的那样健壮。是的,在最明显的情况下它是相当可靠的,但是例如在gcc的bugzilla中,有很多几乎不那么明显的情况被遗漏了。
Marc Glisse

62

现在,当以值显式返回对象时,我感到语法更加清晰,并且编译器通常将使用RVO并使过程更高效。依靠这种优化是不好的做法吗?这对于用户来说使代码更清晰,更易读,这是非常重要的,但是我应该谨慎地假设编译器会抓住RVO的机会吗?

您在一些小型,很少有人访问的博客中读到的并不是鲜为人知的,可爱的,微优化的内容,然后您会在使用上变得聪明而优越。

在C ++ 11之后,RVO是编写此代码的标准方法。如果未实现,则在演讲中提到,在博客中提到,在标准中提到,这是常见的,期望的,教导的,将被报告为编译器错误。在C ++ 17中,该语言更进一步,并在某些情况下要求复制省略。

您绝对应该依靠这种优化。

最重要的是,与按引用返回的代码相比,按值返回只会使代码更易于阅读和管理。值语义是一个强大的功能,它本身可以带来更多的优化机会。


3
谢谢,这很有意义,并且与上面提到的“最小惊讶原则”相一致。这将使代码非常清晰和易于理解,并且更难弄懂指针的恶作剧。
马特

3
@Matt我支持此答案的部分原因是它确实提到了“值语义”。随着您对C ++(以及一般而言,编程)有更多的了解,您会发现偶尔情况下,某些对象不能使用值语义,因为它们是可变的,并且它们的更改必须对使用同一对象的其他代码可见(共享可变性示例)。当发生这些情况时,将需要通过(智能)指针共享受影响的对象。
rwong

16

您编写的代码的正确性永远不应取决于优化。在规范中使用的C ++“虚拟机”上执行时,它应该输出正确的结果。

但是,您谈论的更多是效率问题。如果使用RVO优化编译器进行优化,您的代码将运行得更好。没关系,出于其他答案中指出的所有原因。

但是,如果您需要这种优化(例如,如果复制构造函数实际上会使您的代码失败),那么您现在就在编译器的无聊之中。

我认为在我自己的实践中,最好的例子是尾部调用优化:

   int sillyAdd(int a, int b)
   {
      if (b == 0)
          return a;
      return sillyAdd(a + 1, b - 1);
   }

这是一个愚蠢的示例,但它显示了一个尾部调用,其中在函数末尾递归地调用一个函数。C ++虚拟机将显示该代码可以正常运行,尽管我可能会引起困惑,因为为什么要首先编写这样的加法例程。但是,在C ++的实际实现中,我们有一个堆栈,而且空间有限。如果花哨地完成此功能,则必须在添加时将至少b + 1堆栈帧推入堆栈。如果我要计算sillyAdd(5, 7),这没什么大不了的。如果要计算sillyAdd(0, 1000000000),可能会引起StackOverflow的麻烦(而不是一种好方法)。

但是,我们可以看到,一旦到达最后一条返回线,就真正完成了当前堆栈框架中的所有操作。我们真的不需要保持它。尾调用优化使您可以“重用”现有的堆栈框架以用于下一个功能。这样,我们只需要1个堆栈框架,而不是b+1。(我们仍然必须进行所有这些愚蠢的加法和减法,但它们不会占用更多空间。)实际上,优化将代码转换为:

   int sillyAdd(int a, int b)
   {
      begin:
      if (b == 0)
          return a;
      // return sillyAdd(a + 1, b - 1);
      a = a + 1;
      b = b - 1;
      goto begin;  
   }

在某些语言中,规范明确要求进行尾部调用优化。C ++ 不是其中之一。除非逐案进行,否则我不能依靠C ++编译器来识别这种尾部调用优化机会。在我的Visual Studio版本中,发行版进行了尾部调用优化,而调试版没有(按设计)。

因此,依赖于能够进行计算对我来说是不好的sillyAdd(0, 1000000000)


2
这是一个有趣的极端情况,但是我认为您不能将其概括为第一段中的规则。假设我有一个用于小型设备的程序,当且仅当我使用编译器的减小尺寸的优化程序时,该程序才会加载-这样做是否错误?说我唯一有效的选择是在汇编器中重写它,这似乎有些古怪,特别是如果该重写执行与优化器解决问题相同的操作时。
sdenham

5
@sdenham我认为参数中有一点余地。如果您不再写“ C ++”,而是写“ WindRiver C ++编译器版本3.4.1”,那么我可以在其中看到逻辑。但是,作为一般规则,如果您编写的内容未能按照规范正常运行,那么您将处于截然不同的情况。我知道Boost库具有这样的代码,但是他们总是将其放在#ifdef块中,并且具有可用的符合标准的解决方法。
Cort Ammon

4
这是第二个代码块中的错字b = b + 1吗?
stib 17-10-13

2
您可能要解释“ C ++虚拟机”的含义,因为在任何标准文档中都没有使用该术语。我认为您在谈论的是C ++的执行模型,但不是完全确定的-而且您的说法似乎与“字节码虚拟机”相似,后者涉及完全不同的事物。
Toby Speight

1
@supercat Scala还具有显式的尾递归语法。C ++是它自己的野兽,但是我认为尾部递归对于非功能性语言是单项的,对于功能性语言是强制性的,只剩下一小部分语言可以使用显式的尾部递归语法。从字面上将尾递归转换为循环和显式突变对于许多语言来说都是更好的选择。
prosfilaes

8

实际上, C ++程序期待一些编译器优化。

尤其要注意标准容器实现的标准标头。使用GCC时,您可以使用容器索取大多数源文件(技术翻译单位)的预处理形式(g++ -C -E)和GIMPLE内部表示形式(g++ -fdump-tree-gimple或Gimple SSA with -fdump-tree-ssa)。您会为(使用g++ -O2)完成的优化数量感到惊讶。因此,容器的实现者依赖于优化(在大多数情况下,C ++标准库的实现者知道会发生什么优化,并牢记这些实现;有时他还会在编译器中编写优化过程以处理标准C ++库所需的功能)。

实际上,正是编译器优化才使C ++及其标准容器足够有效。因此,您可以依靠它们。

同样,对于您的问题中提到的RVO案。

C ++标准是经过共同设计的(特别是通过尝试足够好的优化,同时提出新功能)来与可能的优化一起很好地工作。

例如,考虑以下程序:

#include <algorithm>
#include <vector>

extern "C" bool all_positive(const std::vector<int>& v) {
  return std::all_of(v.begin(), v.end(), [](int x){return x >0;});
}

用编译g++ -O3 -fverbose-asm -S。您会发现生成的函数没有运行任何CALL机器指令。因此,大多数C ++步骤(lambda闭包的构造,其重复应用,获取beginend迭代器等)均已优化。机器代码仅包含一个循环(该循环未在源代码中明确显示)。没有这样的优化,C ++ 11将不会成功。

附加物

(2017年12月31 新增)

请参阅CppCon 2017:Matt Godbolt“最近我的编译器为我做了什么?取消“编译器的盖子”讨论。


4

无论何时使用编译器,其理解都在于它将为您生成机器代码或字节代码。它不保证所生成的代码是什么样子,只是它将根据语言的规范实现源代码。请注意,无论所使用的优化级别如何,此保证都是相同的,因此,一般而言,没有理由认为一个输出比另一个更“正确”。

此外,在那种情况下,例如RVO,它是用该语言指定的,因此尽一切努力避免使用它似乎毫无意义,特别是如果它使源代码更简单。

为了使编译器产生有效的输出,我们付出了很多努力,并且显然是要使用这些功能。

可能有使用未优化代码的原因(例如,用于调试),但是此问题中提到的情况似乎不是一种情况(并且如果您的代码仅在优化后失败,并且这不是代码特殊性的结果)设备(正在运行该设备),则某处存在错误,并且不太可能出现在编译器中。)


3

我认为其他人很好地涵盖了有关C ++和RVO的特定角度。这是一个更一般的答案:

说到正确性,通常不应该依赖编译器优化或特定于编译器的行为。幸运的是,您似乎并没有这样做。

在性能方面,通常必须依赖于特定于编译器的行为,尤其是编译器优化。符合标准的编译器可以自由地以其希望的任何方式编译您的代码,只要编译后的代码按照语言规范运行即可。而且我不知道任何主流语言的规范,这些规范规定了每个操作必须达到的速度。


1

编译器优化应该只影响性能,而不影响结果。依靠编译器优化来满足非功能性需求不仅是合理的,而且经常是一个编译器被另一个编译器选中的原因。

确定执行特定操作的标志(例如索引或溢出条件)通常与编译器优化混为一谈,但不应如此。它们明确影响计算结果。

如果编译器优化导致不同的结果,那就是一个错误-编译器中的错误。从长远来看,依靠编译器中的错误是一个错误-修复后会发生什么?

使用可以更改计算工作方式的编译器标志应该有据可查,但可以根据需要使用。


不幸的是,许多编译器文档在指定各种模式下的保证或不保证方面做得很差。此外,“现代”的编译器作者似乎对程序员确实需要和不需要的保证的组合完全忘了。如果程序x*y>z在溢出的情况下任意产生0或1的情况下运行良好,前提是它没有其他副作用,则要求程序员必须不惜一切代价防止溢出,或者强制编译器以某种特殊方式评估表达式不必要的损害优化与说...
超级猫

...编译器可能在其休闲行为就像x*y促进其操作数一些任意长型(从而使起重强度降低,将改变一些溢出情况下的行为方式)。但是,许多编译器要求程序员不惜一切代价防止溢出,或者在发生溢出的情况下强制编译器截断所有中间值。
超级猫

1

没有。

那就是我一直在做的事。如果需要访问内存中的任意16位块,请执行此操作

void *ptr = get_pointer();
uint16_t u16;
memcpy(&u16, ptr, sizeof(u16)); // ntohs omitted for simplicity

...并依靠编译器尽其所能来优化那段代码。该代码可在ARM,i386,AMD64上运行,并且几乎可以在其中的每个架构上运行。从理论上讲,非优化的编译器实际上可能会调用memcpy,从而导致完全不佳的性能,但这对我来说不是问题,因为我使用了编译器优化。

考虑替代方案:

void *ptr = get_pointer();
uint16_t *u16ptr = ptr;
uint16_t u16;
u16 = *u16ptr;  // ntohs omitted for simplicity

如果get_pointer()返回未对齐的指针,则此替代代码无法在需要正确对齐的计算机上工作。此外,替代方案中可能还会出现混叠问题。

使用memcpy技巧时,-O2和-O0之间的区别非常大:IP校验和性能为3.2 Gbps,而IP校验和性能为67 Gbps。相差一个数量级!

有时您可能需要帮助编译器。因此,例如,您可以自己完成操作,而不是依赖编译器展开循环。通过实施著名的Duff设备,或者采用更简洁的方法。

依靠编译器优化的缺点是,如果您运行gdb调试代码,则可能会发现许多优化已经被淘汰。因此,您可能需要使用-O0重新编译,这意味着调试时性能将完全消失。考虑到优化编译器的好处,我认为这是一个值得克服的缺点。

无论您做什么,请确保您的方式实际上不是不确定的行为。由于混叠和对齐问题,以16位整数形式访问某些随机内存块无疑是未定义的行为。


0

除了汇编语言以外,所有尝试以高效代码编写的高效代码的尝试都非常非常依赖编译器的优化,从最基本的基础(如高效的寄存器分配)开始,以避免多余的堆栈溢出到处,并且至少在合理范围内(如果不是很好的话)选择指令。否则,我们将回到80年代,在那里我们必须register到处放置提示,并在函数中使用最少数量的变量来帮助过时的C编译器,或者甚至在goto有用的分支优化时更早地使用。

如果我们不希望依靠优化器的能力来优化代码,那么我们仍然会在汇编中编写性能关键的执行路径。

这实际上是您感觉到可以进行优化的可靠程度的一个问题,最好通过剖析并查看您拥有的编译器的功能,甚至可能在存在热点的情况下进行拆解,以解决您似乎无法确定编译器似乎在哪里的问题。未能进行明显的优化。

RVO已经存在了很长时间,并且至少排除了非常复杂的情况,编译器已经能够可靠地将其很好地应用于各个年龄段。解决不存在的问题绝对不值得解决。

依赖优化器而不是害怕的错误

相反,我会说过错,过于依赖编译器优化而不是过于依赖编译器优化,而这个建议来自一个在性能非常关键的领域工作的人,在这个领域中,效率,可维护性和客户之间的感知质量非常重要。所有一个巨大的模糊。我宁愿让您过分自信地依赖优化器,并找到一些晦涩难懂的案例,在这些案例中,您过于依赖而不是过于依赖,只是在余生中一直出于迷信的恐惧进行编码。至少,如果事情没有按预期的速度执行,您至少会获得探查器并进行适当的调查,并在此过程中获得有价值的知识,而不是迷信。

您在依靠优化器方面做得很好。保持。不要像那个开始明确要求内联循环调用的每个函数的家伙,甚至在分析出于对优化器缺点的误解之前就开始内联。

分析

分析实际上是回旋,但最终可以回答您的问题。急于编写高效代码的初学者经常无法解决的问题不是优化的问题,而是无法优化的问题,因为他们会产生各种关于效率低下的错误观念,尽管这是人类直觉,但在计算上是错误的。真正开始使用Profiler进行开发的经验将使您不仅对您可以放心地依靠的编译器的优化功能,而且对硬件的功能(以及局限性)有了适当的了解。可以说,在剖析中学习不值得优化的内容要比学到什么有价值。


-1

可以使用C ++在非常不同的平台上并且出于许多不同的目的编写软件。

它完全取决于软件的目的。是否易于维护,扩展,修补,重构等。还是其他更重要的事情,例如性能,成本或与某些特定硬件的兼容性或开发所需的时间。


-2

我认为无聊的答案是:“取决于”。

它是不好的做法编写依赖于编译器优化代码可能被关闭,并在该漏洞未记录并在有问题的代码是不是单元测试,因此,如果它没有打破你会知道它?大概。

编写依赖于编译器优化的代码是不好的做法,这种编译器优化不可能被关闭,已被记录并且已经过单元测试了?也许不吧。


-6

除非您没有告诉我们更多信息,否则这是一种不好的做法,但这并不是出于您建议的原因。

可能与您之前使用过的其他语言不同,在C ++中返回对象的值会生成该对象的副本。如果您随后修改该对象,那么您正在修改另一个对象。也就是说,如果我有Obj a; a.x=1;Obj b = a;,那么我做b.x += 2; b.f();,则a.x仍然等于1,而不是3。

因此,不能,将对象用作值而不是引用或指针不能提供相同的功能,并且最终可能会导致软件中的错误。

也许您知道这一点,但这不会对您的特定用例产生负面影响。但是,根据问题的措辞,您似乎可能不知道该区别;诸如“在函数中创建对象”之类的措辞。

“在函数中创建对象”听起来像new Obj;“按值返回对象”听起来像Obj a; return a;

Obj a;并且Obj* a = new Obj;是非常非常不同的事物;如果未正确使用和理解,前者会导致内存损坏,而如果未正确使用和理解,则前者可能导致内存泄漏。


8
返回值优化(RVO)是一种定义明确的语义,其中编译器在堆栈帧上一级构造一个返回的对象,特别是避免了不必要的对象复制。这是定义良好的行为,早在C ++ 17对其强制执行之前就已得到支持。甚至在10到15年前,所有主要的编译器都支持此功能,并且一直如此。

@Snowman我不是在谈论物理的低级内存管理,也没有在讨论内存膨胀或速度。正如我在回答中特别说明的那样,我在谈论逻辑数据。从逻辑上讲,提供对象的值就是创建对象的副本,而不管编译器的实现方式或幕后使用的程序集如何。幕后的低级内容是一回事,语言的逻辑结构和行为是另一回事;它们是相关的,但它们不是同一件事-两者都应理解。
亚伦

6
您的回答是“在C ++中返回对象的值会生成该对象的副本”,这在RVO的上下文中是完全错误的-该对象是在调用位置直接构造的,并且从未复制过。您可以通过删除复制构造函数并返回RVO要求return语句构造的对象进行测试。此外,然后您继续讨论关键字new和指针,这与RVO无关。我相信您要么不理解这个问题,要么不理解RVO,或者可能两者都不明白。

-7

Pieter B在推荐最少惊讶方面是绝对正确的。

为了回答您的特定问题,这(最有可能)在C ++中意味着什么,您应该将a返回std::unique_ptr给构造的对象。

原因是这很清楚 对于C ++开发人员

尽管您的方法最有可能行得通,但实际上,您实际上在发出信号,表明该对象是小值类型。最重要的是,您将放弃接口抽象的任何可能性。对于您当前的目的,这可能还可以,但是在处理矩阵时通常非常有用。

我很高兴地发现,如果您来自其他语言,那么最初所有的信号都会令人困惑。但是请注意不要假设不使用它们会使代码更清晰。实际上,情况恰恰相反。


在罗马做到入乡随俗。

14
对于本身不执行动态分配的类型,这不是一个好的答案。OP感觉到用例很自然,就是按值返回,这表明OP对象在调用方具有自动存储时间。对于简单的,不太大的对象,即使是简单的复制返回值实现也将比动态分配快几个数量级。(另一方面,如果该函数返回一个容器,则与按值返回天真的编译器相比,返回unique_pointer甚至可能更为有利。)
Peter A. Schneider

9
@Matt如果您没有意识到这不是最佳实践。不必要地对用户进行内存分配和强制使用指针语义是不好的。
nwp

5
首先,使用智能指针时,应该返回std::make_unique,而不是std::unique_ptr直接返回。其次,RVO并不是某种深奥的,特定于供应商的优化:它已融入标准之中。即使不是这样,它也得到了广泛的支持和预期的行为。首先不需要std::unique_ptr指针时,没有意义返回a 。

4
@Snowman:没有“何时不是”。尽管它只是最近才成为强制性的,但是每个C ++标准都曾经认识到[N] RVO,并为使其启用提供了便利(例如,始终向编译器明确授予其在返回值上省略复制构造函数的使用权限,即使它返回有明显的副作用)。
杰里·科芬
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.