在C ++中,从函数返回向量仍然是不好的做法吗?


102

简短版本:通常以许多编程语言返回大对象(例如向量/数组)。如果该类具有move构造函数,那么C ++ 0x现在可以接受这种样式吗,还是C ++程序员认为它是怪异的/丑陋的/讨厌的?

长版:在C ++ 0x中,仍然认为这是错误的形式吗?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

传统版本如下所示:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

在较新的版本中,从返回的值BuildLargeVector是一个右值,因此将使用std::vector(N)RVO不发生的move构造函数构造v 。

甚至在C ++ 0x之前,由于(N)RVO,第一种形式通常也很“高效”。但是,(N)RVO由编译器决定。现在我们有了右值引用,可以保证不会进行深层复制。

编辑:问题实际上不是关于优化。所示的两种形式在现实程序中的性能几乎相同。过去,第一种形式的性能可能会降低几个数量级。结果,很长一段时间以来,第一种形式是C ++编程中的主要代码味道。我希望不再了吗?


18
谁曾说过这是不好的形式?
Edward Strange 2010年

7
在过去的日子里,这肯定是一种难闻的代码气味,这正是我来自的地方。:-)
Nate

1
我当然希望如此!我希望看到按值传递越来越受欢迎。:)
sellibitze 2010年

Answers:


73

Dave Abrahams 对传递/返回值的速度进行了相当全面的分析。

简短的答案,如果您需要返回一个值,则返回一个值。不要使用输出引用,因为编译器还是会这样做。当然有一些警告,因此您应该阅读该文章。


24
“无论如何,编译器都会这样做”:不需要编译器这样做==不确定性==坏主意(需要100%确定性)。“综合分析”该分析存在一个巨大的问题-它依赖于未知编译器中未记录/非标准的语言功能(“尽管标准从来不需要复制省略”)。因此即使使用它也不是一个好主意-绝对没有保证它可以按预期工作,也没有保证每个编译器都将始终以这种方式工作。IMO依赖该文档是一种不良的编码习惯。即使您会失去表现。
SigTerm 2010年

5
@SigTerm:这是一个很棒的评论!!!大多数被引用的文章都太含糊,甚至不能考虑用于生产中。人们认为写《红色深度》一书的任何作者都是福音,应该坚持下去,而无需任何进一步的思考或分析。在ATM上,市场上没有提供像阿伯拉罕斯在本文中使用的示例那样多样化的复制许可功能的编译器。
Hippicoder

13
@SigTerm,编译器不需要执行很多操作,但是您仍然认为它可以执行。编译器不“需要”,以改变x / 2x >> 1intS,但是你认为它会的。该标准也没有说明如何要求编译器实现引用,但是您假定使用指针可以有效地处理它们。该标准也没有说明v表,因此您也不能确定虚拟函数调用是否有效。本质上,您有时需要相信编译器。
彼得·亚历山大

16
@Sig:除了程序的实际输出外,实际上几乎没有保证。如果您想100%地确定100%会发生的事情,那么最好直接切换到另一种语言。
丹尼斯·齐克福斯

6
@SigTerm:我处理“实际情况”。我测试编译器的功能并使用它。没有“可能工作更慢”。它根本不会变慢,因为无论标准是否要求编译器都实现RVO。没有“如果”,“但是”或“也许”,这只是简单的事实。
彼得·亚历山大

37

至少对于IMO,这通常是一个糟糕的主意,但并非出于效率原因。这是一个糟糕的主意,因为所讨论的函数通常应编写为通过迭代器产生其输出的通用算法。几乎所有接受或返回容器而不是在迭代器上操作的代码都应视为可疑代码。

不要误会我的意思:有时候传递类似集合的对象(例如字符串)是有意义的,但是对于引用的示例,我会认为传递或返回向量是一个糟糕的主意。


6
迭代器方法的问题在于,即使已知集合元素类型,它也需要将函数和方法进行模板化。这很烦人,当所讨论的方法是虚拟的时,这是不可能的。请注意,我并不是不同意您的回答,但实际上,在C ++中,它变得有点麻烦。
jon-hanson 2010年

