Bootstrap

C++ 多态

前言

多态 是面向对象三大特性的最后一个,多态 说的是 具有继承关系的不同对象,调用同一方法时展现出来的不同状态!也就是说 多态 是基于 继承 实现的!本博客将详细介绍C++的多态及其原理!

前言

1、多态的概念

• 构成多态的两个必要条件

2、虚函数

2.1 什么是虚函数

2.2 虚函数的重写

• 虚函数重写的两个例外

2.3 final 和 override

2.4 重载、重写、重定义

3、抽象类

4、多态的原理

4.1 虚函数表和虚函数表指针

• 子类的虚表是如何生成的?

• 虚函数表补充

4.2 多态原理

• 两个思考问题

4.3 静态绑定和动态绑定

5、多继承与多态

5.1 多继承与多态

5.2 虚继承与多态


1、多态的概念

多态 指的是 具有继承关系不同对象在调用同一函数 时形成的不同状态!

举个例子:比如我们平时买高铁/火车票时,不同的人买票买的结果都是不一样的!学生是半价、成人是全价、军人是军人优先票 等!而这三种对象本质都是继承自同一个 Person 的父类,所以他们执行同一操作时不同的结果就是多态

• 构成多态的两个必要条件

1、必须通过基类/父类的 指针或者引用 调用虚函数

2、派生类必须得对基类的虚函数进行重写

举个栗子先见一见:

class Person
{
public:
	// 虚函数
	virtual void BuyTicket()
	{
		cout << "Person::买票-成人-全价" << endl;
	}
};

class Student : public Person // 构成继承关系
{
public:
	// 虚函数
	virtual void BuyTicket()
	{
		cout << "Student::买票-学生-半价" << endl;
	}
};

int main()
{
	// 父类对象的指针 调用虚函数
	Person* pp = new Student;
	pp->BuyTicket();
	// 父类对象的引用 调用子类的对象
	Student st;
	Person& rp = st;
	rp.BuyTicket();

	Person p;
	Person& r = p;// 父类对象的引用 调用虚函数
	r.BuyTicket();
	Person* ptr = &p;// 父类对象的指针 调用虚函数
	ptr->BuyTicket();

	return 0;
}

此时,不同的对象去以父类的指针/引用去调用虚函数时结果是不一样的

C++的多态是基于虚函数和虚函数的重写实现的,所以在我们正式的介绍多态之前,得先把虚函数和虚函数的重写给介绍了!

2、虚函数

2.1 什么是虚函数

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

