C++ Primer, 5th Edition 笔记3

类设计者的工具

拷贝控制

当定义一个类时,我们显式地或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。

拷贝、赋值与销毁

拷贝构造函数的参数必须是引用类型。如Sales_data(const Sales_data&);
重载赋值运算符,赋值运算符通常应该返回一个指向其左侧运算对象的引用。Foo& operator=(const Foo&);
析构函数没有参数,不能被重载。析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分进行的。
需要析构函数的类也需要拷贝和赋值操作。需要拷贝操作的类也需要赋值操作,反之亦然。

可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本。定义在类内的是内联函数,在类外的就不是了。

1
2
3
4
5
6
7
8
9
10
class Sales_data {
public:
// 拷贝控制成员;使用default
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data &);
~Sales_data() = default;
// 其他
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;

阻止拷贝,之前是通过将其拷贝构造函数和拷贝赋值运算符声明为private。现在不推荐用了,应该用C++11的方法。

1
2
3
4
5
6
7
8
9
10
11
// Before
class PrivateCopy {
// 无访问说明符;接下来的成员默认为private的;
// 拷贝控制成员是private的,因此普通用户代码无法访问
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator=(const PrivateCopy&);
// 其他成员
public:
PrivateCopy() = default; // 使用合成的默认构造函数
~PrivateCopy(); // 用户可以定义此类型的对象,但无法拷贝它们
};

在C++11里,可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的:

1
2
3
4
5
6
7
8
// C++11
struct NoCopy {
NoCopy() = default; // 使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
// 其他
};

=delete必须出现在函数第一次声明的时候,=default不是。可以对任何函数指定=delete,但只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default
注意析构函数不能是删除的成员。
如果一个类有const成员,则它不能使用合成的拷贝赋值运算符。毕竟,此运算符试图赋值所有成员,而将一个新值赋予一个const对象是不可能的。

拷贝控制和资源管理

拷贝语义有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
像一个值,意味它有自己的状态。拷贝出的副本和原对象是完全独立的。改变副本不会对原对象有任何影响,反之亦然。
像一个指针,意味共享状态。拷贝出的副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
举例,在标准库中,标准库容器和string类的行为像一个值,shared_ptr类提供类似指针的行为,IO类型和unique_ptr不允许拷贝或赋值,行为既不像值也不像指针。

行为像值的类

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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); // 拷贝底层string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}

编写赋值运算符时要注意:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。销毁左侧运算对象资源之前拷贝右侧运算对象。
行为像指针的类

需要定义拷贝构造函数和拷贝赋值运算符,最好使用shared_ptr来管理类中的资源。如果希望自己直接管理资源,使用引用计数。
使用引用计数的示例(副本和原对象都指向相同的string):

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
class HasPtr {
public:
// 构造函数分配新的string和新的计数器,将计数器置为1
HasPtr(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) { // 如果引用计数变为0
delete ps; // 释放string内存
delete use; // 释放计数器内存
}
}
// 重载赋值运算符
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // 递增右侧运算对象的引用计数
if (--*use == 0) { // 然后递减本对象的引用计数
delete ps; // 如果没有其他用户
delete use; // 释放本对象分配的成员
}
ps = rhs.ps; // 将数据从rhs拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}

交换操作

对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。如果没有类自定义版本,算法会使用标准库定义的swap。理论上交换两个对象需要进行一次拷贝和两次赋值。如果是交换两个类值的HasPtr对象,会涉及到多次内存分配,没有必要,我们更希望swap交换指针,而不是分配string的新副本。

1
2
3
4
5
6
7
8
9
10
class HasPtr {
friend void swap(HasPtr&, HasPtr&);
// 其他成员定义,行为像值的类
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{

using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是string数据
swap(lhs.i, rhs.i); // 交换int成员
}

与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap可能是一种很重要的优化手段。
swap函数应该调用swap,而不是std::swap。防止该类有自定义swap却调用了std的。

可以在赋值运算符中使用swap。拷贝并交换,这种技术将左侧运算对象与右侧运算对象的一个副本进行交换。使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值。

1
2
3
4
5
6
7
8
// 注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
// 将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
// 交换左侧运算对象和局部变量rhs的内容
swap(*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; // rhs被销毁,从而delete了rhs中的指针
}

动态内存管理类

示例,实现标准库vector类的一个简化版本。没使用模板,我们的类只用于string,命名为StrVec。

StrVec类的设计

vector类将其元素保存在连续内存中。为了获得可接受的性能,vector预先分配足够的内存来保存可能需要的更多元素。vector的每个添加元素的成员函数会检查是否有空间容纳更多的元素。如果有,成员函数会在下一个可用位置构造一个对象。如果没有可用空间,vector就会重新分配空间:它获得新的空间,将已有元素移动到新空间中,释放旧空间,并添加新元素。
在StrVec中使用类似策略。使用allocator来获得原始内存,添加新元素的时候用construct创建对象,删除的时候用destroy。
每个StrVec有三个指针成员指向其元素所使用的内存:

  • elements,指向分配的内存中的首元素。
  • first_free,指向最后一个实际元素之后的位置。
  • cap,指向分配的内存末尾之后的位置。

StrVec内存分配策略
还有一个名为alloc的静态成员,类型为allocator。用于分配StrVec使用的内存。
还有4个工具函数:

