如果 const 引用也只需要一个副本,为什么在 C++11 中推荐按值传递(如果需要副本)?

Why is passing by value (if a copy is needed) recommended in C++11 if a const reference only costs a single copy as well?

我试图理解移动语义、右值引用、std::move 等。我一直在尝试通过搜索本网站上的各种问题来弄清楚为什么传递 const std::string &name +如果需要副本,_name(name) 不如 std::string name + _name(std::move(name)) 推荐。

如果我没理解错的话,下面需要一个副本(通过构造函数)加上一个移动(从临时到成员):

Dog::Dog(std::string name) : _name(std::move(name)) {}

另一种(也是老式的)方法是通过引用传递它并复制它(从对成员的引用):

Dog::Dog(const std::string &name) : _name(name) {}

如果第一种方法需要复制和移动两者,而第二种方法只需要一个副本,那么第一种方法如何成为首选,并且在某些情况下更快?

消费数据时,您需要一个可以消费的对象。当您获得 std::string const& 时,您 必须独立于是否需要参数来复制对象。

当按值传递对象时,如果必须复制对象,则对象将被复制,即当传递的对象不是临时对象时。但是,如果它恰好是临时对象,则可以就地构建对象,即,任何副本都可能已被删除,您只需支付移动构建费用。也就是说,有可能实际上没有复制发生。

考虑用左值和右值调用各种选项:

  1. Dog::Dog(const std::string &name) : _name(name) {}
    

    无论是用左值还是右值调用,这都需要一个副本,以从 name 初始化 _name。移动不是一种选择,因为 nameconst

  2. Dog::Dog(std::string &&name) : _name(std::move(name)) {}
    

    这只能用右值调用,它会移动。

  3.  Dog::Dog(std::string name) : _name(std::move(name)) {}
    

    当使用左值调用时,这将复制以传递参数,然后移动以填充数据成员。当使用右值调用时,这将移动到传递参数,然后移动到填充数据成员。在右值的情况下,可以省略传递参数的移动。因此,用左值调用它会导致一次复制和一次移动,而用右值调用它会导致一到两次移动。

最佳解决方案是同时定义(1)(2)。解决方案 (3) 可以相对于最优解有额外的移动。但是编写一个函数比编写两个几乎相同的函数更短且更易于维护,而且移动被认为是便宜的。

当使用可隐式转换为字符串的值调用时,如 const char*,会发生隐式转换,这涉及长度计算和字符串数据的副本。然后我们陷入了右值的情况。在这种情况下,使用 string_view 提供了另一种选择:

  1. Dog::Dog(std::string_view name) : _name(name) {}
    

    当使用字符串左值或右值调用时,会生成一个副本。当使用 const char* 调用时,进行一次长度计算并复制一份。

首先简短回答:通过 const& 调用将始终花费一个副本。根据条件按值调用可能只花费一步 .但这取决于(请查看下面的代码示例以了解此 table 所指的场景):

            lvalue        rvalue      unused lvalue  unused rvalue
            ------------------------------------------------------
const&      copy          copy        -              -
rvalue&&    -             move        -              -
value       copy, move    move        copy           - 
T&&         copy          move        -              -
overload    copy          move        -              - 

所以我的执行摘要是如果

  • 一步很便宜,因为可能会有额外的一步
  • 无条件使用该参数。如果不使用参数,按值调用也会花费一个副本,例如因为 if 子句或某事。

按价值调用

考虑一个用于复制其参数的函数

class Dog {
public:
    void name_it(const std::string& newName) { names.push_back(newName); }
private:
    std::vector<std::string> names;
};

如果左值传递给 name_it,在右值的情况下,您也将有两个复制操作。那很糟糕,因为我可以移动右值。

一种可能的解决方案 是为右值编写重载:

class Dog {
public:
    void name_it(const std::string& newName) { names.push_back(newName); }
    void name_it(std::string&& newName) { names.push_back(std::move(newName)); }
private:
    std::vector<std::string> names;
};

这解决了问题,一切都很好,尽管您有两个代码,两个函数的代码完全相同。

另一个可行的解决方案 是使用完美转发,但这也有几个缺点,(例如完美转发函数非常贪婪并且使现有的重载 const& 函数无用,通常他们需要在头文件中,他们在目标代码中创建几个函数等等。)

class Dog {
public:
    template<typename T>
    void name_it(T&& in_name) { names.push_back(std::forward<T>(in_name)); }
private:
    std::vector<std::string> names;
};

另一种解决方案 是使用按值调用:

class Dog {
public:
    void name_it(std::string newName) { names.push_back(std::move(newName)); }
private:
    std::vector<std::string> names;
};

重要的是,正如你提到的std::move。这样您将拥有一个用于右值和左值的函数。您将移动右值但接受左值的额外移动,这可能很好如果移动便宜并且您复制或移动参数而不考虑条件。

所以最后我真的认为推荐一种方式而不是其他方式是完全错误的。这在很大程度上取决于。

#include <vector>
#include <iostream>
#include <utility>

using std::cout;

class foo{
public:
    //constructor
    foo()  {}
    foo(const foo&)  { cout << "\tcopy\n" ; }
    foo(foo&&)  { cout << "\tmove\n" ; }
};

class VDog {
public:
    VDog(foo name) : _name(std::move(name)) {}
private:
    foo _name;
};

class RRDog {
public:
    RRDog(foo&& name) : _name(std::move(name)) {}
private:
    foo _name;
};

class CRDog {
public:
    CRDog(const foo& name) : _name(name) {}
private:
    foo _name;
};

class PFDog {
public:
    template <typename T>
    PFDog(T&& name) : _name(std::forward<T>(name)) {}
private:
    foo _name;
};

//
volatile int s=0;

