美文网首页Effective Modern C++
【Effective Modern C++(5)】右值引用、移动

【Effective Modern C++(5)】右值引用、移动

作者: downdemo | 来源:发表于2018-12-11 16:05 被阅读31次
  • 移动语义使编译器可以用开销较低的移动操作替换昂贵的拷贝操作。移动语义也使创建move-only类型对象成为可能,这些类型包括std::unique_ptr、std::future和std::thread
  • 完美转发允许撰写接受任意实参的函数模板,并将其转发到其他函数,目标函数会收到与转发函数所接受的完全相同的实参
  • 右值引用是将这两个不相关的语言特性粘合起来的底层语言机制,正是它使得移动语义和完美转发成为了可能
  • 它们比看上去更为微妙,比如std::move并不进行任何移动, std::foward并不进行任何转发,右值引用并不一定是右值,完美转发并不完美,移动操作的开销并不总比拷贝低,移动操作能成立的语境中并不一定能调用移动操作,T&&并不总是表示右值引用。这一章将统一这些碎片化知识

23 理解std::move和std::forward

  • std::move和std::forward实际是强制类型转换函数,std::move无条件转右值,std::forward只在满足特定条件时进行同样的转换,两者在运行期不做任何操作
  • std::move不完全符合标准的实现如下
template<typename T> // in namespace std
typename remove_reference<T>::type&& // 确保返回右值引用
move(T&& param)
{
    using ReturnType = typename remove_reference<T>::type&&;
    return static_cast<ReturnType>(param);
}

// C++14
template<typename T> // still in namespace std
decltype(auto) move(T&& param)
{
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}
  • 利用std::move转换成右值来移动std::string来替代拷贝
class A {
public:
    explicit A(const std::string text)
    : val(std::move(text)) {} // 将text移动到val
private:
    std::string val;
};
  • 但这样实际上执行的还是拷贝,text是左值const std::string,转换后是右值const std::string,因为const的存在,最终调用的是std::string的拷贝构造函数
class string { // std::string is actually a typedef for std::basic_string<char>
public:
    …
    string(const string& rhs); // 拷贝构造函数:可以接受const右值
    string(string&& rhs); // 移动构造函数:只能接受non-const右值
    …
};
  • 因此,如果希望用std::move后对某个对象实现移动,就不要将其声明为常量,否则对常量的移动就会变成拷贝
  • std::forward与std::move类似,只不过std::move是无条件转换,而std::forward有条件
void process(const Widget& lvalArg); // 处理左值
void process(Widget&& rvalArg); // 处理右值

template<typename T>
void logAndProcess(T&& param)
{
    auto now = std::chrono::system_clock::now(); // 获取当前时间
    makeLogEntry("Calling 'process'", now);
    process(std::forward<T>(param));
}

Widget w;
logAndProcess(w); // 传入左值
logAndProcess(std::move(w)); // 传入右值
  • 预期的目的是传入左值,则调用处理左值的函数,右值同理。但形参都是左值,因此如果直接将param转发给process,则调用的都是左值版本。于是希望当初始化param的实参是右值时,则param被强制转换成右值,这就是std::forward所做的事
  • 虽然std::forward也能实现到右值的转换,但并不能完全替代std::move,std::move的优势在于清晰方便,能减少错误
class Widget {
public:
    Widget(Widget&& rhs)
    : s(std::move(rhs.s))
    { ++moveCtorCalls; }
    …
private:
    static std::size_t moveCtorCalls;
    std::string s;
};

// 用std::forward替代std::move
class Widget {
public:
    Widget(Widget&& rhs)
    : s(std::forward<std::string>(rhs.s))
    { ++moveCtorCalls; }
    …
};
  • 直观对比可以发现,std::forward需要多指定一个模板实参,且接受的必须是非引用类型,而std::move则没有这些麻烦

24 区分转发引用和右值引用

  • T&&不一定就是右值引用
