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

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

这是微优化吗?设计代码时应该牢记?

评论

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

即使对象很大,@ Walfrat也要达到兆字节数量级?由于要解决的问题的性质,我的数组可能会变得很大。

@马特,我不会。引用/指针正是为此目的而存在。编译器优化应该超出了程序员在构建程序时应考虑的范围,尽管是的,但经常是两个世界重叠。
@Matt除非您要做的是非常具体的事情,否则要求开发人员具有超过10年的C /内核经验,而硬件交互较少,则您不需要。如果您认为自己属于某个非常具体的东西,请编辑您的文章并添加对应用程序应做的事情的准确描述(实时?繁重的数学计算?...)

是的,在C ++(N)RVO的特殊情况下,完全依靠这种优化是有效的。这是因为,在现代编译器已经在执行C ++ 17标准的情况下,它才必须执行。

#1 楼

采用最少惊讶的原则。

使用此代码的人是你自己,也只有你一个人,并且确定三年后会不会感到惊讶?

然后继续。

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

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

评论


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

– Pieter B
17-10-12在14:11



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

–user251748
17-10-12在14:18

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

– marcelm
17-10-12在16:08

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

– DeadMG
17-10-12在16:13

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

– Pieter B
17-10-12在21:53

#2 楼

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


RVO和NRVO是众所周知的健壮的优化,即使在C ++ 03中,任何体面的编译器也应该真正进行这些优化。
移动语义可确保在未发生(N)RVO的情况下将对象移出函数。这仅在对象内部使用动态数据时才有用(例如std::vector确实如此),但如果它很大,那确实应该是这样-堆栈溢出对于大型自动对象是有风险的。
C ++ 17强制执行RVO。因此,请放心,它不会消失在您身上,只有在编译器为最新版本时,它才会完全完成自身的建立。

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

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

评论


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

– nwp
17-10-12在16:13

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

–user22815
17-10-12在21:04

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

– Marc Glisse
17-10-13在9:32



#3 楼


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


您可以在一些很小的,很少有人访问的博客中了解到已知的,可爱的,微优化的信息,然后您会在使用时感到聪明和优越。

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

您绝对应该依赖此优化。

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

评论


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

–马特
17-10-12在17:39



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

–rwong
17-10-13在21:53

#4 楼

您编写的代码的正确性永远不应取决于优化。在规范中使用的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)

评论


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

–斯登纳姆
17-10-12在21:29



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

–Cort Ammon
17-10-12在21:48



第二个代码块中的错字是b = b + 1吗?

–stib
17-10-13在4:27

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

– Toby Speight
17-10-13在14:16

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

– prosfilaes
17-10-15在7:18

#5 楼

实际上,C ++程序期望对编译器进行一些优化。