class Dog {
public:
    void name_it_cr(const foo& in_name) { names.push_back(in_name); }
    void name_it_rr(foo&& in_name)   { names.push_back(std::move(in_name));}
    
    void name_it_v(foo in_name) { names.push_back(std::move(in_name)); }
    template<typename T>
    void name_it_ur(T&& in_name) { names.push_back(std::forward<T>(in_name)); }
private:
    std::vector<foo> names;
};


int main()
{
    std::cout << "--- const& ---\n";
    {
        Dog a,b;
        foo my_foo;
        std::cout << "lvalue:";
        a.name_it_cr(my_foo);
        std::cout << "rvalue:";
        b.name_it_cr(foo());
    }
    std::cout << "--- rvalue&& ---\n";
    {
        Dog a,b;
        foo my_foo;
        std::cout << "lvalue: -\n";
        std::cout << "rvalue:";
        a.name_it_rr(foo());
    }
    std::cout << "--- value ---\n";
    {
        Dog a,b;
        foo my_foo;
        std::cout << "lvalue:";
        a.name_it_v(my_foo);
        std::cout << "rvalue:";
        b.name_it_v(foo());
    }
    std::cout << "--- T&&--\n";
    {
        Dog a,b;
        foo my_foo;
        std::cout << "lvalue:";
        a.name_it_ur(my_foo);
        std::cout << "rvalue:";
        b.name_it_ur(foo());
    }
    
    
    return 0;
}

输出:

--- const& ---
lvalue: copy
rvalue: copy
--- rvalue&& ---
lvalue: -
rvalue: move
--- value ---
lvalue: copy
    move
rvalue: move
--- T&&--
lvalue: copy
rvalue: move

除了性能原因之外,当副本在按值构造函数上抛出异常时,它首先抛给调用者,而不是在构造函数本身内。这使得编写 noexcept 构造函数变得更容易,而不必担心资源泄漏或构造函数上的 try/catch 块。

struct A {
    std::string a;

    A( ) = default;
    ~A( ) = default;
    A( A && ) noexcept = default;
    A &operator=( A && ) noexcept = default;

    A( A const &other ) : a{other.a} {
        throw 1;
    }
    A &operator=( A const &rhs ) {
        if( this != &rhs ) {
            a = rhs.a;
            throw 1;
        }
        return *this;
    }
};

struct B {
    A a;

    B( A value ) try : a { std::move( value ) }
    { std::cout << "B constructor\n"; }
    catch( ... ) {
        std::cerr << "Exception in B initializer\n";
    }
};

struct C {
    A a;

    C( A const &value ) try : a { value }
    { std::cout << "C constructor\n"; }
    catch( ... ) {
        std::cerr << "Exception in C initializer\n";
    }
};

    int main( int, char ** ) {

    try {
        A a;
        B b{a};
    } catch(...) { std::cerr << "Exception outside B2\n"; }



    try {
        A a;
        C c{a};
    } catch(...) { std::cerr << "Exception outside C\n"; }

    return EXIT_SUCCESS;
}

会输出

Exception outside B2
Exception in C initializer
Exception outside C

我做了一个实验:

#include <cstdio>
#include <utility>

struct Base {
  Base() { id++; }
  static int id;
};

int Base::id = 0;

struct Copyable : public Base {
  Copyable() = default;
  Copyable(const Copyable &c) { printf("Copyable [%d] is copied\n", id); }
};

struct Movable : public Base {
  Movable() = default;

  Movable(Movable &&m) { printf("Movable [%d] is moved\n", id); }
};

struct CopyableAndMovable : public Base {
  CopyableAndMovable() = default;

  CopyableAndMovable(const CopyableAndMovable &c) {
    printf("CopyableAndMovable [%d] is copied\n", id);
  }

  CopyableAndMovable(CopyableAndMovable &&m) {
    printf("CopyableAndMovable [%d] is moved\n", id);
  }
};

struct TEST1 {
  TEST1() = default;
  TEST1(Copyable c) : q(std::move(c)) {}
  TEST1(Movable c) : w(std::move(c)) {}
  TEST1(CopyableAndMovable c) : e(std::move(c)) {}

  Copyable q;
  Movable w;
  CopyableAndMovable e;
};

struct TEST2 {
  TEST2() = default;
  TEST2(Copyable const &c) : q(c) {}
  //  TEST2(Movable const &c) : w(c)) {}
  TEST2(CopyableAndMovable const &c) : e(std::move(c)) {}

  Copyable q;
  Movable w;
  CopyableAndMovable e;
};

int main() {
  Copyable c1;
  Movable c2;
  CopyableAndMovable c3;
  printf("1\n");
  TEST1 z(c1);
  printf("2\n");
  TEST1 x(std::move(c2));
  printf("3\n");
  TEST1 y(c3);

  printf("4\n");
  TEST2 a(c1);
  printf("5\n");
  TEST2 s(c3);

  printf("DONE\n");
  return 0;
}

结果如下:

1
Copyable [4] is copied
Copyable [5] is copied
2
Movable [8] is moved
Movable [10] is moved
3
CopyableAndMovable [12] is copied
CopyableAndMovable [15] is moved
4
Copyable [16] is copied
5
CopyableAndMovable [21] is copied
DONE

结论:

template <typename T>
Dog::Dog(const T &name) : _name(name) {} 
// if T is only copyable, then it will be copied once
// if T is only movable, it results in compilation error (conclusion: define separate move constructor)
// if T is both copyable and movable, it results in one copy

template <typename T>
Dog::Dog(T name) : _name(std::move(name)) {}
// if T is only copyable, then it results in 2 copies
// if T is only movable, and you called Dog(std::move(name)), it results in 2 moves
// if T is both copyable and movable, it results in one copy, then one move.