类通过定义拷贝控制操作来控制类的对象拷贝、移动、赋值和销毁。
拷贝控制操作包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数
若一个类没有定义这些拷贝控制成员,编译器会自动为它定义缺失的操作。
1.拷贝、赋值和销毁
1.1、拷贝构造函数
拷贝构造函数的第一个参数为自身类类型的引用,且任何额外参数都有默认值。
class foo{
foo(); //默认构造函数
foo(const foo&); //拷贝构造函数
};
拷贝构造函数通常不是explicit的,允许被隐式地使用。
合成拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。
成员的类型决定了它如何拷贝:类类型的成员会使用其拷贝构造函数来拷贝,而内置类型的成员则直接拷贝。
拷贝初始化类似于赋值操作,而直接初始化使用圆括号来赋值给左值。
string dot(10,"a"); //直接初始化
string s(dot); //直接初始化
string s2 = dot; //拷贝初始化
string s3 = string(10,"b"); //拷贝初始化
拷贝初始化通常使用拷贝构造函数来实现,有时使用移动构造函数。
拷贝初始化在以下几种情况下会发生:
1、将一个对象作为实参传递给非引用类型的形参。
2、从一个返回类型为非引用类型的函数返回一个对象。
3、花括号列表初始化一个数组的元素或一个聚合类的成员。
4、某些类类型对它们所分配的对象使用拷贝初始化。
在函数调用过程中,具有非引用类型的参数要进行拷贝初始化,参数对象调用拷贝构造函数。
若我们希望使用一个explicit构造函数,必须显式使用。
void f(vector<int>); //vector的构造函数是explicit的
f(10); //错误,不能用explicit构造函数来拷贝
f(vector<int>(10)); //正确,直接显式构造一个vector<int>
在拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象;但是拷贝/移动构造函数必须是存在且可访问的。
1.2、拷贝赋值运算符
重载运算符本质上是函数,名称由关键字operator加表示要定义的运算符的符号组成。
foo& operator=(const foo&); //赋值运算符
重载运算符的参数表示运算符的运算对象,左侧对象绑定到隐式的this参数上。
赋值运算符通常返回一个指向其左侧运算对象的引用。
对于某些类来说,合成拷贝赋值运算符用来禁止该类型对象的赋值。
1.3、析构函数
析构函数释放对象使用的资源,并销毁对象的非static数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名组成,没有参数和返回值。
~foo();
由于析构函数没有参数,因此它不能被重载,在类中是唯一的。
在析构函数中,先执行函数体,然后销毁成员;成员按初始化顺序的逆序销毁。
销毁类类型的成员需要执行成员自己的析构函数,析构部分是隐式的。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
对于指向一个动态分配的对象的引用或指针离开作用域时,析构函数不会执行。
编译器自动定义的合成析构函数的函数体为空,内置类型成员的销毁并不需要做什么操作。
析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。
1.4、三/五法则
C++并不要求我们定义所有拷贝控制操作,但是,这些操作一般被视为一个整体。
若类需要定义析构函数,那么该类也应定义拷贝构造函数和拷贝赋值运算符。
若类需要定义拷贝构造函数,则该类应定义拷贝赋值运算符。
1.5、使用=default
通过将拷贝控制成员定义为=default来显式地要求编译器生成合成版本。
foo(const foo&) = default; //合成拷贝构造函数
当我们在类内用=default来修饰成员的声明时,合成函数将隐式地声明为内联。
只能对具有合成版本的成员函数使用=default(默认构造函数或拷贝控制成员)。
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符。
对于某些类来说,拷贝构造函数和拷贝赋值运算符是没有合理的意义,如iostream类。
在新标准下,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。
删除的函数:函数虽然被我们所定义,但不能一任何方式使用它们。
nocopy(const nocopy&) = delete; //阻止拷贝
nocopy& operator=(const nocopy&) = delete; //阻止赋值
与=default不同,=delete必须出现在函数第一次声明的时候。
=delete可以对任何函数指定,但不包含析构函数。
对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类型的临时对象。
对于一个删除了析构函数的类型,可以动态分配这种类型的对象,但不能释放这些对象。
struct foo{
foo() = default;
~foo() = delete; //删除了析构函数
};
foo d; //错误,不能定义foo类型的对象
foo* p = new foo(); //正确,但我们不能delete p
delete p; //错误,foo的析构函数是删除的
若一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数被定义为删除的。
一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的。
类可以通过将其拷贝构造函数和拷贝赋值运算符声明为private的来阻止拷贝(不推荐)。
2.拷贝控制和资源管理
通常来说,管理类外资源的类必须定义拷贝控制成员。
类型对象的拷贝语义:行为像值的类、行为像指针的类。
类的行为像值,意味着类对象有自己的状态,拷贝的副本和源对象两者是完全独立的。
类的行为像指针,意味着类对象共享状态,拷贝的副本和源对象使用相同的底层的数据。
2.1、行为像值的类
对于类管理的资源,每个对象都应拥有一份自己的拷贝。
class hasptr{
public:
hasptr(const hasptr& p): ps(new std::string(*p.ps); //重新拷贝一份副本
private:
std::string *ps;
}
类值拷贝赋值运算符会销毁左侧运算对象的资源,从右侧对象拷贝数据。
hasptr& hasptr::operator=(const hasptr& rhs)
{
auto newp = new string(*rhs.ps); //先拷贝右侧对象数据
delete ps; //再删除左侧对象
ps = newp; //将副本赋给左侧对象
return *this; //返回本对象
}
编写赋值运算符时,推荐先将右侧运算符拷贝到一个局部临时对象,再销毁左侧运算对象。
2.2、定义行为像指针的类
对于行为像指针的类,需要定义拷贝构造函数和拷贝赋值运算符来拷贝指针成员本身而不是数据。
令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的数据。
若我们希望直接管理资源,且不适用shared_ptr,可以使用引用计数技术。
当创建一个对象时,分配一个计数器给该对象,当拷贝或赋值对象时,拷贝指向计数器的指针。
class hasptr{
public:
hasptr(const std::string &s = std::string()) :
ps(new std::string(s)),use(new std::size_t(1)) {} //重新创建新对象,分配新计数器,并置为1
hasptr(const hasptr& p) : ps(p.ps),use(p.use){++*use;} //拷贝对象,拷贝数据,并递增计数器
~hasptr();
private:
std::string *p;
std::size_t *use; //用来记录有多少个对象共享*ps的成员
}
析构函数不能无条件地delete ps,可能有其他对象指向这块内存。
hasptr::~hasptr()
{
if(--*use == 0) //若引用计数为0
{
delete ps;
delete use;
}
};
拷贝运算符必须处理自赋值情况,在释放左侧对象时,应检查引用计数条件。
3.交换操作
管理资源的类通常还定义了swap函数,用于交换两个元素。
与拷贝控制成员不同,swap并不是必要的。
通常来说,我们希望swap交换指针,而不是分配数据的新副本。
inline swap(hasptr& lhs,hasptr& rhs)
{
using std::swap;
swap(lhs.ps,rhs.ps); //交换指针
}
定义swap的类通常用swap来定义它们的赋值运算符(拷贝并交换)。
hasptr& haptr::operator=(hasptr rhs)
{
swap(*this,rhs); //rhs指向本对象曾经的内存
return *this;
} //rhs离开作用域,被销毁
在本版本的赋值运算符中,参数是由右侧运算对象以传值方式赋给运算符,rhs是一个副本。
使用拷贝并交换的赋值运算符能自动处理自赋值情况,是异常安全的。
4.拷贝控制示例
薄记操作的设计,概述两个类的设计:
1、message类,该类包含string类型的text文本和保存它所在folder的指针的set。
2、folder类,该类包含一个保存它所包含的message的指针的set。
每个message对象可以出现在多个folder中。
4.1、message函数详解
1、save和remove操作
save操作:向给定的folder添加一条message。
remove操作:在给定的folder中删除一条message。
当拷贝一个message时,副本和源对象将是不同的message对象。
save/remove操作中,应该包含向每个包含此消息的folder中添加/删除指向新/旧的message的指针。
2、message类的拷贝控制成员
由于拷贝构造函数和拷贝赋值运算符都需要遍历folders,对每个指向原message的folder添加一个指向新message的指针,因此我们定义add_to_folders函数来完成该工作。
析构函数必须从指向此message的folder中删除该message指针,因此也定义了一个公共函数remove_folders来完成。
拷贝赋值运算符从左侧对象的folders中删除此message的指针,再将指针添加到右侧运算对象的folders中。
完整代码如下:
class message {
friend class folder;
public:
explicit message(const std::string &str = " "):text(str) {} //folders被隐式初始化为空集合
message(const message&); //拷贝构造函数
message(message&&); //移动构造函数
message& operator=(const message&); //拷贝赋值运算符
message& operator=(message&& rhs); //移动赋值运算符
~message(); //析构函数
void move_folders(message*); //移动赋值运算符的更新folder
void save(folder&);
void remove(folder&);
void swap(message&, message&);
void addfldr(folder* f) { folders.insert(f); } //将给定的folder添加到该message中
private:
std::string text; //消息文本
std::set<folder*> folders; //message中的保存folder的指针的set
void add_to_folders(const message&); //将message添加到指向参数的folder中
void remove_folders(); //从folders中的每个folder中删除message
};
class folder {
public:
friend string& operator*(const std::set<string>&); //友元声明
folder() : msgs() {} //默认构造函数
folder(const folder& f) : msgs(f.msgs) { add_to_messages(f); } //拷贝构造函数
~folder() { remove_messages(); } //析构函数
folder& operator=(const folder&); //拷贝赋值运算符
void addmsg(message* m) { msgs.insert(m); } //插入文本m
void remmsg(message* m) { msgs.erase(m); }
void print(); //打印数据
private:
std::set< message* > msgs; //保存folder中所有文本的指针集
void add_to_messages(const folder& f); //将该folder添加到所有的message中的set
void remove_messages(); //删除版本
};
void folder::print() //打印函数
{
for (auto f : msgs)
std::cout << f->text << endl;
}
string& operator*(const std::set<string>&f) //介意用运算符重载
{
return *f;
}
void folder::add_to_messages(const folder& f)
{
for (auto msg : f.msgs) //将该folder添加到所有的message中
msg->addfldr(this);
}
void folder::remove_messages()
{
while (!msgs.empty()) //将该folder从它所有的message中删除(析构)
(*msgs.begin())->remove(*this); //先执行begin函数再解引用
}
folder& folder::operator=(const folder& f)
{ //先删除再拷贝
remove_messages(); //从每个message中删除该folder
msgs = f.msgs; //拷贝
add_to_messages(f); //将该folder添加到每个message
return *this;
}
message::message(message&& m) : text(std::move(m.text)) //text直接作为右值引用赋给text
{ //set通过move_folders函数进行移动
move_folders(&m); //&不是解引用符,是取地址符
}
void message::move_folders(message* m)
{
folders = std::move(m->folders); //使用set的移动赋值运算符
for (auto f : folders) //对每个folder进行删除旧对象指针,添加新对象指针
{
f->remmsg(m);
f->addmsg(this);
}
m->folders.clear(); //确保m中无数据
}
message& message::operator=(message&& rhs) noexcept
{
if (this != &rhs) //检查自赋值 &符号是取地址符
{
remove_folders(); //删除folder中的message
text = std::move(rhs.text); //移动赋值运算符
move_folders(&rhs); //重置folders指向本message
}
return *this;
}
void message::save(folder& f)
{
folders.insert(&f); //将给定的folder添加到folders列表中
f.addmsg(this); //将本message添加到f的集合中
}
void message::remove(folder& f)
{
folders.erase(&f); //将给定的folder从folders列表中删除
f.remmsg(this); //将本message从f中删除
}
void message::add_to_folders(const message& m)
{
for (auto f : m.folders) //对每个包含m的folder插入新的message
f->addmsg(this);
}
message::message(const message& m) : text(m.text), folders(m.folders) //拷贝构造函数
{
add_to_folders(m); //将本message添加到指向原m的folder中
}
void message::remove_folders()
{
for (auto f : folders) //对folders中每个指针指向的folder中删除本message
f->remmsg(this);
}
message::~message() //析构函数 内置参数会被编译器自动地调用内置类型析构函数销毁
{
remove_folders();
}
message& message::operator=(const message& rhs) //拷贝赋值运算符
{
remove_folders(); //删除掉左侧对象在所有folder中的存放位置
text = rhs.text; //从左侧对象拷贝text内容
folders = rhs.folders; //拷贝指针列表folders
add_to_folders(rhs); //将本message添加到那些folder中
return *this; //返回左侧对象
}
void message::swap(message& lhs, message& rhs)
{
//先删除左右对象在各自的folder的指针,然后交换数据结构,接着通过set将容器添加到folder中
using std::swap;
for (auto f : lhs.folders)
f->remmsg(&lhs); //使用引用,避免发生拷贝构造
for (auto f : rhs.folders)
f->remmsg(&rhs);
swap(lhs.folders, rhs.folders); //交换folder指针set
swap(lhs.text, rhs.text); //交换文本
for (auto f : lhs.folders)
f->addmsg(&lhs);
for (auto f : rhs.folders)
f->addmsg(&rhs);
}
int main()
{
folder f,f1,f3;
message m("hello world");
message m1("gudanxinshi");
message m3("c++ primer");
m.save(f);
m.save(f1);
m3.save(f);
m1 = m;
f.print();
f.remmsg(&m);
f.print();
return 0;
}
5.动态内存管理类
实现标准库vector类的简化版本,该版本只适用于string,命名为strvec。
strvec的设计策略:使用allocator来获取未构造的内存,在原始内存中创建对象。
strvec的三个指针成员:elements、first_free、cap。
elements:指向分配的内存中的首元素。
first_free:指向最后一个实际元素之后的位置。
cap:指向分配的内存末尾之后的位置。
strvec的工具函数:
alloc_n_copy:分配内存,并拷贝给定范围中的元素。
free:销毁构造的元素并释放内存。
check_alloc:检查是否有内存能使用,若无内存,则分配新的内存空间。
reallocate:在内存用完时为strvec分配新的内存。
为了使用原始内存,必须调用construct来向指定位置构造元素,构造前应检查是否有内存空间。
free成员先调用destroy来销毁元素,再调用deallocate来释放内存,元素按逆序销毁。
alloc_n_copy所分配的空间恰好等于给定的元素数,cap与first_free指向相同的位置。
移动构造函数通常是将资源从给定对象移动到正在创建的对象。
我们调用move来表示希望使用string的移动构造函数。
完整代码如下:
#include<memory>
#include<string>
#include<list>
#include <algorithm>
class strvec {
public:
strvec() : elements(nullptr),first_free(nullptr),cap(nullptr) {} //默认初始化
strvec(std::initializer_list<std::string>);
strvec(const strvec&); //拷贝构造函数
strvec& operator=(const strvec&); //拷贝赋值运算符
~strvec(); //析构函数
void push_back(const std::string&);
size_t size() const { return first_free - elements; } //元素数量
size_t capacity() const { return cap - elements; } //最大能容纳的元素数量
std::string* begin() const { return elements; }
std::string* end() const { return first_free; }
void reserve(size_t n) { if (n > capacity()) reallocate(n); } //申请n个空间
void resize(size_t n);
void resize(size_t n, const std::string& s);
private:
static std::allocator<std::string> alloc;
void check_alloc() { if (size() == capacity()) reallocate(); } //检查是否有内存可使用
std::pair<std::string*, std::string* > alloc_n_copy
(const std::string*, const std::string*); //分配内存,并拷贝一个给定范围内的元素到新内存中
void free(); //销毁元素并释放内存
void reallocate(); //重新分配内存并拷贝已有元素
void reallocate(size_t);
std::string* elements; //指向首元素
std::string* first_free; //指向未分配元素的第一个位置
std::string* cap; //指向整个内存最后的尾后位置
};
void strvec::push_back(const std::string& s)
{
check_alloc(); //检查是否有空间,若没有则重新分配空间
alloc.construct(first_free++, s); //在first_free指向位置构造s的副本
}
std::pair<std::string*, std::string* > strvec::alloc_n_copy(const std::string* b, const std::string* e)
{
auto data = alloc.allocate(e - b); //给范围内的元素分配内存空间,返回开始位置
return { data,std::uninitialized_copy(b,e,data) };
//返回一个pair,由data和uninitialized_copy的返回值构成,列表初始化
}
void strvec::free()
{
if (elements) //指针不为空
{
for (auto p = first_free; p != elements;) //逆序销毁旧元素
alloc.destroy(--p);
//for_each(elements, first_free, [](std::string& s) {alloc.destroy(&s); });
//for_each和lambda的版本
alloc.deallocate(elements, cap - elements); //回收内存
}
}
strvec::strvec(const strvec& s)
{
auto newdata = alloc_n_copy(s.begin(), s.end()); //返回一个pair
elements = newdata.first;
first_free = newdata.second;
cap = newdata.second; //由于alloc_n_copy分配的空间刚好等于给定的元素,因此cap = first_free
}
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;
}
void strvec::reallocate()
{
auto newcapacity = size() ? size() * 2 : 1; //确定分配内存空间大小
auto newdata = alloc.allocate(newcapacity); //分配内存空间,返回指向新数组第一个位置的指针
auto dest = newdata; //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 + newcapacity;
}
void strvec::reallocate(size_t newcapacity)
{
auto newdata = alloc.allocate(newcapacity); //分配内存空间,返回指向新数组第一个位置的指针
auto dest = newdata; //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 + newcapacity;
}
inline void strvec::resize(size_t n)
{
if (n > size())
{
while (size() < n)
push_back("");
}
else if (n < size()) //销毁字符串
{
while (size() > n)
alloc.destroy(--first_free);
}
}
inline void strvec::resize(size_t n, const std::string& s)
{
if (n > size())
{
while (size() < n)
push_back(s);
}
}
inline strvec::strvec(std::initializer_list<std::string> il)
{
auto newdata = alloc_n_copy(il.begin(), il.end()); //分配空间,返回pair
elements = newdata.first;
first_free = cap = newdata.second;
}
6.对象移动
新标准的特性:可以移动而非拷贝对象,移动对象能大幅度提升性能。
标准库容器、string和shared_ptr类既支持移动也支持拷贝。
6.1、右值引用
右值引用必须绑定到右值的引用,且只能绑定到一个将要被销毁的对象(临时对象)。
一般来说,左值表达式是一个对象的身份,右值表达式是对象的值。
类似于引用,右值引用是某个对象的另一个名字。
右值引用可以绑定到字面常量、要求转换的表达式和返回右值的表达式,但不能绑定到左值上。
int i = 42;
int &r = i * 42; //错误,i*42是右值
const int &r1 = i * 42; //正确,可以将一个const的引用绑定到右值上
int &&r2 = i * 42; //正确
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。
左值有持久的状态,右值是字面常量或临时对象。
变量是左值,因为变量是持久的,直至离开作用域才被销毁。
int &&r = 42; //正确,字面常量是右值
int &&r2 = r; //错误,变量是左值
通过调用标准库函数move来获得绑定到左值的右值引用,函数定义在头文件utility中。
int &&r1 = std::move(r); //r是左值
调用move意味着我们处了对r进行赋值或销毁它之外,不能再做其他任何操作。
使用move时,应使用std::move而不是move,这样能有效避免名字冲突。
6.2、移动构造函数和移动赋值运算符
移动构造函数的第一个参数是该类类型的右值引用,任何额外的参数都必须有默认实参。
移动构造参数必须确保移动后的原对象不再指向被移动的资源。
移动构造函数不分配任何新内存,它接管给定对象的内存。
移动构造函数通常不分配任何资源,因此,移动操作通常不会抛出任何异常。
在构造函数中指明关键字noexcept,表明我们的函数不会抛出异常。
必须在类头文件的声明和定义中都指定noexcept。
class strvec{
strvec(strvec&&) noexcept; //声明
};
strvec::strvec(strvec &&s) noexcept //定义
{ //函数体 }
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
标准库容器能对异常发生时其自身的行为提供保障,保证源数据不会发生改变。
移动构造函数通过移动一个对象来完成构造,通常会改变原对象的值;因此,标准库会优先使用拷贝构造函数,必须通过noexcept来显式告诉标准库我们的移动构造函数可以安全使用。
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成时,源对象会被销毁。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为类合成移动构造函数或移动赋值运算符。
移动操作永远不会隐式定义为删除的函数。
当我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则移动操作定义为删除的函数。
若一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使调用move来移动。
用拷贝构造函数来替代移动构造函数是安全的,拷贝构造函数实际上不会改变原对象的值。
与任何赋值运算符一样,移动赋值运算符必须销毁左侧运算对象的旧状态。
新标准库中定义了一种移动迭代器适配器,移动迭代器的解引用运算符生成一个右值引用。
标准库中的make_move_iterator函数将一个普通迭代器转换成一个移动迭代器。
6.3、右值引用和成员函数
成员函数可以同时提供拷贝和移动版本,一个接受指向const的左值引用,一个接受指向非const的右值引用。
区别移动和拷贝的重载函数通常有一个版本接受const T& ,另一个接受T&&。
void push_back(const arr&);
void push_back(arr&&);
当我们调用具有移动和拷贝版本的函数时,实参类型决定了新元素是拷贝还是移动到容器中。
通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。
string s1 = "value";
string s2 = "another";
auto n = (s1+s2).find('a');
在新标准库类中允许向右值赋值,但是可以通过引用限定符来强制左侧运算对象是左值。
引用限定符只能用于非static成员函数,且必须同时出现在函数的声明和定义中。
foo& operator=(const foo&) &; //只能向可修改的左值赋值
foo& operaotr=(const foo&) &&; //只能向可修改的右值赋值
一个函数可以同时用const和引用限定,引用限定符必须跟随在const之后。
foo somemem() const &; //正确
引用限定符也可以区分重载版本。
当对象是一个右值时,意味着没有其他用户,因此我们可以随意改变对象。
若一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
foo sorted() &&;
foo sorted(); //错误,必须加上引用限定符