C3P第三部分

第三部分

第十三章

拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

合成拷贝构造函数可以用来阻止拷贝该类类型的对象

class Foo{ public: Foo(const Foo&); }

合成的拷贝构造函数:会将参数的成员逐个拷贝到正在创建的对象中,内置类型直接拷贝,虽然不能直接拷贝一个数组,但会逐个元素进行拷贝,类类型则使用元素的拷贝函数

拷贝初始化通常使用拷贝构造函数来完成

  • =定义变量时。
  • 将一个对象作为实参传递给一个非引用类型的形参。
  • 从一个返回类型为非引用类型的函数返回一个对象。
  • 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员。

拷贝赋值运算符

赋值运算符通常返回一个指向其左侧运算对象的引用

合成拷贝赋值运算符可以用来禁止该类型对象的赋值

析构函数

析构函数释放对象使用的资源,并销毁对象的非static数据成员,析构函数不接受参数,没有返回值

在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁。析构函数体本身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的

当指向一个对象的引用或指针离开作用域时,析构函数不会执行

合成析构函数可以用来阻止该类型的对象被销毁

三/五法则

  • 需要析构函数的类也需要拷贝和赋值操作
  • 需要拷贝操作的类也需要赋值操作,反之亦然

使用=default

类内使用=default修饰成员的声明时,隐式声明了该成员为内联的

如果不希望合成的成员是内联函数,则应该对其类外定义使用=default

阻止拷贝

  • 大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
  • 定义删除的函数:=delete ,虽然声明了它们,但是不能以任何方式使用它们。
  • 析构函数不能是删除的成员。
  • 如果一个类有数据成员不能默认构造、拷贝、复制或者销毁,则对应的成员函数将被定义为删除的
  • 对于具有引用或无法默认构造的const成员的类,编译器不会为其合成默认构造函数。如果具有引用成员的类合成拷贝赋值运算符,则赋值后,左侧运算对象仍指向与赋值前一样的对象,因此对于有引用成员的类,合成拷贝赋值运算符也被定义为删除的

拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员

  • 类的行为像值:对象有自己的状态,副本和原对象是完全独立的

    如果将一个对象赋予它自身,赋值运算符必须能正确工作;大多数赋值运算符组合了析构函数和拷贝构造函数的工作

  • 行为像指针:共享状态,拷贝一个这种类的对象时,副本和原对象使用相同的底层数据

IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为不像值也不像指针

HasPtr程序

定义行为像值的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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& hp){
auto tmp = new string(*hp.ps);
delete ps;
ps = tmp;
i = hp.i;
return *this;

}
//析构函数
~HasPtr() {
if(--*use==0){
delete ps;
delete use;
}
}
private:
string* ps;
int i;
};

定义行为像指针的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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& hp)
{
++*hp.use;
if(--*use==0)
{
delete ps;
delete use;
}
ps = hp.ps;
i = hp.i;
use = hp.use;
return *this;
}
//析构函数
~HasPtr() {delete ps;}
private:
string* ps;
int i;
//引用计数,记录有多少对象共享相同的string
size_t *use;
};

交换操作

除了定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。拷贝一个类值的HasPtr会分配一个新string并将其拷贝到HasPtr指向的位置,理论上这些内存分配是不必要的,因为可以只交换指针

类值版本下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class HasPtr{
friend void swap(HasPtr&, 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& hp){
++*hp.use;
if(--*use==0)
{
delete ps;
delete use;
}
ps = hp.ps;
i = hp.i;
use = hp.use;
return *this;
}
//析构函数
~HasPtr() {
if(--*use==0){
delete ps;
delete use;
}
}
private:
string* ps;
int i;
};

inline
void swap(HasPtr& lhs, HasPtr& rhs)
{
swap(lhs.ps,rhs.ps);
swap(lhs.i,rhs.i);
}

由于三个swap参数类型不同, 不会导致递归循环,swap函数应该调用特定版本的swap而不是std::swap

在赋值运算符中使用swap

定义swap的类通常用swap来定义它们的赋值运算符,使用拷贝并交换的技术

1
2
3
4
5
HasPtr& HasPtr::operator=(HasPtr hp)
{
swap(*this, hp);
return *this;
}

参数不是引用,传入的是右侧运算对象的一个副本,交换副本与*this中的数据成员,*this将指向新分配的string——右侧运算对象中string的一个副本,当赋值运算符结束时,hp将被销毁,析构函数deletehp指向的内存,即原来左侧运算对象的内存。并且,它自动处理了自赋值情况,通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确

拷贝控制示例

