将参数作为const传递时是否过早优化?


20

“过早的优化是万恶之源”

我认为我们都可以同意。我尽力避免这样做。

但是最近我一直想知道通过const Reference而不是Value传递参数的做法。我已经被教导/了解到,非平凡的函数参数(即大多数非原始类型)最好通过const引用传递-我读过的很多书都将其推荐为“最佳实践”。

我仍然不禁感到奇怪:现代的编译器和新的语言功能可以解决奇迹,因此我所学的知识可能已经过时了,而且如果两者之间存在任何性能差异,我实际上也不会费心去剖析。

void fooByValue(SomeDataStruct data);   

void fooByReference(const SomeDataStruct& data);

我所学的做法是-传递const引用(对于非平凡类型默认为)-过早优化吗?


1
另请参阅:C ++核心指南中的F.call,以讨论各种参数传递策略。
阿蒙(Amon)


1
@DocBrown该问题的公认答案是指最小惊讶原则,它在这里也可能适用(即,使用const引用是行业标准等)。就是说,我不同意这个问题是重复的:您所指的问题是(通常)依赖编译器优化是否是错误的做法。这个问题反过来说:传递const是否引用了(过早的)优化?
CharonX

@CharonX:如果这里可以依靠编译器优化,那么您问题的答案显然是“是的,手动优化不是必需的,还为时过早”。如果不能依靠它(也许是因为您事先不知道哪些编译器将用于该代码),答案是“对于较大的对象,它可能还不成熟”。因此,即使这两个问题在字面上并不相等,它们的恕我直言似乎也足够相似,可以将它们作为重复链接在一起。
布朗

1
@DocBrown:因此,在将其声明为Dupe之前,请指出问题所在的地方,指出该编译器将被允许并能够对其进行“优化”。
重复数据删除器

Answers:


49

“过早的优化”不是要尽早使用优化。它是关于在问题被理解之前,运行时间被理解之前进行优化,并且通常会使代码的可读性和可维护性变得更差。

使用“ const&”代替按值传递对象是一种易于理解的优化方法,它对运行时具有很好的理解,几乎不需要任何努力,并且对可读性和可维护性没有任何不良影响。它实际上改善了两者,因为它告诉我调用不会修改传入的对象。因此,在编写代码时直接添加“ const&”不是PREMATURE。


2
我同意您回答中“几乎没有任何努力”的部分。但是过早的优化是最重要的,然后才是对性能的明显影响。而且我认为大多数C ++程序员(包括我自己)在使用之前都无法进行任何测量const&,因此我认为这个问题非常明智。
布朗

1
在进行优化之前先进行测量,以了解是否需要进行权衡。使用const&时,总的工作是键入七个字符,它还有其他优点。当您不打算修改传入的变量时,即使没有提高速度,它也是有利的。
gnasher729

3
我不是C专家,所以一个问题:const& foo表示该函数不会修改foo,因此调用方是安全的。但是复制的值表示没有其他线程可以更改foo,因此被调用方是安全的。对?因此,在多线程应用程序中,答案取决于正确性,而不是优化。
user949300

1
@DocBrown您最终可能会质疑开发人员将const&?如果他只是出于性能考虑而这样做,则可能被认为是过早的优化。现在,如果因为他知道它将是一个const参数而放了它,那么他只是在自我记录自己的代码,并给编译器优化的机会,这更好。
Walfrat

1
@ user949300:很少有函数允许其参数同时或通过使用的回调进行修改,并且它们明确地声明了这一点。
重复数据删除器

16

TL; DR:考虑所有事情,在C ++中通过const引用传递仍然是一个好主意。不是过早的优化。

TL; DR2:大多数格言没有意义,直到做到。


目标

这个答案只是试图扩展C ++核心准则(在amon的评论中首先提到)上的链接项。

这个答案并没有试图解决如何正确思考和应用程序员圈子中广泛流传的各种格言的问题,特别是在矛盾的结论或证据之间调和的问题。


适用性

此答案仅适用于函数调用(同一线程上不可拆分的嵌套作用域)。

(旁注。)当可通过的事物可以逃避范围(即,其生存期可能超出外部范围)时,满足应用程序对对象生存期管理的需求就变得尤为重要。通常,这要求使用也可以进行生命周期管理的引用,例如智能指针。另一种选择是使用管理器。注意,lambda是一种可分离范围。lambda捕获的行为就像具有对象范围。因此,请注意lambda捕获。此外,请注意lambda本身的传递方式-通过复制还是通过引用。


何时传递价值