void f(Widget&& param); // 右值引用
Widget&& var1 = Widget(); // 右值引用
auto&& var2 = var1; // 转发引用
template<typename T>
void f(std::vector<T>&& param); // 右值引用
template<typename T>
void f(T&& param); // 转发引用
  • 转发引用涉及类型推断,既可以绑定到右值,也可以绑定到左值。如果T&&没有涉及类型推断,则是右值引用
void f(Widget&& param); // 无类型推断:右值引用
Widget&& var1 = Widget(); // 无类型推断:右值引用
  • 转发引用也是引用,所以必须初始化,初始化值在调用处提供。初始化值为左值则转发引用为左值引用,右值亦然
template<typename T>
void f(T&& param); // param是转发引用
Widget w;
f(w); // 传递左值,param是Widget&,一个左值引用
f(std::move(w)); // 传递右值,param是Widget&&,一个右值引用
  • 注意,转发引用必须严格按T&&的形式涉及类型推断,如下形式虽然涉及类型推断,但不是转发引用
template<typename T>
void f(std::vector<T>&& param); // param是右值引用
std::vector<int> v;
f(v); // 错误:不能将右值引用绑定到左值

template<typename T> // 即使多了const修饰也不行
void f(const T&& param); // param是右值引用
  • T&&在模板中也可能不涉及类型推断
template<class T, class Allocator = allocator<T>>
class vector {
public:
    void push_back(T&& x);
    …
};
  • 这里并不涉及类型推断,因为push_back的声明类型由vector的实例化确定
std::vector<Widget> v;
// 对应的std::vector实例化为
class vector<Widget, allocator<Widget>> {
public:
    void push_back(Widget&& x); // 右值引用
    …
};
  • 但类似于push_back的emplace_back却涉及类型推断
template<class T, class Allocator = allocator<T>>
class vector {
public:
    template <class... Args>
    void emplace_back(Args&&... args); // 转发引用
    …
};
  • auto&&都是转发引用,因为一定涉及类型推断。C++14中,lambda形参可以声明为auto&&
auto timeFuncInvocation =
    [](auto&& func, auto&&... params) // C++14
    {
        启动计时器;
        std::forward<decltype(func)>(func)(
            std::forward<decltype(params)>(params)...
        );
        停止计时器并计算时间;
    };

25 对右值引用使用std::move,对转发引用使用std::forward

  • 右值引用只会绑定到可移动对象上,为了把这些对象传递给其他函数,就要用std::move将形参转换为右值
class Widget {
public:
    Widget(Widget&& rhs) // rhs是右值引用,会绑定到可移动对象上
    : name(std::move(rhs.name)),
    p(std::move(rhs.p))
    { … }
    …
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};
  • 转发引用不一定会绑定到可移动对象上,只有被右值初始化时才会转为右值类型,因此应当使用std::forward
class Widget {
public:
    template<typename T>
    void setName(T&& newName) // newName是转发引用
    { name = std::forward<T>(newName); }
    …
};
  • 之前提过std::forward不能完全替代std::move,反之亦然
class Widget {
public:
    template<typename T>
    void setName(T&& newName) // 转发引用
    { name = std::move(newName); } // 可编译,但很糟糕
    …
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); // 工厂函数
Widget w;
auto n = getWidgetName(); // n是局部变量
w.setName(n); // 把n移入w,n值现在未知
…
  • 当然,上述问题可以通过分别为常量左值和右值重载构造函数解决
class Widget {
public:
    void setName(const std::string& newName)
    { name = newName; }
    void setName(std::string&& newName)
    { name = std::move(newName); }
    …
};
  • 但这样不仅麻烦,而且对于如下构造,还会产生std::string的临时变量
w.setName("Adela Novak"); // 将构造一个std::string临时变量
// 如果是转发引用版本则会直接赋值
  • 此外,这种设计的扩展性差,当形参增多,所需重载版本数量将呈指数增长。形参甚至可以是无穷多个,比如智能指针中的make函数,此时只能依靠转发引用