class Person
{
public:
	// 虚函数
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

此时的 BuyTicket 就是一个虚函数1、他是成员函数 2、他被virtual修饰

注意virtual 只有修饰的是成员函数是才是虚函数,且virtual 不能修饰非成员函数

2.2 虚函数的重写

虚函数的重写 又称虚函数的覆盖,指的是子类中有一个和父类中一样的"三同 " 虚函数,则称子类中的虚函数重写了父类的虚函数!

三同 指的是 :返回值函数名形参列表类型 都相同

class Person
{
public:
	// 虚函数
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

class Student : public Person // 构成继承关系
{
public:
	// 虚函数
	virtual void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
};

此时,Person类 中的 BuyTicket Student类 中的 BuyTicket 虚函数构成重写!

• 重写必须是虚函数,不是虚函数,父子类中的BuyTicket 函数就是隐藏关系 

子类中的虚函数重写,本质是子类继承了父类的虚函数声明,重写实现部分

第二点很有必要说一下:

class Person
{
public:
	// 虚函数
	virtual void BuyTicket(int a = 10)
	{
		cout << "Person::买票-全价-" << a << endl;
	}
};

class Student : public Person // 构成继承关系
{
public:
	// 虚函数
	virtual void BuyTicket(int b = 20)
	{
		cout << "Student::买票-半价-"<< b << endl;
	}
};

int main()
{
	Person* pp = new Student;// 多态
	pp->BuyTicket();
	return 0;
}

这段代码如果利用多态调用的话,乍一看结果不就是20吗?是不是呢?看结果:

咋是10呢?其实原因就是上面说的,子类虚函数的重写本质是继承了父类的声明,重写了实现

注意这里和形参的名字没有关系,只看类型是否相同! 另外,既然是子类继承了父类的声明,所以子类可以不写 virtual 关键字 但是一般建议还是加上!!

• 虚函数重写的两个例外

1、协变 :子类重写虚函数时,子类和父类的虚函数的返回值不同,子类虚函数返回子类类型的指针/引用,父类虚函数返回父类类型的指针或引用!

class Person
{
public:
	// 虚函数
	// virtual Person& BuyTicket()
	virtual Person* BuyTicket()
	{
		cout << "Person::买票-成人-全价" << endl;
		return nullptr;
	}
};

class Student : public Person // 构成继承关系
{
public:
	// 虚函数
	// virtual Student& BuyTicket()
	virtual Student* BuyTicket()
	{
		cout << "Student::买票-学生-半价" << endl;
		return nullptr;
	}
};

int main()
{
	// 父类对象的指针 调用虚函数
	Person* pp = new Student;
	pp->BuyTicket();

	Person p;
	Person& r = p;// 父类对象的引用 调用子类的对象
	r.BuyTicket();

	return 0;
}

2、析构函数的重写

如果在多态中,父类的析构不写成虚函数,就会可能造成内存泄漏

原因是:多态调用是父类型的指针或引用在调用对应的虚函数,而父子类的析构是不同名的!当父类的指针或引用对象销毁时,就是默认去掉父类的,造成子类对象的内存泄漏!

所以父类必须得将析构写成虚函数,子类继承后默认就是虚函数,就可以合理的释放资源了!

class Person
{
public:
	// 虚函数
	// virtual Person& BuyTicket()
	virtual Person* BuyTicket()
	{
		cout << "Person::买票-成人-全价" << endl;
		return nullptr;
	}

	~Person()
	{
		cout << "~Person()" << endl;
	}
};

class Student : public Person // 构成继承关系
{
public:
	// 虚函数
	// virtual Student& BuyTicket()
	virtual Student* BuyTicket()
	{
		cout << "Student::买票-学生-半价" << endl;
		return nullptr;
	}

