移动语义

移动语义可以说是 C++11 最主要的特性。你可能会⻅过这些类似的描述“移动容器和拷⻉指针⼀样开销小”, “拷⻉临时对象现在如此⾼效,编码避免这种情况简直就是过早优化”这种情绪很容易理解。移动语义确实是这样重要的特性。它不仅允许编译器使⽤开销小的移动操作代替⼤开销的复制操作,而且默认这么做。以 C++98 的代码为基础,使⽤ C++11 重新编译你的代码,然后,哇,你的软件运⾏的更快了。移动语义确实令⼈振奋,但是有很多夸⼤的说法,这个 Item 的⽬的就是给你泼⼀瓢冷⽔,保持理智看待移动语义。

让我们从已知很多类型不⽀持移动操作开始这个过程。为了升级到 C++11,C++98 的很多标准库做了⼤修改,为很多类型提供了移动的能⼒,这些类型的移动实现⽐复制操作更快,并且对库的组件实现修改以利⽤移动操作。但是很有可能你⼯作中的代码没有完整地利⽤ C++11。对于你的应⽤中(或者代码库中),没有适配 C++11 的部分,编译器即使⽀持移动语义也是⽆能为⼒的。的确,C++11 倾向于为缺少移动操作定义的类默认⽣成,但是只有在没有声明复制操作,移动操作,或析构函数的类中才会⽣成移动操作(参考 Item17)。禁⽌移动操作的类中(通过 delete move operation 参考 Item11),编译器不⽣成移动操作的⽀持。对于没有明确⽀持移动操作的类型,并且不符合编译器默认⽣成的条件的类,没有理由期望 C++11 会⽐ C++98 进⾏任何性能上的提升。

即使显式⽀持了移动操作,结果可能也没有你希望的那么好。⽐如,所有 C++11 的标准库都⽀持了移动操作,但是认为移动所有容器的开销都⾮常小是个错误。对于某些容器来说,压根就不存在开销小的⽅式来移动它所包含的内容。对另⼀些容器来说,开销真正小的移动操作却使得容器元素移动含义事与愿违。

考虑⼀下 std::array,这是C++11中的新容器。std::array 本质上是具有 STL 接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本⾝只保存了只想堆内存数据的指针(真正实现当然更复杂⼀些,但是基本逻辑就是这样)。这种实现使得在常数时间移动整个容器成为可能的,只需要拷⻉容器中保存的指针到⽬标容器,然后将原容器的指针置为空指针就可以了。

1
2
std::vector<Widget> vm1;
auto vm2 = std::move(vm1); // move vm1 into vm2. Runs in constant time. Only ptrsin vm1 and vm2 are modified

std::array 没有这种指针实现,数据就保存在 std::array 容器中

1
2
std::array<Widget, 10000> aw1;
auto aw2 = std::move(aw1); // move aw1 into aw2. Runs in linear time. Allelements in aw1 are moved into aw2.

注意 aw1 中的元素被移动到了 aw2 中,这⾥假定 Widget 类的移动操作⽐复制操作快。但是使⽤ std::array 的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷⻉⼀次,这与“移动⼀个容器就像操作⼏个指针⼀样⽅便”的含义想去甚远。

另⼀⽅⾯,std::strnig 提供了常数时间的移动操作和线性时间的复制操作。这听起来移动⽐复制快多了,但是可能不⼀定。许多字符串的实现采⽤了small string optimization(SSO)。”small”字符串(⽐如⻓度小于15个字符的)存储在了 std::string 的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不必复制操作更快。

SSO 的动机是⼤量证据表明,短字符串是⼤量应⽤使⽤的习惯。使⽤内存缓冲区存储而不分配堆内存空间,是为了更好的效率。然而这种内存管理的效率导致移动的效率并不必复制操作⾼。

即使对于⽀持快速移动操作的类型,某些看似可靠的移动操作最终也会导致复制。Item14 解释了原因,标准库中的某些容器操作提供了强⼤的异常安全保证,确保 C++98 的代码直接升级 C++11 编译器不会不可运⾏,仅仅确保移动操作不会抛出异常,才会替换为移动操作。结果就是,即使类提供了更具效率的移动操作,编译器仍可能被迫使⽤复制操作来避免移动操作导致的异常。

因此,存在⼏种情况,C++11的移动语义并⽆优势:

  • No move operations:类没有提供移动操作,所以移动的写法也会变成复制操作
  • Move not faster:类提供的移动操作并不必复制效率更⾼
  • Move not usable:进⾏移动的上下⽂要求移动操作不会抛出异常,但是该操作没有被声明为 noexcept

值得⼀提的是,还有另⼀个场景,会使得移动并没有那么有效率:

  • Source object is lvalue:除了极少数的情况外(例如 Item25),只有右值可以作为移动操作的来源

但是该 Item 的标题是假定不存在移动操作,或者开销不小,不使⽤移动操作。存在典型的场景,就是编写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那样,保守地考虑复制操作。不稳定的代码也是如此,类的特性经常被修改导致可能移动操作会有问题。

但是,通常,你了解你代码⾥使⽤的类,并且知道是否⽀持快速移动操作。这种情况,你⽆需这个 Item 的假设,只需要查找所⽤类的移动操作详细信息,并且调⽤移动操作的上下⽂中,可以安全的使⽤快速移动操作替换复制操作。

总结

  • Assume that move operations are not present, not cheap, and not used.