  • alloc_n_copy会分配内存,并拷贝一个给定范围中的元素。
  • free会销毁构造的元素并释放内存。
  • chk_n_alloc保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存。
  • reallocate在内存用完时为StrVec分配新内存。

StrVec类定义

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 StrVec {
public:
// allocator成员进行默认初始化
StrVec() : 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 first_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; // 指向数组尾后位置的指针
};

使用construct

1
2
3
4
5
6
void StrVec::push_back(const string& s)
{
chk_n_alloc(); // 确保有空间容纳新元素
// 在first_free指向的元素中构造s的副本
alloc.construct(first_free++, s);
}

alloc_n_copy成员

拷贝或赋值StrVec,必须分配独立的内存,并从原StrVec对象拷贝元素至新对象。
alloc_n_copy成员会分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中。
此函数返回一个指针的pair,两个指针分别指向新空间的开始位置和拷贝的尾后位置。

1
2
3
4
5
6
7
8
pair<string*, string*>
StrVec::alloc_n_copy(const string *b, const string *e)
{
// 分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b); // 尾后指针减去首元素指针,来计算需要多少空间。
// 初始化并返回一个pair, 该pair由data和uninitialized_copy的返回值构成
return {data, uninitialized_copy(b, e, data)};
}

free成员

首先destroy元素,然后释放StrVec自己分配的内存空间。

1
2
3
4
5
6
7
8
9
10
void strVec::free()
{
// 不能传递给deallocate一个空指针,如果elements为0,函数什么也不做
if (elements) {
// 逆序销毁旧元素
for (auto p = first_free; p != elements; /*空*/)
alloc.destroy(--p);
alloc.deallocate(elements, cap - elements);
}
}

拷贝控制成员

拷贝构造函数调用alloc_n_copy:

1
2
3
4
5
6
7
StrVec::StrVec(const StrVec &s)
{
// 调用alloc_n_copy分配空间以容纳与s中一样多的元素
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}

析构函数调用free:

1
StrVec::~StrVec() {free();}

拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值了:

1
2
3
4
5
6
7
8
9
StrVec &StrVec::operator=(const StrVec &rhs)
{
// 调用alloc_n_copy分配内存,大小与rhs中元素占用空间一样多
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}

reallocate成员

在重新分配内存的过程中移动而不是拷贝元素
reallocate函数应该:为一个新的、更大的string数组分配内存;在内存空间的前一部分构造对象,保存现有元素;销毁原内存空间中的元素,并释放这块内存。
应尽量避免分配和释放string的额外开销。

使用移动构造函数和std::move。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void StrVec::reallocate()
{
// 我们将分配当前大小两倍的内存空间
auto newcapacity = size() ? 2 * size() : 1;
// 分配新内存
auto newdata = alloc.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 + newcapacity;
}

对象移动

从C++11开始。标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。

右值引用是必须绑定到右值的引用,通过&&获得,只能绑定到一个将要销毁的对象。因此可以自由地将一个右值引用的资源“移动”到另一个对象中。
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。

1
2
3
4
int i = 42;
int &r = i; // r引用i
const int &r3 = i * 42; // 可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42; // 将rr2绑定到乘法结果上

左值持久,右值短暂。右值引用只能绑定到临时对象,即该引用的对象将要被销毁,该对象没有其他用户。所以使用右值引用的代码可以自由地接管所引用的对象的资源。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。

标准库move函数。可以显式地将一个左值转换为对应的右值引用类型。定义在头文件<utility>中。
int &&rr3 = std::move(rr1);注意使用std::move而不是move,避免潜在的名字冲突。

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

接着前文的例子,让StrVec类支持移动和拷贝。

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
// 在声明时
class StrVec {
...
public:
StrVec(StrVec&&) noexcept; // 移动构造函数
...
}
// 在定义时,移动构造函数
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;
}
// 移动赋值运算符
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
// 直接检测自赋值
if (this != rhs) }
free(); // 释放已有元素
elements = rhs.elements; // 从rhs接管资源
first_free = rhs.first_free;
cap = rhs.cap;
// 将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}

不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。因为为了避免潜在的一半在原地一半被移走的问题,除非容器知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类也必须定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝。

对于行为像指针的类HasPtr定义拷贝并交换赋值运算符

1
2
3
4
5
6
7
8
9
10
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;}
...
};

用移动迭代器重写reallocate

C++11中提供了一种移动迭代器。移动迭代器的解引用运算符生成一个右值引用。使用make_move_iterator将一个普通迭代器转换为一个移动迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
void StrVec::reallocate()
{
// 分配大小两倍于当前规模的内存空间
auto newcapaciy = size() ? 2 * size() : 1;
auto first = alloc.allocate(newcapacity);
// 移动元素
auto last = uninitialized_copy(make_move_iterator(begin()),
make_move_iterator(end()), first);
free(); // 释放旧空间
elements = first; // 更新指针
first_free = last;
cap = elements + newcapacity;
}

**建议不要随意使用移动操作。**由于一个移动源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。小心的使用move可以大幅提升性能,随意使用就等着哭吧。

重载版本的push_back

如果一个成员函数同时提供拷贝和移动版本,一般成员函数也会有重载,一个接受指向const的左值引用,一个接受指向非const的右值引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
class StrVec {
...
public:
void push_back(const std::string&); // 拷贝元素
void push_back(std::string&&); // 移动元素
...
};
// 移动元素的push_back
void StrVec::push_back(string &&s)
{
chk_n_alloc(); // 如果需要的话为StrVec重新分配内存
alloc.construct(first_free++, std::move(s));
}

引用限定符暂略。

重载运算与类型转换

基本概念

当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。这也意味着当运算符用于内置类型的运算对象时,我们无法改变运算符的含义。
并不是所有的运算符都可以重载。
运算符
可以直接调用一个重载的运算符函数。

1
2
operator+(data1, data2);	// 对非成员运算符函数的调用
data1.operator+=(data2); // 对成员运算符函数的调用

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

Image Loading

将运算符定义为成员函数还是普通的非成员函数,做决定的准则:
Image Loading

输入和输出运算符

输入输出运算符必须是非成员函数。否则,它们的左侧运算对象将是我们的类的一个对象。所以IO运算符一般被声明为友元。
重载输出运算符<<
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印运算符。

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