	~Student()
	{
		cout << "~Student()" << endl;
	}
};

int main()
{
	// 父类对象的指针 调用虚函数
	Person* pp = new Student;
	// pp->BuyTicket();

	Person* p = new Person;
	// p->BuyTicket();

	delete pp;
	delete p;
}

这是没有将父类的析构设置成虚函数前

可以看到没有将Student的对象释放,而是将 Person 的对象释放了两次!不仅造成内存泄漏,还导致重复析构!!

原因是刚说的,不将父类的析构写成虚函数会导致,父子类中 的析构是不同的函数(函数名不同)构不成重写,所以父类型的指针或引用就会调到父类的析构了!

将父类和子类的析构搞成虚函数的重写,即将父类的析构前加上 virtual 就可以解决!

但是构成重写的条件不是 三同 吗这里明显的函数名不同啊!其实编译器的底层对父子类的析构函数重写进行了统一的处理,名字都是 destructor ,这样他两就构成了虚函数的重写!

这里因为子类继承了父类,子类构造前先要调用父类构造初始化父类的那一部分,结束后 调用完子类的析构后还会调用父类的析构,所以这里多了中间的 ~Person !

2.3 final 和 override

C++11中,提供了 final override 两个关键字

final 修饰父类虚函数,表示该虚函数不能被重写,即不构成多态

override 修饰子类虚函数,检查是否构成重写(是否满足重写的条件),不满足则报错

显然,前者是:不想被重写->不想构成多态;后者是:检查是否重写->想构成多态

对父类的虚函数加上 final 子类就不能重写了

对子类的虚函数加上 override 进行检查

另外,final 还可以修饰类,表示该类不可以被继承,即最终类!

注意final 可以修饰子类,以为子类也有可能变成父类

2.4 重载、重写、重定义

我们目前已经学习了 "三重"重载重写重定义

这三兄弟,看起来很容易混淆,实际上确实容易迷糊!下面就来区分一下

重载:即 函数重载,在同一作用域中,函数名相同、形参列表不同 的函数

重写:即虚函数重写又称覆盖,在父子类中,构成 三同(返回值、函数名、形参列表) 的虚函数

重定义:又称隐藏,在父子类中函数名相同且不是重写的函数

也就是说重定义式包含重写的,因为重写也是函数名相同的!

3、抽象类

抽象类是一种特殊的类,他不能实例化对象,只能当作其他类的基类使用

抽象类的目的是为了设计出一些通用的接口(没有实现),强制要求继承抽象类的子类要重写抽象类中的虚函数

可以认为是提高了编程的整体规范性

定义一个抽象类,该类中必须至少有一个 纯虚函数(虚函数以 =0 结尾且没有函数体的实现)

class Person
{
public:
	virtual void func(int a) = 0;// 纯虚函数,没有实现只有接口的申明
};

子类要使用这这些纯虚函数的接口,就需要对继承下来的纯虚函数的接口进行重写

class Person
{
public:
	virtual void func(int a) = 0;// 纯虚函数,没有实现只有接口的申明
};

class Student final : public Person
{
public:
    // 重写纯虚函数
	virtual void func(int a) override
	{
		cout << "Student::func(int a)" << endl;
	}
};

抽象类的继承很好的体现了虚函数重写时是接口式继承父类的接口申明+子类的实现

普通继承:子类可以直接使用父类中的函数

接口继承:子类继承父类的接口声明,需要自己重写实现(多态/抽象类)

建议:不实现多态,就不要把成员函数定义成虚函数

注意:如果子类只是继承了抽象类,没有重写它的纯虚函数的接口,子类也是无法实例对象的!

4、多态的原理

上面介绍了多态,也知道咋用了!那多态是如何做到不同对象调用同一方法时展现出不同的结果的呢?下面我们就来探索一下多态的原理!

4.1 虚函数表和虚函数表指针

我们先来看一段代码

class A
{
public:
	virtual void Func()
	{
		cout << "Func" << endl;
	}
};

int main()
{
	A a;
	cout << sizeof(a) << endl;
	return 0;
}

这个对象的大小是多少呢?如果你类和对象初阶那里看了,你可能会说是 1 ?是不是呢?

这里虽然类中没有一个成员,但是前面说过当类中没有一个成员属性的时候,会用1个字节表示这是一个空类!

但是这里我们也没有成员啊,为什么这里的大小是 4 字节呢?

那原因肯定和 virtual 有关系喽!

虚函数表

当一个类中包含有虚函数时,编译器在编译阶段会为该类创建一张表叫做虚函数表,简称虚表(virtual function table, VFT);

虚函数表的本质就是一个函数指针数组(nullptr结尾),作用就是存储当前类中的虚函数地址

虚函数表指针

每个包含虚函数类的对象都有一个指向其类的虚函数表的指针,称为虚函数表指针,简称虚表指针VFPTR

虚函数表指针一般存储在对象模型的开始位置!它的作用是:对象运行时通过它找到虚表中的对应虚函数进行调用

• 子类的虚表是如何生成的?

1、子类会将父类的虚函数表继承一份(拷贝一份)

2、如果子类重写了父类的虚函数,则将重写的虚函数地址覆盖掉父类虚函数的地址

3、如果派生类有自己额外的虚函数,则依次添加在子类虚表的结尾

4、虚函数他是不会被放进虚函数表的

注意:派生类一般是不自己生成虚表的,而是直接继承父类的!

这里的第二点其实就是重写为什么叫覆盖的原因,重写是语法叫的,覆盖是底层叫的~!同时,第二点也是实现多态的核心!

我们可以举个例子,用监视窗口看看:

class A
{
public:
	virtual void Func1()
	{
		cout << "Func1" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2" << endl;
	}

