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

Nat*_*ate 102 c++ coding-style return-value-optimization c++11

简短版本:在许多编程语言中返回大型对象(例如向量/数组)是很常见的.如果类有移动构造函数,这个样式现在在C++ 0x中是否可以接受,或者C++程序员认为它是奇怪/丑陋/可憎的?

长版本:在C++ 0x中,这仍然被认为是不好的形式?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();
Run Code Online (Sandbox Code Playgroud)

传统版本如下所示:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);
Run Code Online (Sandbox Code Playgroud)

在较新的版本,则返回值从BuildLargeVector是一个rvalue,所以v将使用的移动构造函数来构造std::vector,假设(N)RVO不会发生.

甚至之前的C++ 0x第一形式常常是因为(N)RVO"有效"的.但是,(N)RVO由编译器决定.现在我们有rvalue引用,保证不会发生深度复制.

编辑:问题实际上与优化无关.所示的两种形式在现实世界的节目中具有几乎相同的性能.然而,在过去,第一种形式可能具有数量级更差的性能.因此,第一种形式是C++编程中的主要代码味道很长一段时间.不再了,我希望?

Pet*_*der 73

Dave Abrahams 对传递/返回值的速度进行了非常全面的分析.

简短的回答,如果你需要返回一个值然后返回一个值.不要使用输出引用,因为编译器无论如何都要这样做.当然有一些警告,所以你应该阅读那篇文章.

  • "编译器无论如何都会这样做":编译器不需要这样做==不确定性==坏主意(需要100%确定性)."综合分析"该分析存在一个巨大的问题 - 它依赖于未知编译器中的未记录/非标准语言功能("虽然标准从不要求复制省略").因此,即使它有效,使用它也不是一个好主意 - 绝对没有保证它可以按预期工作,并且不保证每个编译器将始终以这种方式工作.依靠这份文件是一个糟糕的编码实践,IMO.即使你会失去表现. (23认同)
  • @Sig:除了程序的实际输出外,实际上保证很少.如果你想100%确定100%的时间会发生什么,那么你最好切换到另一种语言. (16认同)
  • @SigTerm,编译器不需要做很多**,但你认为无论如何都要做.对于`int`s,编译器不是"必须"将'x/2`更改为`x >> 1`,但你认为它会.该标准也没有说明编译器如何实现引用,但您假设使用指针有效地处理它们.该标准也没有提及v-tables,因此您无法确定虚函数调用是否有效.从本质上讲,您需要对编译器有所信任. (11认同)
  • @SigTerm:这是一个很好的评论!大多数参考文章都太模糊,甚至不考虑在生产中使用.人们认为任何撰写红色深度书的作者都是福音书,应该坚持下去,不需要进一步思考或分析.ATM市场上没有一个编译器可以提供像Abrahams在文章中使用的示例那样多样化的复制品. (5认同)
  • @SigTerm:我研究"实际情况".我测试编译器的功能并使用它.没有"可能工作得更慢".它简单地工作得不好,因为编译器实现了RVO,无论标准是否需要它.没有ifs,buts或maybes,这只是一个简单的事实. (5认同)
  • @Peter Alexander:"你有时需要对编译器有所信任".我不能立刻相信所有现有的编译器.我不想被束缚在单一的开发工具或平台上. (4认同)
  • 它由编译器记录.去看看你自己. (4认同)
  • @Peter Alexander:"x/2到x >> 1用于`int`s,但你认为它会","但你假设它们是用指针处理的"实际上,我不认为.它是*未知*编译器将做什么,虽然*可能*编译器将按照您的想法执行,但它不是*保证*,因此假设编译器将执行完全相反的操作(墨菲定律)会更安全."你需要表达一些信仰"我不这么认为.我更喜欢"站稳脚跟".假设编程过多就像玩手榴弹一样 - 有时它工作正常,但如果没有,结果是灾难性的. (3认同)
  • @SigTerm.编译器的作用并不"未知".它仅仅是标准没有规定.我们非常清楚编译器在许多情况下做了什么,以及它们执行的优化(通过实证测试).如果你只按标准要求,那么我想你在所有程序中使用`export`(因为标准要求......对吗?).不,当然不.您将了解编译器可以执行的操作,并在编程时将其考虑在内.这只是常识. (3认同)
  • 我的信念是我在编辑器中使用的信任量与我不信任它们所需的额外工作量成正比.信仰=信任编译器/不信任的工作量.当大于或等于1时,我选择编译器并希望它为我优化. (2认同)
  • @Peter Alexander:如果您想使用未记录的语言功能,您可以这样做,但前提是它们是记录在案的编译器功能。即编译器开发人员提供了一份文档(而不是一个博客),说明编译器何时以及如何使用 RVO/elision。您应该期望此功能将在下一个版本中删除,或者您的公司将决定切换到没有该功能的编译器。“编译器确实实现了 RVO” 错误。“*某些* 编译器实现了 RVO”,并且不保证所有编译器都以这种方式运行。 (2认同)
  • @Peter Alexander:我只相信(或多或少)官方文档。这就是我所说的“站在坚实的地面上”的意思。即编译器开发人员(微软、GCC 开发人员等)提供的 C++ 标准或信息。您的“分析”没有引用它们中的任何一个。这意味着它是一个糟糕的建议,它依赖于未知原因“有效”并且将来必定会停止工作的东西。即使是测试也不总是一个好的解决方案,因为您很容易出现人为错误 - 您可能会错过未记录的功能行为不当的情况。我相信这是讨论的结束。 (2认同)