重载输入运算符>>
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
可能发生的错误:

  • 当流含有错误类型的数据时读取操作可能失败。
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
1
2
3
4
5
6
7
8
9
10
istream &operator>>(istream &is, Sales_data &item)
{
double price; // 不需要初始化,因为我们先读入数据到price,之后才使用它
is >> item.bookNo >> item.units_sold >> price;
if (is) // 检查输入是否成功
item.revenue = item.units_sold * price;
else
item = Sales_data(); // 输入失败,对象被赋予默认的状态
return is;
}

算术和关系运算符

一般定义为非成员函数,以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。

如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。

1
2
3
4
5
6
7
8
// 假设两个对象指向同一本书
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs; // 把lhs的数据成员拷贝给sum
sum += rhs; // 把rhs加到sum中
return sum;
}

如果某个类在逻辑上有相等性的含义,应该定义operator==
Image Loading

1
2
3
4
5
6
7
8
9
10
bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
return lhs.isbn() == rhs.isbn() &&
lhs.units_sold == rhs.units_sold &&
lhs.revenue == rhs.revenue;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
return !(lhs == rhs);
}

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

赋值运算符

可以重载赋值运算符,不论形参的类型是什么,赋值运算符都必须定义为成员函数。复合赋值运算符也通常如此。它们都应该返回左侧运算对象的引用。

StrVec中的赋值运算符重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StrVec {
public:
StrVec &operator=(std::initializer_list<std::string>);
...
};
StrVec &StrVec::operator=(initializer_list<string> il)
{
// alloc_n_copy分配内存空间并从给定范围内拷贝元素
auto data = alloc_n_copy(il.begin(), il.end());
free(); // 销毁对象中的元素并释放内存空间
elements = data.first; // 更新数据成员使其指向新空间
first_free = cap = data.second;
return *this;
}
1
2
3
4
5
6
7
// 假定两个对象表示的是同一本书
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}

下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]。
下标运算符必须是成员函数。
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。

StrVec的下标运算符

1
2
3
4
5
6
7
8
9
class StrVec {
public:
std::string& operator[](std::size_t n) {return elements[n];}
const std::string& operator[](std::size_t n) const
{return elements[n];}
...
private:
std::string *elements; // 指向数组首元素的指针
}

递增和递减运算符

建议把递增和递减运算符设定为成员函数。
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
为了区分前置和后置运算符,后置版本接受一个额外的(不被使用)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
30
31
32
33
34
35
36
37
38
class StrBlobPtr {
public:
// 前置,递增和递减运算符
StrBlobPtr& operator++();
StrBlobPtr& operator--();
// 后置,递增和递减运算符
StrBlobPtr operator++(int);
StrBlobPtr operator--(int);
};
// 前置版本:返回递增/递减对象的引用
StrBlobPtr& StrBlobPtr::operator++()
{
// 如果curr已经指向了容器的尾后位置,则无法递增它
check(curr, "increment past end of StrBlobPtr");
++curr; // 将curr在当前状态下向前移动一个元素
return *this;
}
StrBlobPtr& StrBlobPtr::operator--()
{
// 如果curr是0,则继续递减它将产生一个无效下标
--curr; // 将curr在当前状态下向后移动一个元素
check(curr, "decrement past begin of StrBlobPtr");
return *this;
}
// 后置版本:递增/递减对象的值但是返回原值
StrBlobPtr StrBlobPtr::operator++(int)
{
// 此处无需检查有效性,调用前置递增运算时才需要检查
StrBlobPtr ret = *this; // 记录当前的值
++*this; // 向前移动一个元素,前置++需要检查递增的有效性
return ret; // 返回之前记录的状态
}
StrBlobPtr StrBlobPtr::operator--(int)
{
StrBlobPtr ret = *this;
--*this;
return ret;
}

成员访问运算符

在迭代器类及智能指针类中常常用到解引用运算符*和箭头计算符->
箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class StrBlobPtr {
public:
std::string& operator*() const
{
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p)是对象所指的vector
}
std::string* operator->() const
{
// 将实际工作委托给解引用运算符
return & this->operator*();
}
...
}

函数调用运算符

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

1
2
3
4
5
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
}

lambda是函数对象。标准库定义了很多函数对象。

C++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。
不同类型的可调用对象可以共享同一种调用形式。如int(int, int)
可以使用标准库的function类型将它们统一为一种格式。定义在functional头文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 普通函数
int add(int i, int j) {return i+j;}
// lambda,其产生一个未命名的函数对象类
auto mod = [](int i, int j) {return i % j;};
// 函数对象类
struct divide {
int operator()(int denominator, int divisor) {
return denominator / divisor;
}
};
// 列举了可调用对象与二元运算符对应关系的表格
// 所有可调用对象都必须接受两个int、返回一个int
// 其中的元素可以是函数指针、函数对象或者lambda
map<string, function<int(int,int)>> binops = {
{"+", add}, // 函数指针
{"-", std::minus<int>()}, // 标准库函数对象
{"/", divide()}, // 用户定义的函数对象
{"*", [](int i, int j){return i*j;}}, // 未命名的lambda
{"%", mod} // 命名了的lambda对象
};

重载、类型转换与运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
operator type() const;
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。
避免过度使用类型转换函数,为防止意外的隐式转换,可以定义为显式的。

1
2
3
4
5
6
7
8
9
10
11
12
class SmallInt {
public:
SmallInt(int i = 0) : val(i)
{
if (i<0 || i>255)
throw std::out_of_range("Bad SmallInt Value");
}
// 编译器不会自动执行这一类型转换
explicit operator int() const {return val;}
private:
std::size_t val;
};
1
2
SmallInt si = 3;	// SmallInt的构造函数不是显式的
static_cast<int>(si)+3; // 显式地请求类型转换