template<class T, class... Args> // C++11
shared_ptr<T> make_shared(Args&&... args);
template<class T, class... Args> // C++14
unique_ptr<T> make_unique(Args&&... args);
  • 如果在单个函数中把某个对象多次绑定到右值引用或转发引用,就只能在最后一次使用该引用时使用std::move(右值引用)或std::forward(转发引用)
template<typename T> // text is
void setSignText(T&& text) // 转发引用
{
    sign.setText(text); // 使用但不修改text值
    auto now = std::chrono::system_clock::now();
    signHistory.add(now, std::forward<T>(text)); // 有条件的转换
}
  • 少数情况下用std::move_if_noexcept替代std::move
  • 返回值的函数中,如果返回的是一个绑定到右值引用(转发引用)的对象,则需要在返回时对其使用std::move(std::forward)
Matrix // by-value return
operator+(Matrix&& lhs, const Matrix& rhs) // 加法左侧矩阵是个右值
{
    lhs += rhs;
    return std::move(lhs); // move lhs into return value
}
  • 如果lhs是左值,则会拷贝而非移动到返回值的存储位置,如果Matrix不支持移动则没有区别,但如果支持移动返回std::move会更高效
Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return lhs; // copy lhs into return value
}
  • 转发引用的情况类似,下面是一个返回约分后值的函数模板,如果原始对象是右值则将其移动到返回值上,如果是左值则拷贝
template<typename T>
Fraction // by-value return
reduceAndCopy(T&& frac) // 转发引用
{
    frac.reduce();
    return std::forward<T>(frac); // move rvalue into return value, copy lvalue
}
  • 但不要认为std::move用到要返回的局部变量上也能实现避免拷贝的优化
Widget makeWidget() // "Copying" version of makeWidget
{
    Widget w; // 局部变量
    …
    return std::move(w); // 不要这样做
}
  • 实际上不需要std::move,局部变量会直接创建在返回值分配的内存上,从而避免拷贝,这是C++标准诞生时就有的RVO(return value optimization)。RVO的要求十分严谨,它要求局部对象类型与返回值类型相同,且返回的就是局部对象本身,而使用了std::move反而不满足RVO的要求。此外RVO只是种优化,编译器可以选择不采用,但标准规定,即使编译器不省略拷贝,返回对象也会被作为右值处理,所以std::move是多余的

26 避免重载转发引用

std::multiset<std::string> v;

void f(const std::string& s)
{
    v.emplace(s);
}

std::string s("Harry");
f(s); // 传递左值,只能拷贝
f(std::string("Potter")); // 传递右值,拷贝,但有办法改成移动
f("Wizard"); // 传递字面值,拷贝,但有办法改成移动
  • 第二个调用中形参s绑定到了右值,但本身是左值,所以被拷贝进v,但原则上该值可以被移动。第三个调用中,s绑定到右值,同样是拷贝,但如果这个字面值常量能直接传递给emplace,就没必要构造一个std::string的临时对象。为了解决这个问题,只要让函数接受转发引用,再把该引用std::forward到emplace
std::multiset<std::string> v;

template<typename T>
void f(T&& s)
{
    v.emplace(std::forward<T>(s));
}

std::string s("Harry");
f(s); // 只能拷贝
f(std::string("Potter")); // 移动
f("Wizard"); // 在multiset中直接构造一个std::string对象,而非拷贝一个临时对象
  • 到此这个问题就解决了,但如果用户还希望通过索引获取字符串,而非直接提供字符串,就会引发新的问题
std::string nameFromIdx(int n); // 返回索引对应的名字

void f(int n) // 新的重载函数
{
    v.emplace(nameFromIdx(n));
}
  • 调用时的重载解析通常符合预期
// 之前的调用依然会选择T&&重载版本
std::string s("Harry");
f(s); // 只能拷贝
f(std::string("Potter")); // 移动
f("Wizard"); // 在multiset中直接构造一个std::string对象,而非拷贝一个临时对象

f(22); // 调用int重载版本
  • 但对于部分类型的调用则会引发问题