两个类通过拷贝控制进行簿记操作,MessageFolder分别表示电子邮件消息和消息目录,每个Message对象可以出现在多个Folder中,每个Folder中记录其中的所有Message对象

Message中保存一个它所在Folder的指针的set,每个Folder中保存它包含的Message的指针的setMessage类提供saveremove操作,用于创建或删除一个对象时与Folder相关联

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class Folders;
class Message{
friend class Folder;
friend void swap(Message&,Message&);
public:
//不允许隐式转换
explicit Message(const string& s = ""): contents(s) {}

//拷贝构造函数
Message(const Message& me): contents(me.contents), folders(me.folders)
{
//但是在每个folder上并没有更新刚拷贝的Message对象
for(auto f:folders)
{
f->addMsg(this);//调用Folder类中的函数添加Message指针
}
}

//拷贝赋值运算符
Message& operator=(const Message& me)
{
//为了自赋值操作正常,先删除原有的指针,再进行添加
//如果非自赋值,this为空,则不删除;不为空,则需要删除原有的关联再拷贝赋值
for(auto f:folders)
{
f->remMsg(this);//调用Folder类中的函数删除Message指针
}
contents = me.contents;
folders = me.folders;
for(auto f:folders)
{
f->addMsg(me);
}
return *this;
}

//析构函数
~Message()
{
for(auto f:folders)
{
f->remMsg(this);
}
}

void save(Folder &f)
{
folders.insert(&f);
f->addMsg(this);
}

void remove(Folder &f)
{
folders.erase(&f);
f->remMsg(this);
}

void addFldr(Folder *f) { folders.insert(f); }
void remFldr(Folder *f) { folders.erase(f); }
private:
string contents; //保存消息文本
set<Folder*> folders; //保存包含该消息的Folder指针
};


void swap(Message& lme, Message& rme)
{
for(auto f:lme.folders)
{
f->remMsg(lme);
}
for(auto f:rme.folders)
{
f->remMsg(rme);
}
swap(lme.contents,rme.contents);
swap(lme.folders,rme.folders);
for(auto f:lme.folders)
{
f->addMsg(lme);
}
for(auto f:rme.folders)
{
f->addMsg(rme);
}
}

对于拷贝构造函数和拷贝赋值运算符,都需要实现已关联的folders进行更新,因为是拷贝,所以要在每个与原Message对象有关联的Folder对象都增加与新拷贝得到的Message对象进行关联,同理删除也是。另外,拷贝赋值运算符需要额外删除关联,是为了自赋值情况能正常运行,非

[TOC]

自赋值情况下,也需要让原对象删除原有的关联,如果为空则不删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Folders{
friend class Message;
friend void swap(Folders&, Folders&);
public:
Folder() = default;
Folder(const Folder &);
Folder& operator=(const Folder &);
~Folder();
private:
set<Message*> msgs;

void add_to_Message(const Folder&);
void remove_from_Message();

void addMsg(Message *m) { msgs.insert(m); }
void remMsg(Message *m) { msgs.erase(m); }
};

void swap(Folders& lf, Folders& rf)
{
lf.remove_from_Message();
rf.remove_from_Message();

swap(lf.msgs,rf.msgs);

lf.add_to_Message();
rf.add_to_Message();
}

void Folders::add_to_Message(const Folder& f)
{
for(auto m: f.msgs)
{
m->folders.insert(this);
}
}

void Folders::remove_from_Message()
{
for(auto m: msgs)
{
m->folders.erase(this);
}
}

Folder::Folder(const Folder& f): msgs(f.msgs)
{
add_to_Message(f);
}

Folder::~Folder()
{
remove_from_Message();
}

Folder &Folder::operator=(const Folder &rhs)
{
remove_from_Message();
msgs = rhs.msgs;
add_to_Message(rhs);
return *this;
}

Folder类封装了对set中每个Message对象添加和删除该folder对象的函数,减少代码冗余,其余与Message类一致

在以上两种类的拷贝赋值运算符中,都没有使用到拷贝并交换技术,是因为此技术适用在有动态内存分配情况下,如HasPtr的类值实现,否则会增加复杂度

动态内存管理类程序

