C++中的移动语义
引言
在上一篇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,如有不妥之处欢迎指正。
$ 正在加载评论…