本文主要用于笔者复习使用,因此很多地方写的比较简略,还请见谅。
本文讨论的继承都是public继承。
5.1 面向对象编程概念
面向对象编程的第一个概念是继承:派生类会继承父类的public和protected成员,每个派生类也可以选择覆盖或者增加继承而来的东西,virtual关键字可以让成员函数变成虚函数,可以让被修饰的函数的解析在调用的时候才决定要调用哪个虚函数,这就体现了覆盖的思想,C++虚函数机制只能针对引用和指针生效。
面向对象编程的第二个概念是多态:让基类的指针或引用可以十分透明的指向任何一个派生类的对象,如我们的下面的print函数。这样就可以写好统一的基类接口然后利用虚函数机制调用不同派生类的对应虚函数。
面向对象编程的第三个概念是动态绑定:编译器不是在编译的时候就决定执行哪一个函数,而是在执行过程中根据mat所指的实际对象才能确定被实际调用的是哪个派生类的那个函数。
继承特性让我们能够得以定义一群互有关联的类,并共享共通的接口。
多态则让我们得以用一种和类型无关的方式来操作这些对象。我们通过抽象基类的指针或引用来操纵其接口,实际执行的操作需要等到运行时依据pointer或reference所指的实际对象的类型才能决定。
多态和动态绑定的特性,只有在使用指针或引用的时候才能体现。
利用函数指针数组和枚举在某种程度上也能模拟多态,但是每次维护所需要的成本极大。
下面是一个小实验。
class LibMat {
public:
LibMat() { cout << "调用构造函数LibMat::LibMat()\n"; }
virtual ~LibMat()
{ cout << "调用析构函数LibMat::~LibMat()\n"; }
virtual void print() const
{
cout << "调用LibMat::print(),我是一个LibMat对象!\n";
}
};
class Book :public LibMat {
public:
Book(const string& titile, const string& author)
:_titile(titile),_author(author)
{
cout << "调用构造函数Book::Book\n";
}
virtual ~Book()
{
cout << "调用析构函数Book::~Book()\n";
}
virtual void print() const
{
cout << "调用Book::print()\n我是一个Book对象\n"
<< "我的名称是:" << _titile
<< "我的作者是:" << _author << endl;
}
protected://protected的成员派生类都可以访问
string _titile;
string _author;
};
class AudioBook :public Book {
public:
AudioBook(const string& titile,
const string& author, const string& narr)
:Book(titile, author), _narr(narr)
{
cout << "调用构造函数AudioBook::AudioBook\n";
}
~AudioBook()
{
cout << "调用析构函数AudioBook::~AudioBook\n";
}
virtual void print() const
{
cout << "调用AudioBook::print()\n"
<< "我是一个AudioBook对象!\n"
<< "我的名称是:" << _titile
<< "我的作者是" << _author
<< "我的旁白是" << _narr << endl;
}
protected:
string _narr;
};
void print(const LibMat& mat)
{
cout << "我们将要调用mat.print函数\n";
mat.print();
}
int main()
{
AudioBook ab("《范进中举》", "《挂着零封》", "路由器");
print(ab);
}
观察调用顺序,可以发现构建派生类AudioBook对象的时候调用了LibMat类的构造函数,调用了Book的构造函数(析构函数同理);
但是调用mat.print并没有调用LibMat和Book的print函数,而是只调用你了AudioBook的print函数。
这便是虚函数的效果,使得我们的程序在运行的时候才根据实际传过来的对象的类型决定调用哪一个类的print。
5.2 定义抽象基类
定义抽象基类的第一个步骤是找出所有子类共同的操作行为,然后根据这些行为是否与类型相关把他们设计成虚函数。
第二步是试着找出每个操作的访问层级,如果某个操作行为应该让一般的程序都能访问,就声明成public,如果某个操作除了在基类之中别的都不需要使用,就声明为private,如果这个操作需要让所有的派生类的成员函数都能访问,但不允许一般的程序使用,就声明成protected。
这里有几点要注意的点,由于static成员所有对象只有一份,继承时所有类也只有一份,所以static成员函数没法声明成虚函数。
由于虚函数是运行的时候才决定要调用哪一个,所以我们在使用那个接口以前虚函数必须有定义,如果这个虚函数在基类中什么也不干的话可以设计成纯虚函数。
任何一个类中如果有纯虚函数,由于其接口的不完整性(纯虚函数没有函数定义,称之为不完整),程序无法为它产生任何对象,这种类只能作为派生类的子对象使用,而且这些派生类如果想产生对象必须要为所有的虚函数提供确切的定义。
virtual void gen_elems(int pos) = 0;
对于抽象基类,如果这个基类没有需要初始化的数据成员,那么就没必要给他写构造函数。
对于析构函数,最好不要在抽象基类中把他声明成纯虚函数。
以下为我们设计的一个数列的抽象基类。
class num_sequence {
public:
virtual ~num_sequence() {};
//析构函数
virtual int elem(int pos) const = 0;
//返回pos位置的元素
virtual char* what_am_I() const = 0;
//返回确切的数列类型
static int max_elems() { return _max_elems; }
//返回数列中最大的元素项数
virtual ostream& print(ostream& os = cout) const = 0;
//把数列的元素都写入os
protected:
virtual void gen_elems(int pos) const = 0;
//产生直到pos位置的所有元素
bool check_integrity(int pos) const;
//检查pos是否为有效位置
const static int _max_elems = 1024;
};
bool num_sequence::check_integrity(int pos) const
{
if (pos <= 0 || pos > _max_elems)
return false;
return true;
}
ostream& operator<<(ostream& os, const num_sequence& ns)
{
return ns.print();
}
5.3 定义派生类
派生类由基类的非静态数据成员和派生类自己的特有部分组成。
派生类如果想要能够生成对象,必须对从基类继承来的每个纯虚函数给出具体的实现。
class Fibonacci : public num_sequence {
public:
Fibonacci(int length = 1, int beg_pos = 1):
_length(length),_beg_pos(beg_pos)
{}
virtual int elem(int pos) const;
virtual const char* what_am_i() const { return "Fibonacci"; }
virtual ostream& print(ostream& os = cout) const;
int length() { return _length; }
int beg_pos() { return _beg_pos; }
protected:
virtual void gen_elems(int pos) const;
int _length;
int _beg_pos;
static vector<int> _elems;
};
vector<int> Fibonacci:: _elems;//静态成员需要自己实例化一下
我们发现这样设计基类不能访问length()和beg_pos(),所以我们最好在基类中加入两个纯虚函数length()和beg_pos()。
class num_sequence {
public:
virtual ~num_sequence() {};
virtual int elem(int pos) const = 0;
virtual char* what_am_I() const = 0;
static int max_elems() { return _max_elems; }
virtual ostream& print(ostream& os = cout) const = 0;
virtual int length() = 0;
virtual int beg_pos() = 0;
protected:
virtual void gen_elems(int pos) const = 0;
bool check_integrity(int pos) const;
const static int _max_elems = 1024;
};
实现一下elems函数
int Fibonacci::elem(int pos) const
{
if (!check_integrity(pos))
return 0;
if (pos > _elems.size())
Fibonacci::gen_elems(pos);
return _elems[pos - 1];
}
在public继承下,基类的public member在派生类中也是public,基类的protected member在派生类中也是protected member,基类的private member无法给派生类使用。
我们把check_integrity()的功能改造一下,因为调用check_integrity函数的一定是派生类的成员,我们可以用虚函数机制调用gen_elems,所以能够直接帮我们把填充数据那一步做了。
bool num_sequence::check_integrity(int pos, int size) const
{
if (pos <= 0 || pos > _max_elems)
return false;
if (pos > size)
gen_elems(pos);//利用虚函数机制来调用
return true;
}
我们的其他总体设计如下:
class num_sequence {
public:
virtual ~num_sequence() {};
virtual int elem(int pos) const = 0;
virtual const char* what_am_I() const = 0;
static int max_elems() { return _max_elems; }
virtual ostream& print(ostream& os = cout) const = 0;
virtual int length() = 0;
virtual int beg_pos() = 0;
protected:
virtual void gen_elems(int pos) const = 0;
bool check_integrity(int pos, int size) const;
const static int _max_elems = 1024;
};
bool num_sequence::check_integrity(int pos, int size) const
{
if (pos <= 0 || pos > _max_elems)
return false;
if (pos > size)
gen_elems(pos);//利用虚函数机制来调用
return true;
}
ostream& operator<<(ostream& os, const num_sequence& ns)
{
return ns.print();
}
class Fibonacci : public num_sequence {
public:
Fibonacci(int length = 1, int beg_pos = 1):_length(length),_beg_pos(beg_pos)
{}
virtual int elem(int pos) const;
virtual const char* what_am_I() const { return "Fibonacci"; }
virtual ostream& print(ostream& os = cout) const;
int length() { return _length; }
int beg_pos() { return _beg_pos; }
protected:
virtual void gen_elems(int pos) const;
int _length;
int _beg_pos;
static vector<int> _elems;
};
vector<int>Fibonacci::_elems;//静态数据成员必须要先初始化一下。
int Fibonacci::elem(int pos) const
{
if (!check_integrity(pos, _elems.size()))
return 0;
return _elems[pos - 1];
}
void Fibonacci::gen_elems(int pos)const
{
if (_elems.empty())
{
_elems.push_back(1);
_elems.push_back(1);
}
if (pos >= _elems.size())
{
int i = _elems.size();
int a = _elems[i - 2];
int b = _elems[i - 1];
for (; i <= pos; i++)
{
int elem = a + b;
_elems.push_back(elem);
a = b;
b = elem;
}
}
}
ostream& Fibonacci::print(ostream& os) const
{
int elem_pos = _beg_pos - 1;
int end_pos = elem_pos + _length;
if (end_pos > _elems.size())
{
Fibonacci::gen_elems(end_pos);
}
while (elem_pos < end_pos)
{
os << _elems[elem_pos++] << ' ';
}
return os;
}
int main()
{
Fibonacci fib;
cout << fib << endl;
Fibonacci fib2(16);
cout << fib2 << endl;
Fibonacci fib3(8, 12);
cout << fib3 << endl;
Fibonacci fib4(15, 21);
cout << fib4 << endl;
}
5.5 运用继承体系
假如我们设计好了其他的派生类,我们就可以统一设计一些抽象基类的接口,这样就可以利用虚函数机制在运行时动态的选择需要执行的函数,如下面的display函数。
void display(const num_sequence& ns, int pos, ostream& os = cout)
{
os << ns.what_am_I() << "数列"
<< "的第" << pos << "个元素是"
<< ns.elem(pos) << endl;
}
5.6 另一种抽象基类设计理念
另一种基类的设计方式,将所有派生类共有的实现内容剥离出来,移至基类内,这样每个派生类只需要实现自己独特的部分。
class num_sequence {
public:
virtual ~num_sequence() {};
virtual const char* what_am_i() const = 0;
int elems(int pos) const;
ostream& print(ostream& os = cout) const;
int length() const { return _length; }
int beg_pos() const { return _beg_pos; }
static int max_elems() { return 1024; }
virtual void gen_elems(int pos) const = 0;
Fibonacci& operator=(Fibonacci& fib);
protected:
//获取到pos位置之前的元素 设计为纯虚函数以供继承
bool check_integrity(int pos, int size) const;
//检查pos位置是否合法,并且通过虚函数机制调用gen_elems函数
num_sequence(int len, int bp, vector<int>& re)
:_length(len), _beg_pos(bp), _relems(re)
{}
int _length;
int _beg_pos;
vector<int>& _relems;
//设计成一个向量引用是为了规避指针可能是空的问题
//引用不可能是空,每次用之前就不用检查了
};
int num_sequence::elems(int pos) const
{
if (!check_integrity(pos, _relems.size()))
return 0;
return _relems[pos - 1];
}
bool num_sequence::check_integrity(int pos, int size) const
{
if (pos > max_elems())
return false;
if (size < pos)
gen_elems(pos);
return true;
}
ostream& num_sequence::print(ostream& os) const
{
int begin = beg_pos() - 1;
int end = beg_pos() + length();
if (end > _relems.size())
{
gen_elems(end);
}
while (begin < end)
{
os << _relems[begin++] << ' ';
}
return os;
}
ostream& operator <<(ostream& os, num_sequence& ns)
{
ns.print(os);
return os;
}
从下面的成员函数实现可以看出抽象基类中数据成员增加了一个vector的引用是为了方便的在基类实现很多函数,让派生类不用去实现那么多东西了。
现在,每个派生的数列类只需要编写自己独特的部分:数列元素产生器gen_elems()、识别数列类型的what_am_i()、存储数列元素的static vector,以及构造函数,以Fibonacci为例:
class Fibonacci : public num_sequence {
public:
Fibonacci(int len, int beg_pos) :
num_sequence(len, beg_pos, _elems)
{}
virtual const char* what_am_i() const
{
return "Fibonacci";
}
protected:
virtual void gen_elems(int pos) const;
static vector<int> _elems;
};
vector<int> Fibonacci:: _elems;
void Fibonacci::gen_elems(int pos) const
{
if (_elems.empty())
{
_elems.push_back(1);
_elems.push_back(1);
}
if (pos >= _elems.size())
{
int i = _elems.size();
int a = _elems[i - 2];
int b = _elems[i - 1];
for (; i <= pos; i++)
{
int elem = a + b;
_elems.push_back(elem);
a = b;
b = elem;
}
}
}
这里由于我们基类num_sequence使用的是vector的引用而非指针,引用是不存在空引用的,所以基类不存在默认构造函数,所以我们的Fibonacci类也不存在默认构造函数,如果想要保留默认构造函数,就让num_sequence的成员增加一个vector指针,初值可以赋值成0,每次使用的时候检查是否为空指针。
在我们这种框架下,创建一个Fibonacci对象其实是先创建了一个基类对象,然后再补充上自己特殊的部分。
我们再定义一个拷贝构造函数:
Fibonacci(const Fibonacci& rhs):num_sequence(rhs)
{}
根据我们在第四章学到的成员默认初始化函数的知识,我们会把这个引用传给num_sequence的成员默认初始化函数,他会逐一的解析成这样:
_length = rhs._length;
_beg_pos = rhs._beg_pos;
_relems = rhs._relems;
//这是ok的,因为同一个对象的这个数列是静态成员 都是同一份
所以我们无需额外提供拷贝构造函数。
赋值运算符也是同理,我们无需自己再次重载赋值运算符,但是我们必须要把基类的=运算符重载好。
num_sequence& operator=(const num_sequence& rhs)
{
_length = rhs._length;
_beg_pos = rhs._beg_pos;
_relems = rhs._relems;
return *this;
}
Fibonacci& Fibonacci::operator=(Fibonacci& fib)
{
if (this != &fib)
{
num_sequence::operator=(fib);
}
return *this;
}
5.7 在派生类中定义虚函数
当我们定义派生类的时候,必须决定究竟是要将基类中的虚函数覆盖掉,还是原封不动的继承。
如果我们继承了纯虚函数,那么这个派生类也被认为是抽象类,也就无法为它定义任何对象。
如果我们决定覆盖基类所提供的虚函数,那么必须保证提供的函数原型要一模一样,这就包括:参数列表、返回类型、常量性。如果不同,则不会覆盖原有的虚函数,当你用抽象基类的接口调用虚函数的时候,也就无法产生虚函数作用机制,下面是一个简单的例子。
class person {
public:
virtual const char* what_am_i() const {return "person" ;}
};
class teacher : public person {
public:
virtual char* what_am_i() const { return "teacher"; }
}
int main()
{
person a;
teacher b;
num_sequence* ps = &b;
cout << ps->what_am_i;
cout << b.what_am_i;
return 0;
}
VS2019比较激进,直接就报错了。
不过“返回类型完全吻合”这一机制存在例外情况——当基类的虚函数返回某个基类形式(通常是pointer或者reference)。
class person {
public:
virtual person* clone() = 0;
};
class teacher : public person {
public:
virtual teacher* clone() { return new teacher(*this); }
}
在派生类中,覆盖某个虚函数的时候可以不必加关键字virtual,编译器会根据两个函数的原型声明,决定这个函数是否会覆盖其基类中同名的函数。
虚函数的静态解析
以下两种情况,虚函数机制不会出现预期的行为:
- 基类的构造函数和析构函数内。
- 使用的是基类的对象,而非对象的引用或指针。
在我们构造派生类对象的时候,基类的构造函数首先会被调用。如果在基类的构造函数中调用某个虚函数,会发生什么事情呢?
在此刻派生类的data member尚未初始化。如果此时调用派生类的那一份虚函数,它便有可能访问未经初始化的数据成员,出于这个理由,在基类的构造函数内派生类的虚函数绝对不会被调用。如果在基类的构造函数内调用虚函数,无论如何一定会解析成它自己的那份。对析构函数同理。
为了能够“在单一对象中展现多种类型“,多态需要一层间接性。在C++中,只有用基类的指针或引用才能支持面向对象编程概念。下面是一个简单的例子:
void print(LibMat object,
const LibMat *pointer, const LibMat& reference)
{
object.print();
//上面这个一定调用的是LibMat::print()
pointer->print();
reference.print();
//上面这俩一定会通过虚函数机制解析
//我们无法预知哪一份print()会被调用
}
小知识:当我们为基类声明一个实际对象(比如这里print的第一个参数),同时也就分配出了足以容纳该对象的实际内存空间。如果稍后传入的却是一个派生类对象,那就没有足够的内存放置派生类的多出来的数据成员了,这时,只有基类子对象(同时属于基类和派生子类的对象)被复制到为参数object而保留的内存中,其他的子对象则被切割掉了。至于另外两个参数pointer和reference,他们被初始化成对象所在的内存地址,所以他们可以完整的指向派生类对象。
5.8 运行时的鉴定机制
回到数列的what_am_i函数,我们希望提供另外一种设计手法,能够只提供一份what_am_i,令其他派生类通过继承机制来复用,这样其他的派生类就不用提供what_am_i了。
一种设计手法就是给num_sequence增加一个string成员,并且在派生类的构造函数中把名字同时赋值好,打印string对象就行。
另一种设计便是使用typeid运算符,这就是所谓的运行时类型鉴定机制RTTI。它能够帮助我们查询多态化的class pointer或class reference,获得其所指对象的实际类型。
使用这个运算符,需要引头文件:
#include <typeinfo>
#include <typeinfo>
inline const char* num_sequence::what_am_i()
{
return typeid(*this).name();
}
typeid运算符会返回一个type_info对象,其中存储着与类型相关的信息。每一个多态类,都对应一个type_info对象,该对象的name()函数会返回一个const char*,用以表示类名。
typeid(*this)
会返回一个type_info对象,关联至"who_am_i()函数中由this指针所指对象的实际类型"。
type_info class也支持相等和不相等两个比较操作,可以用来判断对象是否是某个属于某个类。
int main()
{
Fibonacci fib(8, 14);
num_sequence* ps = &fib;
if (typeid(*ps) == typeid(Fibonacci))
{
cout << "good job.\n";
}
}
由于指针的静态编译,以下编译会报错。
因为ps不知道自己指向的对象实际是一个Fibonacci类型,为了调用Fibonacci所定义的gen_elems(),我们必须指示编译器,将ps指针的类型强转为Fibonacci类型。
两个运算符可以解决这个问题,一个是static_cast运算符,这个运算符是无条件转换,所以具有一定的风险,我们最好检查一下ps所指的对象确实是Fibonacci类型以后再用。
int main()
{
Fibonacci fib(5, 14);
num_sequence* ps = &fib;
if (typeid(*ps) == typeid(Fibonacci))
{
cout << "good job.\n";
Fibonacci* pf = static_cast<Fibonacci*>(ps);
pf->gen_elems(23);
cout << *pf;
}
}
另一种运算符是dynamic_cast,这是一个运行鉴定机制RTTI运算符,它会进行运行时检验操作,检验ps所指对象是否属于Fibonacci类。如果是,转换操作便会发生,于是pf就会指向Fibonacci对象,如果不是,dynamic_cast运算符会返回0,我们可以设计if语句来确保类型正确。
int main()
{
Fibonacci fib(5, 14);
num_sequence* ps = &fib;
if (Fibonacci* pf = dynamic_cast<Fibonacci*>(ps))
//如果这个表达式值不是0 就说明dynamic_cast返回的值不是0
//说明
{
pf->gen_elems(23);
cout << *pf;
}
}