为了避免有二义性的类型转换。通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
Image Loading
如果在调用函数重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,这通常意味着程序的设计存在不足。

重载的运算符也是重载的函数。表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则会遇到重载运算符与内置运算符的二义性问题。

面向对象程序设计

OOP:概述

OOP的核心是数据抽象、继承和动态绑定。通过使用数据抽象,可以将类的接口与实现分离;使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

小结:
继承使得我们可以编写一些新的类,这些新类既能共享其基类的行为,又能根据需要覆盖或添加行为。动态绑定使得我们可以忽略类型之间的差异,其机理是在运行时根据对象的动态类型来选择运行函数的哪个版本。继承和动态绑定的结合使得我们能够编写具有特定类型行为但又独立于类型的程序。
在C++语言中,动态绑定只作用于虚函数,并且需要通过指针或引用调用。
在派生类对象中包含有与它的每个基类对应的子对象。因为所有派生类对象都含有基类部分,所以我们能将派生类的引用或指针转换为一个可访问的基类引用或指针。
当执行派生类的构造、拷贝、移动和赋值操作时,首先构造、拷贝、移动和赋值其中的基类部分,然后才轮到派生类部分。析构函数的执行顺序则正好相反,首先销毁派生类,接下来执行基类子对象的析构函数。基类通常都应该定义一个虚析构函数,即使基类根本不需要析构函数也最好这么做。将基类的析构函数定义成虚函数的原因是为了确保当我们删除一个基类指针,而该指针实际指向一个派生类对象时,程序也能正确运行。
派生类为它的每个基类提供一个保护级别。public基类的成员也是派生类接口的一部分;private基类的成员是不可访问的;protected基类的成员对于派生类的派生类是可访问的,但是对于派生类的用户不可访问。

定义基类和派生类

基类应该定义虚析构函数。
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
每个类控制它自己的成员初始化过程。首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

在C++11中如果不希望其他类继承某个类,可以将该类设置为final

1
2
3
class NoDerived final {};	// NoDerived不能作为基类
class Base {};
class Last final : Base {}; // Last不能作为基类

从派生类到基类的类型转换只对指针或引用类型有效。

虚函数

基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。
派生类中虚函数的返回类型也必须与基类函数匹配。唯一的例外是,类的虚函数返回类型是类本身的指针或引用时,不过要求从派生类到基类的类型转换是可访问的。

为了防止派生类覆盖基类虚函数时不匹配,C++11中建议显式写上override,有问题会报错。void f(int) const override;
将某个函数指定为final则之后该函数不能再被覆盖。void f(int) const final;

如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

抽象基类

含有纯虚函数的类是抽象基类。不能创建抽象基类的对象。

访问控制与继承

每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问。

类的protected说明符:

  • 和私有成员类似,受保护的成员对于类的用户是不可访问的。
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。

公有、私有和受保护继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
void pub_mem(); // public成员
protected:
int prot_mem; // protected成员
private:
char priv_mem; // private成员
};
struct Pub_Derv : public Base {
// 正确:派生类能访问protected成员
int f() {return prot_mem;}
// 错误:private成员对于派生类来说是不可访问的
char g() {return priv_mem;}
};
struct Priv_Derv : private Base {
// private不影响派生类的访问权限
int f1() const {return prot_mem;}
};

派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:

1
2
3
4
Pub_Derv d1;	// 继承自Base的成员是public的
Priv_Derv d2; // 继承自Base的成员是Private的
d1.pub_mem(); // 正确:pub_mem在派生类中是public的
d2.pub_mem(); // 错误:pub_mem在派生类中是private的

派生访问说明符还可以控制继承自派生类的新类的访问权限:

1
2
3
4
5
6
7
8
struct Derived_from_Public : public Pub_Derv {
// 正确:Base::prot_mem在Pub_Derv中仍然是protected的
int use_base() {return prot_mem;}
};
struct Derived_from_Private : public Priv_Derv {
// 错误:Base::prot_mem在Priv_Derv中是private的
int use_base() {return prot_mem;}
};

基类应该将接口成员声明为公有的;将属于实现的部分分为两组:一组可供派生类访问,声明为受保护的;一组只能由基类及基类的友元访问,声明为私有的。

可以用using声明改变个别成员的可访问性。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
public:
std::size_t size() const {return n;}
protected:
std::size_t n;
};
class Derived : private Base {
public:
// 保持对象尺寸相关的成员的访问级别
using Base::size;
protected:
using Base::n;
};

派生类只能为那些它可以访问的名字提供using声明。

继承中的类作用域

当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。所以派生类才能像使用自己的成员一样使用基类的成员。
派生类的成员将隐藏同名的基类成员。可以通过作用域运算符来使用隐藏的成员。除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
Image Loading
名字查找先于类型查找。如果内层作用域的成员与外层作用域的某个成员同名,即使形参列表不一致,外层作用域该成员也会被隐藏掉。

构造函数与拷贝控制

基类应该定义一个虚析构函数。否则delete一个指向派生类对象的基类指针将产生未定义的行为。
(略过部分内容)

容器与继承

当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。一般在容器中放置(智能)指针而非对象。

模板与泛型编程

定义模板

函数模板
例如:

1
2
3
4
5
6
7
8
9
10
template <typename T> T foo(T* p)
{

T tmp = *p; // tmp的类型将是指针p指向的类型
// ...
return tmp;
}
// 调用
TreeNode n = foo(node);
// 编译器实例化出的是:
TreeNode foo(TreeNode* p);

也可以有非类型模板参数,一个非类型参数表示一个值而非一个类型。当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替,这些值必须是常量表达式。例如:

1
2
3
4
5
6
7
8
9
template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2)[M])
{
return strcmp(p1, p2);
}
// 调用
compare("hi", "mom");
// 编译器实例化出的是:
int compare(const char (&p1)[3], const char (&p2)[4]);

