C++继承和多态(虚函数、纯虚函数、虚继承)
一:继承
继承的概念:为了代码的复用,保留基类的原始结构,并添加派生类的新成员。
继承的本质:代码复用
我们用下图解释下:
那么我们这里就可以提出几个问题了:
①:进程的方式有哪些呢?
这里有三种继承方式:
- public:任意位置可以访问
- protected:只允许本类类中以及子类类中访问
- private:只允许本类类中访问
②:派生类继承了基类的什么?
- 所有成员变量,包括static静态成员变量
- 成员方法,除构造和析构以外的所有方法
- 作用于也继承了,但是友元关系没有继承
③:派生类生成的对象的内存布局是什么样的?
派生类对象构造时,基类数据在前,派生类自身数据在后。
如下图所示:(B继承了A)
④:派生类对象的构造析构顺序
(1):派生类对象的构造顺序:
- 系统调用基类的构造(没有指明构造方式,则按默认的走)
- 系统调用派生类的构造
(2):派生类对象的析构顺序:
- 系统调用派生类的析构
- 系统调用基类的析构
那么基类中不同访问限定符下的成员 以不同的继承方式 继承后在派生类中的访问限定是什么样的呢?
核心思想:继承后的权限不会大于继承方式的权限
我们这里有一张图可以概括:
我们可以用以下代码来验证:
测试代码如下:
-
#include <iostream>
-
-
class Base
-
{
-
public:
-
Base(
int a =
10,
int b =
20,
int c =
30):ma(a), mb(b), mc(c){}
-
public:
-
int ma;
-
protected:
-
int mb;
-
private:
-
int mc;
-
};
-
-
class Derived :
private Base
-
{
-
public:
-
void Show()
-
{
-
std::
cout << ma <<
std::
endl;
-
std::
cout << mb <<
std::
endl;
-
//std::cout << mc << std::endl;
-
}
-
};
-
-
class Derived2 :
public Derived
-
{
-
public:
-
void Show()
-
{
-
std::
cout << ma <<
std::
endl;
-
//std::cout << mb << std::endl;
-
//std::cout << mc << std::endl;
-
}
-
};
-
-
int main()
-
{
-
Derived d;
-
Derived2 d2;
-
d.Show();
-
d2.Show();
-
-
std::
cout <<
sizeof(Derived) <<
std::
endl;
-
return
0;
-
}
二:多态
多态的概念:多态可以使我们以相同的方式处理不同类型的对象,其实用一句话来说,就是允许将子类类型的指针赋值给父类类型的指针。
多态的本质:接口复用(一种接口,不同形态)
多态性在C++中是通过虚函数实现的。
虚函数:就是父类允许被其子类重新定义的成员函数,而子类重新定义父类函数的做法,称为“覆盖”,或者称为“重写”。
子类重写父类中虚函数时,即使没有virtual声明,该重载函数也是虚函数。
我们可以将多态分为3类:
- 静多态:在编译阶段已经确定函数的入口地址,例如函数重载,模板等
- 动多态:在运行阶段时才确定函数的入口地址,例如虚函数调用机制
- 宏多态:例如宏函数,在预编译阶段已经进行了替换
动多态:
- 在编译期间生成虚函数表,表中保存函数入口地址
- 放在只读数据段.rodata
- 一个类共用一个虚表,而不是一个对象
而类中是不存在虚函数表的,主要由于数据冗余,太大了,所以都保存一个指针vfptr,让这个指针指向这个虚函数表即可。
那这个虚函数表长什么样子呢?
注意一点:
- 如果基类中有一个成员函数是虚函数,那么派生类中与其同名同参的函数默认会变成虚函数,这是一个覆盖的关系。
- 编译期间,派生类会生成自己的虚函数表,这时两个虚函数表会进行合并,同名同参的虚函函数覆盖了基类中同名同参的虚函数。
下面,我们将介绍一下什么是覆盖:
首先我们需要知道,类与类之间的关系有三种,分别为:
- 组合 :a part of 是一个 has_a的关系(有一个),例如:A是B的一部分,则不允许B继承A的功能,而是要用A和其他东西组合成B,他们之间就是has_a的关系,现实中则就是眼睛,鼻子和脑袋的关系。
- 继承 :a kind of 是一个is_a的关系(是一个),例如:若B是A的一种,则允许B继承A的功能,他们之间就是is_a的关系,现实中就是香蕉和水果的关系。
- 代理 :限制底层的接口,提供新的接口。
- 这里需要注意的是,用private方式继承,是一个has_a的关系,不是is_a的关系,是有一个的关系,不是是一个的关系。
而同名函数之间也有三种关系,分别为:
- 重载 overload 重载三要素:同名,不同参,同作用域
- 隐藏 overhide 继承时,派生类中同名的函数隐藏了继承来的同名方法,继承来的函数存在,但是看不到
- 覆盖 override 继承时,派生类中同名的虚函数覆盖了继承来的同名方法,继承来的虚函数不存在,直接被覆盖掉了。
内存分布:
如果基类有虚函数,而派生类中也有虚函数,则如下图:
虚函数表和类是一对一的,这个vfptr指向的是派生类对象的虚表。
如果这里有这6个函数,那么哪些可以成为虚函数呢?
- 普通函数×(遵守__cdecall调用约定,不依赖对象调用)
- 构造函数×(虽然遵守__thiscall调用约定,但是手动调用不了)
- 析构函数√(遵守__thiscall调用约定,且可以手动调用)
- static修饰的成员方法×(遵守__cdecall调用约定,不依赖对象调用)
- inline函数×(inline函数无法取地址,它直接在调用点直接展开)
- 普通的成员函数√(遵守__thiscall调用约定,且可以手动调用)
首先我们得知清楚道成为虚函数的条件:
- 能取地址(排除5)
- 依赖对象调用(排除1,2,4)
并且如果在构造函数以及析构函数内调用虚函数,那么只会是一个静态绑定,因为这时依赖调用的对象已经不完整了。
注意:虚函数指针的写入时机,是在构造函数第一行代码之前。
重点:如果有基类的指针指向了派生类的对象,那么基类就要有虚析构。
首先我们需要了解的是动多态的发生时机:
- 调用的对象需要完整(这也是为什么在构造函数以及析构函数中调用虚函数,只会触发静多态的原因)
- 指针调用的是虚函数(要有virtual关键字标识)
这时候我们再来看一看原因:如果派生类申请了内存空间,并在其析构函数中进行了释放,假设由于基类中采用的是非虚析构函数,那么当基类的指针指向了派生类的对象后,当delete释放内存的时候,首先会调用析构函数,但是因为基类的析构函数并不是虚析构,只是普通析构函数,所以只会触发静态绑定(静多态),不会触发动态绑定(动多态),因此调用的是基类的析构函数,而不是派生类的析构函数,那么申请的空间就会得不到释放从而造成内存泄漏,所以,为了防止这种情况的发生,我们需要将基类中的析构函数写成虚析构。
注意:如果基类没写虚函数,而派生类写了虚函数,那么当基类指针指向派生类后,delete pb就会崩溃
例如下面代码:
-
#include <iostream>
-
-
class A
-
{
-
public:
-
A(
int a) :ma(a)
-
{
-
std::
cout <<
"A::A(int)" <<
std::
endl;
-
}
-
void Show()
-
{
-
std::
cout <<
"A::ma:" << ma <<
std::
endl;
-
}
-
~A()
-
{
-
std::
cout <<
"A::~A()" <<
std::
endl;
-
}
-
protected:
-
int ma;
-
};
-
class B :
public A
-
{
-
public:
-
B(
int b) :A(b),mb(b)
-
{
-
std::
cout <<
"B::B()" <<
std::
endl;
-
}
-
virtual void Show()
-
{
-
std::
cout <<
"B::mb:" << mb <<
std::
endl;
-
}
-
~B()
-
{
-
std::
cout <<
"B::~B()" <<
std::
endl;
-
}
-
private:
-
int mb;
-
};
-
int main()
-
{
-
A* pa =
new B(
10);
-
pa->Show();
//class Base*
-
//delete (A*)((char*)pa -4);
-
delete pa;
-
return
0;
-
}
我们画个图分析一下为什么会崩溃:
原因:当基类指针指向派生类的时候,因为new是从0x100开辟的,但是由于基类指针赋值的是基类构造时的地址0x200,所以当开辟地址与释放地址不一致时,则会造成崩溃。
我们可以将基类中析构函数变成虚析构函数,那么基类就会有一个虚函数指针,当两者合并时,就不会产生开辟地址与释放地址不一致的问题了,因为此时内存布局如下:
这时内存开辟地址和释放地址都为0x100,则不会造成崩溃。
三:纯虚函数
纯虚函数:是一种特殊的虚函数,很多情况下,在基类中不能对虚函数给出有意义的实现,从而把它声明为纯虚函数,它的实现留给派生类去做。这就是纯虚函数的作用。
纯虚函数的两个特点:
- 拥有纯虚函数的类叫做抽象类
- 抽象类不能实例化对象
例如,动物这个类,可以派生出狗和猫这两个类,但是由于它俩都有发出叫声这个方法,那么我们想通过一个函数,通过传入不同的基类指针,实现发出不同的叫声。
代码如下:
-
#include <iostream>
-
#include <string>
-
-
class Animal//抽象类
-
{
-
public:
-
Animal(
std::
string name) :mname(name)
-
{
-
std::
cout <<
"Animal::Animal()" <<
std::
endl;
-
}
-
virtual void Bark() =
0;
//纯虚函数
-
virtual ~Animal()
-
{
-
std::
cout <<
"Animal::~Animal()" <<
std::
endl;
-
}
-
protected:
-
std::
string mname;
-
};
-
-
class Dog :
public Animal
-
{
-
public:
-
Dog(
std::
string name) :Animal(name)
-
{
-
std::
cout <<
"Dog::Dog()" <<
std::
endl;
-
}
-
void Bark()
-
{
-
std::
cout << mname <<
" wang wang wang!" <<
std::
endl;
-
}
-
~Dog()
-
{
-
std::
cout <<
"Dog::~Dog()" <<
std::
endl;
-
}
-
};
-
-
class Cat :
public Animal
-
{
-
public:
-
Cat(
std::
string name) :Animal(name)
-
{
-
std::
cout <<
"Cat::Cat()" <<
std::
endl;
-
}
-
void Bark()
-
{
-
std::
cout << mname <<
" miao miao miao!" <<
std::
endl;
-
}
-
~Cat()
-
{
-
std::
cout <<
"Cat::~Cat()" <<
std::
endl;
-
}
-
};
-
-
void ShowBark(Animal* pa)
-
{
-
pa->Bark();
-
}
-
-
int main()
-
{
-
Cat* pc =
new Cat(
"cat");
-
Dog* pd =
new Dog(
"dog");
-
-
ShowBark(pc);
-
ShowBark(pd);
-
-
delete pc;
-
delete pd;
-
return
0;
-
}
我们运行一下,看一看结果:
我们可以看到,通过一个函数Bark写成纯虚函数virtual void Bark() = 0;,这时派生类对象就可以自行定义这个函数Bark,而我们提供的普通函数ShowBark,通过传入不同的基类指针,调用其纯虚函数Bark,则可以发出不同的叫声。
四:虚继承
继承可以分为单继承和多继承,那么就会出现这样一种巧妙地结果,菱形继承:
菱形继承:我们很清楚的可以看到,它存在内存重复的问题,所以我们引进了虚继承。
我们可以对内存重复的间接基类做特殊处理,在B和C继承时,加上关键字virtual,这时就形成了虚继承(class B : virtual public A)
那么A就叫做虚基类,在内存的最下方开辟一块内存,用来存放A,在其原本位置置放一个虚基类指针vbptr,通过这个指针可以找到这块内存,因为内存在开辟期间不能赋值指向,所以只能通过偏移来找到。
如果不加virtual关键字,那么构造顺序则是:ABACD
但是给BC加上virtual,那么构造顺序则是:ABCD
重点:构造时,虚基类的构造顺序最高
重点:而内存分布的时候,是非虚基类顺序>虚基类顺序,但是需要注意的是,虚基类内存向下放的时候,是按照虚继承的顺序,先看见谁,先放谁
完整代码如下(下面会根据情况进行简单修改,并进行测试,以验证以上结论):
-
#include <iostream>
-
-
class A
-
{
-
public:
-
A(
int a) :ma(a)
-
{
-
std::
cout <<
"A" <<
std::
endl;
-
}
-
~A()
-
{
-
std::
cout <<
"~A" <<
std::
endl;
-
}
-
public:
-
int ma;
-
};
-
class B :
virtual
public A
-
{
-
public:
-
B(
int b) :mb(b),A(b)
-
{
-
std::
cout <<
"B" <<
std::
endl;
-
}
-
~B()
-
{
-
std::
cout <<
"~B" <<
std::
endl;
-
}
-
public:
-
int mb;
-
};
-
class C :
virtual
public A
-
{
-
public:
-
C(
int c) :mc(c),A(c)
-
{
-
std::
cout <<
"C" <<
std::
endl;
-
}
-
~C()
-
{
-
std::
cout <<
"~C" <<
std::
endl;
-
}
-
public:
-
int mc;
-
};
-
-
class E
-
{
-
public:
-
E(
int e) :me(e)
-
{
-
std::
cout <<
"E" <<
std::
endl;
-
}
-
~E()
-
{
-
std::
cout <<
"~E" <<
std::
endl;
-
}
-
-
public:
-
int me;
-
};
-
-
class D :
public B,
virtual
public E,
public C
-
{
-
public:
-
D(
int d) :md(d), B(d), C(d), E(d), A(d)
-
{
-
std::
cout <<
"D" <<
std::
endl;
-
}
-
~D()
-
{
-
std::
cout <<
"~D" <<
std::
endl;
-
}
-
public:
-
int md;
-
};
-
int main()
-
{
-
D d(10);
-
//d.ma = 10;
-
-
return
0;
-
}
我们修改类D的继承方式,以验证以上结论:
①:
class B : virtual public A
class C : virtual public A
class D : public B ,virtual public E, public C
运行结果:
内存分布:
可以看到,构造顺序是虚基类的最高。
②:
class B : virtual public A
class C : virtual public A
class D :virtual public E, virtual public B, public C
运行结果:
内存分布:
我们可以看到,内存中首先是非虚基类的C,但是按照虚继承顺序,接下来是E,最后才是A以及B。
查看内存命令:在开发人员命令提示中输入 cl -d1reportSingleClassLayoutD 测试1.cpp (D为类名)
我们可以得到rfptr与rbptr的区别:
- rfptr的偏移是总体作用域减当前
- rbptr的偏移是当前作用域减当前
建议:所以说,有虚继承的话,一般不使用动态开辟内存,一般使用栈开辟内存,因为虚继承总会将基类放到最下面,导致内存开辟的地址和释放的地址不一致,导致崩溃。
至此,C++继承、多态、虚函数、纯虚函数、虚继承基本了解完毕。
本文转载如下:
————————————————
版权声明:本文为优快云博主「WuDi_Quan」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.youkuaiyun.com/IT_Quanwudi/article/details/88081934