Modern C++ 学习笔记 —— 右值、移动篇

学习笔记 专栏收录该内容
10 篇文章 0 订阅

往期精彩:

Modern C++ 学习笔记 – 右值、移动篇

关键字:右值,移动,万能引用,完美转发

引入

先看如下代码:

class Solution {
public:
    bool registerMap(const Struct1 &lhs, const Struct2 &rhs)
    {
        return myMap.insert(make_pair<Struct1, Struct2>(lhs, rhs)).second; // C++ 98
    }
private:
    map<Struct1, Struct2> myMap;
};

上段代码中对make_pair的两个模板参数进行了显示定义,不依赖编译器进行模板的型别推导。在 C++98 的环境是可以编译通过的,但是在 C++11 或者更高版本缺无法通过编译,编译器会毫不犹豫得警告你不能这样玩:cannot convert 'lhs' (type 'const Struct1') to type 'Struct1&&',具体原因可以通过看 make_pair 的标准库得知。

#if __cplusplus >= 201103L
  template<class _T1, class _T2>
    constexpr pair<_T1, _T2>
    make_pair(_T1&& __x, _T2&& __y)
    {
      return pair(std::forward<_T1>(__x), std::forward<_T2>(__y));
    }
#else
  template<class _T1, class _T2>
    inline pair<_T1, _T2>
    make_pair(_T1 __x, _T2 __y) { return pair<_T1, _T2>(__x, __y); }
#endif

上述标准库代码实际上进行了删减,但是不影响我们用来理解 C++高版本带来的变化。在 C++11 版本引入右值后,为适应各个型别在标准库中进行了万能引用的改造。在之前的例子中,对 make_pair 指定模板参数后,编译器会将其推导为如下的形式,自然在传入const Struct &时,编译器就会报错。

constexpr pair<Struct1, Struct2> make_pair(Struct1&& __x, Struct2&& __y)
    {
      return pair(std::forward<Struct1>(__x), std::forward<Struct2>(__y));
    }

右值和移动究竟解决了什么问题?

值类别

C++标准里面规定了下面这些值类别:

在这里插入图片描述

  • 左值 lvalue是有标识符、可以取地址的表达式,通常有:变量\函数\数据成员的名字,返回左值引用的表达式(++x, x=1),字符串字面量(“hello world”).字符串字面量之所以是左值,是因为它需要占用主存,是可以取地址的。而整数,字符等可以直接放在寄存器,不能取地址。cout << "hello world"s.size() << endl;
  • 右值 rvalue表达式结束后不在存在的历史对象。进一步划分为纯右值和将亡值。
  • 纯右值 prvalue是没有标识符、不可取地址的表达式,一般称为“临时对象”,通常有:返回非引用类型的表达式(x++, x+1),除字符串字面量之外的字面量(42、true).
  • 将亡值 xvalue值即将被销毁,却能够被移动的值,他有地址但是仅编译器能够操作,程序不可访问,资源可以服用。
int a = 3;
int setValue()
{
    a = 4; // 4 是字面量,是纯右值
    return a; // 执行结束后 a 成为将亡值
}
int main(void)
{
    int b = setValue(); // 右值赋值给左值
    int *p = &setValue(); // 纯右值没有地址
    1 + 4 = 5; // 右值无法在等号左边
    (b > 3) ? i : j = 4; // 此时的三目运算符是左值
    return 0;
}

移动

以下代码为智能指针shared_ptr的部分实现,我们使用右值引用的目的就是实现移动,而实现移动的意义是减少运行的开销。

template <typename T>
class share_ptr {
public:
    // ...
    // 拷贝构造函数
    template<typename U>
    share_ptr(const share_ptr<U>& other)
    {
        ptr = other.ptr;
        if (ptr) {
            other.shareCount.addCount();
            shareCount = other.shareCount;
        }
    }
    // 移动构造函数
    template<typename U>
    share_ptr(share_ptr<U>&& other)
    {
        ptr = other.ptr // 在 C++中,所有的形参都是左值。
        if (ptr) {
            shareCount = other.shareCount;
            other.ptr = nullptr; // 为析构 other 做准备
        }
    }
    // ...
private:
    T* ptr;
    share_count shareCount;
};