模板程序应该尽量减少对实参类型的要求。
当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。
函数模板和类模板成员函数的定义通常放在头文件中。
保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。

类模板
与函数模板的不同之处是,编译器不能为类模板推断模板参数类型,必须提供模板实参。
例如:

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
template <typename T> class Blob {
public:
typedef T value_type;
typedef typename std::vector<T>::size_type size_type;
// 构造函数
Blob();
Blob(std::initializer_list<T> il);
// Blob中的元素数目
size_type size() const {return data->size();}
bool empty() const {return data->empty();}
// 添加和删除元素
void push_back(const T& t) {data->push_back(t);}
// 移动版本
void push_back(T &&t) {data->push_back(std::move(t));}
void pop_back();
// 元素访问
T& back();
T& operator[](size_type i);
private:
std::shared_ptr<std::vector<T>> data;
// 若data[i]无效,则抛出msg
void check(size_type i, const std::string& msg) const;
};
// 调用时
Blob<int> ia = {0,1,2,3,4}; // 有5个元素的Blob<int>。

一个类模板的每个实例都形成一个独立的类。类型Blob与任何其他Blob类型都没有关联,也不会对任何其他Blob类型的成员有特殊访问权限。
在模板作用域中引用模板类型,例如:
std::shared_ptr<std::vector<T>> data;
可以在类模板外部定义成员函数。而定义在类模板内的成员函数被隐式声明为内联函数。

1
2
3
template <typename T>
ret-type Blob<T>::member-name (param-list)
{}

以构造函数为例。

1
2
3
4
template <typename T>
Blob<T>::Blob()
: data(std::make_shared<std::vector<T>>())
{}

默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。
在一个类模板的作用域内,可以直接使用模板名而不必指定模板实参。例如:

1
2
3
4
5
6
7
template <typename T>
BlobPtr<T> BlobPtr<T>::operator++(int)
{
BlobPtr ret = *this; // 不用写为BlobPtr<T> ret = *this;
++*this;
return ret;
}

当一个类包含一个友元声明时,类与友元各自是否是模板是相互无关。如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。
例如,一对一友好关系,对应实例及其友元间的友好关系。

1
2
3
4
5
6
7
8
9
10
11
// 前置声明,在Blob中声明友元所需要的
template <typename> class BlobPtr;
template <typename> class Blob; // 运算符==中的参数所需要的
template <typename T>
bool operator==(const Blob<T>&, const Blob<T>&);
template <typename T> class Blob {
// 每个Blob实例将访问权限授予用相同类型实例化的BlobPtr和相等运算符
friend class BlobPtr<T>;
friend bool operator==<T> (const Blob<T>&, const Blob<T>&);
// ...
}

或者,设置通用或特定的模板友好关系。注意为了让所有实例成为友元,友元声明中必须使用与类模板本身不同的模板参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 前置声明,在将模板的一个特定实例声明为友元时要用到
template <typename T> class Pal;
class C { // C是一个普通的非模板类
friend class Pal<C>; // 用类C实例化的Pal是C的一个友元
// Pal2的所有实例都是C的友元;这种情况无需前置声明。
template <typename T> friend class Pal2;
};
template <typename T> class C2 { // C2本身是一个类模板
// C2的每个实例将相同实例化的Pal声明为友元
friend class Pal<T>; // Pal的模板声明必须在作用域之内
// Pal2的所有实例都是C2的每个实例的友元,不需要前置声明
template <typename X> friend class Pal2;
// Pal3是一个非模板类,它是C2所有实例的友元
friend class Pal3; // 不需要Pal3的前置声明
};

模板自己的类型参数也可以成为友元。

1
2
3
template <typename Type> class Bar {
friend Type; // 将访问权限授予用来实例化Bar的类型。
};

可以为类模板定义一个类型别名:

1
2
template<typename T> using twin = pair<T, T>;
twin<string> authors; // authors是一个pair<string, string>

定义一个模板类型别名时,可以固定一个或多个模板参数。

1
2
template <typename T> using partNo = pair<T, unsigned>;
partNo<string> books; // books是一个pair<string, unsigned>

类模板可以有static成员,类模板的每个实例都有一个独有的static对象,一个static成员函数也只有在使用时才会实例化。例如:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> class Foo {
public:
static std::size_t count() {return ctr;}
// ...
private:
static std::size_t ctr;
// ...
}
// 定义
template <typename T>
size_t Foo<T>:ctr = 0; // 定义并初始化ctr

模板参数
模板参数遵循普通的作用域规则。在模板内不能重用模板参数名。
一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。
默认情况下,C++语言假定通过使用作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型,用typename。例如:

1
2
3
4
5
6
7
8
template <typename T>
typename T::value_type top(const T& c)
{

if (!c.empty())
return c.back();
else
return typename T::value_type();
}

可以提供默认模板实参,从C++11起,可以为函数和类模板提供默认实参,之前只能为类模板提供默认实参。
函数模板提供默认实参,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// compare有一个默认模板实参less<T>和一个默认函数实参F()
template <typename T, typename F = less<T>>
int compare(const T& v1, const T& v2, F f = F())
{
if (f(v1, v2)) return -1;
if (f(v2, v1)) return 1;
return 0;
}
// 调用
bool i = compare(0, 42); // 使用less;i为-1
// 结果依赖于item1和item2中的isbn
Sales_data item1(cin), item2(cin);
bool j = compare(item1, item2, compareIsbn);

类模板提供默认实参,例如:

1
2
3
4
5
6
7
8
9
template <class T = int> class Numbers {	// T默认为int
public:
Numbers(T v = 0):val(v) {}
// 对数值的各种操作
private:
T val;
};
Numbers<long double> lots_of_precision;
Numbers<> average_precision; // 空<>表示我们希望使用默认类型