22
我不同意。有时使用迭代器进行输出是适当的,但是如果您不编写通用算法,则通用解决方案通常会提供不可避免的开销,这很难证明。在代码复杂性和实际性能方面。
丹尼斯·齐克福斯

1
@Dennis:我不得不说我的经验恰好相反:即使我提前知道所涉及的类型,我也会写很多东西作为模板,因为这样做更简单并且可以提高性能。
杰里·科芬

9
我亲自归还了一个容器。目的很明确,代码更容易,编写代码时我不太在意性能(我只是避免过早的悲观化)。我不确定使用输出迭代器是否可以使我的意图更清楚……并且我需要尽可能多的非模板代码,因为在大型项目中,依赖会杀死开发。
Matthieu M.

1
@丹尼斯:从概念上讲,您永远不应该“构建容器而不是写入范围”。容器就是容器。您的关注点(和代码的关注点)应该与内容有关,而不与容器有关。
杰里·科芬

18

要点是:

复制省略和静脉阻塞避免“可怕的副本”(落实这些优化编译器不是必需的,而且在某些情况下,它不能被应用)

C ++ 0x RValue引用允许使用字符串/向量实现来保证这一点。

如果您可以放弃较早的编译器/ STL实现,则可以自由返回向量(并确保您自己的对象也支持它)。如果您的代码库需要支持“较少”的编译器,请坚持使用旧样式。

不幸的是,这对您的界面有重大影响。如果C ++ 0x不是一个选项,并且您需要保证,则在某些情况下,可以使用引用计数或写时复制对象。但是,它们在多线程方面也有缺点。

(我希望C ++中只有一个答案是简单明了且没有条件的)。


11

确实,自C ++ 11起,大多数情况下复制的成本std::vector就消失了。

但是,请记住,构造新向量(然后对其进行销毁)的成本仍然存在,并且当您希望重用向量的容量时,使用输出参数而不是按值返回仍然有用。C ++核心准则的F.20中将此记录为例外。

让我们比较一下:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

与:

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

现在,假设我们需要numIter在一个紧密的循环中多次调用这些方法,并执行一些操作。例如,让我们计算所有元素的总和。

使用BuildLargeVector1,您将执行以下操作:

size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
    std::vector<int> v = BuildLargeVector1(vecSize);
    sum1 = std::accumulate(v.begin(), v.end(), sum1);
}

使用BuildLargeVector2,您将执行以下操作:

size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
    BuildLargeVector2(/*out*/ v, vecSize);
    sum2 = std::accumulate(v.begin(), v.end(), sum2);
}

在第一个示例中,发生了许多不必要的动态分配/取消分配,在第二个示例中,通过按旧方式使用输出参数,重新使用已分配的内存,可以防止这种情况。该优化是否值得做取决于与计算/更改值的成本相比分配/取消分配的相对成本。

基准测试

让我们的价值观发挥vecSizenumIter。我们将保持vecSize * numIter不变,以便“理论上”应该花费相同的时间(=赋值和相加的次数相同,值也完全相同),并且时间差只能来自于分配,释放和更好地使用缓存。

更具体地说,让我们使用vecSize * numIter = 2 ^ 31 = 2147483648,因为我有16GB的RAM,并且此数字可确保分配的内存不超过8GB(sizeof(int)= 4),从而确保我不会交换到磁盘(所有其他程序均已关闭,运行测试时我有约15GB的可用空间)。

这是代码:

#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>

class Timer {
    using clock = std::chrono::steady_clock;
    using seconds = std::chrono::duration<double>;
    clock::time_point t_;

public:
    void tic() { t_ = clock::now(); }
    double toc() const { return seconds(clock::now() - t_).count(); }
};

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

