本周小贴士#77:临时的,移动的,和拷贝的

本文探讨了C++11中如何通过移动语义简化拷贝行为,介绍了三种对象状态:副本(两个名字)、移动对象(一个名字)和临时对象(无名)。移动语义减少了副本的创建,提高了性能。文章强调了理解移动后对象的状态以及避免僵尸对象的重要性,并提到了Clang-tidy的misc-use-after-move检查。作者鼓励开发者理解并利用这些新规则,以编写更清晰、更高效的代码。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

作为totw/77最初发表于2014年7月9日

由Titus Winters (titus@google.com)创作

更新于2017年10月20日

在不断尝试着给对编程语言不熟悉的人解释C++11是如何改变事物的过程中时,我们展示了“拷贝什么时候产生?”的系列中的另一个条目。这是简化C++中有关拷贝微妙规则并用一系列更简单的规则来替换的一般尝试中的一部分。

你能计数到2吗?

你能?太好了。请记住,“命名规则”意味着你可以为某个特定的资源分配一个唯一的名字,这会影响在流通中对象的副本数。(请参阅TotW55关于命名计数)

简而言之,命名计数

如果你担心一个拷贝被创建,你大概特别担心代码的某些行。因此,看到那个点。你认为正拷贝的数据有多少个名字存在呢?这里只有3种情况需要考虑:

二个名字:它是一个副本

这很简单:如果你给一个相同的数据第二个名字,那么它就是一个副本。

std::vector<int> foo;
FillAVectorOfIntsByOutputParameterSoNobodyThinksAboutCopies(&foo);
std::vector<int> bar = foo;     // 是的,这是一个副本

std::map<int, string> my_map;
string forty_two = "42";
my_map[5] = forty_two;          // 同样是一个副本:my_map[5]作为一个名字

一个名字,它是一个移动对象

这有点令人惊讶:C++11识别到如果你不再引用一个名字,那么你也就不再需要关心那个数据了。该语言必须小心不要破坏你依赖的析构函数(例如absl::MutexLock),因此return是容易识别的情况。

std::vector<int> GetSomeInts() {
  std::vector<int> ret = {1, 2, 3, 4};
  return ret;
}

// 仅仅是一个移动,"ret"或"foo"有数据,但是不是同时拥有
std::vector<int> foo = GetSomeInts();

另外一种方法是告诉编译器你已经处理了名字(来自TotW55“名称擦除器”),这种方法是调用std::move().

std::vector<int> foo = GetSomeInts();
// 不是一个拷贝,移动允许编译器将foo作为一个临时量,因此
// 这正在调用std::vector<int>的移动构造函数
// 注意,它不是通过std::move来完成移动,而是通过构造函数。
// 调用std::Move仅允许将foo作为一个临时对象而不是一个有名对象)
std::vector<int> bar = std::move(foo);

没有名字:它是一个临时对象

临时对象也是特殊的:如果你想避免副本,那么请避免为变量提供名字

void OperatesOnVector(const std::vector<int>& v);

// 没有副本:在由GetSomeInts()返回的vector中值将被移动(O(1))到
// 由这些调用之间构造的临时对象中,并且通过引用传递到OperateOnVector()
OperatesOnVector(GetSomeInts());

注意:僵尸

以上(除了std::move本身)的是希望非常直观,只是我们在C++11之前建立了奇怪的副本概念。对于没有垃圾回收的语言而言,这种解释的类型给我们提供了极好的性能和清晰度上的组合。然而,它不是没有危险,其中最大的是:一个值被移动之后,剩下了什么?

T bar = std::move(foo);
CHECK(foo.empty()); // 这是有效的吗?可能,但不应该依赖它

这是主要的困难之一:我们可以对这些剩下来的值说什么?对于大多数标准库类型而言,这样的值处于“有效但未指定的状态”。非标准类型通常也遵循相同的规则。安全的方法是远离这些对象:你可以对它们重新赋值,或者让它们离开作用域,但是不要对它们的状态做出任何其他的假设。

Clang-tidy提供了一些静态检测,通过misc-use-after-move检测来捕获移动后的使用。然而,静态分析并不会捕获所有这些担心的情况。在代码审查中指出这些,并在你的代码中避免它们。远离这些僵尸。

等等,std::move没有移动?

是的,另一件要注意的事情是,调用std::move实际上并不移动它自己,它仅是一个右值引用转换。只有使用移动构造函数或移动赋值函数才能完成任务。

std::vector<int> foo = GetSomeInts();
std::move(foo); // 不做任何事情
// 调用std::vector<int>的移动构造函数
std::vector<int> bar = std::move(foo);

这几乎不会发生,你可能不应该浪费精力在它上面。如果在std::move和移动构造函数之间的连接让你困惑,其实我真的只是提到它而已。

啊!这一切很复杂呀!为什么呢!?!

首先:它真的没有这样的槽。针对大多数我们自己的值类型,我们有移动操作,所以我们可以废除所有关于“它是一个副本吗?这样有效吗”的讨论,而仅依靠名称计数:两个名字,一个副本。少于那个:没有副本。

忽略副本的问题,用值语义推导更清晰更简单。考虑这两种操作:

void Foo(std::vector<string>* paths) {
  ExpandGlob(GenerateGlob(), paths);
}

std::vector<string> Bar() {
  std::vector<string> paths;
  ExpandGlob(GenerateGlob(), &paths);
  return paths;
}

这些是相同的吗?如果*paths中存在数据怎么办呢?你怎么知道?对于读者来说,值语义比输入/输出更容易推导,此刻你需要考虑(并记录)现有数据发生了什么,以及是否有可能存在指针所有权转移。

在处理值(而不是指针)时,由于存在对生命周期和用法更简单的保证,编译器的优化器更容易对这种风格的代码进行操作。管理良好的值语义也能最小化触发分配器(这是代价小的但不是免费的)。一旦我们理解了移动语义如何帮助我们摆脱副本,编译器的优化器就可以更好地推导对象类型,生命周期,虚分派和许多其他有助于产生更高效机器码的问题。

由于现在大多数的实用程序代码是有考虑移动语义的,因此我们应该停止担心副本和指针语义,而专注于编写易于理解地代码。请确保你了解这些新规则:并非你遇到的所有老接口都能够更新为按值(代替按输出参数)返回,所以混合的形式将一直存在。对你而言,理解什么时候一种情况比另一种情况更适合是很重要的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值