成员模板
一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员就是成员模板。成员模板不能是虚函数。
普通(非模板)类的成员模板,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 函数对象类,对给定指针执行delete
class DebugDelete {
public:
DebugDelete(std::ostream& s = std::cerr) : os(s) {}
// 与任何函数模板相同,T的类型由编译器推断
template <typename T> void operator() (T* p) const
{os << "deleting unique_ptr" << std::endl; delete p;}

private:
std::ostream& os;
};
// 使用
double* p = new double;
DebugDelete d; // 可像delete表达式一样使用的对象
d(p); // 调用DebugDelete::operator()(double*),释放p
int* ip = new int;
DebugDelete()(ip); // 在一个临时DebugDelete对象上调用operator()(int*)
// 可以将DebugDelete用作unique_ptr的删除器
// 销毁p指向的对象,实例化DebugDelete::operator()<int>(int*)
unique_ptr<int, DebugDelete> p(new int, DebugDelete());
// 销毁sp指向的对象,实例化DebugDelete::operator()<string>(string*)
unique_ptr<string, DebugDelete> sp(new string, DebugDelete());

类模板的成员函数,在此情况下,类和成员各自有自己的、独立的模板参数。

1
2
3
4
5
6
7
8
9
10
11
template <typename T> class Blob {
template <typename It> Blob(It b, It e);
// ...
};
// 定义
template <typename T> // 类的类型参数
template <typename It> // 构造函数的类型参数
Blob<T>::Blob(It b, It e) : data(std::make_shared<std::vector<T>>(b, e)) {}
// 使用
int ia[] = {0,1,2,3,4,5,6,7,8,9};
Blob<int> a1(begin(ia), end(ia));

控制实例化
当模板被使用时才会进行实例化,这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。
C++11中,通过显式实例化来避免这种开销,以这种形式:

1
2
extern template declaration;	// 实例化声明
template declaration; // 实例化定义

例如:

1
2
extern template class Blob<string>;	// 声明
template int compare(const int&, const int&); // 定义

对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。由于编译器在使用一个模板时自动对其实例化,因此extern声明必须出现在任何使用此实例化版本的代码之前。实例化定义会实例化所有成员。

效率与灵活性
通过在编译时绑定删除器,unique_ptr避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr使用户重载删除器更为方便。

模板实参推断

从函数实参来确定模板实参的过程称为模板实参推断。
类型转换与模板类型参数
将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换。
例子:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> T fobj(T, T);	// 实参被拷贝
template <typename T> T fref(const T&, const T&); // 引用
// string
string s1("a");
const string s2("b");
fobj(s1, s2); // 调用fobj(string, string); const被忽略
fref(s1, s2); // 调用fref(const string&, const string&),将s1转换为const是允许的
// int数组
int a[10], b[42];
fobj(a, b); // 调用f(int*, int*); 两个数组都被转换为指针
fref(a, b); // 错误:数组类型不匹配。如果形参是一个引用,数组不会转换为指针。两个数组大小不同,因此是不同类型。

如果函数参数类型不是模板参数,则对实参进行正常的类型转换。

函数模板显式实参
显示模板实参按从左到右的顺序与对应的模板参数匹配,所以要显式写的定义在最左边。
例如,允许用户指定函数的返回类型。

1
2
3
4
5
6
// 编译器无法推断T1,它未出现在函数参数列表中
template <typename T1, typename T2, typename T3>
T1 sum(T2, T3);
// 使用
// T1是显示指定的,T2和T3是从函数实参类型推断而来的
auto val3 = sum<long long>(i, lng); // long long sum(int, long)

尾置返回类型与类型转换

1
2
3
4
5
6
7
// 尾置返回允许我们在参数列表之后声明返回类型
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{
// 处理序列
return *beg; // 返回序列中一个元素的引用
}

有时我们无法直接获得所需要的类型,比如上例中我们希望直接返回一个元素的值而非引用。此时可以用标准库的类型转换模板,在头文件type_traits中。
上例可以改写为:

1
2
3
4
5
6
7
8
// 为了使用模板参数的成员,必须用typename,告知编译器type是一个类型
template <typename It>
auto fcn2(It beg, It end) ->
typename remove_reference<decltype(*beg)>::type
{
// 处理序列
return *beg; // 返回序列中的一个元素的拷贝
}

标准类型转换模板

函数指针和实参推断
用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。

1
2
3
template <typename T> int compare(const T&, const T&);
// pf1指向实例int compare(const int&, const int&)
int (*pf1)(const int&, const int&) = compare;
1
2
3
4
// func的重载版本;每个版本接受一个不同的函数指针类型
void func(int(*)(const string&, const string&));
void func(int(*)(const int&, const int&));
func(compare<int>); // 显式模板实参消除func调用的歧义

模板实参推断和引用
编译器会应用正常的引用绑定规则;const是底层的,不是顶层的。
从左值引用函数参数推断类型。

1
2
3
4
5
template <typename T> void f1(T&);	// 实参必须是一个左值
// 对f1的调用使用实参所引用的类型作为模板参数类型
f1(i); // i是一个int;模板参数类型T是int
f1(ci); // ci是一个const int;模板参数类型T是const int
f1(5); // 错误:传递给一个&参数的实参必须是一个左值
1
2
3
4
5
6
template <typename T> void f2(const T&);	// 可以接受一个右值
// f2中的参数是const&;实参中的const是无关的
// 在每个调用中,f2的函数参数都被推断为const int&
f2(i); // i是一个int;模板参数T是int
f2(ci); // ci是一个const int,但模板参数T是int
f2(5); // 一个const&参数可以绑定到一个右值;T是int

从右值引用函数参数推断类型。