	void Func3()
	{
		cout << "Func3" << endl;
	}
};

class  B : public A
{
public:
	virtual void Func1() override
	{
		cout << "Func1" << endl;
	}
};

int main()
{
	A a;
	B b;
	return 0;
}

当然可以加一些字段验证来验证虚表指针在对象模型的最开始

虚表是以 nullptr 结尾的,我们也可以打印出来看看,顺便玩一玩C语言指针的高级玩法

typedef void(*VFPTR)();// 将void(*)() 的函数指针 重定义为 VFPTR
void PrintVFT(VFPTR vf_table[])// 这里是一个指针, 可以写成 VFPTR* vf_table
{
	int i = 0;
	while (vf_table[i])
	{
		printf("%p -> ", vf_table[i]);
		VFPTR f = vf_table[i];
		f();// 调用
		i++;
	}
}

int main()
{
	A a;
	B b;

	PrintVFT((VFPTR*)*(int*)&a);// 将对象取地址,然后强转为4个字节,然后解引用取前四个字节并强转为VFPTR*
	PrintVFT((VFPTR*)*(int*)&b);

	return 0;
}

如果这个指针你可以搞出来,那你的指针水平可以说是很强了! 

注意:当前是 32位 如果在64位需要转为8个字节!

• 虚函数表补充

1、虚函数表是在编译阶段生成的

2、虚函数表是在对象初始化走初始化列表时初始化的

3、虚表指针属于对象,虚表属于类,一般存在常量区

前两点都很好理解,也很好验证,但是第三点真不好验证,我们这里采用对比法验证:

int main()
{
	//验证虚表的存储位置
	A aa;
	B bb;

	int a = 10;	//栈
	int* b = new int;	//堆
	static int c = 0;	//静态区
	const char* d = "xxx";	//常量区

	printf("a-栈地址:%p\n", &a);
	printf("b-堆地址:%p\n", b);
	printf("c-静态区地址:%p\n", &c);
	printf("d-常量区地址:%p\n", d);

	printf("p 对象虚表地址:%p\n", *(VFPTR**)&aa);// 等价于 (VFPTR*)*(int*)&aa
	printf("s 对象虚表地址:%p\n", (VFPTR*)*(int*)&bb);

	return 0;
}

和常量区非常近,所以一般认为 虚函数表存储在常量区,属于类!

4.2 多态原理

当有了虚表指针和虚表我们可以理解多态的调用原理了!

1、当父类的指针/引用, 指向/引用父类对象时,去父类对象的虚表调用对应的虚函数

2、当父类的指针/引用, 指向/引用子类对象时,去子类对象的虚表调用重写的虚函数

3、当使用父类对象调用子类额外的虚函数时,是调不到的,不构成多态

可以把父类的指针/引用子类的对象,去查找虚表的过程认为是对象切片

将子类中父类的那一部分(虚表和其他)切出来给父类,父类去对象首部找到虚表指针,然后在子类虚表中索引调用的虚函数,如果有就调用,没有就去父类查找(有就调用,没有报错)

为什么说是认为是一次对象切片呢?其实这里并不是切片,因为父类指针或引用指向子类对象时,子类对象的首部是继承下来的父类的那一部分,也就是说子类对象的首部依然是虚表指针!和父类指针/引用调用父类对象是没有区别的!这也就做到了指向谁就调用谁的效果,即实现多态!

• 两个思考问题

1、为什么父类对象的指针/引用调不到子类对象的额外虚函数?

因为此时不构成多态,构成多态的两条件:1、重写父类的虚函数 2、父类的指针/引用调用

所以就不会去子类中查找虚表!而是直接根据调用的类型去对应的类查找!

2、为什么将子类对象切片给父类对象,实现不了多态效果?

首先还是构不成多态!这一点毋庸置疑,但是为什么这里就构不成多态,而非要是父类的指针/引用才能构成呢?

原因在于当对象切片时,只会将子类中父类的成员给切出来,会把其他子类独有的(包含虚表)直接丢弃!对象切片的本质是创建一个新的父类对象,并将子类中父类的那部分给这个新的对象,虚表丢弃了,只给了成员!所以使用对象切片是无法实现多态的!

4.3 静态绑定和动态绑定

静态绑定又称前期绑定/静态多态:在程序编译期间就确定了调用的形式。例如:函数重载、模版

动态绑定又称后期绑定/动态多态:在程序运行期间才去确定调用的形式。例如:多态在运行时才去查找虚表中的虚函数!

我们可以利用汇编查看他两的区别:

静态绑定应该是,编译完成直接 call 的,而动态绑定是运行时采取找的,即不是直接call的:

class A 
{
public:
    void f() 
    {
        cout << "A::f() is called." << endl;
    }
    void f(int x) 
    {
        cout << "A::f(int) is called with value: " << x << endl;
    }
};

int main() {
    A a;
    a.f(); // 调用第一个f()  
    a.f(10); // 调用第二个f(int)  
    return 0;
}