定义一个类似于vector的类,需要自己进行内存分配,就要定义拷贝控制成员来管理内存,这个类只用于string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class StrVec{
public:
StrVec(): elements(nullptr), first_free(nullptr), cap(nullptr) {}
StrVec(const StrVec&); //拷贝构造函数
StrVec& operator=(const StrVec&); //拷贝赋值运算符
~StrVec(); //析构函数

void push_back(const string&);
size_t size() const {return first_free - elements;}
size_t capacity() const {return cap - first_free;}
string* begin() const {return elements;}
string* end() const {return first_free;}
private:
static allocator<string> alloc;
pair<string*, string*> alloc_n_copy(const string*, const string*);
void free();
void chk_n_alloc() {if(size==capacity()) reallocate();}
void reallocate();

string* elements; //指向数组开头
string* first_free; //指向数组最后一个元素的后一个位置
string* cap; //指向分配的内存的后一个位置
};
  • alloc是一个静态成员,类型是allocator<string>,能够分配StrVec的内存
  • alloc_n_copy会分配内存,并拷贝一个给定范围中的元素
  • free会销毁构造的元素并释放内存
  • chk_n_alloc_保证StrVec至少有容纳一个新元素的空间,如果没有则调用reallocate分配更多内存
  • reallocate在内存用完时为StrVec分配新内存
1
2
3
4
5
6
7
void StrVec::push_back(const string& s)
{
chk_n_alloc();
//确保有足够空间容纳新元素
alloc.construct(first_free++, s);
//a.construct(p,args) p是一个指针指向原始内存,在p指向的内存中用args构造一个对象
}

allocator分配内存时,内存是未构造的,需要用construct函数在此内存中构造一个对象,同时first_free指针往后递增一位,指向下一个未构造的元素

1
2
3
4
5
pair<string*, string*> StrVec::alloc_n_copy(const string& ls, const string& rs)
{
auto data = alloc.allocate(rs - ls);
return {data, unitialized_copy(ls, rs, data)};
}

rs - ls尾后指针减去首元素指针得出元素空间大小,传给allocate函数返回所分配空间的首地址,unitialzed_copylsrs之间的元素拷贝到data起始的内存中,返回最后一个构造元素之后的位置,即返回的pair由新构建的首元素指针和尾后指针组成

1
2
3
4
5
6
7
8
9
10
11
12
void StrVec::free()
{
if(elements)
{
for(auto p = first_free;p!=elements;p--)
alloc.destory(elements);
alloc.deallocate(element, cap - elements);
}
}

//用for_each实现的free()
for_each(elements, first_free, [this](std::string &rhs){ alloc.destroy(&rhs); });

destroy会执行string的析构函数,释放string自己分配的内存空间。当元素销毁后,要调用deallocate函数释放StrVec分配的内存空间,传递的必须是之前allocate调用所返回的指针,因此在调用前应先对指针判空

1
2
3
4
5
6
7
StrVec::StrVec(const StrVec& sv)
{
auto data = alloc.allocate(sv.begin(), sv.end());
elements = data->first;
first_free = data->second;
cap = data->second;
}

在拷贝构造函数中直接调用alloc_n_copy,返回值是一个指针的pairfirst指向的是第一个构造的元素,second指向的是最后一个元素之后的位置,由于alloc_n_copy分配的空间是刚好容纳给定的元素,所以cap也是在最后一个元素之后的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//析构函数直接调用free()
StrVec::~StrVec()
{
free();
}
//拷贝赋值运算符
StrVec& StrVec::operator=(const StrVec& sv)
{
auto data = alloc_n_copy(sv.begin(),sv.end());
free();
elements = data.first;
cap = first_free = data.second;
return *this;
}

同样地,拷贝赋值运算符也调用alloc_n_copy函数来初始化指针,为了排除赋值左边对象初始值的影响,要先调用free()函数对原对象进行析构,为了正确处理自赋值的情况,**free要在调用alloc_n_copy之后调用**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void StrVec::reallocate()
{
auto newcapacity = size()? 2 * size() : 1;
auto data = alloc.allocate(newcapacity);
auto elem = data;
auto ori = elements;
for(size_t i = 0; i != size(); i++)
{
alloc.construct(elem++, std::move(*ori++));
}
free();
elements = data;
first_free = elem;
cap = elements + newcapacity;
}

首先确定重新分配内存的大小,新分配的容量加倍,如果原对象为空,则分配容纳一个元素的空间。在for循环中,使用construct构建新的string对象,同时用elem表示初始内存的地址并将其不断递增,第二个参数调用move,返回值将会使得construct使用string的移动构造函数,那么这些string管理的内存将不会被拷贝

1
2
3
4
5
6
StrVec::StrVec(const initializer_list<string>& il)
{
auto data = alloc_n_copy(il.begin(), il.end());
elements = data.first;
cap = first_free = data.second;
}

StrVec 类添加一个构造函数,它接受一个 initializer_list<string> 参数,可以直接调用alloc_n_copy函数

手撕string实现代码

对象移动

