在C++中,从函数返回一个vector仍然是不好的实践吗?

111

简短版:在许多编程语言中,返回大对象(例如向量/数组)是很常见的。如果一个类具有移动构造函数,那么在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 返回的值是一个右值引用(rvalue),因此假设没有发生 (N)RVO,v 将使用 std::vector 的移动构造函数来构造。
即使在 C++0x 之前,第一种形式通常也会因为 (N)RVO 而变得“高效”。但是,(N)RVO 取决于编译器。现在我们有了右值引用,可以保证不会发生深拷贝。 编辑: 问题实际上并不涉及优化。在真实世界的程序中,显示的两种形式的性能几乎相同。而过去,第一种形式的性能可能会差一个数量级。因此,第一种形式在很长一段时间内都是 C++ 编程中的一个主要代码异味。希望现在不再是这样了?

19
谁说一开始就不好的形式了? - Edward Strange
9
“旧时代”里,也就是我的出生年代,这绝对是一种糟糕的代码味道。 :-) - Nate
1
我非常希望如此!我希望看到传值调用变得更加流行。 :) - sellibitze
7个回答

77

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

简单来说,如果需要返回一个值,请直接返回该值。不要使用输出引用,因为编译器会自动处理。当然,这存在一些注意事项,所以您应该阅读这篇文章。


28
“compiler does it anyway”:编译器不要求那样做 == 不确定性 == 不好的想法(需要100%的确定性)。 “comprehensive analysis”:这种分析存在一个巨大问题 - 它依赖于未记录/非标准语言特性和未知编译器(“虽然复制省略从未被标准所要求”)。因此,即使它能够工作,使用它也不是一个好主意 - 绝对没有保证它会按预期工作,并且没有保证每个编译器都会始终以这种方式工作。依赖这份文档是一种糟糕的编程实践,我认为即使你会失去性能,也应当避免使用它。 - SigTerm
6
@SigTerm:这是一条非常好的评论!!!引用文章的大部分内容都太模糊了,甚至无法考虑在生产中使用。人们认为任何一位撰写过《红色深度》书籍的作者都是圣经,应该毫无思考或分析地遵循。目前市场上没有一款编译器能够提供像Abrahams在文章中使用的那么多种不同的复制省略方式。 - Hippicoder
15
@SigTerm,编译器有很多不必须做的事情,但你仍然假设它会去做。例如,编译器并不“要求”将 x / 2 转换为 x >> 1(对于整数),但你却假设它会这么做。标准也没有规定编译器必须如何实现引用,但你假设它们能够高效地使用指针来处理。同样,标准也没有关于虚函数表的规定,因此你不能确定虚函数调用是否高效。基本上,有时候你需要对编译器抱有信心。 - Peter Alexander
16
除了你程序的实际输出以外,实际上很少有其他东西是可以保证的。如果你想要100%确定在任何情况下都会发生什么,那么最好直接转换到另一种编程语言。 - Dennis Zickefoose
6
@SigTerm:我处理“实际情况场景”。我测试编译器的行为并与之协作。没有“可能运行更慢”的情况。这是因为编译器确实实现了RVO,无论标准是否要求。没有任何条件或者可能性,这只是简单的事实。 - Peter Alexander
显示剩余16条评论

37

在我看来,通常来说这是一个不好的想法,但并不是因为效率问题。这是一个不好的想法,因为相关的函数通常应该被编写成通过迭代器生成其输出的通用算法。任何接受或返回容器而不是操作迭代器的代码都应被视为可疑。

不要误解我的意思:有时传递类似集合的对象是有道理的(例如字符串),但对于所引用的示例,我认为传递或返回向量是个不好的想法。