int main() {
    Timer t;

    size_t vecSize = size_t(1) << 31;
    size_t numIter = 1;

    std::cout << std::setw(10) << "vecSize" << ", "
              << std::setw(10) << "numIter" << ", "
              << std::setw(10) << "time1" << ", "
              << std::setw(10) << "time2" << ", "
              << std::setw(10) << "sum1" << ", "
              << std::setw(10) << "sum2" << "\n";

    while (vecSize > 0) {

        t.tic();
        size_t sum1 = 0;
        {
            for (int i = 0; i < numIter; ++i) {
                std::vector<int> v = BuildLargeVector1(vecSize);
                sum1 = std::accumulate(v.begin(), v.end(), sum1);
            }
        }
        double time1 = t.toc();

        t.tic();
        size_t sum2 = 0;
        {
            std::vector<int> v;
            for (int i = 0; i < numIter; ++i) {
                BuildLargeVector2(/*out*/ v, vecSize);
                sum2 = std::accumulate(v.begin(), v.end(), sum2);
            }
        } // deallocate v
        double time2 = t.toc();

        std::cout << std::setw(10) << vecSize << ", "
                  << std::setw(10) << numIter << ", "
                  << std::setw(10) << std::fixed << time1 << ", "
                  << std::setw(10) << std::fixed << time2 << ", "
                  << std::setw(10) << sum1 << ", "
                  << std::setw(10) << sum2 << "\n";

        vecSize /= 2;
        numIter *= 2;
    }

    return 0;
}

结果如下:

$ g++ -std=c++11 -O3 main.cpp && ./a.out
   vecSize,    numIter,      time1,      time2,       sum1,       sum2
2147483648,          1,   2.360384,   2.356355, 2147483648, 2147483648
1073741824,          2,   2.365807,   1.732609, 2147483648, 2147483648
 536870912,          4,   2.373231,   1.420104, 2147483648, 2147483648
 268435456,          8,   2.383480,   1.261789, 2147483648, 2147483648
 134217728,         16,   2.395904,   1.179340, 2147483648, 2147483648
  67108864,         32,   2.408513,   1.131662, 2147483648, 2147483648
  33554432,         64,   2.416114,   1.097719, 2147483648, 2147483648
  16777216,        128,   2.431061,   1.060238, 2147483648, 2147483648
   8388608,        256,   2.448200,   0.998743, 2147483648, 2147483648
   4194304,        512,   0.884540,   0.875196, 2147483648, 2147483648
   2097152,       1024,   0.712911,   0.716124, 2147483648, 2147483648
   1048576,       2048,   0.552157,   0.603028, 2147483648, 2147483648
    524288,       4096,   0.549749,   0.602881, 2147483648, 2147483648
    262144,       8192,   0.547767,   0.604248, 2147483648, 2147483648
    131072,      16384,   0.537548,   0.603802, 2147483648, 2147483648
     65536,      32768,   0.524037,   0.600768, 2147483648, 2147483648
     32768,      65536,   0.526727,   0.598521, 2147483648, 2147483648
     16384,     131072,   0.515227,   0.599254, 2147483648, 2147483648
      8192,     262144,   0.540541,   0.600642, 2147483648, 2147483648
      4096,     524288,   0.495638,   0.603396, 2147483648, 2147483648
      2048,    1048576,   0.512905,   0.609594, 2147483648, 2147483648
      1024,    2097152,   0.548257,   0.622393, 2147483648, 2147483648
       512,    4194304,   0.616906,   0.647442, 2147483648, 2147483648
       256,    8388608,   0.571628,   0.629563, 2147483648, 2147483648
       128,   16777216,   0.846666,   0.657051, 2147483648, 2147483648
        64,   33554432,   0.853286,   0.724897, 2147483648, 2147483648
        32,   67108864,   1.232520,   0.851337, 2147483648, 2147483648
        16,  134217728,   1.982755,   1.079628, 2147483648, 2147483648
         8,  268435456,   3.483588,   1.673199, 2147483648, 2147483648
         4,  536870912,   5.724022,   2.150334, 2147483648, 2147483648
         2, 1073741824,  10.285453,   3.583777, 2147483648, 2147483648
         1, 2147483648,  20.552860,   6.214054, 2147483648, 2147483648

基准结果

(Intel i7-7700K @ 4.20GHz; 16GB DDR4 2400Mhz; Kubuntu 18.04)

表示法:在我的平台上,内存(v)= v.size()* sizeof(int)= v.size()* 4。

毫不奇怪,当numIter = 1(即mem(v)= 8GB)时,时间完全相同。确实,在两种情况下,我们仅在内存中分配了一个巨大的8GB向量。这也证明使用BuildLargeVector1()时没有发生复制:我没有足够的RAM来进行复制!

