一、拷贝、赋值与销毁
1.1拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都由默认值,则此构造函数成为拷贝构造函数。拷贝构造函数在某些情况下会被隐式地使用,所以不能定义为expicit
。
合成拷贝构造函数
合成某某函数一般出现在我们没定义该函数时,比如当没有定义任何构造函数时,编译器会给出一个合成默认构造函数。但是即使定义了其它构造函数,合成拷贝构造函数任然会被编译器定义。
每个成员的类型决定了它如何拷贝;对于类类型,会使用其拷贝构造函数进行拷贝;对于内置类型的成员则直接拷贝。
拷贝初始化发生的场景:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型会对它们分配的对象使用拷贝初始化,比如在初始化标准库容器或是调用其insert
或push
成员时,容器会对其元素进行拷贝初始化;使用emplace
则是直接进行初始化。
参数和返回值
在函数调用的过程中,非引用类型的参数要进行拷贝初始化,这也解释了为什么拷贝构造函数的第一个参数必须是引用类型,如果不是,那么为了调用拷贝构造函数,就必须拷贝它的实参,为了拷贝实参,又需要调用拷贝构造函数,造成了无限循环。
拷贝初始化的限制
如果使用的初始化值要求通过一个explicit
的构造函数进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了。第一行代码采用直接初始化,第二行错误,vector
中接收大小参数的构造函数时explicit
的,就无法进行隐式拷贝构造。第三行f
的参数使用拷贝初始化。第四行错误,道理与第二行相同。最后一行显式的使用一个exlicit
构造函数。
1.vector<int> v1(10);
2.vector<int> v2 = 10;
3.void f(vector<int>);
4.f(10);
5.f(vector<int>(10));
编译器可以绕过拷贝构造函数
下面的第二行代码直接忽略了拷贝构造函数,直接创建了对象,但是类里面必须存在拷贝构造函数。(这样做不知道能否提升一点性能?)
string null_book = "999-99-9";
string null_book("999-99-9");
1.2拷贝赋值运算符
与拷贝构造函数一样,如果没有定义编译器会合成一个。
重载赋值运算符
重载运算符本质上是函数。对于一些运算符,包括赋值运算符,必须定义为成员函数,当一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this
参数。对于一个二元运算符,右侧运算对象作为显式参数传递。==一般赋值运算符函数接收的同类型参数是以引用的形式,这样是为了与内置类型的赋值操作保持一致。==赋值运算符通常应该返回一个指向其左侧运算对象的引用。
class foo {
foo &operator=(const foo&);
}
合成拷贝赋值运算符
如果没有其它用途,合成拷贝赋值运算符会将其右侧对象的每个非static
成员赋予左侧运算对象的对应成员,返回一个指向其左侧运算对象的引用。
1.3析构函数
析构函数释放对象使用的资源,并销毁对象的非static
数据成员。
析构函数完成什么工作
在一个构造函数中,成员的初始化时在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁。成员在销毁时依赖其类型,类类型销毁会调用自身的析构函数,由于内置类型没有析构函数,所以什么都不用做。对于合成析构函数,函数体为空,要注意析构函数体自身并不直接销毁成员,成员时在析构函数体之后隐含的析构阶段被销毁的。
1.4三/五法则
三指的是控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。新标准增加了两个额外的函数,一个类还可以定义移动构造函数和一个移动赋值运算符。C++
并不要求我们必须定义这5个函数。
当一个类需要析构函数时,几乎可以肯定也需要一个拷贝构造函数和一个拷贝赋值运算符。
class HasPtr {
public:
HasPtr(const &s = string());
~HasPtr() { delete ps; } // 错误,HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
}
上面代码中自定义了析构函数,但是没有定义拷贝构造函数和一个拷贝赋值运算符,那么在下面情况:
HasPtr f(Hasptr hp) { // 值传递,将被拷贝
HasPtr ret = hp;
return ret;
} // ret 和 hp都会被销毁,调用HasPtr的析构函数
当函数体结束后,由于ret和hp
都包含一个相同的指针值,将会导致指针被delete
两次。
1.5阻止拷贝
在某些情况下,一些类比如iostream
类阻止了拷贝,以避免多个对象写入或读取相同的IO
缓冲。想要阻止拷贝,只不定义拷贝构造函数时没有用的,因为编译器会给出合成的函数。新标准允许我们将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。(删除函数:虽然声明了,但是不能以任何方式使用)
class foo {
foo() = default; // 使用合成的默认构造函数、
foo(const foo&) = delete; // 阻止拷贝
foo &operator() = (cosnt foo&) = delete; // 阻止赋值
~foo() = default; // 使用合成的析构函数
}
析构函数不能是删除的成员
如果给析构函数加上了delete
,那么在任何时候都不能调用该函数,造成了一个对象永远无法被销毁。如果一个类有某个成员删除了析构函数,也不能定义该类的变量或临时对象,但是可以动态的分配该类。
class foo {
foo() = default;
~foo() = delete;
}
foo f; //错误,析构函数是删除的,不能定义类的变量或临时对象
foo *f = new foo(); // 正确,但是不能delete p
合成的拷贝控制成员可能是删除的
书上给了一大堆规则,其实看它后面的总结就行:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
private拷贝控制
新标准之前一般通过把拷贝构造函数或拷贝赋值运算符声明为私有的方式来阻止拷贝,由于析构函数是公有的,所以可以定义类型的对象,但是不能进行拷贝。由于友元和成员函数在这种情况下任然可以拷贝对象,所以可以通过只声明不定义的方式来阻止它们拷贝。(如果要阻止拷贝尽量使用=default的方式)
二、拷贝控制和资源管理
2.1行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
class HasPtr {
public:
HasPtr(const string &s = string()) :
ps(new string(s), i(0)) { }
HasPtr(const HasPtr &p) :
ps(new string(*p.ps), i(p.i)) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps;}
private:
string *ps;
int i;
}
类值拷贝赋值运算符
赋值操作会销毁左侧运算对象的资源,拷贝构造函数会从右侧运算对象拷贝数据。通过先拷贝右侧运算对象,可以处理自赋值情况,并且保证在异常发生时左侧对象处于一个有意义的状态。
HasPtr &HasPtr::opertator=(const HasPtr &rhs) {
auto newp = new string(*rhs.ps);
delete ps; // 释放旧内存
ps = newp;
i = rhs.i;
return *this;
}
2.2行为像指针的类
对于行为类似指针的类,需要定义拷贝构造函数和拷贝赋值运算符,拷贝指针成员本身而不是其指向的对象。shared_ptr
的性质能很方便的让一个类展现类似指针的行为,核心就是里面的一个指针计数器,下面我们不使用shared_ptr
,通过定义引用计数的方式让类像指针一样。
class HasPtr {
public:
HasPtr(const string &s = string()) :
ps(new string(s), i(0), use(new size_t(1))) { } // 注意这里分配use的时候采用动态内存的方式,好处式当拷贝或复制对象时,副本和原对象都会指向相同的计数器。
HasPtr(const HasPtr &p) :
ps(p.ps), i(p.i), use(p.user)) { ++*user }
HasPtr& operator=(const HasPtr &);
~HasPtr() { }
private:
string *ps;
int i;
size_t *use; // 计数器,记录当前有多少个对象共享*ps的成员
}
HasPtr::~HasPtr() {
if(--*use == 0) { // 只有在计数器为0时才释放资源
delete ps;
delete use;
}
}
HasPtr &HasPtr::operator=(const HasPtr &rhs) {
++*rhs.use;
if(--*user == 0) {
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}
三、对象移动
很多情况下发生的对象拷贝在拷贝结束后就被销毁,使用移动而非拷贝会大幅度提高性能。标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
3.1右值引用
右值引用就是必须绑定到右值的引用,使用&&
。右值引用只能绑定到一个将要销毁的对象。对于常规引用(左值),不能绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特征。
int i = 42;
int &r = i;
int &&rr = i; // 错误,i是一个左值,右值引用无法绑定
int &r2 = i * 42; // 错误,i * 42是一个右值
const int &r3 = i * 42; // 可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42;
- 返回左值:
- 赋值
- 下标
- 解引用
- 前置递增/递减运算符
- 返回右值
- 算术
- 关系
- 位
- 后置递增/递减运算符
对于返回右值,我们可以将const
的左值引用绑定到对象上。
左值持久,右值短暂
左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。因此右值引用表示所引用的对象将要被销毁,该对象没有其他用户。这两个特性意味使用右值引用的代码可以自由地接管所引用的对象的资源。
标准库move函数
虽然不能将右值引用绑定到一个左值上,但是可以显式地将一个左值转换为对应的右值引用类型。可以通过新标准的move
函数获得绑定到左值的右值引用。
int r = 2;
int &&r3 = std::move(r);
这样意味着对于源对象r
,除了对它重新赋值或者销毁,都不会再使用它。
3.2移动构造函数和移动赋值函数
类似拷贝构造函数,移动构造函数第一个参数还是引用,不过式右值引用,其它的参数必须右默认实参。除了完成资源移动,移动构造函数必须确保移动后源对象处于被销毁后无害状态,一旦资源完成移动,源对象必须不再指向被移动的资源,这些资源的所有权已经归属于新创建的对象。
StrVec::StrVec(StrVec &&s) noexcept
:elements(s.elements), first_free(s.first_free), cap(s.cap)
{ // 移动操作不抛出异常
s.elements = s.first_free = s.cap = nullptr;
}
移动赋值运算符
移动赋值运算符与移动构造函数一样,不抛出任何异常。
StrVec &StrVec::operator=(StrVec &&rhs) noexcept {
//检测自赋值
if(this != &rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移后源对象必须可析构
合成的移动操作
与拷贝操作不同,编译器不会为某些类合成移动操作,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。当一个类没有移动操作,函数匹配时就会使用拷贝操作来代替移动操作。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
struct X {
int i; // 内置类型可以移动
std::string s; // string定义了自己的移动操作
};
struct hasX {
X mem; // X有合成的移动操作
};
// 使用合成的移动构造函数
X x, x2 = std::move(x);
hasX hx, hx2 = std::move(hx);
移动操作永远不会隐式定义为删除的函数,如果显式的要求编译器生成=default
的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
没有移动构造函数,右值也能被拷贝
calss foo {
public:
foo() = default;
foo(const foo&);
};
foo x;
foo y(x); // x是一个左值,调用拷贝构造函数
foo z(std::move(x)); // 因为没有定义移动构造函数,所以只能调用拷贝构造函数
因为可以将foo&&
转换成一个const foo&
,所以z
的初始化才可以未定义移动构造函数的情况下调用拷贝构造函数。一般情况下,拷贝构造函数满足对应的移动构造函数的要求。
赋值运算符聚合拷贝和移动
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;
}
}
上面代码的赋值运算符接收一个非引用的参数,意味此参数要进行拷贝初始化,要么使用移动构造函数或者拷贝构造函数–左值被拷贝,右值被移动。
移动迭代器
新标准库定义了一种移动迭代器,通过改变给定迭代器的解引用运算符的行为来适配迭代器。一般一个迭代器的解引用运算符返回的是一个指向元素的左值
,但是移动迭代器的解引用符生成是一个右值引用
。通过调用标准库make_move_iterator
函数将一个普通的迭代器转换未一个移动迭代器。
3.3右值引用和成员函数
右值和左值引用成员函数
为了维持向后兼容性,新标准库类仍然允许向右值赋值,但是我们可以通过操作强制左侧运算对象(this指向的对象)是一个左值。在参数列表后放置一个引用限定符&或者&&
分别指出this
可以指向一个左值或右值,
class Foo {
public:
Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值
};
Foo &Foo::operator=(const Foo &rhs) & {
return *this;
}
一个函数可以同时用const
和引用限定,引用限定符必须跟随在const
限定符之后
class Foo {
public:
Foo someMem() const &;
}
重载和引用函数
综合引用限定符和const
可以区分一个成员函数的重载版本。文章来源:https://www.toymoban.com/news/detail-424559.html
class Foo {
public:
Foo fn() &&; // 可用于可改变的右值
Foo fn() const &; // 可用于任何类型的Foo
}
Foo Foo::fn() && {
sort(data.begin(),data.end());
return *this;
}
Foo Foo::fn() const & {
// 由于本对象是一个const或者左值,不能按照原址进行排序,所以需要先进行拷贝
Foo ret(*this);
sort(ret.data.begin(), ret.data.end());
return ret;
}
在定义const
成员函数时,可以定义两个版本,唯一的差别就是有const
限定而另一个没有,引用限定的函数则不一样,如果定义了两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符。文章来源地址https://www.toymoban.com/news/detail-424559.html
class Foo {
public:
Foo fn() &&;
Foo fn() const; //错误:必须加上引用限定符
using Comp = bool(const int&, const int&);
Foo fn(Comp*); // 正确
Foo fn(Comp*) const;
}
到了这里,关于《c++ primer笔记》第十三章 拷贝控制的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!