[TOC]

很多拷贝操作后,原对象会被销毁,因此引入移动操作可以大幅度提升性能

标准库容器、stringshared_ptr类既可以支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

右值引用

通过&&获得右值引用,只能绑定到一个将要销毁的对象,常规引用可以称之为左值引用。左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值

  • 返回左值引用的函数,以及赋值、下标、解引用前置递增递减运算符,可以将左值引用绑定这类返回左值表达式
  • 返回非引用的函数,以及算术、关系、位后置递增递减运算符,不能用左值引用绑定,但可以用**const的左值引用或一个右值引用绑定**

左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象

不能将一个右值引用绑定到一个右值引用类型的变量上

int && rr1 = 42;

int && rr2 = rr1; //错误,rr1是左值

move函数

  • int &&rr3 = std::move(rr1);
  • move告诉编译器,我们有一个左值,但我希望像右值一样处理它。
  • 调用move意味着:除了对rr1赋值或者销毁它外,我们将不再使用它。

移动构造函数和移动赋值运算符

类似于拷贝构造函数,移动构造函数的第一个参数是该类类型的右值引用,其余额外的参数都必须有默认实参

一旦资源完成移动,源对象必须不再指向被移动的对象

1
2
3
4
5
StrVec::StrVec(StrVec&& s) noexcept
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
s.element = first_free = cap = nullptr;
}

移动构造函数接管给定的StrVec中的内存,将给定对象中的指针都置为nullptr,移后源对象会被销毁,将在其上运行析构函数

1
2
3
4
5
6
7
8
9
10
11
12
StrVec& StrVec::operator=(StrVec &&sv) noexcept
{
if(*this!=sv)
{
free();
elements = sv.elements;
first_free = sv.elements;
cap = sv.cap;
sv.elements = sv.first_free = sv.cap = nullptr;
}
return *this;
}

对于自赋值情况,移动赋值运算符通常会先检查this指针与sv的地址是否相同,如果相同,右侧和左侧对象指向相同的对象,则不做任何事直接返回该StrVec对象;否则,释放左侧运算对象的内存,并接管右侧对象的内存,最后再将右侧对象的指针置为nullptr

只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员(非static)都能移动时,编译器才会为它合成移动构造函数或移动赋值运算符

有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者类成员定义自己的拷贝构造函数且编译器不能为其合成移动构造函数,那么合成的移动操作定义为删除

定义了移动操作的类也必须定义自己的拷贝操作,否则,合成拷贝操作都被默认定义为删除的

对于既有移动构造函数,也有拷贝构造函数的类,将会移动右值,拷贝左值,但如果没有移动构造函数,右值也会被拷贝

拷贝并交换赋值运算符和移动操作

[如前所示](# 在赋值运算符中使用swap),非引用参数意味着参数要进行拷贝初始化,对于左值将被拷贝,而右值则会被移动,因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符

hp = hp2;

hp = std::move(hp2);

第一个赋值,右侧运算对象是一个左值,因此将使用拷贝构造函数来初始化;第二个赋值,调用std::move将一个右值引用绑定到hp2上,移动构造函数是精准匹配的,因此,将用移动构造函数拷贝指针,而不分配任何内存

但是,haspt会执行两次的拷贝,一次在调用move函数,另一次在移动赋值运算符拷贝给this指针;而对于普通的移动构造版本,则只会执行一次拷贝

移动迭代器

移动迭代器的解引用运算符生成一个右值引用

make_move_iterator函数讲一个普通迭代器转换为一个移动迭代器

1
2
3
4
5
6
7
8
9
10
void StrVec::reallocate()
{
auto newcapacity = size()? 2 * size() : 1;
auto data = alloc.allocate(newcapacity);
auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), data);
free();
elements = data;
first_free = last;
cap = elements + newcapacity;
}

