文章目录
C++primer-学习心得-第三部分-类设计者的工具
- 拷贝控制
- 操作重载与类型转换
- 面向对象程序设计
- 模板与泛型编程
第13章-拷贝控制
定义一个类的时候我们往往需要显示或隐式的指定此类型的对象在拷贝、移动、赋值和销毁时做什么。我们前面也学到了构造函数,也接触了析构函数了,当然如果我们就构造一个非常简单的类这些操作都可以不指定,构造函数直接使用默认的就行了,这个类中添加我们需要的成员,通常来说这样的类是与其他程序语言相通的,但某些特殊情况下我们还是需要去用到某些特别的操作。一个类同构定义5中特殊的成员函数来控制拷贝、移动、赋值和销毁时会做的操作:拷贝构造函数、拷贝赋值运算符、移动析构函数、移动赋值运算符、析构函数。
13.1 拷贝、赋值与销毁
1. 拷贝构造函数
如果一个构造函数第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo{
public:
Foo();
Foo(const Foo&);//拷贝构造函数
//...
};
如果我们没有为一个类定义拷贝构造函数,编译器会为我们合成一个拷贝构造函数。对有些类来说合成拷贝默认函数用来阻止我们拷贝该类类型的对象。一般情况下,合成拷贝构造函数会将其参数的成员(非static)逐个拷贝到正在创建的对象中。
class Sales_data{
public:
Sales_data(const Sales_data&);
private:
string bookNo;
int unit_sold=0;
double revenue=0;
}
Sales_data::Sales_data(const Sales_data &o){
bookNo(o.bookNo),unit_sold(o.unit_sold),revenue(o.revenue);
}
拷贝初始化不仅在我们使用=定义变量时会发生,下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
某些类类型会对他们所分配的对象使用拷贝初始化。
练习13.5
#include<iostream>
#include<string>
using namespace std;
class HasPtr {
public:
HasPtr(const string &s=string()):ps(new string(s)),i(0){}
HasPtr(const HasPtr& );
private:
string* ps;
int i;
};
HasPtr::HasPtr(const HasPtr& h) {ps = new string(*h.ps); i = h.i; }
2.拷贝赋值运算符
Sales_data trans,accum;
trans=accum;//使用Sales_data的拷贝赋值运算符
首先我们需要了解一些重载运算符的知识。详细内容在下一章会介绍。重载运算符本质上是函数,operator后面接需要重载的运算符。如,赋值运算符就是一个名为operator=的函数。
如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符。
Sales_data& Sales_data::operator=(const Sales_data &r){
bookNo=r.bookNo;
units_sold=r.units_sold;
revenue=r.revenue;
return *this;
}
练习13.8
#include<iostream>
#include<string>
using namespace std;
class HasPtr {
public:
HasPtr(const string &s=string()):ps(new string(s)),i(0){}
HasPtr(const HasPtr& );
HasPtr& operator=(const HasPtr& hr) { ps = new string(*hr.ps); i = hr.i;}//合成拷贝赋值运算符
private:
string* ps;
int i;
};
HasPtr::HasPtr(const HasPtr& h) {ps = new string(*h.ps); i = h.i; }
3.析构函数
析构函数释放对象使用资源,并销毁对象的非static数据成员。
析构函数名字由波浪号接类名构成,没有返回值也不接受参数。
class Foo{
public:
~Foo();
//...
};
类似于构造函数有一个初始化部分和函数体,析构函数也有一个函数体 和析构部分。析构函数中首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁。析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型成员会执行成员自己的析构函数。内置类型没有析构函数,销毁内置类型什么也不需要做。
无论何时一个对象被销毁就会调用析构函数:
- 变量在离开其作用域时
- 一个对象被销毁时成员也被销毁
- 容器被销毁时其元素被销毁
- 动态分配的对象当指向它的指针应用delete运算符时被销毁
- 临时对象,创建它的完整表达式结束时被销毁
通常,合成析构函数的函数体为空
class Sales_data{
public:
~Sales_data(){}
//...
};
练习13.11
#include<iostream>
#include<string>
using namespace std;
class HasPtr {
public:
HasPtr(const string &s=string()):ps(new string(s)),i(0){}
HasPtr(const HasPtr& );
HasPtr& operator=(const HasPtr& hr) { ps = new string(*hr.ps); i = hr.i;}//合成拷贝赋值运算符
~HasPtr() {}//合成析构函数
private:
string* ps;
int i;
};
HasPtr::HasPtr(const HasPtr& h) {ps = new string(*h.ps); i = h.i; }//合成拷贝构造函数
4.三/五法则
需要注意的是如果一个类定义了析构函数却没定义拷贝和赋值操作是很容易出问题的。
比如,如果一个类有指针数据成员,默认的析构函数不会delete一个指针数据成员,需要我们手动定义一个析构函数delete
class HasPtr{
public:
HasPtr(const string &s=string()):ps(new string(s),i(0)){}
~HasPtr(){delete ps;}
};
如果我们没定义拷贝构造函数和拷贝赋值运算符,想想当我们执行类的拷贝操作时,编译器合成默认的拷贝构造函数和拷贝赋值运算符会使得多个指针指向同一个对象,当其中一个指针执行delete时就会出现问题了,这时其他指针就会指向一个无效的对象。或者多个指针delete时内存就会被释放多次,显然是有问题的。
5.使用=default
通过=defalut来显示要求编译器合成默认版本。
6.阻止拷贝
对于某些类来说是需要阻止拷贝的,如iostream类阻止拷贝,以避免多个对象写入或读取相同的IO缓冲。为了阻止拷贝,我就必须显式定义这些操作。我们可以将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝:在函数参数列表后面加=delete。
struct nocopy{
nocopy()=default;
nocopy(const nocopy&)=delete;//阻止拷贝
nocopy& operator=(const nocopy&)=delete;//阻止赋值
~nocopy()=default;
};
注意析构函数不能是删除的函数。
如果一个成员有删除的或不可访问的析构函数会导致合成的默认的和拷贝的构造函数被定义为删除的,因为如果没有析构函数那么我们可能会创建出无法删除的对象。
练习13.18
#include<iostream>
#include<string>
#include<vector>
using namespace std;
class Employee {
public:
Employee(string n) :name(n), id(inc++) {}//构造函数
Employee(const Employee& e) { name = e.name; id = inc++; };
Employee& operator=(const Employee&) = default;
string getname()const { return name; }
unsigned getid()const { return id; }
~Employee() {}
private:
string name;
unsigned id;
static unsigned inc;
};
unsigned Employee::inc = 0;
int main() {
vector<Employee> ess;
ess.emplace(ess.end(),"a");
ess.emplace(ess.end(),"b");
ess.emplace(ess.end(),"c");
Employee a("d");
Employee e(a);
ess.push_back(a);
ess.push_back(e);
for (const auto& cc : ess)
cout << cc.getname() << "--->" << cc.getid() << endl;
}
13.2 拷贝控制和资源管理
练习10.22
#include<iostream>
#include<string>
#include<vector>
using namespace std;
class hasptr {
public:
hasptr() :i(0), ps(new string()) {}
hasptr(const hasptr& s) { i = s.i; ps = new string(*s.ps); }
hasptr& operator=(const hasptr& s) { i = s.i; ps = new string(*s.ps);return*this }
~hasptr() {delete ps;}
private:
int i;
string* ps;
};
1. 行为像值的类
为了事项类值行为,比如一个类hasptr,它需要
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放当前的string并拷贝string。
hasptr& hasptr::operator=(const hasptr &r){
auto newp=new string(*r.ps);
delete ps;//释放旧内存
ps=newp;
i=r.i;
return *this;
}
注意不能先delete ps,因为赋值运算符左右两边有可能是同一个对象,此时如果先delete ps的话就出错了,new拷贝操作时会访问一个指向无效内存的指针。
2.定义行为像指针的类
另一个类展现类似指针的行为的最好方式是使用shared_ptr来管理类中的资源。拷贝或赋值一个shared_ptr会拷贝或赋值shared_ptr所指向的指针。shared_ptr类自己记录有多少用户共享它所指向的对象。当没有用户使用shared_ptr时shared_ptr类负责释放资源。但有时我们希望直接管理资源,此时使用引用计数就很有用。
引用计数的工作方式如下:
- 除了初始化之外,每个构造函数(拷贝构造函数除外)还要构造一个引用计数,用来记录有多少对象与正在创建的对象共享状态,因此将计数器初始化为1。
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成元包括计数器。拷贝构造器递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器。若计数器变为0则析构函数释放状态。
- 拷贝赋值运算符递增右侧运算符对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器为0则拷贝赋值运算符必须销毁状态。
难点在于哪里存放引用计数。解决方案时将计数器保存在动态内存中。
class hasptr{
public:
hasptr(const string &s=string()):ps(new string(s)),i(0),use(new size_t(1)){}
hasptr(const hasptr &p):ps(p.ps),i(p.i),use(p.use){++*use;}
hasptr& operator=(const hasptr& r){
++*r.use;
if(--*use==0){
delete ps;
delete use;
}
ps=r.ps;
i=r.i;
use=rhs.use;
return *this;
};
~hasptr(){
if(--*use==0){
delete ps;
delete use;
}
}
private:
string *ps;
int i;
size_t *use;
}
13.3 交换操作
除了拷贝操作,有时还需要定义一个名为swap的函数。对于那些需要交换两个元素的算法会调用swap。
如果一个类没有定义自己的swap,算法将使用标准库定义的swap。交换两个对象通常需要一次拷贝和两次赋值。如
hasptr temp=v1;
v1=v2;
v2=temp;
但理论上更好的方式是交换指针。我们更希望这样交换
string *temp=v1.ps;
v1.ps=v2.ps;
v2.ps=temp;
所以我们自己版本的swap应该这样来,注意要在类内部声明为友元
inline
void swap(hasptr &l,hasptr &r){
using std::swap;
swap(l.ps,r.ps);
swap(l.i,r.i);
}
一个类定义了swap,通常可以用swap来定义赋值运算符,使用拷贝并交换的操作。
hasptr& hasptr::operator=(hasptr r){
swap(*this,rhs);
return *this;
}
13.30
#include<iostream>
#include<string>
#include<vector>
#include<algorithm>
//using namespace std;
using std::string; using std::endl; using std::cout; using std::vector;
class hasptr {
public:
friend void swap(hasptr&, hasptr&);
//hasptr() :i(0), ps(new string()) {}
hasptr(const string &s=string()) :i(0), ps(new string(s)) {}
hasptr(const hasptr& s) { i = s.i; ps = new string(*s.ps); }
hasptr& operator=(const hasptr& s) { i = s.i; ps = new string(*s.ps); return *this; }
~hasptr() {}
const string getsize()const { return *ps; }
private:
int i;
string* ps;
};
inline
void swap(hasptr& l, hasptr& r) {
using std::swap;
cout << "swaping" << endl;
swap(l.ps, r.ps);
swap(l.i, r.i);
}
int main() {
hasptr a("aaaa"), b("bbbb");
swap(a, b);
}
13.5 动态内存管理类
我们现在实现标准库vector类的简化版本,这个类只适用于string,因此我们把它命名为strvec。
strvec有三个指针成员:
- elements:指向分配的内存的首元素
- first_free:指向最后一个实际元素之后的位置
- cap,指向分配的内存尾部之后的位置
strvec还有一个名为alloc的静态成员,类型为allocator<string>。alloc会分配strvec使用的内存。strvec有四个工具函数(方法):
- alloc_n_copy:分配内存并拷贝一个给定范围中的元素
- free:销毁构造的函数并释放内存
- chk_n_alloc:保证strvec至少有一个新元素的空间。若没有空间添加新元素,调用reallocate来分配更多内存
- reallocate:在内存用完时为strvec分配新内存
#pragma once
#include<string>
#include<memory>
using namespace std;
class strvec {
public:
strvec() :element(nullptr), first_free(nullptr), cap(nullptr) {}
strvec(const strvec& s) {
auto newdata = alloc_n_copy(s.begin(), s.end());
element = newdata.first;
first_free = cap = newdata.second;
}
strvec& operator=(const strvec& r) {
auto data = alloc_n_copy(r.begin(), r.end());
free();
element = data.first;
first_free = cap = data.second;
return *this;
}
~strvec() { free(); };
void push_back(const string& s) {
chk_n_alloc();
alloc.construct(first_free++, s);
};
size_t size() const { return first_free - element; }
size_t capacity()const { return cap - element; }
string* begin()const { return element; }
string* end()const { return first_free; }
private:
static allocator<string> alloc;
void chk_n_alloc() { if (size() == capacity())reallocate(); }
pair<string*, string*>alloc_n_copy(const string* b, const string* e) {
auto data = alloc.allocate(e - b);
return { data,uninitialized_copy(b,e,data) };
};
void free() {
if (element) {
for (auto p = first_free; p != element;)
alloc.destroy(--p);
alloc.deallocate(element, cap - element);
}
}
void reallocate() {
auto newcapacity = size() ? 2 * size() : 1;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = element;
for (size_t i = 0; i != size(); i++)
alloc.construct(dest++, std::move(*elem++));
free();
element = newdata;
first_free = dest;
cap = element + newcapacity;
}
string* element;
string* first_free;
string* cap;
};
13.6 对象移动
有时我们需要可以移动对象,虽然我们也可以拷贝对象并销毁来达到同样效果,但单纯的移动对象可以提升性能。如我们的strvevc类,当我们需要重新分配内存时,从旧内存拷贝到新内存是没必要的,我们要做的是把元素移动到新内存。
1.右值引用
右值引用就是必须绑定到右值的引用。通过&&来获得右值引用。右值引用只能绑定到一个即将销毁的对象。所以我们可以自由地将一个右值引用的资源“移动”到另一个对象中。为了区分,通常的引用我们成为左值引用。我们不能将左值引用绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引 用则相反,我们可以将右值引用绑定到这样的表达式上但不能直接绑定到一个左值上。
int i=42;
int &r=i;
int &&rr=i;//错误
int &r2=i*42;//错误
const int &r3=i*42;//正确,我们可以将一个const的引用绑定到一个右值上
int &&rr2=i*42;//正确,绑定到乘法结果上
int &&rr3=42;//正确,字面值常量是右值
int &&rr4=rr3;//错误,rr1是左值
区分起来的话,左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
标准库move函数
我们可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。
int &&rr5=move(rr3);
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 &&r)noexcept{
if(this!=&r){
freee();
elements=r.elements;
first_free=r.first_free;
cap=r.cap;
r.elements=r.first_free=r.cap=nullptr;
}
return *this;
}
注意移动源对象必须可析构。
如果一个类既有移动构造函数,又有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作也是如此。(如果两者看作函数重载的话是说的通的,形参不同,编译器会根据不同的形参决定调用哪个构造函数,实际我们 用的时候也知道什么时候拷贝,什么时候移动)。但如果没有移动构造函数,编译器不会自己合成移动构造函数,这样的话当我们需要移动时实际会让该类型拷贝,即使通过调用move也同样。
移动迭代器
一个移动迭代器通过改变给定的迭代器的解引用运算符的行为来适配此迭代器。一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。但移动迭代器的解引用运算符生成一个右值引用。
通过调用make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。
3.右值引用和成员函数
实际上其他成员函数也可以是右值引用,这在有些时候也是有用的。
(学到这里其实有些地方不太懂也不想深究了,一方面是决定太复杂了要完全弄懂需要花太多时间,另一方面是自己也没多少兴趣去研究这种复杂又几乎用不上的知识,但我也不太想直接就放弃了这本书的学习。最后这本书剩下的内容不多了(剩6章),但感觉后面会有感兴趣的内容。这章本来想好好学的,但即使完全按照书上的代码但最后出现奇怪的问题不能正常编译运行(就是strvec这个类,它的错误导致后面几个小节也实践不了,课后题也做不了),最后也懒得折腾了,如果后面觉得有必要再好好学这一章的话后面再修正这次的笔记。总之,就这样------2020-2-29)