7
使用迭代器的问题在于,即使已知集合元素类型,它也需要您将函数和方法模板化。这很烦人,而且当涉及到虚拟方法时,这是不可能的。请注意,我并不完全反对您的答案,但在实践中,在C++中使用这种方法有点繁琐。 - jon hanson
25
我不同意。在输出时使用迭代器有时是合适的,但如果您没有编写通用算法,通用解决方案经常会提供难以证明其必要性的额外开销,无论是在代码复杂度还是实际性能方面。 - Dennis Zickefoose
1
@Dennis:我必须说我的经验恰好相反:即使我提前知道所涉及的类型,我仍然会将很多东西编写为模板,因为这样做更简单且可以提高性能。 - Jerry Coffin
9
我个人返回一个容器。意图明确,代码更简单,我在编写时不太关心性能(只是避免过早优化)。我不确定是否使用输出迭代器会使我的意图更清晰...而且尽可能需要非模板代码,因为在大型项目中依赖关系会妨碍开发。 - Matthieu M.
1
@Dennis:我认为从概念上讲,你永远不应该“构建容器而不是写入范围”。容器只是一个容器。你关心的应该是内容,而不是容器本身。 - Jerry Coffin
显示剩余3条评论

19
要点是:
Copy Elision 和 RVO 可以避免“可怕的拷贝”(编译器不一定实现这些优化,有些情况下也无法应用)。
C++ 0x 右值引用允许实现一个字符串/向量实现,保证这个问题。
如果可以放弃旧的编译器/STL实现,则可以自由返回向量(并确保你自己的对象也支持它)。如果您的代码库需要支持“较差”的编译器,请使用旧的风格。
不幸的是,这对您的接口有重大影响。如果 C++ 0x 不是一个选择,并且您需要保证,您可能会在某些场景中使用引用计数或写时复制对象。然而,它们在多线程方面有缺点。
(我希望 C++ 中只有一个答案是简单明了的,没有条件限制。)

18

实际上,自从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

Benchmark results

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

注:mem(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倍 :)

我们可以注意到,从 numIter = 1numIter = 256,time1几乎是恒定的,这意味着分配一个8GB的巨大向量与分配256个32MB的向量成本几乎相同。然而,分配一个8GB的巨大向量肯定比分配一个32MB的向量更昂贵,因此重用向量的容量提供了性能增益。

numIter = 512(mem(v)= 16MB)到numIter = 8M(mem(v)= 1kB)是最佳的选择:两种方法的速度完全相同,并且比所有其他numIter和vecSize组合更快。这可能与我的处理器的L3缓存大小为8MB有关,因此向量几乎完全适合缓存。我没有真正解释为什么time1的突然跳跃是在mem(v)= 16MB时,似乎更合理的是在mem(v)= 8MB之后发生。请注意,令人惊讶的是,在这个最佳状态下,不重复使用容量实际上略微更快!我没有真正解释这一点。
numIter> 8M时,情况开始变得丑陋。两种方法都变慢,但通过值返回向量变得更慢。在最坏的情况下,仅包含一个单独的int的向量,重用容量而不是按值返回,速度要快3.3倍。这可能是由于malloc()的固定成本开始占主导地位。
请注意,time2的曲线比time1的曲线更平滑:不仅重新使用向量容量通常更快,而且更重要的是,它更可预测。
请注意,在优化点上,我们能够在约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);,则以下是结果:

Benchmark 2

结论

  1. 使用输出参数而不是通过值返回可能通过重复使用容量提供性能收益。
  2. 在现代台式电脑上,这似乎只适用于大型向量(>16MB)和小型向量(<1kB)。
  3. 避免分配数百万/数十亿个小向量(<1kB)。如果可能的话,请重复使用容量,或者更好的是,以不同的架构设计您的体系结构。

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


5

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

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

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

请注意,我们使用了自己的String类型(这是必需的,因为我们提供了一个软件开发工具包,插件编写者可能使用不同的编译器和因此不兼容的std :: string / std :: wstring实现)。
我在响应调用图采样分析会话时对代码进行了简单的更改,该会话显示String :: String(const String&)占用了大量时间。像上面示例中的方法是最大的贡献者(实际上,分析会话显示内存分配和释放是最大的热点之一,而字符串复制构造函数是分配的主要贡献者)。
我所做的更改很简单:
static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

