C++学习:3多态

Posted 想文艺一点的程序员

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++学习:3多态相关的知识,希望对你有一定的参考价值。

前面我们已经大概分析了:封装和继承。

  • 封装:将成员变量私有化,然后提供读写的接口供别人进行调用。
  • 继承:子类继承父类的成员变量

先来分析一个 父类指针子类指针

  • 首先明确指针的目的是为了指向对象,所以就会有, 父类指针指向子类对象,和 子类指针指向父类对象

  • 父类指针指向子类对象:父类指针可以指向子类对象,是安全的,开发中经常用到(继承方式必须是public

  • 子类指针指向父类对象:子类指针指向父类对象是不安全的 (后面分析为什么是不安全的)

提供一个好记忆的办法:

  • 父类指针指向子类对象:学生属于一个人,所以人可以指向学生。
  • 子类指针指向父类对象:人不一定都是学生,所以学生不能指向人。


分析安全问题:

父类指针指向子类对象是安全的

  • 访问范围:父类指针只能访问父类当中成员变量。
  • 拥有范围:子类对象当中肯定拥有父类的全部成员变量。

子类指针指向父类对象是不安全的

  • 访问范围:子类指针不仅可以访问父类当中的全部成员,而且可以访问自己的独立的成员。
  • 拥有范围:父类对象当中,肯定不会包含子类当中独特的成员变量。


多态

先来铺垫一个项目:假设我们要写一个动物园训练系统

#include <iostream>
using namespace std;

class Cat
{
public:
	void speak() { cout << "miao miao miao " << endl; }
	void eat() { cout << "miao eat " << endl; }
};

class Dog
{
public:
	void speak() { cout << "wang wang wang " << endl; }
	void eat() { cout << "wang eat " << endl; }
};

class Pig
{
public:
	void speak() { cout << "heng heng heng " << endl; }
	void eat() { cout << "heng eat " << endl; }
};

// 训练猫
void train_Cat(Cat *p)
{
	p->speak();
	p->eat();
}
// 训练狗
void train_Dog(Dog *p)
{
	p->speak();
	p->eat();
}
// 训练猪
void train_Pig(Pig *p)
{
	p->speak();
	p->eat();
}

int main()
{
	train_Cat(new Cat);
	train_Dog(new Dog);
	train_Pig(new Pig);

	getchar();
	return 0;
}
运行结果:
miao miao miao
miao eat
wang wang wang
wang eat
heng heng heng
heng eat

分析:

  • 每个动物训练的项目有很多相同的,比如 吃饭 、 说话、睡觉 等等
  • 假设我们有上百种动物,我们的 train_xxx 函数就需要写上百个。(非常麻烦)

解决:

  • 抽离出共同的项目,然后将他们放到父类当中
  • 然后让子类去继承。

1、重写

我理解的重写有几点要求:

1、是子类 重新写 父类的成员函数。

2、重写函数的参数列表必须相同,(不相同的话就成了重载

3、返回值、函数名、参数列表,都必须和父类的成员函数,完全一模一样

本能的认为:p 指向的 Cat 对象,那么调用的函数也应该是 Cat 的成员函数呀。

可以发现结果是不如人意的

分析:产生这种情况的原因:

  • 默认情况下,C++是不存在多态的。
  • C++编译器只会根据指针类型调用对应的函数
  • 父类的指针,那么就调用父类的成员函数。 子类的指针,那么就调用子类的成员函数。

测试:

分析汇编:

  • 根本不会进行对象检查
  • 根本不理会指向的是一个什么对象


2、虚函数

解决办法:使用虚函数 (先不要理解为什么,后面进行分析)

虚函数:被virtual修饰的成员函数

C++中的多态通过虚函数(virtual function)来实现

  • 只要在父类中声明为虚函数
  • 子类中重写的函数也自动变成虚函数(也就是说子类中可以省略virtual关键字


3、总结多态

我认为的多态:重写函数 + Vritual关键字

  • 子类必须要重写父类的成员函数
  • 父类的对应的成员函数,必须添加 virtual 关键字。

多态的含义:

  • 同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。(函数重写,注意不是重载
  • 在运行时,可以识别出真正的对象类型,调用对应子类中的函数。(虚函数:可以识别出真正的对象类型

多态的要素

  • 子类重写父类的成员函数(override)
  • 父类指针指向子类对象
  • 利用父类指针调用重写的成员函数

注意:重写 和 Vitual 缺一个都不能构成多态。

  • 缺重写:本身就只有一个函数,根本就没有多种形态。

  • 缺Vitual:识别不了对应的对象。


4、虚函数表

  • 虚函数的实现原理是虚表,这个虚表里面存储着最终需要调用的虚函数地址,这个虚表也叫虚函数表

问题:虚函数表在哪块内存里面?

  • 答:在系统给分配的一块内存里面。

问题:怎么找到虚函数表?

  • 答:在每个对象的内存当中有一个指针,指向这个虚函数表。

分析这 12 个字节分别是什么:

  • 最前面的4个字节:存放虚函数表的地址值
  • 后面的 8 个字节 : 存放成员变量

分析虚表里面的值:

  • 0-3 字节:speak() 函数的首地址
  • 4-7 字节:eat() 函数的首地址

注意:所有的 Pig 对象(不管在全局区、栈、堆)共用同一份虚表.


再次分析多态的原理:

(1)如果对象里面没有指向虚函数表的指针,父类指针就根本找不到虚函数表,那么就根本无法调用子类重写的函数。

(2)如果有了 virtual 关键字,那么 对象里面就有了 指向虚函数表的指针,父类指针 就可以顺着找到子类重写的函数。


5、虚表的汇编

首先了解一点:

call 的类型有两种情况

  • 直接 call: 以 E8 开头,直接调用指定函数地址的函数。

  • 间接 call: 以 FF 开头,直接调用 eax 当中放的地址,随之 eax 的值发生改变,调用的地址也发生改变

通过指针来间接调用:

虚函数的作用:

  • 挺高动态性,函数可以进行动态调用。而不是编译的时候就弄好了。

6、虚表的细节

(1)每一个类,都有自己独立的虚表同一类的对象共用一个虚表

(2)调用虚函数的时候,子类当中没有重写该函数,那么调用哪个函数呢?

  • 调用的是父类的成员函数
  • 虚表里面仍然还有 2 个函数地址。(8 个字节)
  • 只是第一个并不是子类的,而是父类的成员函数。


(3)疑问3:当我们是父类指针,指向父类对象的时候,会产生虚表嘛?

  • 也是通过虚表,进行动态调用的。

总结:真正实现的多态,对父类对象和子类对象一视同仁

  • 只要有虚函数,无论子类还是父类都会生成虚表
  • 都是通过对象前四个字节,间接找到虚表的地址,从而调用虚函数。

(4)父类被声明为虚函数,子类会自动变为虚函数。

而子类被声明为虚函数,父类并不会自动变为虚函数。


(5)子类有的方法,父类当中没有这个方法,那会怎么调用?

  • 如果父类当中没有这个方法,就不能通过父类指针来进行多态调用

再会到多态的本质:

  • 子类必须重写该父类的函数。(所以 当父类当中没有这个方法,子类就构不成重写)
  • 将对应的函数变为虚函数
  • 最后,通过父类指针来通过对象当中前 4 个的虚函数指针,来实现动态调用。

7、调用父类的成员函数实现

铺垫:

我们重写父类函数的目的有两种情况:

第一种:

  • 父类函数的思想逻辑,我们一点也不想要
  • 我们要全部自己重新写

第二种:

  • 父类函数的思想逻辑,我们可以参考一点
  • 保留父类当中的代码,我们只写一部分自己独特的思想逻辑


8、虚析构函数

如果有多态,应该将析构函数声明为虚函数(虚析构函数) 。

为什么要这样做呢?

分别分析以下的两种情况:

不变为虚函数:

  • 不会有虚表产生,就是直接调用,根本不会去找子类的析构函数。

  • delete 父类指针的时候,只会调用父类的析构函数,并不会调用子类的析构函数,析构不完整

变为虚函数:

  • 产生虚表,将析构函数放到虚表当中,指针就可以找到子类的析构函数。

  • delete父类指针时,才会先调用子类的析构函数,然后调用父类的析构函数,保证析构的完整性


9、纯虚函数、抽象类

首先不要被唬住,不难就是一个定义而已。

铺垫有一种情况:

父类当中的函数很抽象,我们无法具体的进行实现,所以将他设置为 纯虚函数

纯虚函数:没有函数体且初始化为0的虚函数,用来定义接口规范。

怎么理解定义接口规范:(提示其他人怎么编写代码

  • 比如作为一个动物,应该有这些最基本的函数(eat、speak、run)。

  • 告诉子类,你们应该自己实现这个函数。具体怎么实现,自己去写。


抽象类:

  • 含有纯虚函数的类,不可以实例化不可以创建对象)。(只要有一个纯虚函数,那么就不可以实例化)
  • 抽象类也可以包含非纯虚函数成员变量
  • 如果父类是抽象类,子类没有完全重写纯虚函数,那么这个子类依然是抽象类。

多继承

很多编程语言都没有这个特性,因为太复杂了。

多继承:C++允许一个类可以有多个父类(不建议使用,会增加程序设计复杂度)

分析下列代码:

1、有一个 Student 类,属性为 score,方法为 study

2、有一个 Worker 类,属性为 salary,方法为 work

这时候出现一个 Undergraduate 类,是一个大学生,他可以一边做学生,也可以一边兼职打工

3、Undergraduate 类,有自己独特的属性 grade (年级),独特的方法 play


Undergraduate 将他所有的父类成员变量都继承了过来。


1、多继承体系下的构造函数调用


2、多继承的虚表

  • 如果子类继承的多个父类都有虚函数,那么子类对象就会产生对应的多张虚表


3、同名函数、成员变量的调用

  • 同名函数的调用

  • 同名变量的调用:


4、菱形继承

不要被唬住,就是继承模式看起来像是一个菱形

问题:

1、成员变量的冗余重复

  • Student、Worker 都继承了 m_age ,所以 Student 和 Worker 都有 m_age 成员变量。故每个都有两个成员变量。
  • Undergraduate 里面就有 5 个成员变量,一共20个字节。其中有 2 份m_age
struct Undergraduate {
    int m_age;
    int m_score;
    int m_age;
    int m_salary;
    int m_grade;
}

2、二义性:我们不知道 m_age 来自于 Student 还是 Worker


5、虚继承

  • 虚继承可以解决菱形继承带来的问题

写代码:

  • 两个东西必须同时虚继承同一个类

虚继承分析各个对象的内存分布:

注意:

  • 一但是虚继承,会将虚基类的成员变量放到最后面
  • 一但是虚继承,对象内存当中,会多出 4 个字节(虚表指针)。 (此虚表指针并不是指向虚函数的虚表指针)

虚表的内容:

0:虚表指针 与 本类起始的偏移量。(虚表指针放在本类起始的第一个)

8/20虚基类第一个成员变量 与 本类起始的偏移量虚基类第一个成员变量 在本类当中的什么位置)

内存大小:

  • Student/Worker 类占有 12 个字节。
  • Undergraduate 类 占有 24 个字节。

6、多继承的应用

假设有一个兼职中心,招聘兼职,岗位如下:

1、保姆:要求 扫地、做饭

2、老师:要求 踢足球、打篮球

应聘的角色:

1、在校大学生

2、上班族

分析:

  • 招聘机构定义接口规范,标明保姆需要会什么,老师需要会什么。
  • 然后应聘者自己去继承,从而实现这些接口。
#include <iostream>
using namespace std;

class JobBaomu {

	virtual void clean() = 0; // 保姆必须符合这两个条件,但是你具体怎么做饭、怎么打扫自己实现
	virtual void cook() = 0;
};

class JobTeacher {
	virtual void playFootball () = 0;
	virtual void playBasketball () = 0;
};
// 学生这两个职业都可以胜任,所以可以都继承下来
class Student : public JobBaomu, public JobTeacher {
	int m_score;
public:
	// 这些需要我们自己来实现
	void clean() {
	}

	void cook() {
	}

	void playFootball() {
	}

	void playBasketball() {
	}

};

class Worker {
	int m_salary;


};


int main()
{


	getchar();
	return 0;
}

static 静态成员


静态成员:分为两种

含义:被static修饰的 成员变量成员函数 。(一定要注意可以分为两种

怎么访问?

  • 对象(对象 . 静态成员)
  • 对象指针(对象指针 -> 静态成员)
  • 类访问(类名 :: 静态成员)

1、静态成员变量

接下来分析静态成员变量的特点:

内存位置:存储在数据段(全局区,类似于全局变量),整个程序运行过程中只有一份内存

怎么理解只有一个内存?

  • 所有对象共用这一个成员变量。
  • 对象没有创建之前,就有这个静态成员变量。(静态成员变量是不依赖与对象的


2、对比全局变量,它可以设定访问权限(public、 protected、 private),达到局部共享的目的。

  • 全局变量:在外面我们不能设定访问权限,外面都可以访问。
  • 静态变量设置为 public :外面也可以访问。
  • 静态变量设置为 protected :父类及其子类都可以访问
  • 静态变量设置为 private:只有这个类才可以访问。

3、必须初始化,必须在类外面初始化,初始化时不能带static,如果类的声明和实现分离(在实现.cpp中初始化


2、静态成员函数

怎么访问?

  • 对象(对象 . 静态成员)
  • 对象指针(对象指针 -> 静态成员)
  • 类名访问(类名 :: 静态成员)

静态成员函数的特点:

1、内部不能使用this指针(this指针只能用在非静态成员函数内部)

2、内部不能访问非静态成员变量\\函数,只能访问静态成员变量\\函数

3、非静态成员函数内部可以访问静态成员变量\\函数 (很简单,因为在全局区,所以可以进行访问)

为什么?我们先来分析 this 是干什么用的,this 指向对象的首地址。 (所以说 this 是紧紧依附于对象的)

再来分析类成员函数

  • 因为可以通过类名来进行访问,所以他是不依赖于对象的。
  • 既然不依赖于对象存在所以 this 指针就没有意义了


4、不能是虚函数(虚函数只能是非静态成员函数)

思考:虚函数是用在多态上面。(父类指针 -> 子类对象),所以说是牵着到对象,所以不可以。

5、构造函数析构函数不能是静态

思考:构造函数、析构函数,是在对象创建和销毁的时候调用的,所以说也是牵扯着对象。

6、当声明和实现分离时,实现部分不能带static


3、静态成员汇编分析

  • 普通成员变量:放在栈空间上

  • 静态成员变量:放在 data segment 上面(地址是写死的

  • 全局变量:放在 data segment 上面


4、静态成员的继承

首先明确一点:静态成员只有一份,不会被继承!!!

继承只是继承 非静态成员变量


5、static 的应用

假设我们想要监控该类对象,到底产生了多少个?

思路:

  • 通过构造函数,因为每创建一个对象,就会调用一次构造函数。
  • 通过析构函数,因为每销毁一个对象,就会调用一次析构函数。

这个变量为普通成员变量:

全局变量

  • 外面所有函数都可以访问,所以就比较危险。
  • 我们希望只有 构造函数 和 析构函数 可以访问

private 静态变量:

改进:留个接口来进行访问

  • 依赖于对象,至少创建一个对象。
  • 只能通过对象来进行访问。

继续改进:使用静态成员函数

  • 不依赖于对象,不需要创建一个对象
  • 通过类名来直接访问。


6、静态成员经典应用 – 单例模式

单例模式:设计模式当中的一种

应用场合:保证每个类永远只创建一个对象

  • 在百度网盘运行的时候,只有这一个窗口图标

单例模式的步骤:

1、构造函数私有化(private)———— 外部就不能创建对象了。

2、既然类外不能创建,所以我们要在类内进行创建

分析需求:想要通过一个函数来创建一个对象

  • 参数:不需要
  • 返回值:返回对象的首地址。

要求:只能创建一个对象。

  • 第一次调用:返回新 new 的对象的首地址。
  • 第二次调用:返回之前对象的首地址。

改进:将函数改为 static 函数。

缺点:

  • 全局变量到处可以修改
  • 通过修改全局变量,可以创建多个对象

改进:将全局变量变为类内私有静态变量

完善一个 delete 的接口:

总结单例模式:

1、构造函数私有化

2、定义一个私有的 static 成员变量指向唯一的那个单例对象

3、提供一个公共的访问单例对象的接口


7、单例模式的完善

单例模式:只能生成一个对象

缺点:对象赋值的时候,不报错。 因为只有一个对象,赋值也没有意义。

改进: 将 = (赋值运算符)进行重载

const 类型的参数,不能进行赋值运算。


const 成员

首先要明白,const 成员也分为两部分:

  • const 成员变量
  • const 成员函数


1、const 成员变量

语法糖:

  • 必须初始化(类内部初始化),可以在声明的时候直接初始化赋值。(static 必须在全局区初始化)

  • 非static的const成员变量还可以在构造函数的初始化列表中初始化。

  • 非静态的 const 成员变量,在每一份对象当中都存在

const 的参数可以接收:非const 和 const 成员两种参数


2、const 成员函数

特点:

  • const关键字写在参数列表后面函数的声明和实现都必须带const
  • 内部不能修改非static成员变量、成员函数 (限制内部不能改变普通成员变量、成员函数 )

  • 内部只能调用 const成员函数static成员函数

    因为 const成员函数static成员函数 都不能访问 非static成员变量。


  • const成员函数和非const成员函数构成重载
  • const 对象调用 const 成员函数。

为什么要这么做?

  • 当我们要定义const 对象的时候,我们希望 const 对象当中的 非 static 元素不能进行修改
  • 正好 cosnt 成员函数当中就不可以对 非 static 元素进行修改。

  • 普通对象:改不改都可以
  • const 对象:必须不能改

总结:

  • 非const对象(指针)优先调用非const成员函数 ,实在没有非const成员函数,才会调用 const 成员函数。
  • const对象(指针)只能调用const成员函数、 static成员函数

3、引用类型成员

以上是关于C++学习:3多态的主要内容,如果未能解决你的问题,请参考以下文章

学习攻略C++虚函数表及多态内部原理详解

c++复习笔记——多态详细解析,多态的原理,多态的笔试题

java中封装,继承,多态,接口学习总结

c++学习笔记:多态

学习 C++:多态性和切片

c++ 多态学习