C++移动语义

C++移动语义

ps. 本文引用了<<Effective Modern C++>>一书中的内容, 作为学习后的精炼和总结. 建议读原版书籍, 内容更丰富更精彩:)

C++11后支持的移动语义(move semantics), 移动语义提供了一种机制, 在满足移动语义的情况下, 将施行移动操作而非拷贝操作. 一般情况下移动比拷贝更轻量, 使用移动语义有助于写出更高效的代码.
何种情况才是满足移动语义的情况? 首先需要操作的对象是右值, 接收对象的方式采用右值引用时, 此时移动语义发生了!

左值(lvalue)和右值(rvalue)

说左值和右值, 一般是针对变量或者表达式, 简单的讲, 能取地址的是左值, 不能取地址的则是右值.

void func(int param); // ③param是左值

int x = 10;
func(x); // ①x是左值
func(10); // ②10是右值

此例中,
①中实参x是左值, 因为可以通过&x取得x的地址.
②中10是右值, 无法取得10的地址.
③中形参param是左值, 因为可以通过&param取得param的地址. 此处谨记, 形参总是左值!

左值引用(lvalue reference)和右值引用(rvalue reference)

以往一般使用的形如type&的都是左值引用, C++11后提供了一种新的引用形式 -- 右值引用, 引用形式为type&&.

void func(int& param); // ①param以左值引用的方式接收一个左值

int x = 10;
func(x); // ②x是左值
func(10); // ③error! 10是右值, 无法绑定到左值引用

此例中,
①中形参param是左值, 它以左值引用的方式接收一个左值.
②中实参x是左值, 它可以被传递给一个左值引用.
③中10是右值, 右值无法传递给一个左值引用.

void func(int&& param); // ①param以右值引用的方式接收一个右值

int x = 10;
func(x); // ②error! x是左值, 无法绑定到右值引用
func(10); // ③10是右值

func(std::move(x)); // ④ok! 使用std::move, 将x强转为右值

此例中,
①中形参param是左值, 它以右值引用的方式接收一个右值.
②中实参x是左值, 它无法被传递给一个右值引用.
③中10是右值, 它可以被传递给一个右值引用, 此时施以移动操作!
④中使用std::move, 将x强转为右值后对x施以移动操作, 此后x实际内容已被"移动"到param, 继续使用x程序行为将无法预测, 但x可以重新赋值做为一个新值继续使用.

void func(const int& param); // param以常引用的方式接收一个值, 该值可以是左值也可以是右值
void func(int&& param); // param以右值引用的方式接收一个右值

int x1 = 10;
const int x2 = 10;
func(x1); // x1是左值, 调用 func(const int& param)
func(x2); // x2是左值, 调用 func(const int& param)
func(10); // 10是右值, 调用 func(int&& param)

func(std::move(x1)); // ①调用 func(int&& param)
func(std::move(x2)); // ②??调用 func(const int& param)

此例中,
①std::move(x1)将x1强转为右值, 随后调用func(int&& param).
②std::move(x2)将x2强转为右值, 随后却调用func(const int& param), 为何? 仔细观察, x2和x1之间的不同在于x2被标记为const, 因此经std::move(x2)后得到的是const int&&类型的右值. 若此时发生移动操作, 按上面的说法, x2将被移动(修改), 似乎这样的行为和标记为const的初衷背道而驰. 确实如此, 但此时却并非因x2不应该被修改导致, 而是对于const int&&类型(一个右值), func(const int& param)从语法上能直接匹配, 常引用能直接接收一个右值.

void func(int&& param); // param以右值引用的方式接收一个右值

const int x2 = 10;
func(std::move(x2)); // ①error! 语法错误.

此例中, 剥离了上例中将要讨论的无关部分, 实际上此时会直接报错
①在vs中可在此处看到如下错误提示:

  1. (错误)将"int&&"类型的引用绑定到"std::remove_reference_t<const int&>"类型的初始值设定项时, 限定符被丢弃
  2. (警告)不要对常量变量使用std::move
void func(const int&& param); // ②param以常右值引用?的方式接收一个右值

const int x2 = 10;
func(std::move(x2)); // ①warning! 不要对常量变量使用std::move!

