本文主要是介绍《C++Primer》第十三章 拷贝控制,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
第十三章 拷贝控制
简介
当定义一个类时,我们显式或者隐式地指定此类型对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作:
- 拷贝构造函数
copy constructor
- 拷贝赋值运算符
copy-assignment operator
- 移动构造函数
move constructor
- 移动赋值运算符
move-assignment operator
- 析构函数
destructor
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么;拷贝和移动赋值运算符定能够以了将一个对象赋予同类型的另一个对象时做什么;析构函数定义了当此类型对象销毁时做什么。
拷贝、赋值与销毁
1. 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造。
class Foo {
public:Foo(); // 默认构造函数Foo(const Foo&); // 拷贝构造函数
}
拷贝构造函数几乎总是接收一个
const
的自身类类型的引用。拷贝构造函数在几种情况下都会被隐式地使用,因此拷贝构造函数通常不应该是explicit
的。
1.1 合成拷贝构造函数
当我们没有为一个类定义拷贝构造函数时,编译器会为我们定义一个。对某些类来说,合成拷贝构造函数synthesized copy constructor
用来阻止我们拷贝该类类型的对象。而一般情况合成的拷贝构造函数会将参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static
成员拷贝到正在创建的对象中。
每个成员的类型决定了它如何拷贝:对于类类型的成员会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但是合成拷贝构造函数会将逐个元素地拷贝一个数组类型的成员。当然如果数组成员是类类型,则使用元素的拷贝构造函数来拷贝。
1.2 拷贝初始化
// 直接初始化
string dots(10, '.');
string s(dots);
// 拷贝初始化
string s2 = dots;
string null_book = "9-999-99999-9";
string nines = string(100, '9');
当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。
拷贝初始化除了在我们用=
定义变量时会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 某些类型会对它们所分配的对象使用拷贝初始化,例如当我们初始化标准库容器或者是调用其
insert
或者push
成员,容器会对其元素进行拷贝初始化;使用emplace
成员创建的元素都进行直接初始化
1.3 参数和返回值
- 在函数调用过程中,具有非引用类型的参数要进行拷贝初始化
- 当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,那么其调用永远不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝它的实参,我们又需要调用拷贝构造函数,如此无限循环。
1.4 拷贝初始化的限制
当我们使用
explicit
关键字声明构造函数时,它将只能以直接初始化的形式使用,而且编译器不会再自动转换过程中使用该构造函数。
voctor<int> v1(10); // 正确: 直接初始化
vector<int> v2 = 10; // 错误:接收大小参数的构造函数是explicit的
1.5 编译器可以绕过拷贝构造函数
在拷贝初始化过程中,编译器可以(但不是必须)跳过拷贝/移动构造函数,直接创建对象。即:
string null_book = "9-999-99999-9"; // 拷贝初始化
// 编译器略过拷贝构造函数
string null_book("9-999-99999-9");
虽然编译器跳过了,但是在这个程序点上,拷贝/移动构造函数必须是存在且可访问的。
2. 拷贝赋值运算符
拷贝赋值运算符本质上接受一个与其所在类相同类型的参数:
Foo& operator=(const Foo&); // 赋值运算符, 通常返回一个指向其左侧运算对象的引用
3. 析构函数
构造函数初始化对象的非
static
数据成员,还可能做一些其他操作;析构函数释放对象使用的资源,并销毁对象的非static
数据成员。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型无析构函数,因此销毁内置类型成员什么也不做。
隐式销毁一个内置指针类型的成员不会
delete
它指向的对象。但是智能指针是类类型,所以具有析构函数,因此指向的对象在析构阶段会被销毁。
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量离开其作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针使用
delete
运算符 - 对于临时对象,当创建它的完整表达式结束时被销毁
4. 三/五法则
4.1 需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本的原则是首先确定这个类是不是需要一个析构函数。当这个类需要一个析构函数时,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值函数。下面这个类我们定义了析构函数:
class HasPtr {
public:HasPtr(const std::string &s = std::string()) :ps(new std::string(s)), i(0) { }~HasPtr() { delete ps; }// 错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
}
上面这个类使用了合成的拷贝构造函数和拷贝赋值运算符。这些函数简单拷贝指针成员,这意味着多个HasPtr
对象可能指向相同的内存。那么析构函数会导致多个HasPtr
对象被销毁时delete
相同的指针多次,这是未定义的行为。
4.2 需要拷贝操作的类也需要赋值操作,反之亦然
考虑一个类为每个对象分配一个独有的、唯一的序号,这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他的数据成员。这个类还需要自定义拷贝赋值运算符来避将序号赋予目的对象。但是这个类却不需要析构函数。
如果一个类需要一个拷贝构造函数,那么几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然。无论需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
5. 使用=default
我们可以通过将拷贝控制成员定义为=default
来显式地要求编译器生成合成的版本。
6. 阻止拷贝
虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但是对于某些类来说这些操作没有意义。在此情况下,在定义类时必须采用某种机制组织拷贝或者赋值。例如iostream
类组织了拷贝,以避免多个对象写入或者读取相同的IO
缓冲。为了阻止拷贝,我们不能简单地不定义拷贝控制成员,因为编译器会自动为它生成合成的版本。
6.1 定义删除的函数
有一类函数我们虽然声明了它们,但不能以任何方式使用他们,在函数的参数列表后面加上=delete
来指出我们希望这个函数被删除:
struct NoCopy {NoCopy() = default; // 合成的默认构造函数NoCopy(const NoCopy&) = delete; // 阻止拷贝NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值~Nocopy() = default; // 合成的析构函数// 其他成员
};
6.2 析构函数不能是删除的成员
- 如果析构函数被删除,那么无法销毁此类型的对象
- 对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的来临时对象
- 如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或者临时对象
- 对于删除了析构函数的类型,我们虽然不能定义这种类型的变量或者成员,但是可以鼎泰分配这种类型的对象,但是不能释放这些对象
6.3 合成的拷贝控制成员可能是删除的
对于有些类来说,编译器生成的合成的拷贝控制成员可能被定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或者不可访问的(比如
private
),那么合成析构函数被定义为删除的 - 如果类的某个成员的拷贝构造函数是删除的或者不可访问的;或者类的某个成员的析构函数是删除的或者不可访问的,则类合成的拷贝构造函数也被定义为删除的
- 如果类的某个成员的拷贝赋值运算符是删除的或者不可访问的,或是类有一个
const
的或引用成员,则类的合成拷贝构造函数被定义为删除的 - 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,他没有类内初始化器,或是类有一个
const
成员,他没有类内初始化器且未显式定义默认构造函数,则该类的默认构造函数被定义为删除的
如果一个类由数据成员不能默认构造、拷贝、复制或者销毁,则对应的成员函数将被定义为删除的。
6.4 private拷贝控制
在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为private
来阻止拷贝的。虽然用户代码不能拷贝这个类型的对象,但是,友元和成员函数仍然可以拷贝对象,为了组织友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为priva、te
的,但不定义他们。
拷贝控制和资源管理
通常管理类外资源的类必须定能够以拷贝控制成员,这种累需要通过析构函数释放对象所分配的资源。一旦一个类需要析构函数,你那么它几乎肯定也需要一个拷贝构函数和一个拷贝赋值运算符。
如果一个类需要管理类外资源,那我们首先必须确定此类型对象的拷贝语义。一般有两种选择:
- 使类的行为像一个值:意味着它有自己的状态,当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变副本不会对原对象有任何影响
- 使类的行为像一个指针:当我们拷贝一个这种类时,副本和原对象使用相同的底层数据
在我们使用过的标准库类中,标准库容器和
string
类的行为像一个值,shared_ptr
类提供类似指针的行为。IO
类型和unique_ptr
不允许拷贝或赋值,因此它们的行为既不像值也不像指针。
1. 行为像值的类
class HasPtr {
public:HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }// 对ps指向的string, 每个HasPtr对象有自己的拷贝HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) { }HasPtr& operator=(const HasPtr &);~HasPtr() { delete ps };
private:std::string *ps;int i;
}// 赋值操作会销毁左侧运算对象的资源,并从右侧运算对象拷贝数据
HasPtr& HasPtr::operator={const HasPtr &rhs} {auto newp = new string(*rhs.ps); // 拷贝底层stringdelete ps; // 释放旧内存ps = newp; // 从右侧运算对象拷贝数据到本对象i = rhs.i;return *this; // 返回本对象
}
编写赋值运算符时需注意:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作
2. 定义行为像指针的类
令一个类实现类似指针的行为最好方法是使用shared_ptr
来管理类中的资源你,拷贝/赋值一个shared_ptr
会拷贝/赋值shared_ptr
所指向的指针。当没有用户使用对象时,shared_ptr
类负责释放资源。但是有时候我们希望直接管理资源,在这种情况下使用引用计数就很有用了。引用计数的工作方法:
- 每个构造函数需要创建一个引用计数,当我们创建一个对象时将计数器初始化为
1
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器,同时需要递增共享的计数器
- 析构函数递减计数器,如果计数器为
0
则析构函数释放状态 - 拷贝赋值运算符递增右侧对象的计数器,递减左侧运算对象的计数器
class HasPtr {
public:// 构造函数分配新的string和新的计数器, 将计数器置为1HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) { }// 拷贝构造函数拷贝所有三个数据成员, 并递增计数器HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++*use; }HasPtr& operator=(const HasPtr &);~HasPtr();
private:std::string *ps;int i;std::size_t *use; // 计数多少个对象共享*ps的成员
};HasPtr::~HasPtr()
{if (--*use == 0) {delete ps;delete use;}
}HasPtr& operator=(const HasPtr &)
{++*rhs.use;if (--*use == 0) {delete ps;delete use;}ps = rhs.ps;i = rhs.i;use = rhs.use;return *this;
}
交换操作
除了定义拷贝控制成员外,管理资源的类通常还定义一个swap
函数。swap
的典型实现如下:
class HasPtr {friend void swap(HasPtr&, HasPtr&);// 其他成员定义
};
inline void swap(HasPtr& lhs, HasPtr& rhs) {using std::swap;swap(lhs.ps, rhs.ps)l // 交换指针而不是string数据swap(lhs.i, rhs.i); // 交换int成员
}
我们将
swap
定义为friend
,以便你能够访问到HasPtr
的private
数据成员。
定义swap
的类通常用swap
来定义他们的赋值运算符。这些运算符使用了一种名为拷贝并交换copy and swap
的技术,将左侧运算对象与右侧运算对象的一个副本进行交换:
// 主要rhs是按值传递的,意味着HasPtr的拷贝构造函数将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{// 交换左侧运算对象和局部变量rhs的内容swap(*this, rhs); // rhs现在指向本对象曾经使用过的内存return *this; // rhs被销毁,从而delete了rhs中的指针
}
这个技术自动就是异常安全的,且能正确处理自赋值:
- 在改变左侧对象之前就拷贝右侧运算对象,保证了自赋值的正确性
- 代码中唯一可能抛出异常的就是拷贝构造函数中的
new
表达式,如果真的异常,也会在改变左侧运算对象之前发生
动态内存管理类
某些类需要在运行时分配可变大小的内存空间,这种类通常使用标准库容器来保存它们的数据,比如vector
。某些类需要自己进行内存管理,这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存。
我们使用一个allocator
来获取原始内存,由于allocator
获取的原始内存是未构造的,我们将在需要添加新元素时使用constructor
在原始内存中创建对象,在删除元素时使用destory
销毁元素。每个StrVec
有三个指针成员指向其元素使用的内存:
-
elements
:指向分配的内存中的首元素 -
first_free
:指向最后一个实际元素之后的位置 -
cap
:指向愤怒陪你的内存末尾之后的位置
// 类vector类内存分配策略的简化实现
class StrVec {
public:StrVec() : // allocator成员进行默认初始化elements(nullptr), first_free(nullptr), cap(nullptr) { }StrVec(const StrVec&); // 拷贝构造函数StrVec &operator=(const StrVec&); // 拷贝赋值运算符~StrVec(); // 析构函数void push_back(const std::string&); // 拷贝元素size_t size() const { return frist_free -elements; }size_t capacity() const { return cap - elements; }std::string *begin() const { return elements; }std::string *end() const { return first_free; }
private:static std::allocator<std::string> alloc; // 分配元素// 被添加元素的函数所使用void chk_n_alloc(){ if (size() == capacity()) reallocate(); }// 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用std::pair<std::string*, std::string*> alloc_n_copy(const std::string*, const std::string*);void free(); // 销毁元素并释放内存void reallocate(); // 获取更多你内存并拷贝已有元素std::string *elements; // 指向数据首元素的指针std::string *first_free; // 指向数组第一个空闲元素的指针std::string *cap; // 指向数据尾后位置的指针
};void StrVec::push_back(const std::string&)
{check_n_alloc(); // 确保有空间容纳新元素// 在first_free指向的元素中构造s的副本alloc.construct(first_free++, s);
}pair<std::string*, std::string*> StdVec::alloc_n_copy (const std::string *b, const std::string *e)
{// 分配空间保存给定范围中的元素auto data = alloc.allocate(e - b);// 初始化并返回一个pair// 返回语句对返回值进行了列表初始化, uninnitialized_copy返回一个指向最后一个构造元素之后的指针return {data, uninnitialized_copy(b, e, data)};
}void StrVec::free()
{// 不能传递给deallocate一个空指针if (elements) {// 逆序销毁旧元素for (auto p = first_free; p != elements; /* 空 */)alloc.destroy(--p);alloc.deallocate(elements, cap - elements);}
}StrVec::StrVec(const StrVec &s)
{auto newdata = alloc_n_copy(s.begin(), s.end());elements = newdata.first;first_free = cap = newdata.second;
}StrVec::~StrVec() { free(); }StrVec &StrVec::operator=(const StrVec &rhs)
{auto data = alloc_n_copy(rhs.begin(), rhs.end());free();elements = data.first();first_free = cap = data.second;return *this;
}
在编写reallocate
函数之前,我们思考一下它的功能:
- 为一个新的、更大的
string
数组分配内存 - 在内存空间的前一部分构造对象,保存现有元素
- 销毁原内存空间中的元素,并释放这块内存
这会带来一个问题,为一个string
数组重新分配内存会引起从就内存空间到新内存空间逐个拷贝string
的问题。(因为string
类具有类值行为,当拷贝一个string
时,新老string
是相互独立的)在重新分配内存空间时,如果我们能够避免分配和释放string
的额外开销,那么StrVec
的性能就会好很多。
有一些标准库类(包括string
)定义了“移动构造函数”,该函数将资源从给定对象“移动”而不是拷贝到猪呢个在创建的对象。假设每个string
都有一个指向char
数组的指针,可以假定string
的移动构造函数进行了指针的拷贝,而不是为字符分配内存空间然后拷贝字符。
void StrVec::reallocate()
{// 我们将分配当前大小两倍的内存空间auto newcapacity = size() ? 2 * size() : 1;// 分配新内存auto newdata = allocate(newcapacity);// 将数据从旧内存移动到新内存auto dest = newdata;auto elem = elements;for (size_t i = 0; i != size(); ++i)alloc.construct(dest++, std::move(*elem++));free();elements = newdata;first_free = dest;cap = elements + newcapcity;
}
对象移动
新标准一个最主要的特性就是可以移动而非拷贝对象的能力。
很多时候都会发生对象拷贝,如果对象拷贝完之后就被立即销毁,那么移动对象而非拷贝对象会大幅度提升性能。
使用移动而不是拷贝的另一个原因在于IO
类或者unique_ptr
这样的类,这些类都包含不能被共享的资源(如指针或者IO
缓冲),因此这些类型的对象不能被拷贝但是可以被移动。
1. 右值引用
为了支持移动操作,新标准引入了新的引用类型:右值引用rvalue reference
,这是一种必须绑定到右值的引用。我们通过&&
而不是&
来获得右值引用。右值引用有一个重要的性质——只能班内固定到一个将要销毁的对象,因此我们可以自由地将一个右值引用的资源“移动到另一个对象中”。
左值和右值是表达式的属性,一般而言一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
我们不能将左值引用绑定到要求转换的表达式、字面常量或者是返回右值的表达式,但是可以将一个右值引用绑定到这类表达式上。
int i = 42;
int &r = i; // 正确, r引用i
int &&rr = i; // 错误, 不能将一个右值引用绑定到左值上
int &r2 = i * 42; // 错误: i * 42 是一个右值
const int &r3 = i * 42; // 正确: 我们可以将一个const的引用绑定到右值上
int &&rr2 = i * 42; // 正确:将rr2绑定到右值上
返回左值的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式,我们可以将一个左值引用绑定到这类表达式的结果上。返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符都生成右值,我们不能将一个左值引用绑定到这些表达式上,但是可以将一个
const
的左值引用或者右值引用绑定到这类表达式上。
1.1 左值持久而右值短暂
左值有持久的状态,但是右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。由于右值引用只能绑定到临时对象,我们可以得到:
- 所引用的对象即将被销毁
- 该对象没有任何用户
这两个特性意味着使用右值引用的代码可以自由地接管所引用的对象的资源。
1.2 变量是左值
int &&rr1 = 42; // 正确:字面常量是右值
int &&rr2 = rr1; // 错误:表达式rr1是左值
由于变量是持久的,只有离开作用域才会被销毁,因此变量是左值,即使这个变量是右值引用类型也能被右值引用直接绑定。
1.3 标准库move函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以通过move
显式地将一个左值转移到对应的右值引用类型。
int &&rr3 = std::move(rr1); // 正确
调用move
后意味着我们可以对rr1
赋值或者销毁,但是我们将不能再使用它的值。
2. 移动构造函数和移动赋值运算符
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常// 成员初始化器接管s中的资源: elements(s.elements), first_free(s.first_free), cap(s.cap)
{// 令s进入这样的状态——对其运行析构函数是安全的s.elements = s.first_free = s.cap = nullptr;// 如果我们忘记改变s.first_free,那么销毁移后原对象就会释放掉我们刚刚移动的内存
}
2.1 移动操作和异常
不抛出异常的移动构造函数和移动赋值运算符必须标记为
noexcept
。
首先看两个事实:
- 虽然移动操作符通常不抛出异常,但是抛出异常也是允许的
- 标准库容器能对异常发生时其自身的行为提供保证,比如
vector
保证如果我们调用push_back
时发生异常,那么vector
自身不会发生改变
移动一个对象通常会改变它的值,如果重新分配过程中使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常:旧空间中的移动源元素已经被改变了,但是新空间中未构造的元素可能还不存在。这种情况下,vector
不能满足自身保持不变的要求。
如果vector
使用的是拷贝构造函数并且发生异常,在新内存中构造元素时旧元素保持不变,这时候如果发生异常vector
可以直接释放新分配(但还没构造成功)的内存并返回。vector
中的元素仍然存在。
为了避免这种潜在问题,vector
除非直到元素类型的构造移动函数不会发生异常,否则在重新分配内存的过程中它就必须使用拷贝构造函数而不是移动构造函数。当我们希望在vector
重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数不会发生异常,可以安全使用。
2.2 移动赋值运算符
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{// 直接检测自赋值if (this != &ths) { free(); // 释放已有元素elements = rhs.elements; // 从rhs中接管资源first_free = rhs.first_free;cap = rhs.cap;// 将rhs置于可析构状态rhs.elements = rhs.first_free = rhs.cap = nullptr;}return *this;
}
2.3 移后源对象必须可析构
从一个对象移动数据并不会销毁对象,但有时在移动操作完成后,源对象会被销毁。因此当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。我们的StrVec
的移动操作满足这一要求,这是通过将移后源对象的指针成员置为nullptr
来实现的。
2.4 合成的移动操作
如果我们不声明自己的拷贝构造函数或者拷贝赋值运算符,编译器总会为我们合成这些操作:要么是逐成员拷贝,要么被定义为对象赋值,要么被定义为被删除的函数。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。
只有但那个一个类没有定义任何自己版本的拷贝控制成员,并且它的所有非
static
数据成员都可以移动时,编译器才会为它合成移动构造函数或者移动赋值运算符。
2.5 移动右值,拷贝左值
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定它使用哪个构造函数。拷贝函数接受const StrVec
引用的参数,因此他可以用于任何可以转换为StrVec
的情形,而移动构造函数接受一个StrVec &&
,因此只能用于实参是非static
右值的类型。
如果一个类有一个拷贝构造函数但是未定义移动构造函数,编译器不会合成移动构造函数。这种情况下函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用
move
来移动他们。
2.6 拷贝并交换赋值运算符和移动操作
class HasPtr {
public:// 添加的移动构造函数HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }// 赋值运算符既是移动赋值运算符也是拷贝赋值运算符HasPtr& operator=(HasPtr rhs) { swap(*this, rhs); return *this; }
}
赋值运算符使用的是非引用参数,这意味着此参数要进行拷贝初始化,依赖于实参的类型拷贝初始化要么使用拷贝构造函数要么使用移动构造函数——左值被拷贝,右值被移动。因此单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
Q:这里的拷贝初始化不会浪费性能?造成多余的拷贝?
A:不会,依赖于实参类型可能会选择移动构造函数来初始化此参数
2.7 移动迭代器
StrVec
的reallocate
成员使用了一个for
循环来调用construct
从旧内存将元素拷贝到新内存。我们也可以用uninitialized_copy
来构造新分配的内存。但是它对元素进行拷贝操作,标准库中没有类似的函数将元素“移动”到未构造的内存中。
新标准库中定义了一种移动迭代器move iterator
适配器,一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。一般来说一个迭代器的解引用运算符返回一个指向元素的左值,移动迭代器的解引用运算符生成一个右值引用。 我们可以将移动迭代器传递给uninitialized_copy
:
void StrVec::reallocate()
{// 我们将分配当前大小两倍的内存空间auto newcapacity = size() ? 2 * size() : 1;// 分配新内存auto newdata = allocate(newcapacity);// 将数据从旧内存移动到新内存auto last = uninitialized_copy(make_move_iterator(begin()),make_move_itrator(end()),first);free();elements = newdata;first_free = last;cap = elements + newcapcity;
}
标准库并不保证哪些算法适用移动迭代器,哪些算法不适用。由于移动一个对象可能销毁原对象,只有你确信在为一个元素赋值或者将其传递给一个用户定义的函数不再访问他才能将移动构造器传递给算法。
不要随便使用移动操作:在代码中谨慎地使用
move
可以大幅度提升性能,而如果随意在用户代码(与类的实现代码相对)中使用移动操作,很可能导致难以查找的错误。
右值引用和成员函数
除了构造函数和赋值运算符外,如果一个成员函数同时提供拷贝和移动两种版本,它也能从其中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受指向const
的左值引用,另一个版本接受一个指向非const
的右值引用。
区分移动和拷贝的重载函数通常有一个版本接收一个
const T&
,另一个版本接收T&&
。一般来说我们不需要为函数操作定义接受一个const X&&
或者X&
的版本。当我们希望从实参“窃取”数据时,通常传递一个右值引用。当我们希望从一个对象进行拷贝的操作时不应该改变对象。
class StrVec {
public:void push_back(const std::string&); // 拷贝元素void push_back(std::string&&); // 移动元素
};void StrVec::push_back(const string& s)
{chk_n_alloc(); // 确保有空间容纳新元素// 在first_free指向的元素中构造s的一个副本alloc.construct(first_free++, s);
}void StrVec::push_back(std::string &&s)
{chk_n_alloc();// move返回一个右值引用,传递给construct的实参是string&&类型,因此会使用string的移动构造函数来构造新元素alloc.construct(first_free++, std::move(s));
}
调用:
StrVec vec; // 空StrVec
string s = "some string or another"; // 左值
vec.push_back(s); // 调用push_back(const std::string&)拷贝元素
vec.push_back("done"); // 调用push_back(std::string&&)移动元素
1. 左值和右值引用成员函数
通常我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值,例如:
// 在一个string右值上调用find成员
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');// 旧标准中我们无法阻止对一个右值赋值
s1 + s2 = "wow!";
为了维持向后兼容性,新标准库类仍然允许向右赋值。但是我们可能希望在自己的类中阻止这种用法,在此情况下我们希望强制左侧运算对象(即this
指向的对象)是一个左值。
class Foo {
public:Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值// Foo的其他参数
};
Foo &Foo::&operator=(const Foo &rhs) &
{// 执行将rhs赋予本对象所需的工作return *this;
}
引用限定符可以是&
或者&&
,分别指出this
可以指向一个左值或者右值。类似于const
限定符,引用限定符只能用于非static
成员函数,且同时出现在函数的声明和定义中。
2. 重载和引用函数
我们可以综合引用限定符和const
来区分一个成员函数的重载版本:
class Foo {
public:Foo sorted() &&; // 可用于可改变的右值Foo sorted() const &;
private:vector<int> data;
};// 本对象是一个右值,意味着没有用户,可以直接进行原址排序
Foo Foo::sorted() &&
{sorted(data.begin(), data.end());return *this;
}// 本对象是一个const或者是一个左值, 哪个情况下我们都不能对其进行原址排序
Foo Foo::sorted() const & {Foo ret(*this); // 拷贝一个副本sort(ret.data.begin(), ret.data.end());return ret;
}
这篇关于《C++Primer》第十三章 拷贝控制的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!