short nameIdx;
f(nameIdx); // 调用T&&版本,导致错误
  • 对于short类型来说,T&&版本会将T推断为short,int版本只能在类型提升后匹配到short,因此精确匹配优先,调用T&&版本。调用时,形参s绑定到short类型变量上,再被std::forward到emplace,emplace中又需要将short转发给std::string的构造函数,而这会出错。最终出错的根本原因就是,对于short类型,转发引用比int产生了更好的匹配。事实上,转发引用几乎可以精确匹配任何实参,因此不要让转发引用作为重载候选
  • 如果非要重载转发引用,一个直观解决方法是写一个带完美转发的构造函数,但这依然会导致许多问题。这里换一个例子来更好地解释
class A {
public:
    template<typename T> // 完美转发构造函数
    explicit A(T&& n)
    : s(std::forward<T>(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}
private:
    std::string s;
};
  • 这个例子中,除了传入short会调用完美转发构造函数导致出错外,还有更糟糕的问题,因为类中还有一些默认生成的构造函数(模板不阻止合成其他构造函数),有比看上去更多的重载版本
class A {
public:
    template<typename T> // 完美转发构造函数
    explicit A(T&& n)
    : s(std::forward<T>(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}

    A(const A& rhs); // 默认生成的拷贝构造函数
    A(A&& rhs); // 默认生成的移动构造函数
private:
    std::string s;
};
  • 这隐藏了一个难以察觉的错误
A a("Harry");
auto b(a); // 错误
  • 上述错误在于,不会如预期调用拷贝构造函数,而是调用完美转发构造函数,原因在于拷贝构造函数多了一个const,而模板实例化版本可以精确匹配,最终导致A类型初始化std::string的错误
class A {
public:
    explicit A(A& n) // 完美转发构造函数的实例化
    : s(std::forward<A&>(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}

    A(const A& rhs); // 默认生成的拷贝构造函数
    A(A&& rhs); // 默认生成的移动构造函数
private:
    std::string s;
};

A a("Harry");
auto b(a); // 调用的是完美转发构造函数,实例化如上
  • 如果需要拷贝的是const对象,非模板和模板实例化匹配程度相同,优先选用非模板函数,于是结果如预期
class A {
public:
    explicit A(const A& n) // 完美转发构造函数的实例化
    : s(std::forward<A&>(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}

    A(const A& rhs); // 默认生成的拷贝构造函数
    A(A&& rhs); // 默认生成的移动构造函数
private:
    std::string s;
};

const A a("Harry"); // 现在是const对象
auto b(a); // 调用拷贝构造函数
  • 完美转发构造函数与默认生成的构造函数之间的关系,加上继承后会变得更为复杂,派生类的拷贝和移动操作将产生非预期行为,从而导致编译错误
class A {
public:
    template<typename T> // 完美转发构造函数
    explicit A(T&& n)
    : s(std::forward<T>(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}

    A(const A& rhs); // 默认生成的拷贝构造函数
    A(A&& rhs); // 默认生成的移动构造函数
private:
    std::string s;
};

class B : public A {
public:
    B(const B& rhs) // 拷贝构造函数
    : A(rhs) { ... } // 调用基类的完美转发构造函数而非拷贝构造函数,用B初始化std::string导致出错

    B(B&& rhs) // 移动构造函数
    : A(std::move(rhs)) { ... } // 调用基类的完美转发构造函数而非移动构造函数,同样出错
};

27 重载转发引用的替代方案

1. 放弃重载

  • 可以将重载版本命名为多个不同名的函数,从而避免重载,但这不是万能的,比如对构造函数就无法这样做

2. Pass by const T&

  • 回归C++98,用传const T&替代传转发引用,缺点是不高效

3. Pass by value

  • 尽管这是违反直觉的,但传引用换成传值是一种经常能提高性能而不增加复杂性的方法,这遵循条款41的建议:当一定要拷贝形参时,使用传值
class A {
public:
    explicit A(std::string n) // 替代转发引用
    : s(std::move(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}
private:
    std::string s;
};
  • 这样对于short等非int整型都会调用int构造函数,而对于std::string则调用std::string构造函数

4. 使用标签分派