然而,这样做真的有很大的改善!在后续的分析器会话中,热点消失了。除此之外,我们还进行了大量彻底的单元测试来跟踪应用程序性能。所有种类的性能测试时间在这些简单更改后都显著下降了。
结论:虽然我们没有使用最新的编译器,但我们似乎仍然不能依赖于编译器可靠地优化返回值的复制(至少不是在所有情况下)。对于使用较新的编译器(如MSVC 2010)的人来说可能并非如此。我期待着使用C++0x,并简单地使用右值引用,从而不必担心通过值返回复杂类会使我们的代码变慢。
[编辑] 正如Nate所指出的那样,RVO适用于返回函数内部创建的临时对象。在我的情况下,没有这样的临时变量(除了我们构造空字符串的无效分支),因此RVO将不适用。

3
问题在于:RVO取决于编译器,但如果C++0x编译器决定不使用RVO(假设有一个移动构造函数),则必须使用移动语义。使用三字符操作符会破坏RVO。请参见Peter所提到的http://cpp-next.com/archive/2009/09/move-it-with-rvalue-references/。但是你的示例无论如何都不适合移动语义,因为你没有返回临时对象。 - Nate
@Stinky472:返回成员值始终比引用慢。如果调用者可以使用引用而不是需要复制,则返回对原始成员的引用仍然比rvalue引用慢。此外,由于您具有上下文,因此仍然有许多节省时间的方法,超过rvalue引用。例如,您可以执行String newstring; newstring.resize(string1.size() + string2.size() + ...); newstring += string1; newstring += string2;等。这仍然比rvalues节省大量时间。 - Puppy
@DeadMG 在使用C++0x编译器实现RVO时,与二元运算符+相比,是否能获得实质性的节省?如果是这样的话,那就很遗憾。再说,那也很合理,因为我们仍然需要创建一个临时变量来计算连接的字符串,而 += 可以直接将字符串连接到新字符串上。 - stinky472
如果有这样一种情况:string newstr = str1 + str2; 在实现移动语义的编译器上,它似乎应该与以下代码一样快甚至更快:string newstr; newstr += str1; newstr += str2; 没有预留空间,就是这个意思(我假设你是指预留而不是调整大小)。 - stinky472
5
@Nate:我认为你把<::??!这样的三字符组?:(有时称为三元运算符)这个条件运算符混淆了。 - fredoverflow
@stinky472:对于只有两个字符串的情况,没有什么区别。但如果有更多的字符串,那就很重要了,特别是如果你想要一个在C++03编译器上表现良好的系统。在我看来,编译器应该有自由处理右值的能力。如果标准放宽规定,我们可能会做得更好。 - Puppy

3

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


4
@Billy:std::vector是一个具有复制语义的值类型。当前的C++标准并不保证(N)RVO一定会被应用,实际上,在许多现实场景中,它并没有被应用。 - Nemanja Trifunovic
3
@Billy:再次强调,有一些非常真实的情况,即使使用最新的编译器也不能应用命名返回值优化(NRVO):http://www.efnetcpp.org/wiki/Return_value_optimization#Named_RVO - Nemanja Trifunovic
3
@Billy ONeal: 99%并不足够,你需要100%。墨菲定律——“如果有什么事情可能会出错,它就会出错”。如果您在编写传统软件时存在任何1%的可能性,导致代码无法按照预期工作,那么您应该预计这段代码将引入严重的错误,进而被解雇。此外,这也不是一个标准特性。使用未记录的特性是个坏主意——如果在一年内,编译器放弃了这个特性(因为它不是标准所必需的),那么你将会陷入麻烦。 - SigTerm
4
@SigTerm:如果我们讨论的是行为的正确性,我会同意您的看法。但是,我们正在讨论一项性能优化。这种事情即使不到100%的确定性也是可以接受的。 - Billy ONeal
2
@Nemanja:我不明白这里有什么“依赖”的东西。无论使用RVO还是NRVO,您的应用程序都会运行相同。但如果使用它们,它将运行得更快。如果您的应用程序在特定平台上太慢,并且您追溯到返回值复制,则请务必更改它,但这并不改变最佳实践仍然是使用返回值的事实。如果您绝对需要确保不发生复制,请在shared_ptr中包装向量并称其为一天。 - Billy ONeal
显示剩余6条评论

2

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


1
即使添加了移动构造函数,NRVO仍然存在。 - Billy ONeal
1
@Billy,确实如此,但与问题无关。问题是C++0x是否改变了最佳实践,而NRVO由于C++0x并没有改变。 - Motti

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接