此例中, 从语法上解决了报错并且能够编译成功, 但...
①在vs中仍然可以看到警告, 不要对常量变量使用std::move!
②从目前的语法上确实能够正常运行, 但const限定和右值引用似乎总是水火不容的, 因为对于一个常量, 本质上就是不希望修改它, 更何况是移动它呢! 至于为什么没有禁止这样的语法, 或许尤其存在的价值(还没发现..), 目前只需要记得, 不要对常量变量使用std::move就行了!

理解std::move

是的, 上面还有一条报错没有解决:

将"int&&"类型的引用绑定到"std::remove_reference_t<const int&>"类型的初始值设定项时, 限定符被丢弃

欲知std::move的所为, 最好能了解其源码实现, 好在std::move的实现并不复杂.

template<typename T>
decltype(auto) move(T&& param) // ①param以转发引用的方式接收一个值, decltype(auto)能够获取到返回值的类型
{
    using ReturnType = std::remove_reference_t<T>&&; // ②std::remove_reference_t移除模板所有引用
    return static_cast<ReturnType>(param); // ③static_cast强转
}

void func(int&& param); // param以右值引用的方式接收一个右值

const int x2 = 10;
func(std::move(x2)); // ④error! 语法错误, 将"int&&"类型的引用绑定到"std::remove_reference_t<const int&>"类型的初始值设定项时, 限定符被丢弃.

此例大致就是std::move的源码实现, 其中:
①中T&&型别的转发引用使之能够接收左值和右值, decltype(auto)能够正确推导出想要的返回值类型.
②中std::remove_reference_t将移除模板T的引用性质(const会保留), ReturnType必然是T的右值引用形式.
③中通过static_cast将param强转为右值!
④再看报错. 此时在①中T的型别被推导为const int&, ②中ReturnType的型别是const int&&, ③经static_cast强转后, decltype(auto)得到const int&&类型的参数被传入func, func需要的是int&&, 而接收到的是const int&&, 非常量无法引用一个常量, 于是报出const限定符被丢弃的错误.

返回值优化(RVO, return value optimization)

一旦理解了右值语义, 或许就会想在可以使用移动操作的地方都使用std::move来替代原本的拷贝操作, 那么是否移动操作总是比拷贝操作更高效呢? 并不是, 此处提及的返回值优化即是一种看上去应该使用移动操作, 但最好避免的例子.

返回值优化是编译器内部的优化行为, 对于返回值是局部变量的情况, 编译器将直接在函数外部创建该对象, 然后在函数内部使用, 返回值即是外部对象, 此期间只发生了一次构造, 没有拷贝和析构操作. 编译器施行RVO的两个条件:

  1. 局部变量类型和返回值类型完全一致.
  2. 该局部变量就是函数将要返回的目标.
big_object make_big_object()
{
    big_object o;

    // return std::move(o); // ①RVO被限制, 此时返回的不是o本身, 而是对o的右值引用
    return o; // ②RVO
}

big_object make_big_object(big_object& o)
{
    // return o; // ③不会发生RVO, 返回值不是局部变量
    return std::move(o); // ④确保o不会再使用, 此时适合进行移动
}

此例中:
①中若施行std::move将额外产生返回值移动构造和局部变量的析构操作.
②中符合RVO优化, 除了对象本身需要一次构造外, 不会产生额外的操作.
③中不符合RVO优化, 返回值不是局部变量, 直接返回o将发生拷贝构造和局部变量的析构操作.
④中同①, 但至少比③更高效.

所以, 返回局部对象若能够施行RVO, 则不要对其实施std::move(std::forward同理).

以上可以做个小结:

  1. 移动操作的发生的条件是以右值引用接收一个右值.
  2. 可以将一个左值通过std::move强转为一个右值, 但仅仅是如此了, 是否发生移动还要看接收它的是否是右值引用.
  3. const会影响移动操作是否发生, 不应该对右值或右值引用施加const限定. 添加const的移动操作, 大多默不作声的执行了拷贝操作.
  4. std::move做的是强转, 它意味着一种请求和期盼, 给出一个右值, 表示此时可以的话请施行移动操作, 它实际并不移动任何东西.
  5. 了解编译器的RVO机制, 在发生RVO时不要使用std::move或std::forward.

发表回复

电子邮件地址不会被公开。必填项已用 * 标注