返回介绍

18.2 移动语义和右值引用

发布于 2024-10-08 23:14:14 字数 7665 浏览 0 评论 0 收藏 0

现在介绍本书前面未讨论的主题。C++11 支持移动语义,这就提出了一些问题:什么是移动语义?C++11 如何支持它?为何需要移动语义?下面首先讨论第一个问题。

18.2.1 为何需要移动语义

先来看 C++11 之前的复制过程。假设有如下代码:

vector 和 string 类都使用动态内存分配,因此它们必须定义使用某种 new 版本的复制构造函数。为初始化对象 vstr_copy1,复制构造函数 vector<string>将使用 new 给 20000 个 string 对象分配内存,而每个 string 对象又将调用 string 的复制构造函数,该构造函数使用 new 为 1000 个字符分配内存。接下来,全部 20000000 个字符都将从 vstr 控制的内存中复制到 vstr_copy1 控制的内存中。这里的工作量很大,但只要妥当就行。

但这确实妥当吗?有时候答案是否定的。例如,假设有一个函数,它返回一个 vector<string>对象:

接下来,假设以下面这种方式使用它:

从表面上看,语句#1 和#2 类似,它们都使用一个现有的对象初始化一个 vector<string>对象。如果深入探索这些代码,将发现 allcaps( ) 创建了对象 temp,该对象管理着 20000000 个字符;vector 和 string 的复制构造函数创建这 20000000 个字符的副本,然后程序删除 allcaps( ) 返回的临时对象(迟钝的编译器甚至可能将 temp 复制给一个临时返回对象,删除 temp,再删除临时返回对象)。这里的要点是,做了大量的无用功。考虑到临时对象被删除了,如果编译器将对数据的所有权直接转让给 vstr_copy2,不是更好吗?也就是说,不将 20000000 个字符复制到新地方,再删除原来的字符,而将字符留在原来的地方,并将 vstr_copy2 与之相关联。这类似于在计算机中移动文件的情形:实际文件还留在原来的地方,而只修改记录。这种方法被称为移动语义(move semantics)。有点悖论的是,移动语义实际上避免了移动原始数据,而只是修改了记录。

要实现移动语义,需要采取某种方式,让编译器知道什么时候需要复制,什么时候不需要。这就是右值引用发挥作用的地方。可定义两个构造函数。其中一个是常规复制构造函数,它使用 const 左值引用作为参数,这个引用关联到左值实参,如语句#1 中的 vstr;另一个是移动构造函数,它使用右值引用作为参数,该引用关联到右值实参,如语句#2 中 allcaps(vstr) 的返回值。复制构造函数可执行深复制,而移动构造函数只调整记录。在将所有权转移给新对象的过程中,移动构造函数可能修改其实参,这意味着右值引用参数不应是 const。

18.2.2 一个移动示例

下面通过一个示例演示移动语义和右值引用的工作原理。程序清单 18.2 定义并使用了 Useless 类,这个类动态分配内存,并包含常规复制构造函数和移动构造函数,其中移动构造函数使用了移动语义和右值引用。为演示流程,构造函数和析构函数都比较啰嗦,同时 Useless 类还使用了一个静态变量来跟踪对象数量。另外,省略了一些重要的方法,如赋值运算符。

程序清单 18.2 useless.cpp

其中最重要的是复制构造函数和移动构造函数的定义。首先来看复制构造函数(删除了输出语句):

它执行深复制,是下面的语句将使用的构造函数:

引用 f 将指向左值对象 one。

接下来看移动构造函数,这里也删除了输出语句:

它让 pc 指向现有的数据,以获取这些数据的所有权。此时,pc 和 f.pc 指向相同的数据,调用析构函数时这将带来麻烦,因为程序不能对同一个地址调用 delete [ ]两次。为避免这种问题,该构造函数随后将原来的指针设置为空指针,因为对空指针执行 delete [ ]没有问题。这种夺取所有权的方式常被称为窃取(pilfering)。上述代码还将原始对象的元素数设置为零,这并非必不可少的,但让这个示例的输出更一致。注意,由于修改了 f 对象,这要求不能在参数声明中使用 const。

在下面的语句中,将使用这个构造函数:

表达式 one + three 调用 Useless::operator+(),而右值引用 f 将关联到该方法返回的临时对象。

下面是在 Microsoft Visual C++ 2010 中编译时,该程序的输出:

注意到对象 two 是对象 one 的副本:它们显示的数据输出相同,但显示的数据地址不同(006F4B68 和 006F4BB0)。另一方面,在方法 Useless::operator+() 中创建的对象的数据地址与对象 four 存储的数据地址相同(都是 006F4C48),其中对象 four 是由移动复制构造函数创建的。另外,注意到创建对象 four 后,为临时对象调用了析构函数。之所以知道这是临时对象,是因为其元素数和数据地址都是 0。

如果使用编译器 g++ 4.5.0 和标记-std=c++11 编译该程序(但将 nullptr 替换为 0),输出将不同,这很有趣:

注意到没有调用移动构造函数,且只创建了 4 个对象。创建对象 four 时,该编译器没有调用任何构造函数;相反,它推断出对象 four 是 operator+( ) 所做工作的受益人,因此将 operator+( ) 创建的对象转到 four 的名下。一般而言,编译器完全可以进行优化,只要结果与未优化时相同。即使您省略该程序中的移动构造函数,并使用 g++进行编译,结果也将相同。

18.2.3 移动构造函数解析

虽然使用右值引用可支持移动语义,但这并不会神奇地发生。要让移动语义发生,需要两个步骤。首先,右值引用让编译器知道何时可使用移动语义:

对象 one 是左值,与左值引用匹配,而表达式 one + three 是右值,与右值引用匹配。因此,右值引用让编译器使用移动构造函数来初始化对象 four。实现移动语义的第二步是,编写移动构造函数,使其提供所需的行为。

总之,通过提供一个使用左值引用的构造函数和一个使用右值引用的构造函数,将初始化分成了两组。使用左值对象初始化对象时,将使用复制构造函数,而使用右值对象初始化对象时,将使用移动构造函数。程序员可根据需要赋予这些构造函数不同的行为。

这就带来了一个问题:在引入右值引用前,情况是什么样的呢?如果没有移动构造函数,且编译器未能通过优化消除对复制构造函数的需求,结果将如何呢?在 C++98 中,下面的语句将调用复制构造函数:

但左值引用不能指向右值。结果将如何呢?第 8 章介绍过,如果实参为右值,const 引用形参将指向一个临时变量:

就 Useless 而言,形参 f 将被初始化一个临时对象,而该临时对象被初始化为 operator+() 返回的值。下面是使用老式编译器进行编译时,程序清单 18.2 所示程序(删除了移动构造函数)的部分输出:

首先,在方法 Useless::operator+() 内,调用构造函数创建了 temp,并在 01C337C4 处给它分配了存储 30 个元素的空间。然后,调用复制构造函数创建了一个临时复制信息(其地址为 01C337E8),f 指向该副本。接下来,删除了地址为 01C337C4 的对象 temp。然后,新建了对象 four,它使用了 01C337C4 处刚释放的内存。接下来,删除了 01C337E8 处的临时参数对象。这表明,总共创建了三个对象,但其中的两个被删除。这些就是移动语义旨在消除的额外工作。

正如 g++示例表明的,机智的编译器可能自动消除额外的复制工作,但通过使用右值引用,程序员可指出何时该使用移动语义。

18.2.4 赋值

适用于构造函数的移动语义考虑也适用于赋值运算符。例如,下面演示了如何给 Useless 类编写复制赋值运算符和移动赋值运算符:

上述复制赋值运算符采用了第 12 章介绍的常规模式,而移动赋值运算符删除目标对象中的原始数据,并将源对象的所有权转让给目标。不能让多个指针指向相同的数据,这很重要,因此上述代码将源对象中的指针设置为空指针。

与移动构造函数一样,移动赋值运算符的参数也不能是 const 引用,因为这个方法修改了源对象。

18.2.5 强制移动

移动构造函数和移动赋值运算符使用右值。如果要让它们使用左值,该如何办呢?例如,程序可能分析一个包含候选对象的数组,选择其中一个对象供以后使用,并丢弃数组。如果可以使用移动构造函数或移动赋值运算符来保留选定的对象,那该多好啊。然而,假设您试图像下面这样做:

由于 choices[pick]是左值,因此上述赋值语句将使用复制赋值运算符,而不是移动赋值运算符。但如果能让 choices[pick]看起来像右值,便将使用移动赋值运算符。为此,可使用运算符 static_cast<>将对象的类型强制转换为 Useless &&,但 C++11 提供了一种更简单的方式—使用头文件 utility 中声明的函数 std::move( )。程序清单 18.3 演示了这种技术,它在 Useless 类中添加了啰嗦的赋值运算符,并让以前啰嗦的构造函数和析构函数保持沉默。

程序清单 18.3 stdmove.cpp

该程序的输出如下:

正如您看到的,将 one 赋给 three 调用了复制赋值运算符,但将 move(one) 赋给 four 调用的是移动赋值运算符。

需要知道的是,函数 std::move( ) 并非一定会导致移动操作。例如,假设 Chunk 是一个包含私有数据的类,而您编写了如下代码:

表达式 std::move(one) 是右值,因此上述赋值语句将调用 Chunk 的移动赋值运算符—如果定义了这样的运算符。但如果 Chunk 没有定义移动赋值运算符,编译器将使用复制赋值运算符。如果也没有定义复制赋值运算符,将根本不允许上述赋值。

对大多数程序员来说,右值引用带来的主要好处并非是让他们能够编写使用右值引用的代码,而是能够使用利用右值引用实现移动语义的库代码。例如,STL 类现在都有复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文