 再来一个动态绑定的,即多态的例子:

class A 
{
public:
    virtual void f() 
    {
        cout << "A::f() is called." << endl;
    }
    void f(int x) 
    {
        cout << "A::f(int) is called with value: " << x << endl;
    }
};

class B : public A
{
public:
    virtual void f()
    {
        cout << "B::f() is called." << endl;
    }
};

5、多继承与多态

上面介绍的点多态原理是基于单继承的,其中子类将父类的虚表和虚表指针继承下来,用子类中的重写的虚函数的地址覆盖掉当父类中的虚函数地址,然后将子类自己的虚函数放到继承下来虚表的结尾!这很简单,那如果继承关系不是单继承而是多继承呢?下面我们就来讨论一下多继承的情况!

5.1 多继承与多态

我们直接来一个多继承的栗子

class Base1 
{
public:
	virtual void func1()
	{
		cout << "Base1::func1" << endl;
	}

	virtual void func2()
	{
		cout << "Base1::func2" << endl;
	}

private:
	int b1;
};

class Base2
{
public:
	virtual void func1()
	{
		cout << "Base2::func1" << endl;
	}

	virtual void func2()
	{
		cout << "Base2::func2" << endl;
	}

private:

	int b2;
};

class Derive : public Base1, public Base2
{
public:
	virtual void func1()
	{
		cout << "Derive::func1" << endl;
	}

	virtual void func3()
	{
		cout << "Derive::func3" << endl;
	}

private:
	int d1;
};

此时,Derive 就是多继承的情况!因为是先继承的先存放,对象模型如下:

这里就有两个问题:

1、func3 应该存放在哪一张虚表?

2、子类重写的func1应该如何覆盖?覆盖在继承下来的那一个虚表?

因为VS 的监视窗口不会对子类的虚函数进行在虚表中显示,所以我们直接打印地址:

typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0x%p,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}


int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

我们可以看到:

1、当多继承时,会将子类重写的函数覆盖在第一个继承的虚表中

2、会将子类额外的虚函数放在第一个继承虚表的结尾

按理来说:子类重写之后,因该是把两个继承下来的虚表中的func1的地址进行都覆盖,也就是两个虚表中的func1的地址应该是一样的,这样才能实现多态的效果!但是我们发现这里两个虚表中的 func1的地址明显不一样!那他们是如何实现多态效果的呢?

这里他是这样做的:

1、当第一个继承类的指针/引用访问时,当前虚表指针就在对象的头部,直接查找虚表然后访问

2、当第二个继承累的指针/引用访问时,虚表不在当前对象的头部,需要将当前对象偏移第一个继承类的大小,才能访问到

这也是我么上面打印出他们各自虚表的原理!

5.2 虚继承与多态

在虚继承中存在 虚基表和虚基表指针,在多态这里又有虚表和虚表指针,这很容易混淆,下面来梳理一下:

1、virtual 

虚函数 与 虚继承 共用一个关键字 virtual ,就好像 引用和取地址一样,没多大关系

2、指针

虚表指针 全称 虚函数表指针,指向的是虚函数表/虚表

虚基表指针 指向 虚基表的指针

当出现菱形虚拟继承和多态时,虚基表指针放在虚表指针的上面

3、表

虚表/虚函数表:是一个函数指针数组存的是类中虚函数的地址

虚基表:用来共享间接基类的数据的,里面存的是 当前对象虚基表指针距离共享数据的偏移量

在虚基表中,第一个位置会空出来,存储一个虚基表指针与虚表指针的偏移量

此时D的对象模型结构图如下:

最后的虚拟继承和多态这里,校招面试中不会考的!我们只是分析一下!如果最后这里不明白也没有关系!


OK,本期分享就到这里,我是 cp 我们下期再见!

;