numIter = 2设为时,重用向量容量而不是重新分配第二个向量快1.37倍。

当时numIter = 256,重用向量容量(而不是一遍又一遍地重复分配/取消向量256次...)快了2.45倍:)

我们可以注意到,time1从numIter = 1到几乎是常数numIter = 256,这意味着分配一个8GB的巨大向量与分配256个32MB的向量几乎一样昂贵。但是,分配一个8GB的巨大向量肯定比分配32MB的向量要昂贵得多,因此重用该向量的容量可提高性能。

numIter = 512(mem(v)= 16MB)到numIter = 8M(mem(v)= 1kB)是最有效的方法:这两种方法的速度完全相同,并且比numIter和vecSize的所有其他组合更快。这可能与以下事实有关:我的处理器的L3高速缓存大小为8MB,因此矢量几乎完全适合高速缓存。我并没有真正解释为什么time1mem(v)= 16MB 的突然跳跃,似乎在mem(v)= 8MB之后才发生,这更合乎逻辑。请注意,令人惊讶的是,在这个最佳位置,不重用容量实际上要快一些!我真的没有解释。

numIter > 8M事情开始变得丑陋。两种方法都会变慢,但按值返回向量会变得更慢。在最坏的情况下,如果向量仅包含一个int,则重用容量(而不是按值返回)的速度要快3.3倍。据推测,这是由于malloc()的固定成本开始占主导地位。

请注意,时间2的曲线比时间1的曲线更平滑:不仅重用向量容量通常更快,而且更重要的是,它更可预测

另请注意,在最佳位置,我们能够在约0.5秒内完成20亿个64位整数的加法运算,这在4.2Ghz 64位处理器上是非常理想的。通过并行化计算以使用所有8个内核,我们可以做得更好(上面的测试一次只使用一个内核,我已经通过在监视CPU使用率的同时重新运行测试来验证了这一点)。当mem(v)= 16kB时,可获得最佳性能,这是L1缓存的数量级(i7-7700K的L1数据缓存为4x32kB)。

当然,实际上您必须对数据进行更多的计算,所以差异变得越来越不重要。如果我们替换为,则结果sum = std::accumulate(v.begin(), v.end(), sum);如下for (int k : v) sum += std::sqrt(2.0*k);

基准2

结论

  1. 使用输出参数而不是按值返回可以通过重用容量来提高性能。
  2. 在现代台式计算机上,这似乎仅适用于大型向量(> 16MB)和小型向量(<1kB)。
  3. 避免分配数百万/十亿的小向量(<1kB)。如果可能,请重用容量,或者更好的方法是,以其他方式设计您的体系结构。

在其他平台上,结果可能会有所不同。通常,如果性能很重要,请为您的特定用例编写基准。


6

我仍然认为这是一种不好的做法,但值得注意的是,我的团队使用MSVC 2008和GCC 4.1,因此我们没有使用最新的编译器。

以前,vtune在MSVC 2008中显示的许多热点都归结为字符串复制。我们有这样的代码:

String Something::id() const
{
    return valid() ? m_id: "";
}

...请注意,我们使用了自己的String类型(这是必需的,因为我们提供了一个软件开发工具包,其中插件编写者可能会使用不同的编译器,因此std :: string / std :: wstring的实现方式是不兼容的)。

为了响应调用图采样分析会话,我做了一个简单的更改,显示出String :: String(const String&)占用了大量时间。上面示例中的方法是最大的贡献者(实际上,分析会话显示内存分配和释放是最大的热点之一,其中String copy构造函数是分配的主要贡献者)。

我所做的更改很简单:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

但这真是与众不同!热点在随后的探查器会话中消失了,除此之外,我们还进行了许多详尽的单元测试以跟踪我们的应用程序性能。经过这些简单的更改,各种性能测试时间都大大减少了。

结论:我们并没有使用绝对最新的编译器,但是我们似乎仍然不能依靠编译器来优化复制,以可靠地按值返回(至少不是在所有情况下)。对于那些使用更新的编译器(如MSVC 2010)的用户而言,情况可能并非如此。我期待着何时可以使用C ++ 0x并仅使用rvalue引用,而不必担心我们会通过返回复杂代码来对代码进行悲观按价值分类。