[原版本](# 动态内存管理类程序)中使用一个for循环来调用construct从旧内存将元素拷贝到新内存中。作为一种替换,将使用uninitialized_copy来构造新分配的内存,传入的是移动迭代器,解引用运算生成符生成的是一个右值引用,意味着construct将使用移动构造函数来构造元素

右值引用和成员函数

区分移动和拷贝的重载函数通常有一个版本接受一个cosnt T&,另一个版本接受T &&

通过在参数列表后放置一个引用限定符,可以强制赋值运算符左侧运算对象是左值或者右值

1
2
Foo &operator=(const Foo&) const &;
Foo &operator=(Foo &&) &&;

如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

1
2
3
4
Foo Foo::sorted() const & {
Foo ret(*this);
return ret.sorted();
}

会产生递归并且最终溢出。

1
Foo Foo::sorted() const & { return Foo(*this).sorted(); }

与上一题不同,本题的写法可以正确利用右值引用版本来完成排序。原因在于,编译器认为Foo(*this)是一个[无主]的右值,对它调用sorted会匹配右值引用版本

第十四章 重载运算与类型转换

基本概念

如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上,成员运算符函数的(显式)参数数量比运算符的运算对象少一个

对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数

赋值、下标、调用和成员访问运算符必须是成员函数

输入和输出运算符

必须是普通的非成员函数,需要读写非公有数据成员时,一般被声明为友元

输出运算符的第一个形参是一个非常量ostream对象的引用,因为向流写入内容会改变其状态,且无法拷贝一个ostream对象;第二个形参是一个常量的引用,是想要打印的类类型

1
2
3
4
5
6
ostream &operator<<(ostream& os, const Sales_data& sd)
{
os << sd.isbn << " " << sd.units_sold << " " << sd.revenue
<< " " << sd.avg_price();
return os;
}

输出运算符尽量减少格式化操作,不应该打印换行符

输入运算符的第一个形参是一个非常量istream对象的引用,第二个形参是将要读入的对象的引用,非常量

1
2
3
4
5
6
7
8
9
10
istream &operator>>(istream& is, Sales_data &sd)
{
double price;
is >> sd.bookNo >> sd.units_sold >> price;
if(is)
sd.revenue = sd.units_sold * price;
else
sd = Sales_data();
return is;
}

if语句检查读取操作是否成功,如果发生IO错误,则运算符将给定的对象重置为空Sales_data

输入运算符必须处理输入可能失败的情况

算术和关系运算符

一般不需要改变运算对象的状态,所以形参都是常量的引用。计算两个运算对象并得到一个新值,通常位于一个局部变量之内,最终返回该局部变量的一个副本

如果类同时定义了算数运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算数运算符,可以避免使用临时对象

相等运算符和不等运算符的一个应该把工作委托给另一个

如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果同时还包含==,则当且晋档<的定义和++产生的结果一直时才定义<运算符

赋值运算符

1
2
3
4
5
6
7
8
StrVec& StrVec::operator=(initializer_list<string> il)
{
auto data = alloc_n_copy(il.begin(),il.end());
free();
elements = data.first;
cap = first_free = data.second;
return *this;
}

拷贝赋值运算符不同,这个运算符无需检查对象向自身的赋值,因为形参initializer_list确保ilthis指向的不是同一个对象

下标运算符

以所访问元素的引用作为返回值,一般同时定义下标运算符的常量版本和非常量版本

递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本

前置运算符应该返回递增或递减后对象的引用;后置运算符应该返回递增或递减前对象的值,后置版本接受一个额外的,不被使用的int类型的形参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//前置版本
inline StrBlobPtr& StrBlobPtr::operator++()
{
check(curr, "increment past end of StrBlobPtr");
++curr;
return *this;
}

inline StrBlobPtr& StrBlobPtr::operator--()
{
--curr;
check(curr, "decrement past begin of StrBlobPtr");
return *this;
}

//后置版本
inline StrBlobPtr StrBlobPtr::operator++(int)
{
StrBlobPtr ret = *this;
++*this;
return ret;
}

inline StrBlobPtr StrBlobPtr::operator--(int)
{
StrBlobPtr ret = *this;
--*this;
return ret;
}

注意前置递增运算符先将当前值传递给check函数,而递减运算符是先递减curr,再调用check函数

后置版本无需检查有效性,因为返回的是递增或递减前的状态的副本

成员访问运算符

1
2
3
4
5
6
7
8
9
10
string& operator*() const
{
auto p = check(curr,"dereference past end");
return (*p)[curr];
}

string& operator->() const
{
return& this->operator*();
}

解引用运算符检查curr是否在作用范围内,如果在则返回curr指针所指元素的引用;箭头运算符调用解引用运算符并返回解引用结果元素的地址

函数调用运算符

如果累定义了调用运算符,则该类的对象称作函数对象

一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别

lambda捕获变量:lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,由lambda产生的类当中的函数调用运算符是一个const 成员函数

标准库函数对象:

算术 关系 逻辑
plus<Type> equal_to<Type> logical_and<Type>
minus<Type> not_equal_to<Type> logical_or<Type>
multiplies<Type> greater<Type> logical_not<Type>
divides<Type> greater_equal<Type>
modulus<Type> less<Type>
negate<Type> less_equal<Type>

比较两个指针将产生未定义的行为,但可以通过标准库函数对象比较指针内存地址

1
2
3
vector<string*> vec;
sort(vec.begin(),vec.end(),[](string* a, string* b) {return a < b;});//错误,不能直接比较两个指针
sort(vec.begin(),vec.end(),greater<string*> ());//正确,按降序排列指针的地址

统计大于1024的值有多少个

1
cout_if(vec.begin(),vec.end(),bind(greater<int>(), _1, 1024));

找到第一个不等于pooh的字符串

1
find_if(str.begin(),str.end(),bind(not_equal_to<string>(), _1, "pooh"));

将所有的值乘以2

1
transform(vec.begin(),vec.end(),bind(multiplies<int>(), _1 ,2));

判断一个给定的int值是否能被 int 容器中的所有元素整除

1
2
3
4
int input = 30;
modulus<int> mod;
auto predicator = [&](int i) { return 0 == mod(input, i); };
auto is_divisible = std::any_of(data.begin(), data.end(), predicator);//any_of返回truefalse

可调用对象与function

可调用对象:函数、函数指针、lambda表达式、重载调用运算符的类和bind创建的对象

每一个lambda都有自己唯一的未命名的类类型,函数和函数指针的类型则由返回值和实参类型决定,但两个不同类型的可调用对象可以有相同的调用形式,调用形式包含调用的返回类型和实参类型,int (int, int)

1
2
3
4
5
6
7
8
9
10
11
12
13
//函数
int add(int i, int j) {
return i + j;
}
//lambda
auto mod = [](int i, int j){return i%j;};
//函数对象类
struct divide{
int operator()(int i, int j)
{
return i/j;
}
};

定义一个函数表,用于存储指向这些可调用对象的指针map<string, int(*)(int, int)> calc

calc.insert("+", add);可添加add的指针至函数表中,但不能直接存入moddivide,因为它们并不是函数指针类型,与map中所要求的类型不匹配,但可以通过function类型来解决

标准库function类型

操作 解释
function<T> f; f是一个用来存储可调用对象的空function,这些可调用对象的调用形式应该与类型T相同。
function<T> f(nullptr); 显式地构造一个空function
function<T> f(obj) f中存储可调用对象obj的副本
f f作为条件:当f含有一个可调用对象时为真;否则为假。
定义为function<T>的成员的类型
result_type function类型的可调用对象返回的类型
argument_type T有一个或两个实参时定义的类型。如果T只有一个实参,则argument_type
first_argument_type 第一个实参的类型
second_argument_type 第二个实参的类型

声明一个function类型,它可以表示接受两个int,返回一个int的可调用对象。function<int(int, int)>

1
2
3
4
5
6
7
map<string, function<int(int,int)>> calc = {
{"+", add}, //函数指针
{"-", minus<int>()}, //标准库函数对象
{"*", [](int i, int j){return i*j;}}, //lambda表达式
{"/", divide()}, //重载调用运算符的类,函数对象
{"%", mod} //命名的lambda对象
};

重载、类型转换与运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型

operator type() const;

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const

int i = 42;

cin << i;

使用istreambool类型转换运算符将cin转换成bool,而这个bool会被提升为int进而执行左移42位的操作

可以通过显式的类型转换运算符防止自动隐式转换,这样,在执行类型转换时,需通过显式的强制类型转换

static_cast<int> (si) + 3;

bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit

1
2
3
4
struct Integral {
operator const int();
operator int() const;
}

第一条语句指明类型转换得到的值为const int,不能对其进行修改

第二条语句指明不能对类对象进行修改

两个类提供相同的类型转换,例如A定义一个接受B的构造函数,同时B类定义了一个转换目标是A的类型转换运算符,则产生二义性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct A{
A(const B&);
};
struct B{
operator A() const;
};

A f(const A&);
B b;
A a = f(b); //二义性:f(B::operator A())与f(A::A(const B&))

//显式调用类型转换运算符或者构造函数
A a1 = f(b.operator A());
A a2 = f(A(b));

同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct LongDouble{
LongDouble(double = 0.0);
LongDouble operator+(const SmallInt&);
oprerator double();
operator float();
};

class SmallInt{
SmallInt operator+(const SmallInt&, const SmallInt&);
SmallInt (int = 0);
operator int() const {
return val;
}
};

SmallInt s1, s2;
SmallInt s3 = s1 + s2; // 成员函数重载+
int i = s3 + 0; // 二义性: s3转换成int再使用内置+ 或 0转换成SmallInt再使用重载+

LongDouble operator+(LongDouble&, double);
SmallInt si;
LongDouble ld;
ld = si + ld; //错误
ld = ld + si; //正确

ld = si + ld; 中两个类都不能相互转换,因此不能使用重载+,2si转换成intld转换成doublefloat,将产生二义性

ld = ld + si; 中可以使用ld的重载+,ld在左,接受double的右侧运算对象,精确匹配;还可以ld转换成doublefloatsi转换成int,再使用内置+,但优先级低于前者,故无二义性

第十五章 面向对象程序设计

定义基类和派生类

层次关系的根部是一个基类,其他类可通过直接或间接继承基类,继承得到的类称为派生类

派生类要通过派生类列表显式指出从哪个基类继承而来,如class Bulk_quote : public Quote{//类定义};,且只在类定义时才指出派生类列表,声明中只包含类名但不包含派生类列表class Bulk_quote;

对于某些函数,基类希望它的派生类个自定义适合自己的版本,此时基类就将这些函数声明成虚函数virtual

派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即在函数的形参列表之后加一个override关键字

如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义

派生类对象中包含了:派生类自己定义的成员的子对象(非静态)以及所继承的基类的子对象,即派生类对象中也含有基类对应的组成部分,可以把派生类对象当成基类对象来使用,通过将基类的指针或引用绑定到派生类对象中的基类部分上

1
2
3
4
5
Quote item;
Bulk_quote bulk;
Quote *p = &item; //p指向Quote对象item
p = bulk; //p指向Bulk_quote对象bulk中的Quote部分
Quote &r = bulk; //r绑定到bulk中的Quote部分

这种转换称为派生类到基类的转换

派生类必须使用基类的构造函数去初始化它的基类部分

如果将某个类作为基类,则该类必须已定义而非只有声明

防止继承发生可以在类名或函数名后加上关键字final

静态类型与动态类型

静态类型在编译时已知,是根据变量声明或表达式生成的类型;动态类型则是变量或表达式表示的在内存中的类型,在运行时才可知

如上例子中p或者r在绑定bulk后动态类型与静态类型不一样了,静态类型为Quote而动态类型为Bulk_quote

因为一个基类对象可能是派生类对象一部分也可能不是,所以不存在从基类向派生类的隐式类型转换

用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,派生类部分则会被忽略掉

虚函数

使用虚函数可以执行动态绑定,动态绑定只有通过指针或者引用调用虚函数时才会发生

当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同

当派生类覆盖了某个虚函数时,该函数在某类中的形参必须与派生类中的形参严格匹配

如果我们想覆盖某个虚函数,但不小心把形参列表弄错了,这个时候就不会覆盖基类中的虚函数。加上override可以明确程序员的意图,让编译器帮忙确认参数列表是否出错

如果通过基类引用或指针调用函数,则使用基类中定义的默认实参,因此派生类中定义的默认实参最好与基类一致

抽象基类

纯虚函数用于清晰地告诉用户当前的函数是没有实际意义的。只用在函数体的位置前书写=0就可以将一个虚函数说明为纯虚函数

含有纯虚函数的类是抽象基类,抽象基类不能被直接创建成对象

访问控制与继承

  • protected : 基类和和其派生类还有友元可以访问。
  • private : 只有基类本身和友元可以访问。

派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。

只有当公有继承自基类时,用户代码才能将派生类转换成基类

1
2
Base *p = &d1;		//正确,d1是公有继承
p = &d2; //报错,d2是私有继承

派生访问说明符的目的是:控制派生类用户对于基类成员的访问权限。比如struct Priv_Drev: private Base{}意味着在派生类Priv_Drev中,从Base继承而来的部分都是private

供派生类访问 应声明为受保护的,则派生类及其友元不能访问私有成员由基类及其基类的友元访问 应声明为私有的

不能继承友元关系

使用using改变个别成员的可访问性,using声明语句中名字的访问权限由该using声明语句之前的访问说明符决定

继承中的类作用域

派生类的作用域嵌套在其基类的作用域之内

派生类的成员将隐藏同名的基类成员

构造函数与拷贝控制

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。因此基类通常定义一个虚析构函数virtual ~Quote() = default;

如果一个类定义了析构函数,即使是通过=default的形式使用合成的版本,编译器也不会合成移动操作

派生类构造函数在初始化阶段不仅要初始化派生类自己的成员,还负责初始化基类部分。拷贝、赋值和移动同理,而析构函数只负责销毁派生类自己分配的资源

当为派生类定义对应的拷贝或移动构造函数时,通常使用对应的基类构造函数来初始化派生类对象的基类部分

容器与继承——Basket类程序

如果希望使用容器存放具有继承关系的对象时,由于派生类赋值给基类对象时,其中的派生类对象会被忽略掉,因此实际上存放的应该是基类的(智能)指针

为了实现basket存放shared_ptr,定义一个表示购物篮的类,并提供添加和打印输出的接口,私有成员定义一个multiset存放指向不同Quoteshared_ptrmultiset<shared_ptr<Quote>>

1
2
3
4
5
6
7
8
9
10
11
class Basket{
public:
void add_item(const shared_ptr<Quote>& q) {items.insert(q);}
double total_receipt(ostream&) const;
private:
bool compare(const shared_ptr<Quote> &l, const shared_ptr<Quote> &r)
{
return l->isbn() < r->isbn();
}
multiset<shared_ptr<Quote>, decltype(compare)*> items(compare);
};

multiset中存放的是shared_ptr,因此需要自定义小于运算符,初始化items并令其使用compare函数

1
2
3
4
5
6
7
8
9
10
11
12
double total_receipt(ostream& os) const
{
double sum = 0.0;
for(auto iter = items.begin();
iter != item.end();
iter = upper_bound(*iter))
{
sum += print_total(os, **iter, items.count(*iter));
}
os << "total sale:" << sum << endl;
return sum;
}

for循环中,*iter解引用得到指向Quote的智能指针,调用upper_bound函数可以返回multiset中第一个非*iter智能指针指向的元素,替代了递增操作,意味着不需要打印重复的书本,在相同的书中只取一本作为代表打印。**iter解引用则返回Quote元素

接下来要实现add_item函数,因为存放的是Quote类型的智能指针,而并不确定指针所指向的具体类型,即该指针的静态类型有可能与动态类型不一样,在make_shared(*类型*)或者shared_ptr pt = new *类型*时无法确定使用基类还是派生类,如果使用基类,则派生类中的非基类部分会被忽略。因此,Quote类添加一个虚函数,返回一个新申请的当前对象的拷贝

1
2
3
4
5
6
7
8
9
10
11
class Quote{
public:
virtual Quote* clone() const & {return new Quote (*this);}
virtual Quote* clone() && {return new Quote (move(*this));}
};

class Bulk_quote : public Quote {
public:
Bulk_quote* clone() const & {return new Bulk_quote (*this);}
Bulk_quote* clone() && {return new Bulk_quote (move(*this));}
};

实现add_item函数

1
2
3
4
5
6
7
8
9
10
//原版本
//void add_item(const shared_ptr<Quote>& q) {items.insert(q);}
void Basket::add_item(const Quote& sale)
{
items.insert(shared_ptr<Quote>(sale.clone()));
}
void Basket::add_item(const Quote&& sale)
{
items.insert(shared_ptr<Quote>(move(sale).clone()));
}

新版本add_item可以直接传入quote对象,程序自动生成相应类型的智能指针。clone无论是拷贝或者是移动数据,都会返回shared_ptr,然后调用insert直接加入到items

第十六章——模板与泛型编程

函数模板

template <typename T> int compare(const T &v1, const T &v2){}

以关键字 template开始,后接模板形参表,模板形参表是用尖括号<>括住的一个或多个模板形参的列表,用逗号分隔,不能为空

类型模板参数——在关键字typrename或者class之后,可以用来指定返回类型或函数的参数类型

非类型模板参数——用来表示一个值而非一个类型,通过特定的类型名指定非类型参数当一个模板实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替

1
2
3
4
5
6
7
8
template<unsighned N, unsigned M>
int compare(const char (&p1)[n], const char (&p2)[M])
{
return strcmp(p1, p2);
}

compare("hi", "mom");
//当调用compare时,编译器使用字面常量的大小来代替M和N,即调用 int compare(const char (&p1)[3],const char (&p2)[4])

一个非类型参数可以是一个整数,或者是一个指向对象或函数类型的指针或引用;绑定到非类型整型参数的实参必须是一个常量表达式,必须具有静态的生存期。

inlineconstexpr说明符放在模板参数列表之后,返回类型之前

template <typename T> inline T min(const T&, const T&);

编写类型无关的代码

  • 模板中的函数参数是const的引用
  • 函数体中的条件判断仅使用<比较运算

模板编译

只有当实例化出模板的一个特定版本时,编译器才会生成代码

  • 普通函数的声明和类定义放在头文件,普通函数的定义和类成员函数的定义放在源文件中
  • 函数模板或类模板成员函数的声明和定义都要在头文件中

C3P第三部分
https://kevin346-sc.github.io/2022/11/09/C3P第三、四部分/
作者
Kevin Huang
发布于
2022年11月9日
许可协议