3. 类设计者工具
3.1 拷贝控制
- 五种函数
拷贝构造函数
拷贝赋值运算符
移动构造函数
移动赋值运算符
析构函数
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么;
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。 - 拷贝构造函数
合成拷贝构造函数是编译器定义的。
直接初始化:实际上要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。
拷贝初始化:要去编译器将右侧运算对象拷贝到正在创建的对象中,需要的话还要进行类型转换。
拷贝初始化发生的情况:
1)用=定义变量时
2)将一个对象作为实参传递给一个非引用类型的形参
3)从一个返回类型为非引用类型的函数返回一个对象
4)用花括号列表初始化一个数组中的元素或一个聚合类中的成员 - 容器调用insert或push时,会对其元素进行拷贝初始化。
emplace成员进行直接初始化。 - 拷贝构造函数被用来初始化非引用类型参数,这也解释了为什么拷贝构造函数自己的参数时引用类型。如果不是引用类型,则调用不会成功,会陷入无限循环。
class Foo {
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
};
string dots(10, '-'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string null_book = "9-999-999-9"; //拷贝初始化
string nines = string(100, '9'); //拷贝初始化
- 重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。
- 拷贝赋值运算符
赋值运算符必须定义为成员函数,左侧运算对象就绑定到隐式的this参数,右侧运算对象作为显示参数传递。
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符:编译器定义的。
- 析构函数
析构函数有一个函数体和一个析构部分。首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
析构部分是隐式的。销毁类类型需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型不会delete它所指向的对象;当指向一个对象的引用或指针离开作用域时,析构函数不会执行。 - 析构函数什么时候调用——对象被销毁时
1)变量在离开其作用域
2)对象被销毁时,成员被销毁
3)容器被销毁时,其元素被销毁
4)对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁
5)对于临时对象,当创建它的完整表达式结束时被销毁 - 合成析构函数
成员是在析构函数体之后隐含的析构阶段中被销毁的。
- 三/五法则
拷贝构造函数、拷贝赋值运算符、构造函数、移动构造函数、移动赋值运算符被看作一个整体。
1)需要析构函数的类也需要拷贝和赋值操作
2)需要拷贝操作的类也需要赋值操作,反之亦然
例子:一个类为每个对象分配一个独有的、唯一的序号。拷贝构造函数和拷贝赋值运算符需要手动定义,以避免将序号赋予目的对象。
只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default。
class HasPtr{
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) {}
~HasPtr() {delete ps;}
//需要定义拷贝构造函数和拷贝赋值运算符
//因为合成的只是进行值拷贝,也就是指针拷贝
//会导致多个HasPtr对象指向相同的内存。
};
- 阻止拷贝
iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓存。
通过将拷贝构造函数和拷贝赋值运算符定义为删除函数来阻止拷贝。
删除函数:虽然进行了声明,但不能以任何方式使用。
在函数的参数列表后面加上=delete即可。
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。(包括const成员和引用)
- 通常,管理类外资源的类必须定义拷贝控制成员,两种定义拷贝操作的方法:
1)行为像值的类
标准库容器和string类的行为像一个值。
2)行为像指针的类
shared_ptr类提供类似指针的行为。 - IO类型和unique_ptr不允许拷贝或赋值,因此它们的行为既不像值也不像指针。
- 行为像值的类
每个对象都应该拥有一份自己的拷贝。
赋值运算符通常组合了析构函数和构造函数的操作。赋值操作会销毁左侧运算对象的资源,会从右侧运算对象拷贝数据。
1)保证一个对象赋予它自身,也保证正确
2)异常安全——当异常发生时,能将左侧运算对象置于有意义的状态(下面的貌似不算绝对的异常安全)
class HasPtr {
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) {}
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);
delete ps; //不算绝对的异常安全,应该使用后面的swap
ps = newp;
i = rhs.i;
return *this;
}
-
行为像指针的类
析构函数不能单方面地释放关联的string。只有当最后一个指向string的HasPtr销毁时,它才可以释放string。可以使用shared_ptr,也可以设计自己的引用计数。
计数器必须是所有对象共有的,所以可以保存在一块动态内存中,所有对象共享。
class HasPtr{
public:
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;
};
HasPtr::~HasPtr() {
if (--*use == 0) {
delete ps;
delete use;
}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs) {
++*rhs.use;
if (--*use == 0) {
delete ps;
delete use;
}
ps = rhs.ps
i = rhs.i;
use = rhs.use;
return *this;
}
- 交换操作
每个swap调用都应该是未加限定的,而不是std::swap。using声明(using std::swap)不会隐藏自定义版本的swap声明。
定义了swap的类通常用swap来定义其赋值运算符。这些运算符使用了拷贝并交换技术。
使用拷贝和交换的赋值运算符时异常安全的,且能正确处理自赋值。唯一肯呢个抛出异常的是拷贝构造函数中的new,若发生异常,也是改变左侧对象之前发生的。
HasPtr& HasPtr::operator=(HasPtr rhs) { //传值的方式
swap(*this, rhs);
return *this; //rhs被销毁
}
- 拷贝控制的应用场合
1)资源管理
2)簿记操作或其他操作Message和Folder
- 动态内存管理类
某些类需要在运行时分配可变大小的内存空间:
1)使用标准容器
2)自己进行内存分配,一般必须定义自己的拷贝控制成员来管理分配的内存。StrVec
- 对象移动
在某些情况下,对象拷贝后就立即被销毁了。此时,移动而非拷贝对象会大幅度提升性能。
使用移动而不是拷贝的另一个原因源于IO类或unique_ptr类。这些类包含不能被共享的资源(如指针或IO缓冲),因此,这些类型的对象不能拷贝但可以移动。 - 右值引用
必须绑定到右值的引用,通过&&来获得。
右值引用有一个重要性质——只能绑定到一个将要销毁的对象。因此,可以将一个右值引用的资源“移动”到另一个对象中。
右值引用可以绑定到要求转换的表达式、字面常量或返回右值的表达式,但不能直接绑定到一个左值上。
1)返回左值的例子:返回左值的函数、赋值、下标、解引用、前置递增/递减运算符
2)返回右值的例子:返回非引用的函数、算术、关系、位、后置递增/递减运算符(可将const左值引用、右值绑定到这些类型)
左值有持久的状态,右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
可以通过move获得绑定到左值上的右值引用。可以销毁一个移后源对象,可以赋予其新值,但不能使用一个移后源对象的值。
int &&rr1 = 42;
错 int &&rr2 = rr1;
int &&rr3 = std::move(rr1);
- 移动构造函数
移动操作通常不分配任何资源。一旦资源完成移动,源对象不能再指向被移动的资源,这些资源的所有权已经归属新创建的对象。
一个移动操作不抛出异常,是因为两个相互关联的事实:首先,虽然移动操作通常不抛出异常,但抛出异常是允许的;其次,标准库容器能对异常发生时起自身的行为提供保障。例如vector调用push_back发生异常时,vector自身不会发生改变。
除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造含。
(这个noexcept解释的不清不楚!!)
StrVec::StrVec(StrVec &&s) noexcept : //移动操作不应抛出任何异常
elements(s.elements), first_free(s.first_free), cap(s.cap) {
s.elements = s.first_free = s.cap = null_ptr;
}
- 移动赋值运算符
必须正确处理自赋值
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数据成员都可以移动时,编译器才会为它合成移动构造函数和移动赋值运算符。编译器可以移动内置类型的成员,若是类类型,且该类有对应的移动操作,编译器也能移动这个成员。 -
与拷贝操作不同,移动操作不会隐式地定义为删除的函数。若显式地要求编译器生成=default移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
合成的移动操作定义为删除的函数遵循的原则:
- 如果类定义了一个移动构造函数或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。因此该类必须定义自己的拷贝操作。
- 移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝
拷贝构造函数满足对应的移动构造函数的要求,拷贝赋值运算符合移动赋值运算符类型。 - 拷贝并交换赋值运算符和移动操作
赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
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;}
};
hp = hp2; //拷贝构造函数来拷贝
hp = std::move(hp3); // 移动构造函数来拷贝
- 总结性的建议:
某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般说来,拷贝一个资源会导致一些额外的开销。在这种拷贝并非必要的情况下,定义移动构造函数和移动赋值运算符的类就可以避免此问题。 - 移动迭代器适配器
移动迭代器的解引用运算符生成一个右值引用。make_move_iterator将一个普通迭代器转换为一个移动迭代器。
由于移动一个对象可能销毁掉源对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。 - 只有确信移后源对象没有其他用户时,才使用移动操作。
- 区分移动和拷贝的重载函数通常有一个版本接受const T&,另一个版本接受T&&
- 指出this的左值/右值属性的方式与定义const成员函数相同,在参数列表后放置一个引用限定符。引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。
class Foo{
public:
Foo &operator=(const Foo&) &; //只能向可修改的左值赋值
};
Foo &Foo::operator=(const Foo &rhs) & {
...
return *this;
}
- 引用限定符可以区分重载版本。若定义了两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有的函数都加上引用限定符,或者所有都不加。
class Foo {
public:
Foo sorted() &&; //可用于可改变的右值
Foo sorted() const &; //可用于任何类型的Foo
private:
vector<int> data;
};
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
//本对象时const或是一个左值,不能对其进行原址排序
Foo Foo::sorted() const &{
Foo ret(*this);
sort(ret.data.begin(), ret.data.end());
return ret;
}
Foo &retFoo(); //返回一个引用;retFoo调用是一个左值
Foo retVal(); //返回一个值;retVal调用是一个右值
retVal().sorted(); //调用Foo::sorted() &&
retFoo().sorted(); //调用Foo::sorted() const &
3.2 重载运算符与类型转换
- 除了operator()之外,其他重载运算符不能含有默认实参
-
如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。
- (, & && ||)某些运算符指定了运算对象求值的顺序,因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。包括:逻辑与、或和逗号,两个对象总是会被求值。
逗号和取地址运算符,C++已经定义了这两种运算符用于类类型对象时的特殊含义,所以不应重载。 -
重载时,使用与内置类型一直的含义
当在内置的运算符和自己的操作之间存在逻辑映射关系时,运算符重载的效果最好。只有当操作的含义对于用户来说清晰明了才使用运算符。
-
将运算符定义为成员函数还是普通的非成员函数做出的抉择:
- 重载输出运算符<<
<<的第一个形参是非常量ostream对象的引用:非常量,向流写入内容会改变其状态;引用,无法直接复制一个ostream对象。
输出运算符应该主要打印对象的内容而非控制格式,输出运算符不应该打印换行符。
输入输出运算符必须是非成员函数。
ostream &operator<<(ostream &os, const Sales_data &item){
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
-
重载输入运算符>>
输入运算符必须处理输入可能失败的情况,输出不需要。
出错的情况:
istream &operator>>(istream &is, Sales_data &item) {
double price;
is >> item.bookNo >> item.units_soled >> price;
if is) {
item.revenue = item.units_sold * price;
} else { //处理出错的情况
item = Sales_data();
}
return is;
}
- 通常,将算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变对象的状态,所以形参都是常量引用。
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。 -
相等运算符的设计准则
- 关系运算符
1)定义顺序关系,令其与关联容器中对关键字的要求一致
2)如果类同时也含有==运算符,则定义一种关系令其与==保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个。
如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。
- 赋值运算符——必须定义为成员函数
在拷贝赋值和移动赋值运算符外,vector还定义了第三种赋值运算符,即接受花括号内的元素列表作为参数的赋值运算符。
initializer_list<string>确保il与this所指的不是同一个对象,所以无需检查对象向自身的赋值。
StrVec &StrVec::operator=(initializer_list<string> il) {
auto data = alloc_n_copy(il.begin(), il.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
- 复合赋值运算符
不非得是类的成员,不过倾向于定义为成员函数。
- 下标运算符——必须是成员函数
最好同时定义下标运算符的常量和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保不会给返回的对象赋值。
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;
};
- 递增和递减运算符——建议定义成类的成员
- 定义前置递增/递减运算符
前置运算符应该返回递增或递减后对象的引用
class StrBlobPtr {
public:
StrBlobPtr& operator++();
StrBlobPtr& operator--();
};
- 区分前置和后置运算符
后置接受一个额外的(不被使用)int类型的形参。
为了与内置版本保持一致,后置运算符应该返回对象的原值,返回的形式是一个值,而非引用。
class StrBlobPtr {
public:
StrBlobPtr operator++(int);
StrBlobPtr operator--(int);
};
- 编译器gcc是怎样区分前置和后置版本的递增和递减符号???
-
成员访问运算符
->运算符必须是类的成员,解引用通常也是类的成员。
point->mem执行过程如下:
也即:重载的->运算符必须返回类的指针或者自定义了
class StrBlobPtr {
public:
std::string& operator*() const {
auto p = check(curr, "dereference past end");
return (*p)[curr];
}
std::string* operator->() const {
return & this->operator*();
}
};
- 不是特别理解->的定义,查看gcc编译器是怎么处理的???
- 函数调用运算符
必须是成员函数。一个类可以定义多个不同版本的调用运算符。
如果类定义了调用运算符,则该类的对象称作函数对象。
函数对象类可以含有状态。
函数对象常常作为泛型算法的实参
class PrintString {
public:
PrintString(ostream &o = cout, char c = ' '):
os(o), sep(c) {}
void operator()(const string &s) const {os << s << sep; }
private:
ostream &os;
char sep;
};
PrintString printer;
printer(s);
PrintString errors(cerr, '\n');
errors(s);
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
- lambda是函数对象
编译器会将lambda表达式翻译成一个未命名类的未命名对象,在lambda表达式产生的类中含有一个重载的函数调用运算符。
stable_sort(words.begin(), words.end(), [](const string &a, const string &b) {return a.size() < b.size();});
//lambda类似于下面这个类的一个未命名对象
class ShorterString {
public:
bool operator() (const string &s1, const string &s2) const {
return s1.size() < s2.size();
}
};
stable_sort(words.begin(), words.end(), ShorterString());
- 表示lambda捕获行为的类
lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。
auto wc = find_if(words.begin(), words.end(), [sz](const string &a)
{return a.size() >= sz;});
class SizeComp {
public:
SizeComp(size_t n): sz(n) {}
bool operator() (const string &s) const
{return s.size() >= sz;}
private:
size_t sz;
};
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
-
标准库定义的函数对象
表示运算符的函数对象类常用来替换算法中的默认运算符。
sort(svec.begin(), svec.end(), greater<string>());
- C++中有几种可调用的对象
函数
函数指针
lambda表达式
bind创建的对象
重载了函数调用运算符的类 - 可调用对象也有类型,lambda有它自己唯一的(未命名)类类型,函数及函数指针的类型则由其返回值类型和实参类型决定。
两个不同类型的可调用对象可能共享同一种调用形式。 -
标准库的function类型
function可将不同类型的可调用对象都存储在同一个function类型中。
function<int(int, int)> f1 =add;
function<int(int, int)> f2 = divide();
function<int(int, int)> f3 = [](int i, int j) {return i * j;};
map<string, function<int(int, int)>> binops; //函数表
- 重载函数与function
不能直接将重载函数的名字放入function类型的对象中。
二义性问题解决方法:
1)存储函数指针
2)使用lambda来消除二义性
- 用户定义的类类型转换
1)转换构造函数:有一个实参调用的非显式构造函数定义了一种隐式的类型转换
2)类型转换运算符 - 类型转换运算符——必须定义成类的成员函数
operator type() const;
其中type表示某种类型。类型需要能作为返回类型,因此不允许数组、函数类型,但是允许指针或者引用类型。
不能声明返回类型,形成列表必须为空。通常应该是const,不改变待转换对象的内容。
如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性。
class SmallInt {
public:
SmallInt(int i = 0) : val(i) { //int转为SmallInt对象
if (i < 0 || i > 255) {
throw std::out_of_range("Bad SmallInt value");
}
operator int() const {return val;} //定义SmallInt对象转换成int
}
private:
std::size_t val;
};
si = 4; // 4转换成SmallInt
si + 3; // si转换成int
- 在实践中,类很少提供类型转换运算符。例外情况:定义向bool的类型转换还是比较普遍的现象。
- 显式的类型转换运算符
例外:如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。
class SmallInt {
public:
explicit operator int() const {return val;} //定义SmallInt对象转换成int
}
};
si = 4; // 4转换成SmallInt
si + 3; // 错误
static_cast<int>(si) + 3; // 正确
- 无论什么时候在条件中使用流对象,都会使用IO类型定义的operator bool。
下面语句读入数据到value并返回cin,cin被istream operator bool类型转换函数隐式地执行了转换,如果cin的条件状态是good,则该函数返回为真,否则返回假。
向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit。
while (std::cin >> value)
- 避免有二义性的类型转换
1)相同的类型转换B->A:一种使用A的以B为参数的构造函数;一种使用B的类型转换运算符
2)定义了多个转换规则,而这些类型本身可以通过其他类型转换联系在一起。典型的例子是算术类型。
通常类型下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。 - 总之,除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显示构造函数。
- 重载函数与转换构造函数
如果在调用重载函数时需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足
struct C {
C(int);
};
struct D {
D(int);
};
void manip(const C&);
void manip(const D&);
manip(10); //二义性错误
- 重载函数与用户定义的类型转换
在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。
struct E {
E(double);
};
void manip(const C&);
void manip(const E&);
manip(10); //二义性错误
- 函数匹配与重载运算符
a sym b可能是:
1)a.operatorsym(b); //成员函数
2)operatorsym(a,b); //普通函数
表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
如果对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
3.3 面向对象程序设计
- 面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。
通过使用数据抽象,将类的接口和实现分离;
使用继承,可以定义相似的类型并对其相似关系建模;
使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。 - 对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数。
派生类使用类派生列表之处它是从哪个基类继承而来。
派生类必须在其内部对所有重新定义的虚函数进行声明。新标准可以通过在形参列表后加override关键字来显式地标明改写。 - 函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称作运行时绑定。
- 定义基类和派生类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
派生类需要对类型相关的操作(虚函数)提供自己的新定义以覆盖(override)从基类继承而来的旧定义,派生类中该函数隐式地也是虚函数。
任何构造函数之外的非静态函数都可以是虚函数。virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
成员函数如果没有被声明成虚函数,则其解析过程发生在编译时而非运行时。
派生是公有的,则能将公有派生类型的对象绑定到基类的引用或指针上。 - 一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对于的子对象。
- 派生类必须使用基类的构造函数来初始化它的基类部分。
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。 - 每个类负责定义各自的接口。派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
- 如果想将某个类用作基类,则该类必须已经定义而非仅仅声明
- 防止继承的发生,可以在类名后加一个关键字final,该类将不能作为基类。
- 静态类型:编译时已知,它是变量声明时的类型或表达式生成的类型
动态类型:变量或表达式表示的内存中的对象的类型,直到运行时才知道。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一直。
基类的指针或引用可以绑定到派生类对象,智能指针类也支持派生类向基类的类型转换。 - 因为一个基类对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换。
即使一个基类指针或引用绑定在一个派生类对象上,也不能执行从基类向派生类的转换。因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。基类中含有虚函数,可以通过dynamic_cast请求类型转换;如果已知转换是正常,可以使用static_cast。 - 在派生类和基类的对象之间不存在类型转换。
当用一个派生类对象为一个基类初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
Bulk_quote bulk; //派生类对象
Quote item(bulk); //调用基类Quote::Quote(const Quote&)构造函数
item = bulk; //调用基类Quote::operator=(const Quote&)
- 总结:
1)派生类向基类的类型转换只对指针或引用类型有效
2)基类向派生类不存在隐式类型转换
3)和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行
4)可以将派生类对象拷贝、移动或赋值给一个基类对象,这些操作只处理派生类对象的基类部分
- 派生类中的虚函数的返回类型必须与基类函数匹配。
该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,如D由B派生得到,则基类的虚函数可以返回B而派生类的对应函数可以返回D,不过这样的返回类型要求从D到B的类型转换是可访问的。 - override如果标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。
将某个函数指定为final,则之后任何尝试覆盖该函数的操作都将引发错误。 - 如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
- 回避虚函数机制,可以通过使用作用域运算符实现这一目的。
需要回避虚函数机制的地方?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。
- 纯虚函数,在函数体的位置=0,一个纯虚函数无需定义,也可以定义在类的外部,不能在类的内部进行定义。
- 含有纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。不能(直接)创建抽象基类的对象。
后续的派生类必须覆盖纯虚函数,否则仍将是抽象基类。
- protected的一个重要性质:派生类的成员或友元只能通过派生类的对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问权限。
这个也是为了维持封装(数据抽象)的需要,否则顶一个继承子类就可以规避掉protected的属性,封装形同虚设。 - 派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没有影响。对于基类的成员访问权限只与基类中的访问说明符有关。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。 -
派生类向基类转换的可访问性
- 基类应该将其接口声明为公有的,将属于实现的部分分为两组:一组可供派生类访问,声明为保护的;另一组只能由基类及基类的友元访问,声明为私有的。
- 友元关系不能传递,也不能继承。
每个类负责控制自己的成员的访问权限,包括基类内嵌在其派生类对象中的情况。 - 派生类只能为那些它可以访问的名字提供using声明,而这可以改变该成员的可访问性(类的实现者、派生类、普通用户)。
- 派生类的作用域位于基类作用域之内
- 一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型不一致。
这个可以用作用域嵌套来解释,因为基类的指针或引用是从基类的作用域开始搜索的,无法访问内嵌在其里面的派生类的作用域。 - 派生类的成员将隐藏同名的基类成员,同理可用作用域嵌套来解释。
可以通过作用域运算符来使用一个被隐藏的基类成员。 -
函数调用的解析过程,假定调用p->mem()或obj.mem(),则依次执行以下四个步骤:
- 声明在内层作用域的函数并不会重载声明在外层作用域的函数。这是因为名字查找先于类型检查。
因此若派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏该基类的成员。 - 如果派生类希望所有的重载版本对于它都是可见的,则要么覆盖所有版本,要么一个也不覆盖。
using声明语句指定一个名字而不指定形参列表,基类的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。派生类只需要定义其特有的函数就可以了。
class Base {
public:
virtual int fcn();
};
clas D1 : public Base{
public:
int fcn(int); //该fcn不是虚函数,隐藏基类的fcn,这里应该不算隐藏吧!
virtual void f2();
};
class D2 : public D1{
public:
int fcn(int); //隐藏D1::fcn(int)
int fcn(); //覆盖了Base的虚函数fcn
void f2(); //覆盖D1的虚函数f2
};
Base bobj;
D1 d1obj;
D2 d2obj;
Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); //虚调用,Base::fcn
bp2->fcn();//虚调用,Base::fcn,不是照样可以调用么,不算隐藏
bp3->fcn();//虚调用,D2::fcn
D1 *d1p = &d1obj;
D2 *d2p = &d2obj;
bp2->f2(); //错误,基类指针不能访问派生类的成员
d1p->f2(); //虚调用,D1::f2()
d2p->f2(); //虚调用,D2::f2()
Base *p1 = &d2obj;
D1 *p2 = &d2obj;
D2 *p3 = &d2obj;
p1->fcn(42); //错误,没有匹配的函数
p2->fcn(42); //静态绑定,调用D1::fcn(int)
p3->fcn(42); //静态绑定,调用D2::fcn(int)
- 虚析构函数
基类定义一个虚析构函数,这样就能动态分配继承体系中的对象。
delete指针时,将析构函数定义为虚函数以确保执行正确版本的析构函数。
如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
一个基类总是需要析构函数(内容为空),但是无法推断该基类还需要赋值运算符或拷贝构造函数。
虚析构函数将阻止合成移动操作。 -
合成拷贝控制与继承
某些定义基类的方式也可能导致派生类成员成为被删除的函数:
如果定义了拷贝构造函数,编译器不会合成移动构造函数。
一旦基类定义了移动操作,必须同时显式地定义拷贝操作。派生类将自动获得合成的移动操作。 - 派生类的拷贝控制成员
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
当为派生类定义拷贝或移动构造函数时,通常在初始值列表中显式使用对应的基类构造函数(或移动函数)初始化对象的基类部分。
派生类赋值运算符也许要显式地为其基类部分赋值。
析构函数与构造、赋值不同,派生类析构函数只负责销毁由派生类自己分配的资源,基类部分是隐式销毁的。
如果构造函数或析构函数调用了某个虚函数,则应该执行与构造函数或析构函数所属类型相对应的虚函数版本。 - 继承的构造函数
派生类继承基类构造函数的方式是提供注明基类名的using声明语句。如果派生类含有自己的数据成员,这些成员将被默认初始化。
using声明不会改变构造函数的访问级别,以及explicit、constexpr属性。
当一个基类构造函数含有默认实参,将获得多个构造函数,其中每个构造函数额外获得分别省略一个含有默认实参的形参。
派生类会继承所有构造函数,除了:
1)派生类重新定义了某个相同类型的构造函数
2)默认、拷贝和移动构造函数不会被继承。
- 容器与继承
当在容器中存放具有继承关系的对象时,实际上存放的通常是基类的指针(最好用智能指针)。 - 单词查询的面向对象的解决方案——一个很经典的方法
3.4 模板与泛型编程
- 定义模板
<>是模板参数列表,该列表不能为空。
编译器通常用函数实参来推断出模板实参,也即实例化一个特定版本的函数。
模板类型参数的每个参数前都要有typename或class。
非类型参数表示一个值,指定特定的类型名,调用时,这些值必须是常量表达式。绑定到指针或引用非类型参数的实参必须具有静态生存期。
函数模板可以声明为inline或constexpr。
template<typename T>
int compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
template <typename T> inline T min(const T&, const T&);
- 编写泛型代码的两个重要原则
1)模板中的函数参数时const 的引用
2)函数体中的条件判断仅使用<比较运算
模板程序应该尽量减少对实参类型的要求。 - 只有当实例化出模板的一个特定版本时,编译器才会生成代码。
为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。 - 类模板
编译器不能为类模板推断模板参数类型。
定义在类模板之外的成员函数必须以关键字template开始,接类模板参数列表。
默认情况下,一个类模板的成员函数只有当程序用到它时才进行实例化。
在类模板自己的作用域中,可以直接使用模板名而不提供实参。 - 类模板与友元
1)一对一关系
友元的声明用Blob的模板形参作为它们自己的模板实参,因此,友元关系被限定在用相同类型实例化的Blob与BlobPtr相对运算符之间。
2)通用和特定的模板友好关系
3)模板自己的类型参数可以是友元 - 可以定义一个typedef来引用实例化的类
可以为类模板定义一个类型别名,并且可以固定一个或多个模板参数。 - 类模板的static成员函数同样只在使用时才会实例化。
- 在模板内不能重用模板参数名
错误 template <typename V, typename V>
- 默认情况下,C++假定通过作用域运算符访问的名字不是类型。
若希望使用一个模板类型参数的类型程序,必须使用typename(不能使用class)显式告诉编译器该名字是一个类型。
template <typename T>
typename T::value_type top(const T& c) {
}
- 新标准中可以为函数和类模板提供默认实参
template <typename T, typename F = less<T>>
int compare (const T &v1, const T &v2, F f = F() ) { //f是类型F的一个默认初始化对象
if (f(v1, v2)) return -1;
if (f(v2, v1)) return 1;
return 0;
}
希望使用默认实参时,在类模板名之后跟一个空尖括号对即可。
成员模板
一个类(普通类或是类模板)可以包含本身是模板的成员函数,成员模板不能是虚函数。
1)普通类的成员模板
类似unique_ptr所使用的默认删除器类型。由于删除器适用于任何类型,所以将调用运算符定义为一个模板。
2)类模板的成员模板
此时,类和成员各自有自己的独立的模板参数。
为了实例化一个类模板的成员模板,必须同时提供类和函数模板的实参。与普通函数模板一样,编译器通常根据传递给成员模板的函数实参来推断它的模板实参。控制实例化
模板使用时才实例化意味着,相同的实例可能出现在多个对象文件中,额外开销可能非常严重。
可以通过显式实例化来避免这种开销。
当编译器遇到extern模板声明时,不会在本文件中生成实例化代码。对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。
extern声明必须出现在任何使用此实例化版本的代码之前。
实例化定义会实例化所有成员,与处理类摩拜的普通实例化不同(使用时才实例化)。
extern template declaration; // 实例化声明
template declaration; //实例化定义
- 效率与灵活性
1)在运行时绑定删除器——shared_ptr
2)在编译时绑定删除器——unique_ptr
unique_ptr有两个模板参数,一个表示它所管理的指针,另一个表示删除器的类型。
通过在编译时绑定删除器,unique_ptr避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr使用户重载删除器更为方便。
-
类型转换与模板类型参数
顶层const会被忽略。
能在调用中应用于函数模板的类型转换:
其他类型转换(算术转换、派生类向基类转换、用户定义的转换)都不能应用于函数模板。
数组的大小不同,因此是不同类型。转换为指针后一样,但是如果是引用,则数组不会转换为指针。
template <typename T> T fobj(T, T); //实参被拷贝
template <typename T> T fref(const T&, const T&); //引用
int a[10], b[42];
fobj(a, b); //调用f(int *, int *)
fref(a, b); //错误,数组类型不匹配
- 一个模板类型参数可以用作多个函数形参的类型,传递给这些形参的实参必须具有相同的类型。如果推断出的类型不匹配,则调用就是错误的。
如果希望允许对函数实参进行正常类型转换,可以将函数模板定义为多个类型参数。
long lng;
compare (lng, 1024);//错误:不能实例化compare(long, int)
template <typename A, typename B>
int flexibleCompare(const A& v1, const B& v2);
- 如果函数参数不是模板参数,则对实参进行正常类型转换
- 指定显式模板实参
如下,没有任何函数实参的类型可以推断T1的类型,每次调用sum时必须为T1提供一个显示模板实参。
显示模板实参按由左至右与对应的模板参数匹配。只有尾部(最右)参数的显式模板实参才可以忽略,前提是他们可以从函数参数推断出来。
template <typename T1, typename T2, typename T3>
T1 sum(T2, T3);
auto val3 = sum<long long>(i, lng); // long long sum(int, long)
- 正常类型转换应用于显式指定的实参
long lng;
compare(lng, 1024); //错误
compare <long>(lng, 1024); //正确,实例化compare(long, long)
compare<int>(lng, 1024); //正确实例化compare(int, int)
- 尾置返回类型与类型转换
接受序列的一对迭代器和返回序列中一个元素的引用,在编译器遇到函数的参数列表之前,beg是不存在,因此使用尾置返回类型。
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg){
return *beg;
}
-
进行类型转换的标准库模板类——头文件type_traits
迭代器操作只会生成元素的引用,为了获得元素类型(值),可以使用标准库的类型转换模板。
template <typename It>
auto fcn2(It beg, It end) ->
typename remove_reference<decltype(*beg)>::type{
return *beg;//返回序列中的一个元素的拷贝
}
//第二个typename告知编译器,type是一个类型,前面有该知识点。
*函数指针和实参推断
函数指针初始化或赋值为函数模板,编译器用指针的类型来推断模板实参。
如果函数指针是重载版本,则无法确定模板实参的唯一类型,会出现错误。此时需要用显式模板实参。
- 模板实参推断和引用
1)从左值引用函数参数推断类型
2)从右值引用函数参数推断类型
3)引用折叠和右值引用参数
通常不能将一个右值引用绑定到一个左值上。
两个例外规则,允许这种绑定,这两个例外规则是move标准库设施正确工作的基础。
例外一、影响右值引用参数的推断如何进行。将一个左值传递给函数的右值引用参数,且此右值引用指向模板类型参数(如T&&)时,编译器推断模板类型参数为实参的左值引用类型。此时意味着函数参数是一个类型int&的右值引用。(不能直接定义引用的引用)
例外二、若间接创建一个引用的引用,则这些形参形成了折叠。折叠成一个普通的左值引用类型。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用。
3-1)X& &、X& &&和X&& &都折叠成X&
3-2)X&& &&折叠成X&&
注意:引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。
如果一个函数参数时指向模板参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参,如果一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(T&)
4)编写接受右值引用参数的模板函数
当传入一个左值,会改变传入的实参,这会使得编写正确的代码变得困难!
右值引用通常用于两种情况:
情况1.模板转发其实参
情况2.模板被重载
- 理解std::move
move的参数是一个指向模板类型参数的右值引用。
针对右值引用的特许规则:虽然不能隐式地将一个左值转换为右值引用,但可以用static_cast显式地将一个左值转换为一个右值引用。
建议:统一使用std::move使得在程序中查找潜在的截断左值的代码变得很容易。
template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
return static_cast<typename remove_reference<T>::type&&>(t);
}
string s1("hi!"), s2;
s2 = std::move(string("bye!"));
s2 = std::move(s1);
第一个:string&& move(string &&t)
第二个:string&& move(string &t)
- 转发
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。包括是否是const以及实参是左值还是右值。
通过将一个函数参数定义为一个指向模板类型参数的右值引用,可以保持其对应实参的所有类型信息。
在头文件utility中的forward能保持原始参数的类型。forward返回显式实参类型的右值引用,即forward<T> 返回的类型是T&&。
注意:std::move和std::forward最好不使用using声明。
void f(int v1, int &v2); // v2是一个引用
void g(int &&i, int &j);
template <typename F, typename T1, typename T2>
void flip2(F f, T1 &&t1, T2 &&t2) {//接受左值引用可以工作,但不能接受右值引用参数的函数
f(t2, t1);
}
flip2(g, i, 42); // 函数参数与其他任何变量一样,都是左值表达式,因此flip2中对g的调用将传递给g的右值引用一个左值。也即将t2传递给g的右值引用参数!
//因为这里t2可能被折叠成左值引用,这种情况下传递会有问题!
//(而且因为g并不是模板形参,而是一个普通的函数,这里不属于特殊规则,此时不能将右值引用绑定到左值。)
template <typename Type> intermediary(Type &&arg) {
finalFcn(std::forward<Type>(arg));
}
template<typename F, typename T1, typename T2>
void flip(F f, T1 &&t1, T2 &&t2) {
f(std::forward<T2>(T2), std::forward<T1>(t1));
}
flip(g, i, 42); // i将以int&类型传递给g,42将以int&&类型传递给g
-
涉及函数模板的函数匹配规则
注意:正确定义一组重载的函数模板需要对类型间的关系及模板函数允许的有限的实参类型转换有深刻的理解。
- 重载模板
debug_rep(const T&)本质上可以用于任何类型,包括指针类型。此模板比debug_rep(T*)更通用,后者只能用于指针类型。
当有多个重载模板对一个调用提供同样好的匹配时,应该选择最特例化的版本。 - 在定义任何函数之前,记得声明所有重载的函数版本。这样就不需要担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。
- 可变参数模板
一个可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包。存在两种参数包:模板参数包,表示零个或多个模板参数;函数参数包,表示零个或多个函数参数。
模板参数列表:typename...或class...指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。
函数参数列表:如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。
编译器从函数实参推断模板参数类型。
当需要知道包中有多少元素时,可以使用sizeof...运算符。
//Arg是一个模板参数包
//rest是一个函数参数包
template <typename T, typename... Args>
void foo(const T &t, const Arg&... rest);
int i =0;
double d = 3.14;
string s = "how now brown cow";
foo(i, s, 42, d); //包中有三个参数
foo(s, 42, "hi"); //包中有两个参数
foo(d, s); //包中有一个参数
foo("hi"); //空包
template<typename... Args> void g(Args ... args) {
cout << sizeof...(Args) << endl; //类型参数的数目
cout << sizeof...(args) << endl; //函数参数的数目
}
- 编写可变参数的两个方法
1)使用initializer_list,所有实参必须具有相同类型
2)当既不知道实参数目,也不知道类型时,可变参数函数很有用 - 编写可变参数函数模板
当定义可变参数版本的print时,非变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。
对最后一个调用,两个函数提供同样好的匹配。但是,非可变参数模板比可变参数模板更特例化,因此编译器选择非可变参数版本。 -
包扩展
扩展一个包就是将它分解成构成的元素,对每个元素应用模式,获得扩展后的列表。
通过在模式右边放一个省略号来触发扩展操作。
print中的函数参数包扩展仅仅将包扩展为其构成元素。error_Msg对每个实参调用debug_rep,这是另一种更复杂的包扩展。
扩展中的模式会独立地应用于包中的每个元素。
template <typenaem T>
ostream &print(ostream &os, const T &t) {
return os << t;
}
template <typename T, typename ..Args>
ostream &print(ostream &os, const T &t, const Args.. rest) { // 扩展Args
os << t << ", ";
return print(os, rest...); //扩展rest
}
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)...);
}
- 转发参数包
1)为了保持实参中的类型信息,必须将emplace_back的函数参数定义为模板类型参数的右值引用
2)使用forward来保持实参的原始类型
可变参数函数通常将它们的参数转发给其他函数。
template <class... Args>
inline
void StrVec::emplace_back(Args&&.. args) {
chk_n_alloc();
alloc.construct(first_free++, std::forward<Args>(args)...);
}
- 模板特例化
当不能或者不希望使用模板版本时,可以定义类或函数模板的一个特例化版本。
当特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参。template后跟空尖括号对。
一个特例化本质上是一个实例。
注意:模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。
//可以比较任意两个类型
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"); //调用第二个版本
// compare的特例化版本,处理字符数组的指针
template <>
int compare (const char* const &p1, const char* const &p2) {
return strcmp(p1, p2);
}
-
类模板特例化
在定义特例化版本的hash时,唯一复杂的地方是:必须在原模板定义所在的命名空间中特例它。
一个特例化hash类必须定义:
为了让Sales_data的用户能使用hash的特例化版本,应该在Sales_data的头文件中定义该特例化版本。
//定义一个能处理Sales_data的特例化hash版
namespace std{
template <>
struct hash<Sales_data> {
typedef size_t result_type;
typedef Sales_data argument_type;
size_t operator() (const Sales_data& s) const;
};
}
- 类模板的部分特例化本身还是一个模板
部分特例化版本的模板参数列表式原始模板的参数列表的一个子集或者是一个特例化版本。
// 原始的、最通用的版本
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;
//原始版本
remove_reference<decltype(42)>::type a;
//特例化版本T&
remove_reference<decltype(i)>::type b;
//特例化版本T&&
remove_reference<decltype(std::move(i))>::type c;
- 特例化成员而不是类模板