  • 传左值常量和传值都不支持完美转发,如果使用转发引用就是为了完美转发,那就只能选择转发引用。转发引用的问题在于可以匹配几乎任何实参,但如果转发引用只是形参列表的一部分,列表中还有其他非转发引用形参,则可以规避转发引用的万能匹配问题,这个想法就是标签分派手法的基础
std::multiset<std::string> v;
// 原有的函数模板
template<typename T>
void f(T&& s)
{
    v.emplace(std::forward<T>(s));
}

// 加上标签分派的想法
template<typename T>
void f(T&& s)
{
    fImpl(std::forward<T>(s), std::is_integral<T>()); // 还不够正确
}
  • 标签分派手法中,函数把形参转发给了fImpl,并附带另一个用来表示T是否为整型的实参。如果传给f的实参是右值整型,则std::is_integral<T>为真,但如果是左值int,则T被推断为int&,std::is_integral<T>为假,因此还需要一个移除引用的std::remove_reference,最终正确的写法如下
template<typename T>
void f(T&& s)
{
    fImpl(std::forward<T>(s),
    std::is_integral<typename std::remove_reference<T>::type>());
}

// C++14中可以写得简单一些
template<typename T>
void f(T&& s)
{
    fImpl(std::forward<T>(s),
    std::is_integral<std::remove_reference_t<T>>());
}
  • 最后再实现fImpl
template<typename T>
void fImpl(T&& s, std::false_type)
{
    v.emplace(std::forward<T>(s));
}

str::string nameFromIdx(int n);

void fImpl(int n, std::true_type)
{
    f(nameFromIdx(n)); // 委托回原函数
}

5. 使用std::enable_if限制模板

  • 能使用标签分派的关键在于,存在一个无重载的函数作为客户API,该函数把工作分派到实现函数。创建无重载的函数不难,但对于类的完美转发构造函数例外,编译器会合成拷贝和移动构造函数,如果只写一个构造函数并使用标签分派,有些对构造函数的调用就会被默认合成的版本处理,从而绕过了标签分派。这时需要一种削弱采用转发引用模板的条件的手法,即std::enable_if,它可以强制编译器在部分条件下禁用模板
class A {
public:
    template<typename T,
        typename = typename std::enable_if<condition>::type>
    explicit A(T&& n);
    …
};
  • 而启用模板构造函数的条件是,T是A以外的类型,这就可以用std::is_same<A, T>::value来判断,不过转发引用构造函数中的T可以被推断为A&,而std::is_same<A, A&>::valule结果为假
A a("Harry");
auto b(a); // T被推断为A&
  • 因此还需要去掉引用和cv限定符,这可以通过std::decay完成,std::decay<T>::type和T完全相同(但会把数组和函数类型转为指针),最终得到的表达式就应当是
class A {
public:
    template<
        typename T,
        typename = typename std::enable_if<
            !std::is_same<A,
                typename std::decay<T>::type
            >::value // is_same<A, T>::value
        >::type // enable_if<T>::type
    >
    explicit A(T&& n);
    …
};
  • 但这仍未解决继承的问题,因为传递给基类构造函数的是派生类对象,std::decay<B>::type仍然和A不同
class B : public A {
public:
    B(const B& rhs) // 拷贝构造函数
    : A(rhs) { ... } // 调用基类的完美转发构造函数而非拷贝构造函数,用B初始化std::string导致出错