1
2
template <typename T> void f3(T&&);
f3(42); // 实参是一个int类型的右值;模板参数T是int

引用折叠和右值引用参数。
一般来说不能将一个右值引用绑定到一个左值上。但是C++有两个例外规则。
一,当我们将一个左值(如i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&)时,编译器推断模板类型参数为实参的左值引用类型。
二,如果我们间接创建一个引用的引用,则这些引用形成了折叠,饮用会折叠成一个普通的左值引用类型。
例如X& &、X& &&和X&& &都折叠成类型X&。类型X&& &&折叠成X&&。
引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。

1
2
f3(i);	// 实参是一个左值;模板参数T是int&
f3(ci); // 实参是一个左值;模板参数T是一个const int&

这两个规则导致了两个重要结果:

  • 如果一个函数参数是一个指向模板类型参数的右值引用(如T&&),则它可以被绑定到一个左值;且
  • 如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(T&)。

这两个规则暗示,可以将任意类型的实参传递给T&&类型的函数参数。即,
如果一个函数参数是指向模板参数类型的右值引用(如T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(如T&)。

编写接受右值引用参数的模板函数。

1
2
3
4
5
6
template <typename T> void f3(T&& val)
{

T t = val; // 拷贝还是绑定一个引用?
t = fcn(t); // 赋值是只改变t还是既改变t又改变val?
if (val == t) {/*...*/} // 若T是引用类型,则一直为true
}

如果对一个右值(如字面常量42)调用f3,T为int,此时局部变量t的类型为int,且通过拷贝参数val的值被初始化。当我们对t赋值时,参数val保持不变。
如果对一个左值(如i)调用f3,T为int&,此时t的类型也为int&,初始化被绑定到val。当我们对t赋值时,也同时改变了val的值。
此时代码自然容易出错。
使用右值引用的函数模板通常使用这种方式重载:

1
2
template <typename T> void f(T&&);	// 绑定到非const右值
template <typename T> void f(const T&); // 左值和const右值

理解std::move
标准库move函数是使用右值引用的模板的一个很好的例子。

1
2
3
4
5
6
// 在返回类型和类型转换中也要用到typename
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{

return static_cast<typename remove_reference<T>::type&&>(t);
}

使用示例

1
2
3
4
5
string s1("hi"), s2;
// T推断为string。实例化move<string>即string&& move(string &&t),返回static_cast<string&&>(t),t的类型已经是string&&,所以static_cast什么都不做。
s2 = std::move(string("bye!")); // 正确:从一个右值移动数据。
// T推断为string&。实例化move<string&>即string&& move(string &t),返回static_cast<string&&>(t),t的类型是string&,static_cast将其转换为string&&。
s2 = std::move(s1); // 正确:但在赋值之后,s1的值是不确定的。

从一个左值static_cast到一个右值引用是允许的。

转发
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。

1
2
3
4
5
6
7
// 接受一个可调用对象和另外两个参数的模板
// 对“翻转”的参数调用给定的可调用对象
template <typename F, typename T1, typename T2>
void flip2(F f, T1&& t1, T2&& t2)
{

f(std::forward<T2>(t2), std::forward<T1>(t1));
}

如果一个函数参数是指向模板类型参数的右值引用(如T&&),它对应的实参的const属性和左值/右值属性将得到保持。
当用于一个指向模板参数类型的右值引用函数参数(T&&)时,forward会保持实参类型的所有细节,定义在头文件utility中。
与std::move相同,对std::forward不使用using声明是一个好主意。

重载与模板

函数模板可以被另一个模板或一个普通非模板函数重载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 打印任何我们不能处理的类型
template <typename T> string debug_rep(const T& t)
{

ostringstream ret;
ret << t; // 使用T的输出运算符打印t的一个表示形式
return ret.str(); // 返回ret绑定的string的一个副本
}
// 打印指针的值,后跟指针指向的对象
// 注意:此函数不能用于char*
template <typename T> string debug_rep(T* p)
{

ostringstream ret;
ret << "pointer: " << p; // 打印指针本身的值
if (p)
ret << " " << debug_rep(*p); // 打印p指向的值
else
ret << " null pointer"; // 或指出p为空
return ret.str(); // 返回ret绑定的string的一个副本
}

使用:

1
2
3
4
5
string s("hi");
cout << debug_rep(s) << endl; // 只有第一个版本可用,因为s是非指针对象。
cout << debug_rep(&s) << endl; // 两个函数都可用,但第二个更精确,用第二个。
const string* sp = &s;
cout << debug_rep(sp) << endl; // 两个都同样精确,但重载函数模板的特殊规则会选择最特例化的版本,即第二个。

当有多个重载模板对一个调用提供同样好的匹配时,应选择最特例化的版本。
对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。

1
2
3
4
5
6
7
8
9
// 针对char*,将字符指针转换为string,并调用string版本的debug_reg
string debug_rep(char* p)
{

return debug_rep(string(p));
}
string debug_rep(const char* p)
{

return debug_rep(string(p));
}

在定义任何函数之前,记得声明所有重载的函数版本。例如:

1
2
3
template <typename T> string debug_rep(const T& t);
template <typename T> string debug_rep(T* p);
string debug_rep(const string&);

可变参数模板

可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包,模板参数包或函数参数包。

1
2
3
4
5
6
7
8
9
// Args是一个模板参数包;rest是一个函数参数包
// Args表示零个或多个模板类型参数
// rest表示零个或多个模板参数
template <typename T, typename ... Args>
void foo(const T& t, const Args& ... rest);
// 使用
int i = 0; double d = 3.14; string s = "how now brown cow";
foo(i, s, 42, d); // 包里有三个参数
foo("hi"); // 空包

可以用sizeof...计算包中有多少元素:

1
2
3
4
5
template <typename ... Args> void g(Args ... args)
{

cout << sizeof...(Args) << endl; // 类型参数的数目
cout << sizeof...(args) << endl; // 函数参数的数目
}

编写可变参数函数模板
可变参数函数通常是递归的,需要写一个终止递归的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 用来终止递归并打印最后一个元素的函数
// 此函数必须在可变参数版本的print定义之前声明
template<typename T>
ostream& print(ostream& os, const T& t)
{

return os << t; // 包中最后一个元素之后不打印分隔符
}
// 包中除了最后一个元素之外的其他元素都会调用这个版本的print
template <typename T, typename... Args>
ostream& print(ostream& os, const T& t, const Args&... rest)
{

os << t << ", "; // 打印第一个实参
return print(os, rest...); // 递归调用,打印其他实参
}
// 使用
print(cout, i, s, 42); // 包中有两个参数

包扩展
扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。通过在模式右边放一个省略号…来触发扩展操作。
扩展中的模式会独立地应用于包中的每个元素。

1
2
3
4
5
6
7
// 在print调用中对每个实参调用debug_rep
template <typename... Args>
ostream& errorMsg(ostream& os, const Args&... rest)
{

// print(os, debug_rep(a1), debug_rep(a2), ..., debug_rep(an)
return print(os, debug_rep(rest)...);
}

转发参数包
组合使用可变参数模板与forward机制来编写函数,实现将其实参不变地传递给其他函数。

1
2
3
4
5
6
7
8
9
10
11
class StrVec {
public:
template <class... Args> void emplace_back(Args&&..);
// 其他成员的定义
};
template <class... Args>
inline void StrVec::emplace_back(Args&&... args)
{
chk_n_alloc(); // 如果需要的话重新分配StrVec内存空间
alloc.construct(first_free++, std::forward<Args>(args)...);
}

模板特例化

当不能或不希望使用模板版本时,可以定义类或函数模板的一个特例化版本。

函数模板特例化

1
2
3
4
5
6
7
8
9
// 第一个版本;可以比较任意两个类型
template <typename T> int compare(const T&, const T&);
// 第二个版本处理字符串字面常量
template <size_t N, size_t M>
int compare(const char (&)[N], const char (&)[M]);
// 使用
const char *p1 = "hi", *p2 = "mom";
compare(p1, p2); // 调用第一个模板,因为无法将一个指针转换为一个数组的引用
compare("hi", "mom"); // 调用有两个非类型参数的版本

定义函数模板特例化,为字符数组的指针定义一个特例化的版本。
为了指出我们正在实例化一个模板,应使用关键字template后跟一个空尖括号对(<>)。空尖括号指出我们将为原模板的所有模板参数提供实参。
定义一个特例化版本时,函数参数类型必须与一个先前声明的模板中对应的类型匹配。

1
2
3
4
5
6
// compare的特殊版本,处理字符数组的指针
template <>
int compare(const char* const &p1, const char* const &p2)
{

return strcmp(p1, p2);
}

特例化的本质是实例化一个模板,而非重载它。因此,特例化不影响函数匹配。
模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。

类模板特例化
例子:为标准库hash模板定义一个特例化版本,用来将Sales_data对象保存在无序容器中。
必须在原模板定义所在的命名空间中特例化它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 打开std命名空间,以便特例化std::hash
namespace std {
template <> // 定义一个特例化版本,模板参数为Sales_data
struct hash<Sales_data>
{
// 用来散列一个无序容器的类型必须要定义下列类型
typedef size_t result_type;
typedef Sales_data argument_type; // 默认情况下,此类型需要==
size_t operator()(const Sales_data& s) const;
// 我们的类使用合成的拷贝控制成员和默认构造函数
};
size_t hash<Sales_data>::operator()(const Sales_data& s) const
{
return hash<string>()(s.bookNo) ^ hash<unsigned>()(s.units_sold) ^ hash<double>()(s.revenue);
}
} // 关闭std命名空间
// 使用
unordered_multiset<Sales_data> SDset;
// 由于hash<Sales_data>使用Sales_data的私有成员,需要将其声明为Sales_data的友元
template <class T> class std::hash; // 友元声明所需要的
class Sales_data {
friend class std::hash<Sales_data>;
// 其他
};

为了让Sales_data的用户能使用hash的特例化版本,应该在Sales_data的头文件中定义该特例化版本。

类模板部分特例化
只能部分特例化类模板,不能部分特例化函数模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 原始的、最通用的版本
template <class T> struct remove_reference {
typedef T type;
};
// 部分特例化的版本,将用于左值引用和右值引用
template <class T> struct remove_reference<T&> // 左值引用
{typedef T type;};
template <class T> struct remove_reference<T&&> // 右值引用
{typedef T type;};
// 使用
int i;
// decltype(42)为int,使用原始模板
remove_reference<decltype(42)>::type a;
// decltype(i)为int&,使用第一个(T&)部分特例化版本
remove_reference<decltype(i)>::type b;
// decltype(std::move(i))为int&&,使用第二个(T&&)部分特例化版本
remove_reference<decltype(std::move(i))>::type c;

特例化类成员而不是整个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T> struct Foo {
Foo(const T &t = T()) : mem(t) {}
void Bar() {/*...*/}
T mem;
// Foo的其他成员
};
template <> // 我们正在特例化一个模板
void Foo<int>::Bar() // 我们正在特例化Foo<int>的成员Bar
{/*anything*/}
// 使用
Foo<string> fs; // 实例化Foo<string>::Foo()
fs.Bar(); // 实例化Foo<string>::Bar()
Foo<int> fi; // 实例化Foo<int>::Foo()
fi.Bar(); // 使用我们的特例化版本Foo<int>::Bar()

跨时6月才完成,饱含泪花(˘̩̩̩ε˘̩ƪ)