代码之家  ›  专栏  ›  技术社区  ›  Nate

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

  •  98
  • Nate  · 技术社区  · 14 年前

    短版本:

    长版本: 在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 是一个r值,因此v将使用的move构造函数构造 std::vector

    甚至在C++ 0x之前,第一种形式通常是“高效”的,因为(n)RVO。但是,(N)RVO由编译器决定。现在我们有了右值引用 放心 不会有深度复制。

    编辑 :问题不是关于优化。所示的两种形式在实际程序中的性能几乎相同。然而,在过去,第一种形式的性能可能会差一个数量级。因此,第一种形式是C++编程中的一种主要代码气味。希望不会了?

    7 回复  |  直到 14 年前
        1
  •  73
  •   Mgetz    10 年前
        2
  •  37
  •   Jerry Coffin    14 年前

    为了提高效率。这是一个糟糕的想法,因为所讨论的函数通常应该作为一个通过迭代器生成其输出的通用算法来编写。几乎任何接受或返回容器而不是在迭代器上操作的代码都应该被视为可疑代码。

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

        3
  •  18
  •   peterchen    10 年前

    要点是:

    复制省略和RVO 可以 避免“可怕的副本”(编译器不需要实现这些优化,在某些情况下它不能被应用)

    允许 保证 那个。

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

    不幸的是,这对您的接口有很大的影响。如果C++ 0x不是选项,并且需要保证,那么在某些场景中,可以使用引用计数或复制写对象。不过,多线程也有缺点。

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

        4
  •  13
  •   Boris Dalstein    6 年前

    事实上,由于C++ 11,成本 这个 std::vector 在大多数情况下都不见了。

    构造 自毁 它)仍然存在,当您希望重用向量的容量时,使用输出参数而不是按值返回仍然很有用。这在中被记录为一个例外 F.20 的C++核心指南。

    让我们比较一下:

    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);
    }
    

    在第一个例子中,有许多不必要的动态分配/取消分配发生,在第二个例子中,通过使用输出参数旧的方式,重用已经分配的内存来防止这些发生。这个优化是否值得做取决于分配/取消分配的相对成本与计算/改变值的成本相比。

    基准

    让我们来玩玩 vecSize

    更具体地说,让我们使用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

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

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

    numIter = 2 ,重用向量容量而不是重新分配第二个向量的速度快了1.37倍。

    什么时候? numIter = 256

    我们可以注意到time1从 数字=1 数字=256 ,这意味着分配一个8GB的大向量与分配256个32MB的向量开销相当。然而,分配一个8GB的大向量肯定比分配一个32MB的向量更昂贵,因此重用向量的容量可以提高性能。

    numIter = 512 (内存(v)=16MB)至 numIter = 8M 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时达到最佳性能,这是一级缓存的数量级(i7-7700K的一级数据缓存为4x32kB)。

    当然,实际需要对数据进行的计算越多,差异的相关性就越小。下面是我们替换的结果 sum = std::accumulate(v.begin(), v.end(), sum); 通过 for (int k : v) sum += std::sqrt(2.0*k); :

    Benchmark 2

    1. 使用输出参数而不是按值返回 可以 通过重新使用容量提高性能。
    2. 避免分配数百万/数十亿个小向量(<1kB)。如果可能的话,重用容量,或者更好的是,以不同的方式设计您的体系结构。

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

        5
  •  6
  •   stinky472    14 年前

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

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

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

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

    我做的改变很简单:

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

    然而,这让世界变得不同了!热点在随后的profiler会话中消失了,除此之外,我们还做了大量彻底的单元测试来跟踪我们的应用程序性能。在这些简单的更改之后,各种性能测试时间都显著减少。

    结论:我们并没有使用绝对最新的编译器,但我们似乎仍然不能依赖编译器优化复制以可靠地返回值(至少不是在所有情况下)。对于那些使用像MSVC 2010这样的新编译器的情况可能不是这样。我期待着当我们可以使用C++ 0x并简单地使用R值引用,而不必担心我们通过值返回复杂的类来对代码进行悲观。

    [编辑] 正如Nate指出的,RVO适用于返回函数内部创建的临时变量。在我的例子中,没有这样的临时变量(除了构造空字符串的无效分支),因此RVO不适用。

        6
  •  3
  •   Nemanja Trifunovic    14 年前

    boost::shared_array

        7
  •  2
  •   Motti    14 年前

    如果性能是一个真正的问题,那么您应该意识到移动语义并不是一个真正的问题 比复制更快。例如,如果有一个字符串使用 小串优化

    推荐文章