    B(B&& rhs) // 移动构造函数
    : A(std::move(rhs)) { ... } // 调用基类的完美转发构造函数而非移动构造函数,同样出错
};
  • 这就需要std::is_base_of,它可以判断一个类型是否由另一个类型派生而来,即std::is_base_of<Base, Derived>::value为真,所有类都可以认为从自身派生而来,因此std::is_base_of<T, T>::value也为真。这样用std::is_base_of替代std::is_same就可以得到想要的结果
class A {
public:
    template<
        typename T,
        typename = typename std::enable_if<
            !std::is_base_of<A,
                typename std::decay<T>::type
            >::value // is_base_of<A, T>::value
        >::type // enable_if<T>::type
    >
    explicit A(T&& n);
    …
};

// C++14中可以使用std::enable_if_t和decay_t简化代码
class A {
public:
    template<
        typename T,
        typename = std::enable_if_t<
            !std::is_base_of<A, typename std::decay_t<T>>::value
        >
    >
    explicit A(T&& n);
    …
};
  • 最后还需要一点微小的改动,即实现最初的目标,区分实参是否为整型。这只需要添加一个接受整型的构造函数,再限制模板在接受整型时被禁用即可
class A {
public:
    template<
        typename T,
        typename = std::enable_if_t<
            !std::is_base_of<A, typename std::decay_t<T>>::value
            &&
            !std::is_integral<std::remove_reference_t<T>>::value
        >
    >
    explicit A(T&& n)
    : s(std::forward<T>(n)) {}

    explicit A(int n) // int构造函数
    : s(nameFromIdx(n)) {}

    … // 拷贝和移动构造函数等
private:
    std::string s;
};

6. 权衡

  • 放弃重载、传const T&、传值都要对函数形参逐个指定类型,而标签分派、限制模板则利用了完美转发,无需指定类型。完美转发效率更高,但对某些类型无法使用(见条款30),其次涉及模板不易理解,如果编译出错将难以直观地找到错误。为了让编译器直接指定错误,使用static_assert来验证,比如用std::is_constructible判断某个类型对象是否能由另一类型构造
class A {
public:
    template<
        typename T,
        typename = std::enable_if_t<
            !std::is_base_of<A, typename std::decay_t<T>>::value
            &&
            !std::is_integral<std::remove_reference_t<T>>::value
        >
    >
    explicit A(T&& n)
    : s(std::forward<T>(n))
    {
        static_assert( // 断言可以从T类型对象构造一个std::string类型对象
            std::is_constructible<std::string, T>::value,
            "Parameter n can't be used to construct a std::string"
        );
    }
    ...
};
  • 用一个无法构造std::string类型对象的类型来创建A类型对象时,将产生指定的错误信息。遗憾的是static_assert位于构造函数的函数体中,而转发代码位于初始化列表中,因此static_assert的错误信息会产生在原有的冗长的错误信息之后

28 理解引用折叠

  • 引用折叠会出现在四种语境中:模板实例化、生成auto变量、typedef或别名声明、decltype类型推断
  • 引用折叠是支持std::forward运行的关键机制。C++中,引用的引用是非法的
int x = 42;
auto& & rx = x; // 错误:不能声明引用的引用
  • 当左值传给接受转发引用的模板时
template<typename T>
void f(T&& param);

A a; // 左值
f(a); // T为A&
  • 如果把T的推断结果用于实例化模板,就会出现引用的引用
void f(A& && param);
  • 为了使实例化成功,编译器生成引用的引用时,将使用引用折叠的机制:如果任一引用为左值引用,结果为左值引用,否则为右值引用
  • std::forward会针对转发引用实施
template<typename T>
void f(T&& param)
{
    someF(std::forward<T>(param));
}
  • 由于param是转发引用,传递给模板的实参信息(左值还是右值)会被编码到T中,std::forward的任务就是仅当T中信息表明实参是右值时,将param转换为右值
// 不完整的实现
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
    return static_cast<T&&>(param);
}

// C++14
template<typename T>
T&& forward(remove_reference_t<T>& param)
{
    return static_cast<T&&>(param);
}
  • 如果传递的是左值A,T被推断为A&,则std::forward产生如下效果。传递左值实参时,std::forward实例化的结果就是接受并返回左值引用,内部的static_cast不做任何事
A& && forward(typename remove_reference<A&>::type& param)
{
    return static_cast<A& &&>(param);
}

// 简化后
A& forward(A& param)
{
    return static_cast<A&>(param);
}
  • 如果传递是右值A,T被推断为A,则std::forward产生如下效果。传递右值实参时,左值形参param会被转换为右值
A&& forward(typename remove_reference<A>::type& param)
{
    return static_cast<A&&>(param);
}

// 简化后
A&& forward(A& param)
{
    return static_cast<A&&>(param);
}
  • auto变量的类型推断和模板本质上相同,模板中的形式也能用auto模仿
A a;
auto&& a1 = a; // a是左值,auto被推断为A&,a1为A& &&,折叠为A&
auto&& a2 = makeA(); // 工厂函数,右值,auto被推断为A,a1为A&&
  • 可见,转发引用实际就是推断过程会区分左值和右值、会发生引用折叠的右值引用
  • 如果在typedef的创建或求值中出现了引用的引用,就会发生引用折叠
template<typename T>
class A {
public:
    typedef T&& RvalueRef;
    ...
};

A<int&> a;
  • 用int&替代T,得到typedef如下
typedef int& && RvalueRef;
// 简化后
typedef int& RvalueRef;
  • 如果decltype的类型推断中出现了引用的引用,就会发生引用折叠

29 移动操作缺乏优势的情形:不存在、高成本、不可用

