文章目录
11.面向对象—继承和多态篇
继承与多态是面向对象程序设计的另外两大特征,二者的联系还是相当紧密的,因为对于运行时多态,是要依赖于类的继承实现的。
(1). 什么是继承?
你生活在一个幸福的家庭中,作为爸爸妈妈的孩子,你从他们那里继承了不少东西,例如长相、身高、血型等等都是,在C++中,我们用继承来描述通过某个基类派生得到新的类的过程。
举个简单的例子,作为一家公司,属于这个公司的每个人,无论是老板还是经理,还是普通的职员,他们都是员工,因此员工就是基类,而在这基础上出现了不同的分类,例如项目经理、产品经理、设计师等等不同的职位,他们在本身作为员工的情况下,还有作为自己职位的特别属性或特别方法,例如:一般员工的等级不足以进入保密区,而总经理就可以,就是这样,继承或者说派生类就是在抽象的基础上继续完成完成抽象,也就是说,我们在已经提取了每一个类别的特性之后,要继续在各个类别中找到他们的共性,从而形成一个更基本的类型,再由这个基本类型派生得到不同的类型。例如:
class Employee
{
private:
unsigned long long ID;
std::string name;
int age;
int entry_time;
};
class Manager : public Employee
{ ... };
class Designer : public Employee
{ ... };
class Boss : public Employee
{ ... };
这里的代码你可能不太懂,没关系,我们之后再来理解,在这里你只要知道,Manager、Designer和Boss都是从Employee类派生出来的就好了。
这样做的好处首先有一点很明显,假设经理、设计师和老板共有员工ID、姓名、年龄和入职年限这几个共同的属性,如果从一个员工基类派生,我们就不需要在每个类中再写一次这四个属性了,这样可以很明显地提高同一段代码的复用率。但是你可能会发现一个问题,就提高代码的复用率的操作,好像把员工类作为这几个类的一个成员,也可以实现啊! 你说得对,例如:
class Employee
{
public:
unsigned long long ID;
std::string name;
int age;
int entry_time;
};
class Manager
{
private:
Employee base;
...
};
class Designer
{
private:
Employee base;
...
};
class Boss
{
private:
Employee base;
...
};
这不是也一样吗?有什么很大的区别吗?让我们先去看看多态,你很快就会明白了。
(2). 多态又是什么?
#1.打个怪先
我决定采取我们C++老师的一个例子,在一个开放世界冒险游戏中,你作为指挥官对于角色发出攻击指令,A可能拿的是单手剑,B拿的是长柄武器,C拿的是弓箭,你要求A、B、C攻击,我想没有什么游戏会针对不同角色设定不同的普通攻击键吧? 这样出一个角色,就要加一个键,这太夸张了。
C++提供了多态,对于基于同一个类的派生类,我们可以调用相同的方法,并且不同的对象会基于此进行不同的操作,这就是一种多态的实现。因此,在这里我们可以把使用单手剑、长柄武器和弓箭的三类角色全都使用一个Role来派生,而Role具有attack() 方法,之后我们再对三类角色覆写attack()方法,或许就可以实现我们需要的功能。
#2.编译期多态和运行时多态
前面我们好像说,“这就是一种多态的实现”,也就是说,多态还有其他的实现方式,在C++中,多态分为编译期多态和运行时多态,前面说的基于派生类的实现方法就是运行时多态,而编译期多态我们其实已经用过了,函数重载和我们之后会提到的模板都是编译期多态的实现方式。
#3.回答一下之前的问题
或许我们可以对每个类基于基类进行一次attack()方法的封装,但是,你能把这样的不同类的对象放到同一个变量中吗?例如,A、B、C都是包含了Base的类,也封装了一次attack()方法,但是在调用的时候我们不能说存在一个变量solider,它可以同时保存A、B、C,然后直接用solider.attack()完成攻击操作。
(3). 继承的方法
首先我们有一个基类:
class Base
{
public:
int a;
};
有另一个类A需要从基类继承,我们需要这么写:
class A : (...) Base
{
...
};
在Base前有个圆括号,其中我们要填入public,protected或private,分别表示公有继承、保护继承和私有继承。成员的三个属性对于访问控制的严格程度满足public < protected < private,其中private是最严格的。
因此在继承前加的这个关键字相当于限定了我们的继承方式,继承方式会导致被继承的类中的成员的访问控制属性变化为继承方式对应的访问控制属性,也就是说,原本为public的,只有在公有继承的情况下,才仍为public,而private和protected属性不受影响,原本为protected的,在public和protected继承的情况下,仍为protected,而private无论在什么情况下都是private,我们可以总结为下面这张表:
继承方式/成员属性 | public | protected | private |
---|---|---|---|
public继承 | public | protected | private |
protected继承 | protected | protected | private |
private继承 | private | private | private |
(4). protected关键字
首先我们来考虑一个问题,基类的私有属性是否能被子类访问呢?我们来测试一下:
#include <iostream>
using namespace std;
class Base
{
private:
int a;
public:
Base() : a(0) {}
Base(int c) : a(c) {}
~Base() = default;
};
class A : public Base
{
public:
A() : Base(10) {}
void get()
{
cout << a << endl;
}
};
int main()
{
A a();
a.get();
return 0;
}
很好,私有属性是不能访问的,所以我们就知道了,即便是继承得到的私有属性,在子类中也是不能直接访问的,其实很好理解:即便是你爸妈也有自己的秘密,他们不一定要告诉你,而这一部分就是属于私有属性。
我们在说类成员关键字的时候,只提到了private和public,其实在C++中还有一个protected关键字,从外部访问的情况来看,protected和private一致,被protected修饰的成员同样无法在外部访问,那怎么办,如果我还是希望能够访问一些基类中的数据,岂不是只能把它变成public了?那这样的话通过外部也能修改了,就会严重破坏类的封装性。
所以C++提供了protected关键字,protected关键字保证了通过继承方式得到的属性在类内是可以直接访问的,同时也保证了在类外不可直接访问,这样就好了,我们把上面的代码改成这样:
class Base
{
protected:
int a;
public:
Base() : a(0) {}
Base(int c) : a(c) {}
~Base() = default;
};
class A : public Base
{
public:
A() : Base(10) {}
void get()
{
cout << a << endl;
}
};
这样就好了,代码没有什么大问题了。
(5). 继承的内存结构
继承,貌似基类的属性也是能在类中访问的,那是不是说,其实基类的成员被放进了派生类之中呢?我们来探索一下:
class Base
{
public:
int a;
double b;
};
class Test : public Base
{
public:
char c;
int64_t d;
};
class Test2
{
public:
char c;
int64_t d;
};
我们试着把Base类、Test类和Test2类的字节数打印出来:
C++类和C语言中的struct有着相同的对齐规则,所以Base和Test2是16字节是可以理解的,也是因为对齐数正好是8,所以Test是32 = 16 + 16字节,这就很好理解了,我们也可以用VS提供的工具查看一下Test类的内存结构(在VS开发者Powershell中使用cl /d1 reportSingleClassLayoutTest a.cpp,其中Test是类名,a.cpp是文件名):
和你我想象的应该是一致的,对吧?基类的成员会被拿过来作为派生类的成员占有对应的空间。
(6). 实现多态的方法
接下来我们要再回到标题,想想多态要怎么实现,既然内存结构是基类+派生类的新增部分,那么我们就可以有一些比较自然的想法:指针和引用。
首先是指针,对于不同类型的指针,C++在取数据的时候会取从首地址起,一整个对象字节数的数据,因为派生类的首地址到首地址加对象字节数的位置正好是基类的全部数据,所以我们可以把派生类的指针赋值给基类,这样一来就可以在一定程度上实现多态了。
二来是引用,引用其实和指针是一致的,因为引用的本质是指针常量,它的背后实际上还是指针的赋值,因此可以和指针一样实现一部分操作。
不过这样有个问题:这两种方法取出来的都只有派生类对象内部的基类对象部分,如果派生类有点什么新的属性,岂不是就不能通过基类指针/引用操作了?我们试试看:
#include <iostream>
using namespace std;
class Base
{
public:
int a;
double b;
};
class Test : public Base
{
public:
char c;
int64_t d;
};
int main()
{
Base* b1{ nullptr };
Test t2{ 'c', 123 };
b1 = &t2;
cout << b1->c << endl;
return 0;
}
你在写b1->的时候它给的代码提示就应该已经告诉你结果了:只能访问到a和b的值,你仔细想想其实很合理,因为b1就是一个Base指针,它不可能因为有了派生类就去修改自己的定义,不然如果用基类指针真的就存一个基类,岂不是还能访问到基类数据之外的数据了?这不就发生越界访问了吗?
不过至少有一点我们可以知道,就是基类的指针或引用,的确可以操作派生类的对象,这是符合我们对多态的预期的。
(7). 派生类的初始化
在了解了派生类的内存结构之后,我们或许就需要考虑一下:怎么初始化一个派生类对象呢? 其实很简单,我们只要调用基类对应的构造函数即可,例如:
class Employee
{
protected:
std::string name;
uint64_t ID;
public:
Employee() = default;
Employee(const std::string& _name, const uint64_t _ID)
: name(_name), ID(_ID) {}
~Employee() = default;
void greeting()
{
std::cout << "Hello! I'm an employee!" << std::endl;
}
};
class Manager : public Employee
{
public:
Manager() : Employee() {}
Manager(const std::string& _name, const uint64_t _ID)
: Employee(_name, _ID) {}
~Manager() = default;
};
就像这样,我们可以直接通过代理构造的方式完成对于基类的构造。
(8). 函数重写
接下来有个问题,Manager作为Employee的派生类,它是可以调用greeting()函数的,但是Manager作为一个更加高级的Employee,它打招呼应该说明自己是一个Manager,所以应该重写一下基类中的greeting()函数,所以你可能会这么写:
class Manager : public Employee
{
public:
Manager() : Employee() {}
Manager(const std::string& _name, const uint64_t _ID)
: Employee(_name, _ID) {}
~Manager() = default;
void greeting()
{
std::cout << "Hello! I'm a manager!" << std::endl;
}
};
这样的操作就叫做函数重写,即我们在新的类中定义了同名但不同内容的方法。所以这个时候我们通过Manager对象调用greeting()方法的时候理论上讲就应该是调用新的greeting()方法了对吧,我们试试看:
效果如我们所料,接下来考虑下一个问题:如果是从基类指针/引用调用这个函数会怎么样? 比如:
int main()
{
Employee* eptr{ nullptr };
Manager m{ "Voltline", 123456789 };
eptr = &m;
Employee& eref{ m };
eptr->greeting();
eref.greeting();
return 0;
}
啊,非常遗憾,调用的还是基类的方法,这就是直接实现函数重写的问题所在,如果直接重写基类的方法,实际上只会将基类原本定义的相同方法“隐藏”掉,也就是说,如果我们直接重写基类方法,就不能实现真正的多态,因为把在操作基类指针/引用的时候还是只会调用基类本身的方法,那C++有没有什么办法改变这个事情呢?
(9). 虚函数、抽象类与纯虚函数
#1.什么是虚函数?
当然有啦,C++提供了虚函数来解决这个问题,被声明为虚函数的方法,编译器和链接器能够保证在调用的时候,调用对应每个类的正确方法,来看个例子:
class Employee
{
protected:
std::string name;
uint64_t ID;
public:
Employee() = default;
Employee(const std::string& _name, const uint64_t _ID)
: name(_name), ID(_ID) {}
~Employee() = default;
virtual void greeting()
{
std::cout << "Hello! I'm an employee!" << std::endl;
}
};
class Manager : public Employee
{
public:
Manager() : Employee() {}
Manager(const std::string& _name, const uint64_t _ID)
: Employee(_name, _ID) {}
~Manager() = default;
virtual void greeting()
{
std::cout << "Hello! I'm a manager!" << std::endl;
}
};
然后再通过我们一开始写的main函数完成一次调用:
int main()
{
Employee* eptr{ nullptr };
Manager m{ "Voltline", 123456789 };
eptr = &m;
Employee& eref{ m };
eptr->greeting();
eref.greeting();
return 0;
}
结果是:
无论是引用还是指针,都无比正确的调用了Manager类重写过的greeting()函数,太棒了!这就是我们需要的多态!
#2.如何使用虚函数?
就如上面的例子,如果我们需要声明某个函数为虚函数,我们只需要在基类的函数声明前加上一个virtual即可:
virtual Type FuncName(Type1 arg1, ...);
之后的派生类中如果要重写这个函数,可以加virtual,也可以不加,结果是一样的,如果不进行重写,那就会按照基类中的虚函数定义继续执行。
#3.不要忘了虚析构函数
先别急,看看这个:
class Base
{
private:
int* a;
public:
Base() : a(new int[100]{0}) {}
~Base()
{
delete[] a;
a = nullptr;
}
};
class A : public Base
{
private:
int* b;
public:
A() : Base(), b(new int[120]{0}) {}
~A()
{
delete[] b;
b = nullptr;
}
};
A的析构函数,我们应该把b给释放掉,这没问题,但是如果我们用一个数组存储:
std::vector<Base*> v;
在程序结束的时候,应该要调用析构函数析构掉每个对象,假设存入的是基类,那没问题,就正常调用Base的析构函数,那假设说是A呢?因为我们没有虚析构函数,因此也只是调用了Base的析构函数,A中的指针b没有被释放掉,哦吼?这可就出问题了!
因此对于存在内存管理问题的类,且一定会被继承的,一定要写一个虚析构函数,从而避免发生内存泄露的问题。
#4.虚函数的工作原理是什么?
说了这么多其实我们还没讲到本质,有个很奇怪的点:为什么加个virtual就可以让指针/引用调用正确的函数了? 实际上C++采取了虚函数表(vtbl) 的方式来实现,在真正通过指针或引用调用某个类的方法时,如果调用了虚函数,就会根据虚函数表查询对这个类真正需要调用的函数是什么,从而实现正确的多态。
但是虚函数表是有代价的! 听听你就知道了,虚函数表的存在会明显增大一个类占用的内存,我们再用VS的工具看看,当Employee的greeting()不是虚函数时:
Manager只是有一个基类的内容,占用40字节的内存,而当greeting()是虚函数时:
虚函数和虚函数表占用了一部分空间,Manager的内容已经达到了48字节,假设虚函数更多,占用的空间肯定也会更多,因此使用运行时多态的代价就是占用的内存或许会更多。
#5.如果我希望派生类一定要重写虚函数怎么办?
有的时候,我们发现虽然每个派生类都有相同的方法,但是每个派生类都不一样,甚至说都不应该一样,那这个方法如果我声明为虚函数,还是要给它定义的,比如:
class Employee
{
protected:
std::string name;
uint64_t ID;
public:
Employee() = default;
Employee(const std::string& _name, const uint64_t _ID)
: name(_name), ID(_ID) {}
~Employee() = default;
virtual void greeting();
};
class Manager : public Employee
{
public:
Manager() : Employee() {}
Manager(const std::string& _name, const uint64_t _ID)
: Employee(_name, _ID) {}
~Manager() = default;
void greeting();
};
在这里Employee类和Manager类我都没有定义greeting(),但这段代码在不调用greeting()的情况下可以通过编译,也就是说,如果不做点什么限制,我是不能强制要求派生类一定要重写虚函数的,所以C++引入了纯虚函数的概念,包含纯虚函数的类被称为抽象类,纯虚函数在类的声明中不必给出定义,由此也导致抽象类不能用于产生对象。
所以它的基本形式如下:
class Employee
{
protected:
std::string name;
uint64_t ID;
public:
Employee() = default;
Employee(const std::string& _name, const uint64_t _ID)
: name(_name), ID(_ID) {}
~Employee() = default;
virtual void greeting() = 0;
};
我们只要在虚函数声明后加一个=0即代表声明这个函数为纯虚函数,如果Manager类如下:
class Manager : public Employee
{
public:
Manager() : Employee() {}
Manager(const std::string& _name, const uint64_t _ID)
: Employee(_name, _ID) {}
~Manager() = default;
};
那么在main中如果你试图创建一个Manager对象就会报错,它会说不能创建抽象类的对象,也就是说,继承了抽象类的派生类,如果没有重写所有纯虚函数,派生类仍然是抽象类,不能用于生成对象,这就很好了,对吧?
(10). 好像忘了什么事情
我们之前讲的全部都是基类指针/引用,如果我直接把派生类对象赋值给基类会怎么样呢? 实际上不会怎么样,派生类对象肯定包含了基类中所有的成员,因此赋值操作完全合法,只不过派生类对象中不属于基类的部分会被抛弃掉,这也没办法。
不过如果是基类赋值给派生类会怎么样呢? 再来看个例子:
没错,从基类到派生类是需要发生类型转换的,因此我们不能直接进行赋值,所以如果要实现这个操作就必须要自己再实现一个从基类到派生类的转换函数。
(11). 多继承、菱形继承和虚继承
#1.多继承的实现方法
最后我们再来说说C++的特性:多继承。这个部分不要求掌握,因为实际开发中你可能真的不会用到多继承这个特性,多继承其实语法很简单,就是在冒号后面多加几个类,比如:
class A : public B, public C, public D, ...
{
...
};
#2.菱形继承
就是这样,大部分情况都是和单继承一致的,它的内存分布什么的,也都符合之前所说的情况。但是多继承带来的问题不在这里,在C#、Java之类的语言中,多继承是不被支持的,因为可能产生一些很麻烦的问题,比如我们看看下面这个问题:
class Base
{
private:
int* a;
};
class A : public Base
{
private:
int* b;
};
class B : public Base
{
private:
int* c;
};
class CN : public A, public B
{
private:
int* d;
};
在这里A和B同时继承自Base,而CN继承自A和B,那么CN中会同时具有两份基类的内容:
这好像,有点问题?因为两个基类内容带来的可能是后期调用数据时候可能产生的各种问题,同时有两个Base存在也会导致CN的内存占用变得很大,这好像,不太好吧?这种问题被称为菱形继承问题,因为它的结构如下:
#3.虚继承与内存分布
C++中使用了虚继承来解决菱形继承的问题,实现的时候,我们只需要在继承方式前再加一个virtual,例如:
class Base
{
private:
int* a;
};
class A : virtual public Base
{
private:
int* b;
};
class B : virtual public Base
{
private:
int* c;
};
class CN : public A, public B
{
private:
int* d;
};
我们再看看内存布局:
哇哦,很神奇的,A和B中都有的Base在CN中只出现了一次,也就是没有重复的a的,不过下面出现了关于A和B的vbtable(虚基类表),vbptr(虚基类表指针),虚继承中会采取虚基类表和虚基类表指针来完成防止出现多个基类的情况,它的内存占用情况是:非顶级基类的各个基类中的元素字节数+虚基类表指针数*8+顶级基类中的元素总字节数+本身的属性字节数+虚基类表个数*vbte(用于保存虚基类表偏移量的字节数) + 所有对齐字节数。
这个部分相当复杂,你只需要简单了解一下就好,如果让我说,我的建议是尽可能不用多继承,更不要用到菱形继承,这样真的会造成很多不便,而菱形继承和多继承的更多的细节可能需要你自己去探索了。
小结
继承与多态是面向对象程序设计中相当重要的两大部分,我们在此使用了虚函数完成了对于多态的实现,如果想要真正理解多态,你可能需要比较清楚地了解派生类的内存分布和虚函数表的实现细节,这还是有相当大的难度的。
这是面向对象的最后一篇了,下一篇我们就来介绍介绍C++中的泛型程序设计模式——模板。