对于标量值(适合于机器寄存器且具有值语义的标准原语)而言,不需要按变异通信(共享引用),则按值传递。

对于被调用方需要克隆对象或集合的情况,请按值传递,其中被调用方的副本可以满足对克隆对象的需求。


何时通过引用等

对于所有其他情况,请传递指针,引用,智能指针,句柄(请参阅:handle-body惯用语)等。每当遵循此建议时,请照常应用const-correctness原理。

出于性能原因,应始终将内存占用量足够大的事物(聚合,对象,数组,数据结构)设计为促进按引用传递。当它是数百个字节或更多时,此建议绝对适用。当该建议为数十个字节时,这是临界点。


不寻常的范例

有一些特殊用途的编程范例,这些范例有意复制。例如,字符串处理,序列化,网络通信,隔离,第三方库的包装,共享内存的进程间通信等。在这些应用程序区域或编程范例中,数据从结构复制到结构,或有时重新打包到结构中字节数组。


考虑优化之前,语言规范如何影响此答案。

Sub-TL; DR传播参考不应调用任何代码;通过const-reference满足此条件。但是,所有其他语言都可以轻松满足此条件。

(建议新手C ++程序员完全跳过本节。)

(本节的开头部分受到gnasher729的回答的启发。但是,得出了不同的结论。)

C ++允许用户定义的副本构造函数和赋值运算符。

(这是一个大胆的选择,既令人惊讶又令人遗憾。这绝对是与当今语言设计可接受的规范背道而驰。)

即使C ++程序员没有定义一个,C ++编译器也必须根据语言原理生成此类方法,然后确定是否需要执行除之外的其他代码memcpy。例如,包含成员的class/ 必须具有复制构造函数和非平凡的赋值运算符。structstd::vector

在其他语言中,不鼓励复制构造函数和对象克隆(除非绝对必需和/或对应用程序的语义有意义),因为对象通过语言设计具有引用语义。这些语言通常具有基于可达性而不是基于范围的所有权或引用计数的垃圾收集机制。

当在C ++(或C)中传递引用或指针(包括const引用)时,程序员可以确保除了地址值的传播之外,不会执行任何特殊代码(用户定义或编译器生成的函数) (引用或指针)。这是C ++程序员感到满意的行为的清晰说明。

但是,背景是C ++语言不必要地复杂,因此这种行为的清晰性就像核辐射区周围的某个绿洲(可生存的栖息地)。

为了增加祝福(或侮辱),C ++引入了通用引用(r值),以促进具有良好性能的用户定义的移动运算符(move-constructor和move-assignment运算符)。通过减少复制和深度克隆的需求,这可以使高度相关的用例(将对象从一个实例移动(转移)到另一个实例)中受益。但是,在其他语言中,谈论对象的这种移动是不合逻辑的。


(非主题部分)专门讨论文章“想要速度?通过价值传递!”的部分。写于2009年左右。

该文章写于2009年,解释了C ++中r值的设计依据。该文章对我在上一节中的结论提出了有效的反驳。但是,本文的代码示例和性能声明早已遭到反驳。

Sub-TL; DR C ++中r值语义的设计允许在a上惊人地优雅的用户端语义Sort函数。无法用其他语言建模(模仿)这种优雅。

排序功能应用于整个数据结构。如上所述,如果涉及大量复制,将会很慢。作为一种性能优化(实际上是相关的),排序函数被设计为在除C ++之外的许多其他语言中具有破坏性。破坏性意味着修改目标数据结构以实现排序目标。

在C ++中,用户可以选择调用以下两种实现之一:具有更好性能的破坏性实现,或不修改输入的常规实现。(为简洁起见,省略了模板。)

/*caller specifically passes in input argument destructively*/
std::vector<T> my_sort(std::vector<T>&& input)
{
    std::vector<T> result(std::move(input)); /* destructive move */
    std::sort(result.begin(), result.end()); /* in-place sorting */
    return result; /* return-value optimization (RVO) */
}

/*caller specifically passes in read-only argument*/ 
std::vector<T> my_sort(const std::vector<T>& input)
{
    /* reuse destructive implementation by letting it work on a clone. */
    /* Several things involved; e.g. expiring temporaries as r-value */
    /* return-value optimization, etc. */
    return my_sort(std::vector<T>(input));  
}

/*caller can select which to call, by selecting r-value*/
std::vector<T> v1 = {...};
std::vector<T> v2 = my_sort(v1); /*non-destructive*/
std::vector<T> v3 = my_sort(std::move(v1)); /*v1 is gutted*/    

除了排序之外,这种优雅还可以通过递归分区在数组(最初未排序)的破坏性中值发现算法的实现中使用。