  • 在如下场景中,C++11的移动语义没有优势
    • 无移动操作:待移动对象不提供移动操作,移动请求将变为拷贝请求
    • 移动不比拷贝快:待移动对象虽然有移动操作,但不比拷贝操作快
    • 移动不可用:本可以移动时,要求移动操作不能抛异常,但未加上noexcept声明
  • 除了上述情况,还有一些特殊场景无需使用移动语义,比如条款25提到的RVO
  • 移动不一定比拷贝代价小得多。比如C++11引进的std::array,它实际是带STL接口的内置数组。这点和其他容器不同,因为其他容器的内容存放于堆上,概念上只需要持有一个指向堆内存的指针,由于该指针的存在,只要把指针从源容器拷贝到目标容器,再把源容器的指针置空,就实现了常数时间内对容器的移动
std::vector的移动操作示意图
  • 而std::array的内容直接存储在std::array对象中,不存在这样的指针。如果元素类型的移动比拷贝快,则对应的std::array的移动比拷贝快。但无论移动还是拷贝std::array对象,都需要线性时间复杂度,因为每个元素要逐个拷贝或移动,所以移动并不比拷贝快多少
std::array的移动操作示意图
  • 另一个例子是std::string,它提供常数时间的移动和线性时间的拷贝,但它的移动不一定比拷贝快。std::string的实现会采用small string optimization(SSO),小型字符串(如不超过15个字符的字符串)会存储在std::string对象的某个缓冲区而不使用堆上分配的内存,因此对小型字符串的移动并不比拷贝快

30 完美转发的失败情形

  • 完美转发不仅转发对象,还转发类型、左值还是右值、cv限定符等明显特征。一般会使用转发引用,因为只有转发引用能将传入实参的左值还是右值的信息编码
template<typename T>
void fwd(T&& param) // accept any argument
{
    f(std::forward<T>(param)); // forward it to f
}

// 接受任意多个参数的版本
template<typename... Ts>
void fwd(Ts&&... params) // accept any arguments
{
    f(std::forward<Ts>(params)...); // forward them to f
}
  • 如果给定目标函数f和转发函数fwd,用相同实参调用两者,执行不同操作,则称完美转发失败
f( expression ); // 执行某操作
fwd( expression ); // 执行另一操作,则完美转发失败
  • 完美转发失败源于模板类型推断的失败,会导致完美转发失败的实参种类有:大括号初始化值、作为空指针的0和NULL、只声明未定义的整型static const数据成员、重载的函数名称和模板名称、位域

Braced initializers

void f(const std::vector<int>& v);

template<typename T>
void fwd(T&& param) // accept any argument
{
    f(std::forward<T>(param)); // forward it to f
}

f({ 1, 2, 3 }); // OK,{1, 2, 3}隐式转换为std::vector<int>
fwd({ 1, 2, 3 }); // 无法推断T,导致编译错误

// 解决方法是借用auto推断出std::initializer_list类型再转发
auto il = { 1, 2, 3 }; // il的类型被推断为std::initializer_list<int>
fwd(il); // OK

0 or NULL as null pointers