显而易见拷贝构造函数和移动构造函数的差异仅在于 1.少了一次 other.shareCount.addCount()的调用;2.被移动的指针被清空,因而析构时也少了一次 shared_count->reduce_count()的调用。或许这个例子不能够清楚的说明移动带来的好处,在使用容器类的情况下,移动更有意义。看如下例子:

string result = string("hello, ") + name + ".";

在 C++11 之前的年代里面,这种写法会引入很多额外的开销,执行流程大致如下:

  1. 调用构造函数 string(const char *),生成临时对象 1;"hello, "复制一次。
  2. 调用 operator+(const string &, const char *),生成临时对象 2;“hello, "复制 2 次,name 复制 1 次。
  3. 调用 operator+(const string &, const char *),生成对象 3;“hello, "复制 3 次,name 复制 2 次, "."复制一次。
  4. 最优情况下,返回值优化(RVO)能够生效,对象 3 可以直接在 result 里面构造完成。
  5. 临时对象 2 析构,释放指向 string("hello, ") + name 的内存。
  6. 临时对象 1 析构,释放指向 string("hello, ")的内存。

既然 C++是一门追求性能的语言,一名合格的 C++程序员会写,这样的话就可以调用构造函数一次和 string::operator+=两次,没有任何临时对象需要生成和析构。

string result = "Hello, "; // 啰嗦,但是性能高
result += name;
result += ".";

从 C++11 开始,同样是上面单行的语句,执行流程大致如下:

  1. 调用构造函数 string(const char*),生成临时对象 1;"Hello, " 复制 1 次。
  2. 调用 operator+(string&&, const string&),直接在临时对象 1 上面执行追加操作,并把结果移动到临时对象 2;name 复制 1 次。
  3. 调用 operator+(string&&, const char*),直接在临时对象 2 上面执行追加操作,并把结果移动到 result;"." 复制 1 次。
  4. 临时对象 2 析构,内容已经为空,不需要释放任何内存。
  5. 临时对象 1 析构,内容已经为空,不需要释放任何内存。

性能上,所有的字符串只复制了一次,虽然比啰嗦的写法仍然要增加临时对象的构造和析构,但由于这些操作不牵涉额外的内存分配和释放,是相当廉价的。牺牲一点点性能,就可以大大增加代码的可读性。

此外还有很关键点的一点是,C++里的对象缺省都是值语义,在下面这样的代码里

class A {
    B b;
    C c;
};

从实际内存不仅的角度,C++会直接把 B 和 C 对象放在 A 的内存空间中。优势是保证了内存访问的局域性。带来的缺点是复制对象的开销大大增大,这就是为什么 C++需要移动语义这一优化
一句话总结,移动语义使得在 C++ 里返回大对象(如容器)的函数和运算符成为现实,因而可以提高代码的简洁性和可读性,提高程序员的生产率。所有的现代 C++ 的标准容器都针对移动进行了优化。

不要返回本地变量的引用

在引入右值和移动后,伴随有std::movestd::forward,说明一下:std::move 并不进行任何移动,std::forward 也不进行任何转发。记住这一点,之后会进行解释。

首先先来看一种常见的 C++编程错误,是在函数返回一个本地对象的引用。由于在函数结束时本地对象即被销毁,返回一个指向本地对象的引用属于未定义行为。

在 C++11 之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization,或 NRVO),能把对象直接构造到调用者的栈上。从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move 进行干预——使用 std::move 对于移动行为没有帮助,反而会影响返回值优化。下面是个例子:

class Obj {
public:
    Obj()
    {
        cout << "Obj()" << endl;
    }
    Obj(const Obj &)
    {
        cout << "Obj(const Obj &)" << endl;
    }
    Obj(Obj&&)
    {
        cout << "Obj(Obj&&)" << endl;
    }
};
Obj simple()
{
    Obj obj;
    return obj; // gcc 默认打开 NRVO 编译选项
}
Obj simple_with_move()
{
    Obj obj;
    return std::move(obj); // std::move 会禁止 NRVO
}
Obj complicated(int n)
{
    Obj obj1;
    Obj obj2;
    if (n % 2 == 0) { // 有分支,一般无 NRVO
        return obj1;
    } else {
        return obj2;
    }
}
int main()
{
    cout << "*** 1 ***" << endl;
    auto obj1 = simple();
    cout << "*** 2 ***" << endl;
    auto obj2 = simple_with_move();
    cout << "*** 3 ***" << endl;
    auto obj3 = complicated(42);
}

输出通常为:
在这里插入图片描述
也就是,用了 std::move 反而妨碍了返回值优化。针对 NRVO/RVO 与 std::move()的区别,详细可见此处的讨论(https://stackoverflow.com/questions/4986673/c11-rvalues-and-move-semantics-confusion-return-statement)..?fileGuid=kGHpQChwGGVJCkhV)

填补上面的坑:std::move 做的是无条件的强制型别转换,不做的是移动。std::forward 做的是有条件的强制型别转换。(effective modern C++, 条款 23)

template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

生命周期延长

这个话题也算上是老生常谈了,对于临时对象 C++的规则为:一个临时对象在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁。除非有生命周期延迟发生。下面实际的代码可以演示这一行为:

class shape {
public:
    virtual ~shape() {}
};
class circle : public shape {
public:
    circle() { puts("circle()"); }
    ~circle() { puts("~circle()"); }
};
class result {
public:
    result() { puts("result()"); }
    ~result() { puts("~result()"); }
};
result process_shape(const shape& shape1)
{
    puts("process_shape()");
    return result();
}
int main()
{
    process_shape(circle());
    puts("something else");
}

结果是:
在这里插入图片描述
为了方便对临时对象的使用,C++对临时对象有特殊的生命周期延长规则:如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。我们只需将上面代码该一行就可以演示此效果result&& r = process_shape(circle());,我们可以看到不同的结果了:
在这里插入图片描述
需要万分注意的是,这条规则支队 prvalue 有效。如果由于某种原因,prvalue 在绑定到引用以前已经变成了 xvalue,那生命周期并不会延长。不注意这点的话会引入隐秘的 bug

result&& r = std::move(process_shape(circle()));

这时代码输出变回了前一种情况。虽然执行到 something else 时我们仍有一个有效的变量 r,但它指向的对象已不存在了,对 r 的解引用是一种未定义的行为。由于 r 指向的是栈空间,通常不会立即导致程序崩溃。
接下来的例子同样说明此问题:

std::string getWidgetName(); // 工厂函数
class Widget {
public:
    template<typename T>
    void setName(T&& newName) // 万能引用
    {
        name = std::move(newName);
    }
    ...
private:
    std::string name;    
};
auto n = getWidgetName(); // n 是个局部变量
Widget w;
w.setName(n);  // 将 n 移入 w,n 的值将变得未知。

这里,局部变量 n 被传递给 w.setName,但由于 setName 函数内部使用了 std::move 把它的引用形参无条件地强制转换到右值,n 的值就会被移入 w.name。这么一来,调用完 setName 函数返回时,n 将变成一个不确定的值。这样的行为会让调用者绝望

万能引用

对于一个实际类型 T,它的左值引用是 T&,右值引用是 T&&,那么:

  1. 是不是看到 T&,就一定是个左值引用?
  2. 是不是看到 T&&,就一定是个右值引用? 对于前者的回答是“是”,对于后者的回答是“否”。实际上T&&有两种不同的含义,其一,理所当然,是右值引用——仅仅会绑定右值,识别可移动对象。T&&的另一种含义,则表示其既可以是右值引用,亦可是左值引用。它有个独特的名字——万能引用(universal reference)。万能引用会出现在两种场景下,最常见的一种场景是函数模板的参数,第二个场景是auto声明。
template<typename T>
void func(T&& param); // param 是个万能引用。
auto&& var2 = var1; // var2 是个万能引用。

这两个场景的共同之处在于它们都涉及型别推导。在模板 func 中,param 的型别是推导而来的;var2 的声明语句中,var2 的型别也是推导而来,也许一个反面例子能更好的帮助理解:

void func(widget&& param); // 不涉及型别推导,param 是个右值引用。

在容器 std::vector 中也存在着同样的用法,观察和 push_back 概念上类似的成员函数emplace_back实实在在地涉及型别推导,其中的形参_Args 独立于 vector 的型别形参 T,每次调用 emplace_back 时进行推导。更深一步的话还可以看看push_back是如何实现的,也正是更推荐使用 emplace_back 的原因。

template<class T, class Allocator = allocator<T>> // 来自 C++标准
class vector {
public:
    template<typename... _Args>
    void emplace_back(_Args&&... __args);
    ...
};

也许你会好奇为何形如T&&既可以绑定右值,又可以绑定左值,其实底层的真相是一个被称为引用折叠(也被称为引用坍缩)的概念。先不急于深究明细,文章最后会给出解释。 有更紧急的问题需要我们解决。
先给出结论:避免依万能引用型别进行重载

#include "chrono"
std::multiset<std::string> names; // 全局数据结构
void logAndAdd(const std::string& name)
{
    auto now = std::chrono::system_clock::now(); //获取当前时间
    time_t tt = std::chrono::system_clock::to_time_t(now);
    cout << ctime(&tt) << "logAndAdd\n";
    names.emplace(name);  //将名字添加到全局变量数据结构中
}
std::string petName("Darla");
logAndAdd(perName); // 传递左值 std::string
logAndAdd(std::string("personName")); // 传递右值 std::string
logAndAdd("personName"); // 传递字符串字面量。

在以上三个调用语句中,第一个调用语句由于 name 是左值,复制不可避免。但是第二、三个调用语句付出了额外的复制的性能消耗。也许你能通过再写一份重载函数void logAndAdd(std::string&& name);解决问题,但是这样不可避免的出现了重复代码。有了万能引用可以很好的解决这个问题:

template<typename T>
void logAndAdd(T&& name)
{
    ...
    names.emplace(std::forward<T>(name)); // 实施向右值的有条件强制类型转换。
} 

千万别得意,故事还未结束呢。考虑一个场景,我们有些时候不能直接访问到 logAndAdd 所要求的名字,有些只能访问到一个索引,为了支持这样的客户,logAndAdd 提供了重载版本:

std::string getNameFromIdx(int idx); // 返回索引对应的名字
void logAndAdd(int idx) // 不改变接口的前提下新的重载函数
{
    ... // 省略
    names.emplace(getNameFromIdx(idx));
}
logAndAdd(22); // 本句调用了形参型别为 int 的重载版本。

原有的调用一如此前调用了形参型别为 T&&的重载版本,又同时满足了对 int 型别的重载。似乎一切都朝着我们期望的方向前进,但是,总是有但是,生活不如那么如意,假设有如下使用:

short nameIdx;
... // 赋值给 nameIdx
logAndAdd(nameIdx); // 发生错误

解释一下发生了什么,logAndAdd 存在两个重载版本,形参型别为T&&的版本可以将 T 推导为 short,从而产生精确匹配。而形参型别为 int 的版本缺只能在型别提升后才能匹配到 short 型别的实参。而精确匹配优先级更高,所以最终会调用的版本未万能引用的版本,这也是发生错误的原因。
这就是为何把重载和万能引用两者结合起来几乎总是馊主意:一旦万能引用称为重载候选,它就会吸引走大批的实参型别,远比撰写重载代码的程序员期望的多。如何解决依万能引用型别进行重载的问题,在《Effective Modern C++》一书中条款27给出了如下解决方案:

  • 如果不适用万能引用和重载的组合,则替代方案包括使用彼此不同的函数名字、传递const T&型别的形参、传值和标签分派。
  • 经由std::enable_if对模板施加限制,就可以将万能引用和重载一起使用,控制了编译器可以调用到接受万能引用的重载版本的条件。
  • 万能引用形参通常在性能方面具备优势,但在易用性方面一般会有劣势。

引用折叠

还记得在前文有个遗留问题还未解释清楚嘛,正是引用折叠(也称引用坍缩)。在解释这个名字前,首先要知道一个事实,在C++中“引用的引用”是非法的,若你想尝试一个int x; ...; auto& & rx = x;,编译器一定不会让你好过。了解了这个事实后再来看如下的例子:

template<typename T>
void func(T&& param); // 万能引用
Widget widgetFactory(); // 返回右值的函数
Widget w; // 变量,左值
func(w); // 调用func并传入左值,T的推导结果型别为Widget&
func(widgetFactory); // 调用func并传入右值,T的推导结果型别为Widget

两个对func的调用,传递的实参型别都为Widget,只不过一个是左值另一个是右值,这个不同导致了针对模板形参T得出了不同的型别推导结果。我们考虑对func(w)的调用,将T的推导结果型别代码实例化:

void func(Widget& && param);

引用的引用?!是的,但是为何编译器一声不吭。为何编译器最终会生成如下的终极版函数呢?

void func(Widget& param);

答案就是引用折叠,编译器在特殊语境下会对引用的引用使用引用折叠的机制(这也是std::forward得以运作的关键):

如果任一引用为左值引用,则结果为左值引用。否则(即两个皆为右值引用),结果为右值引用。

引用折叠会在四种语境中发生:模板实例化、auto型别生成、创建和运用typedef和别名声明,以及decltype。

到底应不应该返回对象

还记得在第二部分提到的返回值优化嘛(NRVO\RVO),是时候再拉出来遛一遛了。假设有如下代码:

class S {
public:
    S() {cout << "Creat S\n";}
    ~S() {cout << "Destory S\n";}
    S(const S&) {cout << "Copy S\n";}
    S(S&&) {cout << "Move S"<<endl;}
};
S getS_unnamed(int n)
{
    return S();
}
int main()
{
    auto tmp = getS_unnamed(42);
}

经过之前的介绍,你可能会认为执行结果中应当有“Copy S”或者“Move S”,但是切莫忘记了返回值优化的威力。在gcc编译器的环境下只会输出两行:

Creat S
Destory S

稍微对代码进行一个修改:

S getS_named(int n)
{
    S s1;
    return s1;
}

这样还是会被NRVO优化掉,我们在gcc环境下关闭掉返回值优化-fno-elide-constructors就可以看到我们预想的结果了:
在这里插入图片描述
进一步修改:

S getS_complex(int n)
{
    S s1;
    S s2;
    if (n % 2 == 0) {
        return s1;
    } else {
        return s2;
    }
}

此时的返回执行结果变成了如下这样,编译器被难倒了!

  Creat S 
  Creat S
  Move S 
  Destory S 
  Destory S 
Destory S 

关于返回值优化的实验暂时就到这里了。下一步我们将移动构造函数删除看看会发生什么?

//    S(S&&) {cout << "Move S"<<endl;}

我们可以立即看到“Copy S”出现在了结果输出中,说明目前结果变成了拷贝构造了。
如果再进一步,把拷贝构造函数也删除呢(注,此时是标成=delete,而不是简单注释掉,避免编译器默认提供拷贝构造函数和移动构造函数)?,是不是上面的getS_xxx都不能工作了?

在C++ 14之前确实是这样。但是从C++17开始,对于getS_unnamed这样的情况,即使对象不可拷贝、不可移动,这个对象仍然是可以被返回的!C++ 17对于这种情况,对象必须被直接构造在目标位置上,不经过任何拷贝或移动的步骤。
在这里插入图片描述
详细可参考:https://zh.cppreference.com/w/cpp/language/copy_elision

参考资料

[1] 《Effective Modern C++》
[2] cppreference.com, “Value categories”.
https://zh.cppreference.com/w/cpp/language/value_category
https://en.cppreference.com/w/cpp/language/value_category
[3] www.baidu.com
[4] www.google.com

  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 代码科技 设计师:Amelia_0503 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值