但是,请注意,大多数语言将平衡二进制搜索树方法应用于排序,而不是对数组应用破坏性排序算法。因此,该技术的实际相关性并不像看起来那样高。


编译器优化如何影响此答案

在多个级别的函数调用中应用内联(以及整个程序优化/链接时优化)时,编译器能够(有时是穷举性)看到数据流。发生这种情况时,编译器可以应用许多优化,其中一些可以消除在内存中创建整个对象的过程。通常,在这种情况下,通过值或const引用传递参数都没有关系,因为编译器可以进行详尽的分析。

但是,如果较低级别的函数调用的内容超出了分析范围(例如,编译之外的其他库中的内容,或者调用图太简单了),则编译器必须进行防御性优化。

大于机器寄存器值的对象可以通过显式的内存加载/存储指令或通过调用尊贵的对象来复制 memcpy功能。在某些平台上,编译器生成SIMD指令以便在两个内存位置之间移动,每个指令移动数十个字节(16或32)。


有关冗长或视觉混乱问题的讨论

C ++程序员习惯了这一点,即,只要程序员不讨厌C ++,在源代码中编写或读取const-reference的开销就不会太可怕。

成本效益分析之前可能已经进行了很多次。我不知道是否应该引用任何科学论文。我想大多数分析都是非科学的或不可重复的。

这就是我的想象(没有证据或可靠的参考资料)...

  • 是的,它会影响使用这种语言编写的软件的性能。
  • 如果编译器可以理解代码的目的,那么它可能足够聪明,可以自动化
  • 不幸的是,在支持可变性(而不是功能纯净)的语言中,编译器会将大多数事物归类为变异,因此自动推导constness会将大多数事物视为非const
  • 精神开销取决于人。那些认为这是沉重的精神负担的人会拒绝C ++作为可行的编程语言。

在这种情况下,我希望我可以接受两个答案,而不必选择一个答案...
CharonX

8

在DonaldKnuth的论文“ StructuredProgrammingWithGoToStatements”中,他写道:“程序员浪费大量时间来思考或担心程序非关键部分的速度,而这些效率的尝试实际上在考虑调试和维护时会产生严重的负面影响。我们应该忘记效率低下的问题,例如大约97%的时间:过早的优化是万恶之源,但我们不应该在这3%的临界水平上放弃机会。” -过早的优化

这并不建议程序员使用最慢的技术。这是关于在编写程序时注重清晰度。通常,清晰度和效率是一个权衡:如果您只需要选择一个,就选择清晰度。但是,如果您可以轻松实现这两个目标,那么就不必为了避免效率而削弱清晰度(例如表示某些内容是恒定的)。


3
“如果必须只选择一个,请选择清晰度。” 相反,应该首选第二个,因为您可能不得不选择另一个。
重复数据删除器

@Deduplicator谢谢。但是,在OP的上下文中,程序员可以自由选择。
劳伦斯

您的答案比这更笼统...
Deduplicator

@Deduplicator啊,但是我的回答的上下文也是程序员选择的。如果选择是由程序员决定的,那么选择:)并不是“你”。我考虑了您建议的更改,并且不反对您相应地编辑我的答案,但我更倾向于使用现有措词,因为其含义清晰。
劳伦斯

7

传递([const] [rvalue] reference)|(value)应该与接口的意图和承诺有关。它与性能无关。

里奇的经验法则:

void foo(X x);          // I intend to own the x you gave me, whether by copy, move or direct initialisation on the call stack.     

void foo(X&& x);        // I intend to steal x from you. Do not use it other than to re-assign to it after calling me.

void foo(X const& x);   // I guarantee not to change your x

void foo(X& x);         // I may modify your x and I will leave it in a defined state

3

从理论上讲,答案应该是。而且,实际上是有一段时间的-实际上,通过const引用传递而不是仅传递一个值可能是一种悲观,即使在传递的值太大而无法容纳单个值的情况下注册(或人们尝试使用的其他大多数启发式方法来确定何时按值传递)。多年前,大卫·亚伯拉罕(David Abrahams)撰写了一篇题为《希望速度?通过价值传递!》的文章。涵盖其中一些案例。不再容易找到它,但是,如果您可以挖掘出副本,则值得一读(IMO)。

在以const引用传递的具体情况,但是,我想说的成语是这么好建立的情况或多或少是相反的:除非你知道的类型将是char/ short/ int/ long,人们希望看到它通过了常量默认情况下是引用,因此最好还是这样做,除非您有相当具体的理由要这样做。

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.