何时更喜欢 const 左值引用而不是右值引用模板

When to prefer const lvalue reference over rvalue reference templates

当前正在阅读 cpr 请求库的代码库: https://github.com/whoshuu/cpr/blob/master/include/cpr/api.h

注意到这个库的接口经常使用完美转发。只是学习右值引用,所以这对我来说都是相对较新的。

根据我的理解,右值引用、模板化和转发的好处是被环绕的函数调用将通过右值引用而不是值来获取其参数。这避免了不必要的复制。它还可以防止由于引用扣除而不得不生成一堆重载。

然而,根据我的理解,const 左值引用本质上做同样的事情。它避免了重载的需要,并通过引用传递所有内容。需要注意的是,如果被包裹的函数采用非常量引用,它将无法编译。

但是,如果调用堆栈中的所有内容都不需要非常量引用,那么为什么不通过 const 左值引用传递所有内容呢?

我想我的主要问题是,什么时候应该使用一个而不是另一个以获得最佳性能?尝试使用以下代码对此进行测试。得到了以下比较一致的结果:

编译器:gcc 6.3 OS: Debian GNU/Linux 9

<<<<
Passing rvalue!
const l value: 2912214
rvalue forwarding: 2082953
Passing lvalue!
const l value: 1219173
rvalue forwarding: 1585913
>>>>

这些结果在运行之间保持相当一致。看起来对于右值 arg,const l 值签名稍微慢一些,尽管我不确定为什么,除非我误解了这一点并且 const 左值引用实际上复制了右值。

对于左值 arg,我们看到计数器,右值转发较慢。为什么会这样?引用推导不应该总是产生对左值的引用吗?如果是这样的话,就性能而言,它不应该或多或少等同于 const 左值引用吗?

#include <iostream>
#include <string>
#include <utility>
#include <time.h>

std::string func1(const std::string& arg) {
    std::string test(arg);
    return test;
}

template <typename T>
std::string func2(T&& arg) {
    std::string test(std::forward<T>(arg));
    return test;
}

void wrap1(const std::string& arg) {
    func1(arg);
}

template <typename T>
void wrap2(T&& arg) {
    func2(std::forward<T>(arg));
}

int main()
{
     auto n = 100000000;

     /// Passing rvalue
     std::cout << "Passing rvalue!" << std::endl;

     // Test const l value
     auto t = clock();
     for (int i = 0; i < n; ++i)
         wrap1("test");
     std::cout << "const l value: " << clock() - t << std::endl;

     // Test rvalue forwarding
     t = clock();
     for (int i = 0; i < n; ++i)
         wrap2("test");
     std::cout << "rvalue forwarding: " <<  clock() - t << std::endl;

     std::cout << "Passing lvalue!" << std::endl;

     /// Passing lvalue
     std::string arg = "test";

     // Test const l value
     t = clock();
     for (int i = 0; i < n; ++i)
         wrap1(arg);
     std::cout << "const l value: " << clock() - t << std::endl;

     // Test rvalue forwarding
     t = clock();
     for (int i = 0; i < n; ++i)
         wrap2(arg);
     std::cout << "rvalue forwarding: " << clock() - t << std::endl;

}

首先,here are 与您的代码的结果略有不同。如评论中所述,编译器及其设置非常重要。特别是,您可能会注意到所有情况都有类似的运行时间,除了第一个,它大约慢两倍。

Passing rvalue!
const l value: 1357465
rvalue forwarding: 669589
Passing lvalue!
const l value: 744105
rvalue forwarding: 713189

让我们看看每种情况下到底发生了什么。

1) 调用 wrap1("test") 时,由于该函数的签名需要一个 const std::string &,您传递的 char 数组将在每次调用时隐式转换为临时 std::string 对象(即 n 次),这涉及值的副本*。然后,对该临时对象的 const 引用将被传递到 func1,其中另一个 std::string 是从它构造的,它再次涉及一个副本(因为它是一个 const 引用,它不能被移动,尽管在事实上是暂时的)。即使按值 returns 函数,由于 RVO,如果使用 return 值,该副本将保证被删除。在这种情况下,不使用 return 值,我不完全确定标准是否允许编译器优化 temp 的构造。我怀疑不会,因为通常这样的构造可能会产生明显的副作用(并且您的结果表明它没有得到优化)。综上所述,在这种情况下,std::string 的完整构造和破坏被执行了两次。

2) 当调用 wrap2("test") 时,参数类型是 const char[5],它作为右值引用一直被转发到 func2,其中一个 std::string来自 const char[] 的构造函数被调用以复制值。模板参数 T 的推导类型是 const char[5] &&,很明显,尽管它是一个右值引用,但它不能被移动(因为两者都是 const 不是 是一个 std::string)。与前一种情况相比,字符串的 construction/destruction 每次调用仅发生一次(const char[5] 文字始终在内存中,不会产生开销)。

3) 当调用 wrap1(arg) 时,您通过链将左值作为 const string & 传递,并且在 func1.

中调用了一个复制构造函数

4) 当调用 wrap2(arg) 时,这与前面的情况类似,因为 T 的推导类型是 const std::string &.

5) 我假设您的测试旨在展示当需要在调用链底部制作参数副本时完美转发的优势(因此创建 temp) .在这种情况下,您需要将前两种情况下的 "test" 参数替换为 std::string("test") 才能真正拥有 std::string && 参数,并将您的完美转发修复为 std::forward<T>(arg),如评论中所述。在这种情况下,the results 是:

Passing rvalue!
const l value: 1314630
rvalue forwarding: 595084
Passing lvalue!
const l value: 712461
rvalue forwarding: 720338

这与我们之前的类似,但现在实际上调用了一个移动构造函数。

我希望这有助于解释结果。可能还有一些与函数调用内联和其他编译器优化相关的其他问题,这将有助于解释案例 2-4 之间较小的差异。

关于使用哪种方法的问题,我建议阅读 Scott Meyer 的 "Effective Modern C++" 项目 23-30。对于书籍参考而不是直接答案表示歉意,但没有灵丹妙药,最佳选择总是视情况而定,因此最好只了解每个设计决策的权衡。


* 由于短字符串优化,复制构造函数可能涉及也可能不涉及动态内存分配;感谢 ytoledano 在评论中提出这个问题。此外,我在整个答案中都隐含地假设副本比移动要贵得多,但情况并非总是如此。