引言

在上一篇RAII的文章里,我给资源类补上了拷贝构造和copy and swap的赋值,每次复制都要重新new一片内存,再把内容原样抄一遍。可是有些对象明明马上就要销毁了,比如函数返回的临时对象,把它的资源原样偷过来岂不是更省事。

C++11的移动语义就是干这个的。

在此篇文章中,我将记录下自己对移动语义的理解,以供思考,如有不足之处欢迎指正。

正文

左值与右值

移动语义的入口,是左值与右值的区分。

简单来说,左值是有名字、能取地址的对象,右值是临时的、即将消亡的值。

int a = 10;      // a是左值,有名字,能对它取地址
int b = a + 1;   // a+1是右值,运算产生的临时值,本行结束即销毁

Book book1{1, "first book"};  // book1是左值
createBook();                 // 函数按值返回的临时对象是右值

C++11为此引入了右值引用T&&,专门用来绑定右值。参数写成右值引用,等于调用者向被调用方承诺:这个对象我不要了,你可以随意掏空它。

严格的值类别划分是lvalue、xvalue、prvalue三类,右值是后两类的统称。日常记不住也没关系,"能取地址的是左值,临时的是右值"足够应付绝大多数场景。

拷贝构造与移动构造

Book类里两个构造函数并排放着,区别一目了然:

Book(const Book &s)          // 拷贝构造:const引用,不许动源对象
    : m_id(s.m_id)
{
    copyName(s.m_name);      // 重新new一片内存,把字符串抄一遍
}

Book(Book &&s) noexcept      // 移动构造:右值引用,可以掏空源对象
    : m_id(s.m_id), m_name(s.m_name)  // 直接把指针偷过来
{
    s.m_name = nullptr;      // 源对象指针置空,防止析构时二次释放
}

移动不是复制,移动是把资源的所有权从源对象转让给新对象,全部代价只是几次指针赋值。

最容易被漏掉的是s.m_name = nullptr;这一行。移动之后源对象并不会凭空消失,它还要走自己的析构函数,如果指针仍指向被偷走的内存,析构时就会把同一块内存delete两次。而delete nullptr是安全的空操作,所以置空即可。

移动后的对象处于"有效但未指定"的状态:可以给它赋新值,也可以安全析构,但不要再假设它保有原来的内容。标准库容器里被移动过的string、vector都是这个待遇。

std::move与std::move_if_noexcept

移动构造只接受右值,那想移动一个左值怎么办?使用std::move:

Book temp{std::move(first)};  // 把first强转成右值引用,随后选中移动构造

std::move本身不移动任何东西,它只是一次到右值引用的强制转换,真正的移动发生在随后被选中的移动构造或移动赋值里。这个名字起得相当有误导性,叫std::rvalue_cast或许更名副其实。

不过测试代码里三处用的都是std::move_if_noexcept,它是std::move的审慎版本:

Book temp{std::move_if_noexcept(first)};  // 移动构造是noexcept,才真正走移动
first  = std::move_if_noexcept(second);
second = std::move_if_noexcept(temp);

只有当移动构造被标记为noexcept时,move_if_noexcept才返回右值引用,否则退化为const引用,老老实实走拷贝。 它把"移动是否安全"的判断交还给了类型系统。

noexcept与异常安全

这就引出一个问题:为什么移动构造非要标noexcept不可?

拷贝构造只读源对象,new失败抛出异常,源对象毫发无损,一切可以当作没发生过。移动构造不同,它先掏空源对象再干活,一旦中途抛异常,新对象没构造出来,源对象的数据也残缺了,两头都保不住。

所以对移动构造、移动赋值、swap这类函数,要么保证内部不抛异常并标上noexcept,要么就别标,让move_if_noexcept和调用方自己去决定。

Book(Book &&s) noexcept;                              // 移动构造,承诺不抛异常
Book &operator=(Book &&s) noexcept;                   // 移动赋值同理
void swap_move(Book &first, Book &second) noexcept;   // 交换函数也尽量noexcept

一个现实的后果:vector扩容搬移元素时,靠的正是move_if_noexcept这套判断。移动构造不是noexcept,它宁可逐个拷贝也不肯移动,因为拷贝失败还能回滚,移动失败就是数据丢失。noexcept不是免费的注释,它是一份写给编译器和标准库的承诺。

复制省略与移动交换

main里还有一个测试:

Book book2{createBook()};  // 复制省略:直接在book2的内存上构造
Book book3;
book3 = createBook();      // 临时对象移动赋值给book3

Book book2{createBook()}按老标准想,返回值要先构造临时对象,再移动构造进book2。但实际输出里只有一次constructed,连移动构造都没调用。这就是复制省略(copy elision),C++17起按值返回纯右值是保证省略的,临时对象压根不存在,直接在book2的位置上构造。

而book3 = createBook()里book3已经存在,无法省略,于是临时对象把资源移动赋值给book3,自己随即析构。

两种swap的对比,是移动语义收益最直观的地方:

void swap(Book &first, Book &second) noexcept  // 复制版交换
{
    const Book temp{first};   // 拷贝构造:new一次
    first = second;           // 拷贝赋值:delete一次,new一次
    second = temp;            // 拷贝赋值:delete一次,new一次
}

void swap_move(Book &first, Book &second) noexcept  // 移动版交换
{
    Book temp{std::move_if_noexcept(first)};  // 偷指针
    first = std::move_if_noexcept(second);    // 偷指针
    second = std::move_if_noexcept(temp);     // 偷指针
}

复制版三次深拷贝,三次new三次delete;移动版只是三次指针转让,一次堆都不碰。跑起来的输出里,前者刷出一串Copied name,后者一行都没有。

补充要点

写齐了移动构造和移动赋值,Book这个类就凑够了三/五法则(rule of five):析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值,五者要么都不写交给编译器生成,要么就得一起考虑。类里手动管理了资源,只写拷贝不写移动,性能白白损失;只写移动不写析构,就是内存泄漏。

移动赋值里的自赋值检查也值得留意:

Book &operator=(Book &&s) noexcept
{
    if (this == &s) return *this;  // 防止book1 = std::move(book1)把自己掏空
    m_id = s.m_id;
    m_name = s.m_name;             // 偷指针
    s.m_name = nullptr;            // 置空源指针
    return *this;
}

没有这行检查的话,先偷指针再置空源指针,最后this和s的指针都变成nullptr,资源直接丢了。

有意思的是,上一篇RAII文章里copy and swap的赋值天然自赋值安全,并不需要这个检查。写法各有取舍,但每种写法的风险点必须心里有数。

另外,与std::move相对的还有std::forward,它用在模板转发时保持实参原本的左右值属性,属于完美转发的话题,等以后写模板相关的内容再展开。

小结

移动语义的本质,是把"即将销毁的对象"和"还要继续使用的对象"区分对待:后者复制保平安,前者掏空捡便宜。

拷贝构造保证两个对象各自独立,移动构造只保证接管方拿到资源,用一份"源对象不再可信"的代价,换来了零堆操作的搬运。写完这个Book类再回头看,std::swap、vector扩容、unique_ptr的所有权转移,底层全是同一套"偷指针、置空源"的把戏。

本文所用的测试代码收录于Learncpp,如有不妥之处欢迎指正。