  • 0和NULL作为空指针传递给模板时,类型推断为整型(一般为int)而非实参的指针类型,导致完美转发失败
void f(bool) { cout << "1"; }
void f(int) { cout << "2"; }
void f(void*) { cout << "3"; }

int main()
{
    f(0); // 2
    f(NULL); // 2,也可能不通过编译
    f(nullptr); // 3
}

Declaration-only integral static const data members

  • 一个通用的规定是,不用给类中的整型static const成员定义,只需要声明,因为编译器会为这些成员值执行const propagation,从而不需要为它们保留内存
class Widget {
public:
    static const std::size_t MinVals = 28; // 声明MinVals
    …
};
… // 未定义MinVals
std::vector<int> v;
v.reserve(Widget::MinVals); // 使用MinVals,编译器会直接用28替代
  • 如果对整型static const成员取址,可以通过编译,但会导致链接期的错误。转发引用也是引用,在编译器生成的机器代码中,引用一般会被当成指针处理。程序的二进制代码中,从硬件角度看,指针和引用本质上是同一事物
void f(std::size_t val);
f(Widget::MinVals); // OK,视为f(28)
fwd(Widget::MinVals); // 错误,fwd形参是转发引用,需要取址,无法链接
  • 虽然要求传引用时要求整型static const成员有定义,但并非所有编译器的实现有此要求,所以上述代码也可能可以链接。但考虑到移植性,最好的做法是提供整型static const成员的定义
// file "widget.cpp"
const std::size_t Widget::MinVals; // 已经指定过初始化值,无需重复指定

Overloaded function names and template names

  • 函数的参数可以是函数指针
void f(int (*pf)(int));
void f(int pf(int)); // 和上述写法相同
  • 如果此时有多个重载版本的函数,将函数名称直接传给f是可行的,f会选择只有一个int参数的版本。然而对于函数模板fwd就无法得知应该选择的版本,因为单独的函数名称没有类型,模板无法进行类型推断
int processVal(int value);
int processVal(int value, int priority);

f(processVal); // OK,选择第一个processVal
fwd(processVal); // error! which processVal?
  • 把重载函数的函数名称换成函数模板也会出现同样的问题,因为函数模板代表许多函数
template<typename T>
T workOnVal(T param) // template for processing values
{ … }

fwd(workOnVal); // error! which workOnVal instantiation?
  • 要让转发函数接受重载函数名称或模板名称,只能手动指定需要转发的重载版本或模板实例
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal; // 指定需要的签名
fwd(processValPtr); // OK
fwd(static_cast<ProcessFuncType>(workOnVal)); // OK
  • 但完美转发的目的就是接受任何实参类型,一般不能得知要传递的函数指针的具体类型

Bitfields

struct IPv4Header {
    std::uint32_t version:4,
    IHL:4,
    DSCP:6,
    ECN:2,
    totalLength:16;
    …
};

void f(std::size_t sz); // function to call
IPv4Header h;
…
f(h.totalLength); // fine
fwd(h.totalLength); // error!
  • 问题在于转发函数的形参是引用,h.totalLength是non-const位域,标准规定non-const引用不能绑定到位域,因为位域不能直接取址。要完美转发位域也很简单,实际上接受位域实参的函数只会收到位域值的拷贝,因此按值传递或按const引用(标准规定const引用会绑定到某种整型对象的位域值的拷贝)传递即可转发位域的拷贝
// copy bitfield value
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // forward the copy

相关文章

网友评论

    本文标题:【Effective Modern C++(5)】右值引用、移动

    本文链接:https://www.haomeiwen.com/subject/rllehqtx.html