[编辑]正如Nate所指出的那样,RVO适用于在函数内部创建的返回临时对象。在我的情况下,没有这样的临时变量(除了无效分支以外,我们在其中构造了一个空字符串),因此RVO将不适用。


3
就是这样:RVO依赖于编译器,但是如果C ++ 0x编译器决定不使用RVO(假设有一个move构造函数),则必须使用move语义。使用Trigraph运算符会使RVO失败。参见Peter提及的cpp-next.com/archive/2009/09/move-it-with-rvalue-references。但是您的示例无论如何都不符合移动语义,因为您没有返回临时变量。
内特

@ Stinky472:按值返回成员总是比引用慢。Rvalue引用仍然比返回对原始成员的引用要慢(如果调用者可以使用引用而不需要副本)。另外,由于右上下文引用,您仍然可以保存很多次,因为您具有上下文。例如,您可以执行String newstring; newstring.resize(string1.size()+ string2.size()+ ...); newstring + = string1; newstring + = string2; 等等。相对于右值而言,这仍然是一笔可观的节省。
小狗

@DeadMG即使使用实现RVO的C ++ 0x编译器,也可以大大节省二进制运算符+?如果是这样,那就太可惜了。再说一次,由于我们仍然不得不创建一个临时来计算级联的字符串,而+ =可以直接级联到newstring,因此麻木的感觉。
stinky472

像这样的情况如何:string newstr = str1 + str2; 在实现了移动语义的编译器上,它看起来应该和以下内容一样快甚至更快:string newstr; newstr + = str1; newstr + = str2; 没有储备,可以这么说(我假设您的意思是储备而不是调整大小)。
stinky472

5
@Nate:我想你混淆了三合喜欢<::??!有条件的经营者 ?:(有时被称为三元运算符)。
fredoverflow

3

只是稍微提一下:在许多编程语言中,从函数返回数组并不常见。在大多数情况下,返回对数组的引用。在C ++中,最接近的类比将返回boost::shared_array


4
@Billy:std :: vector是具有复制语义的值类型。当前的C ++标准不能保证(N)RVO会被应用,实际上,有许多现实情况没有应用。
Nemanja Trifunovic

3
@Billy:同样,在某些非常真实的情况下,即使最新的编译器也未应用NRVO:efnetcpp.org/wiki/Return_value_optimization#Named_RVO
Nemanja Trifunovic 2010年

3
@Billy ONeal:99%不够,您需要100%。墨菲定律-“如果出了问题,那就会发生”。如果您要处理某种模糊逻辑,则不确定性很好,但是对于编写传统软件而言,这不是一个好主意。如果甚至有1%的可能性代码无法按照您的想法工作,那么您应该期望该代码将引入严重的错误,这将使您被解雇。另外,它不是标准功能。使用无证功能是一个坏主意-如果知道从编译器一个今年将下降功能(它不是必需的?通过标准,右),你会是一个麻烦。
SigTerm 2010年

4
@SigTerm:如果我们在谈论行为的正确性,我会同意你的看法。但是,我们正在谈论性能优化。这些事情的确定性不到100%。
Billy ONeal

2
@Nemanja:我看不到这里在“依赖”什么。无论使用RVO还是NRVO,您的应用程序都运行相同。如果使用它们,它将运行得更快。如果您的应用程序在特定平台上运行太慢,并且您将其追溯到返回值复制,那么请务必对其进行更改,但这并不能改变最佳实践仍然使用返回值这一事实。如果您绝对需要确保不会发生复制,则将向量包裹在a中shared_ptr并将其命名为day。
Billy ONeal

2

如果性能是一个真正的问题,您应该意识到,移动语义并不总是比复制快。例如,如果您有一个使用小字符串优化的字符串,那么对于小字符串,移动构造函数必须完成与常规副本构造函数完全相同的工作量。


1
NRVO不会仅仅因为添加了move构造函数就消失了。
Billy ONeal 2010年

1
@Billy,是的,但没关系,问题是C ++ 0x改变了最佳实践,而NRVO并没有因为C ++ 0x而改变
Motti 2010年
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.