请特别注意标准容器实现的标准标头。使用GCC,您可以使用容器要求大多数源文件(技术上是翻译单元)的预处理形式(g++ -C -E)和GIMPLE内部表示形式(g++ -fdump-tree-gimple或Gimple SSA和-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“最近我的编译器为我做了什么?取消“编译器的盖子”对话。

#6 楼

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

此外,在某些情况下(例如RVO),它是用语言指定的,因此竭尽所能避免使用它似乎毫无意义,特别是如果它使源代码更简单。

努力使编译器产生有效的输出,并且显然是要使用那些功能。

可能有使用未优化代码的原因(例如,用于调试),但是提到了这种情况这个问题似乎不是一个问题(并且如果您的代码仅在优化后失败,并且不是由于您正在运行的设备的某些特殊性造成的,那么某个地方就有一个错误,并且不太可能是在编译器中。)

#7 楼

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

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

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

#8 楼

编译器优化应该只影响性能,而不影响结果。依靠编译器优化来满足非功能性需求不仅是合理的,而且经常是一个编译器被另一个编译器选中的原因。
确定如何执行特定操作(例如索引或溢出条件)的标志经常与编译器优化混为一谈,但不应该这样。它们会明确影响计算结果。

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

评论


不幸的是,许多编译器文档在指定各种模式下的保证或不保证方面做得很差。此外,“现代”的编译器作者似乎对程序员确实需要和不需要的保证相结合。如果x * y> z在溢出情况下任意产生0或1的情况下程序正常运行,前提是它没有其他副作用,则要求程序员必须不惜一切代价防止溢出或迫使编译器评估表达特定的方式会不必要地影响优化效果,而不是说...

–超级猫
17-10-16在15:34

...编译器可能会随意地表现为x * y将其操作数提升为任意更长的类型(从而允许某种形式的提升和强度降低,从而改变某些溢出情况的行为)。但是,许多编译器要求程序员不惜一切代价防止溢出,或者在发生溢出的情况下强制编译器截断所有中间值。

–超级猫
17-10-16在15:40

#9 楼

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

如果我们不觉得我们可以依靠优化器的能力来优化代码,那么我们仍然会在汇编中编写对性能至关重要的执行路径。

这确实是一个问题感觉优化的可靠程度,最好是通过剖析并查看您拥有的编译器的功能,甚至在有热点的情况下进行拆解,就无法确定编译器似乎无法完成的位置,从而最好地进行选择一个明显的优化。

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

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

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

您做得不错,可以依靠优化程序。保持。不要像那个开始明确要求内联循环调用的每个函数的人,甚至在对性能优化器的缺点的误导性恐惧之前进行性能分析之前。确实是回旋处,但是对您问题的最终答案。急于编写高效代码的初学者经常遇到的问题不是要优化的东西,而是要优化的东西,因为他们会因效率低下而产生各种误导的预感,而这些直觉却是人为直观的,但在计算上是错误的。真正开始使用Profiler进行开发的经验将使您不仅对您可以放心地依靠的编译器的优化功能,而且对硬件的功能(以及限制)都有适当的了解。可以说,在剖析中学习不值得优化的内容要比学习过的东西更有价值。

#10 楼

不。

那就是我一直在做的事。如果我需要访问内存中的任意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。相差一个数量级!

有时您可能需要帮助编译器。因此,例如,您可以自己完成操作,而不是依赖编译器展开循环。

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

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

#11 楼

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

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

#12 楼

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

编写依赖于编译器优化的代码是不好的做法,该编译器优化可能会被关闭,并且该漏洞没有记录在案,并且有问题的代码没有经过单元测试,因此如果它破了,您就会知道吗?

编写依赖于不可能被关闭,已记录且已进行单元测试的编译器优化的代码是否是错误的做法?也许不是。

#13 楼

除非您没有告诉我们更多信息,否则这是一种不好的做法,但这并不是出于您建议的原因。
与以前使用的其他语言不同,在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;是非常非常不同的事物;如果未正确使用和理解,前者会导致内存损坏,而如果未正确使用和理解,则后者可能导致内存泄漏。

评论


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

–user22815
17-10-12在21:12

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

–亚伦
17-10-12在21:38

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

–user22815
17-10-12在22:33



#14 楼

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

要回答您的特定问题,这(最有可能)在C ++中的含义是应将std::unique_ptr返回给构造的对象。

原因是对于C ++开发人员来说,这很清楚。

尽管您的方法最有可能奏效,但实际上是在暗示对象不是小值类型,实际上是在发出信号。最重要的是,您丢弃了接口抽象的任何可能性。对于您当前的目的,这可能还可以,但是在处理矩阵时通常非常有用。但是请注意不要假设不使用它们会使代码更清晰。实际上,情况恰恰相反。

评论


在罗马做到入乡随俗。

–user251748
17-10-12在14:19

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

–彼得-恢复莫妮卡
17-10-12在15:56



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

– nwp
17-10-12在16:21

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

–user22815
17-10-12在21:09

@Snowman:没有“何时不是”。尽管它只是最近才成为强制性的,但是每个C ++标准都曾经认识到[N] RVO,并为使其启用提供了便利(例如,始终向编译器明确授予其在返回值上忽略使用复制构造函数的权限,即使有明显的副作用)。

–杰里·科芬(Jerry Coffin)
17-10-13在7:04