C++继承和多态(虚函数、纯虚函数、虚继承)

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类:

  • 静多态:在编译阶段已经确定函数的入口地址,例如函数重载,模板等
  • 动多态:在运行阶段时才确定函数的入口地址,例如虚函数调用机制
  • 宏多态:例如宏函数,在预编译阶段已经进行了替换

 

动多态:

  1. 在编译期间生成虚函数表,表中保存函数入口地址
  2. 放在只读数据段.rodata
  3. 一个类共用一个虚表,而不是一个对象

而类中是不存在虚函数表的,主要由于数据冗余,太大了,所以都保存一个指针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个函数,那么哪些可以成为虚函数呢?

  1. 普通函数×(遵守__cdecall调用约定,不依赖对象调用)
  2. 构造函数×(虽然遵守__thiscall调用约定,但是手动调用不了)
  3. 析构函数√(遵守__thiscall调用约定,且可以手动调用)
  4. static修饰的成员方法×(遵守__cdecall调用约定,不依赖对象调用)
  5. inline函数×(inline函数无法取地址,它直接在调用点直接展开)
  6. 普通的成员函数√(遵守__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++继承、多态、虚函数、纯虚函数、虚继承基本了解完毕。

  • 29
    点赞
  • 65
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值