Jer*_*fin 37

至少IMO,这通常是一个糟糕的主意,但不是出于效率原因.这是一个糟糕的主意,因为有问题的函数通常应该写成通过迭代器生成输出的通用算法.几乎任何接受或返回容器而不是在迭代器上运行的代码都应该被认为是可疑的.

不要误解我的意思:有时候传递类似于集合的对象(例如字符串)是有意义的,但对于引用的例子,我会考虑传递或返回向量一个糟糕的想法.

  • 我不同意.使用迭代器进行输出有时是合适的,但如果您不编写通用算法,通用解决方案通常会提供难以证明的不可避免的开销.在代码复杂性和实际性能方面. (22认同)
  • 我亲自归还一个容器.意图很明确,代码更容易,我写的时候并不关心性能(我只是避免早期的悲观).我不确定使用输出迭代器是否会使我的意图更清晰......我需要尽可能多的非模板代码,因为在大型项目中依赖性会导致开发失败. (9认同)
  • 出色的见解. (5认同)
  • 迭代器方法的问题是它需要使模板化的函数和方法,即使已知集合元素类型.这是令人恼火的,当有问题的方法是虚拟的,不可能.注意,我对你的答案本身并不反对,但实际上它在C++中变得有点麻烦. (5认同)

pet*_*hen 18

要点是:

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

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

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

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

(我希望C++中的一个答案简单明了,没有条件).


Bor*_*ein 8

实际上,从C++ 11开始,复制的成本std::vector在大多数情况下都消失了.

但是,应该记住,构造新向量(然后对其进行破坏)的成本仍然存在,并且当您希望重用向量的容量时,使用输出参数而不是按值返回仍然很有用.这在C++核心指南的F.20中被记录为例外.

让我们比较一下:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}
Run Code Online (Sandbox Code Playgroud)

有:

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

现在,假设我们需要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);
}
Run Code Online (Sandbox Code Playgroud)

使用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);
}
Run Code Online (Sandbox Code Playgroud)

在第一个示例中,发生了许多不必要的动态分配/解除分配,在第二个示例中,通过使用旧方式的输出参数,重用已经分配的内存来防止这种情况.这种优化是否值得进行取决于分配/解除分配的相对成本与计算/变异值的成本相比.

基准

让我们的价值观发挥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;
}
Run Code Online (Sandbox Code Playgroud)

这是结果:

$ 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
Run Code Online (Sandbox Code Playgroud)

基准测试结果

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

我们可以注意到,time1从numIter = 1to到几乎是恒定的numIter = 256,这意味着分配一个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);:

基准2

结论

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

其他平台的结果可能不同.像往常一样,如果性能很重要,请为您的特定用例编写基准.


sti*_*472 6

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

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

String Something::id() const
{
    return valid() ? m_id: "";
}
Run Code Online (Sandbox Code Playgroud)

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

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

我所做的改变很简单:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}
Run Code Online (Sandbox Code Playgroud)

然而,这让世界变得与众不同!热点在随后的分析器会话中消失了,除此之外,我们还进行了大量彻底的单元测试,以跟踪我们的应用程序性能.在这些简单的更改后,各种性能测试时间显着下降.

结论:我们没有使用绝对最新的编译器,但我们似乎仍然无法依靠编译器优化复制以便可靠地返回值(至少在所有情况下都不是这样).对于那些使用像MSVC 2010这样的新编译器的人来说情况可能并非如此.我期待我们何时可以使用C++ 0x并简单地使用rvalue引用,而不必担心我们通过返回复杂来使我们的代码变得悲观按价值分类.

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

  • @Nate:我想你混淆了*三合*像`<::`或``??与*条件运算符*`:!`(有时称为*三元运算符*). (5认同)
  • 这就是事情:RVO依赖于编译器,但如果C++ 0x编译器决定不使用RVO(假设有一个移动构造函数),它必须使用移动语义.使用trigraph操作符可以击败RVO.请参阅Peter提到的http://cpp-next.com/archive/2009/09/move-it-with-rvalue-references/.但是你的例子不适合移动语义,因为你没有返回临时. (3认同)