Bootstrap

C++之多态的深度剖析

目录

前言

1.多态的概念

2.多态的定义及实现

2.1多态的构成条件

2.1.1重要条件

2.1.2 虚函数

2.1.3 虚函数的重写/覆盖

2.1.4 选择题

2.1.5 虚函数其他知识

协变(了解)

 析构函数的重写

override 和 final关键字

3. 重载,重写,隐藏的对比

 4.纯虚函数和抽象类

结束语


前言

在前面我们对C++的封装,继承等特性都有了了解和学习,接下来我们将对C++的第三大特性-多态进行认识和掌握。内容分为来两大部分,第一个是对多态的认识和运用,第二大部分是对多态原理的了解和扩展。

1.多态的概念

多态(Polymorphism)是面向对象编程(OOP)中的一个核心概念,它指的是同一个行为具有多个不同表现形式或形态的能力。在编程中,多态通常通过继承(inheritance)和接(interfaces来实现。

以下是多态的几个主要方面:

  1. 编译时多态(静态多态):这是在编译时确定的多态性,通常通过函数重载(function overloading)和模板(templates)来实现。编译器根据函数的参数类型或数量来决定调用哪个函数。

  2. 运行时多态(动态多态):这是在程序运行时确定的多态性,主要通过虚函数(virtual functions)和继承来实现。在运行时,根据对象的实际类型来调用相应的成员函数。

之所以叫编译时多态,是 因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种 形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<)喵“,传狗对象过去,就是"汪汪"。

多态的关键特性包括:

  • 继承:子类继承父类的属性和行为,可以对这些行为进行重写(override)。
  • 虚函数:在基类中声明为虚的成员函数,可以在派生类中被重写,使得通过基类指针或引用调用函数时,能够根据对象的实际类型来调用相应的函数版本。
  • 虚函数表:用于实现运行时多态的数据结构,它存储了虚函数的地址,使得程序能够在运行时确定调用哪个函数。
  • 向上转型:将派生类对象的引用或指针转换为基类类型的引用或指针,这是多态实现的基础。

2.多态的定义及实现

2.1多态的构成条件

多态是一个继承关系的下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象优惠买票。

2.1.1重要条件

被调用的函数必须是虚函数
指针或者引用调用虚函数
说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生 类对象;第二派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的函数,多 态的不同形态效果才能达到。
ac30984662c6415b9d1e6519bc6a0a3c.png

2.1.2 虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意⾮成员函数不能加virtual修
饰。
class Person {
public:
	virtual void BuyTicket() {
		cout << "买票全额" << endl;
	}
};

2.1.3 虚函数的重写/覆盖

虚函数的重写/覆盖: 派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表(类型,数量)完全相同),称派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承 后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样 使用,不过在考试选择题中,经常会故意买这个坑,让判断是否构成多态

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Person {
public:
	virtual void BuyTicket() {
		cout << "买票全额" << endl;
	}
};
class Student : public Person {
public:
	virtual void BuyTicket() {
		cout << "学生票半价" << endl;
	}
};
//引用调用
void func(Person& p) {
	p.BuyTicket();
}
//指针调用
void func1(Person* p) {
	p->BuyTicket();
	// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket
    // 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
}
int main() {
	Person p1;
	Student s1;
	Person* p2 = new Person();
	Student* s2 = new Student();
	func(p1);
	func(s1);
	p1.BuyTicket();
	s1.BuyTicket();
	func1(&p1);
	func1(&s1);
	p2->BuyTicket();
	s2->BuyTicket();

	return 0;
}

 0a05807e075c4caca7e97d76458d4218.png

void func(Student& p) {
    p.BuyTicket();
}
//指针调用
void func1(Student* p) {
    p->BuyTicket();
}

如果改成Student,就会出问题,就不是多态了,也就不能传Person对象了。 

#include <iostream>
using namespace std;

class Pet {
public:
	virtual void eat() const{
		cout << "Eat food" << endl;
	}
};
class Dog : public Pet{
public:
	virtual void eat() const {
		cout << "Dog eats meat!" << endl;
	}
};
class Cat :public Pet {
public:
	virtual void eat()const {
		cout << "Cat eats fish!" << endl;
	}
};
void func(const Pet& p) {
	p.eat();
}
int main() {
	Pet p;
	Dog g;
	Cat c;
	func(p);
	func(g);
	func(c);
	return 0;
}

 上述是宠物的一个多态实现。

这里我们测试一下,基类函数不加virtual会怎样,

class Pet {
public:
     void eat() const{
        cout << "Eat food" << endl;
    }
};

da905b0d2aef46c3b394655b042a457f.png

我们会发现多态效果没有实现,所以一定要加上virtual. 

2.1.4 选择题

下面程序输出结果是什么?(B)

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

class A {
public:
virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;}
virtual void test(){ func();}
};
class B : public A {
public:
void func(int val = 0){ std::cout<<"B->"<< val <<std::endl; }
};
int main(int argc ,char* argv[]) {
 B*p = new B;
 p->test();
return 0; }

  • B* p = new B; 创建了一个 B 类型的对象,并通过基类指针 p 指向它。
  • p->test(); 调用了 A 类的 test 方法(因为 B 类没有重写 test 方法)。
  • 在 A 类的 test 方法中,func(val) 被调用,没有指定 val 的值,因此它使用 A 类 func 方法的默认参数 1
  • 由于 func 是虚函数,并且 p 指向一个 B 类型的对象,所以 B 类的 func 方法被调用,接收到的参数是 1

2.1.5 虚函数其他知识

协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;

class A {};
class B : public A {};
class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};
class Student : public Person {
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};
void Func(Person* ptr)
{
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}
 析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor, 所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

故在C++中,当一个基类的析构函数被声明为虚函数时,它确保了当通过基类指针或引用删除派生类对象时,会调用正确的析构函数,即派生类的析构函数,然后再调用基类的析构函数。这是因为虚析构函数允许动态绑定,确保了派生类对象被正确地销毁。

#include <iostream>
using namespace std;
class A {
public:
	virtual ~A() {
		cout << "delete A" << endl;
	}
}; 
class B :public A {
	public:
		~B() {
			cout << "~B()->delete:" << _p << endl;
			delete _p;
		}
protected:
	int* _p = new int[10];
};
int main() {
	A* a = new A;
	A* b = new B;
	delete a;
	delete b;
	return 0;
}

2c9ec88991b54a408d740498d87f66fc.png

当我们不把基类析构函数设置成virtual时, 会发现没有调用B的析构,该释放的资源没有释放掉。

public:
	~A() {
		cout << "delete A" << endl;
	}
}; 

e451daade64e44c1a890b5c6dbc382e7.png

故基类的析构函数我们要设置成虚函数。

override 和 final关键字
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失。
如果不想让派生类重写这个虚函数,那么可以用final去修饰。
在C++中,override 和 final 关键字是C++11标准引入的,用于增强类继承和虚函数的声明。

override 关键字用于明确指出一个成员函数旨在重写(覆盖)其基类中的一个虚函数。如果该函数没有正确地重写基类中的任何虚函数,编译器将报错。这有助于避免因拼写错误或参数列表不匹配而意外地没有重写虚函数的情况。
 

	class Car {
	public:
		virtual void Dirve()
		{}
	};
	class Benz :public Car {
	public:
		virtual void Drive() override { cout << "Benz-舒适" << endl; }
	};

比如上面这个例子,函数名写错了,重写失败,编译报错。

3c24128dc270456985512ecfe80ec180.png


final 关键字用于防止类被进一步派生,或者防止虚函数被重写。当应用于类时,它表示这个类不能被继承。当应用于虚函数时,它表示这个虚函数不能在派生类中被重写。

class Car {
public:
	virtual void Dirve() final
	{}
};
class Benz :public Car {
public:
	virtual void Dirve(){ cout << "Benz-舒适" << endl; }
};

 992bd20cde2c433b998df1a03c050a49.png



class Base final { // 不能从这个类派生其他类
public:
    virtual void doSomething() const final {} // 这个虚函数不能被重写
};
// 下面的类声明会导致编译错误,因为 Base 是 final 的
// class Derived : public Base {};
// 下面的函数声明也会导致编译错误,因为 doSomething 是 final 的
// class Derived : public Base {
// public:
//     void doSomething() const override {} // 错误:不能重写 final 函数
// };

使用 final 关键字可以确保类或虚函数的行为不会被意外的继承或重写改变,这对于设计那些不打算被扩展的类或函数非常有用。 

3. 重载,重写,隐藏的对比

重载(Overloading)

  • 定义:在同一作用域内,可以定义多个同名函数,只要它们的参数列表(参数的数量、类型或顺序)不同。
  • 特点
    • 发生在同一类中。
    • 参数列表必须不同。
    • 返回类型可以不同,但不是区分重载的主要因素。

重写(Overriding)

  • 定义:在派生类中提供一个与基类中虚函数同名、参数列表和返回类型相同的函数,以实现多态。
  • 特点
    • 发生在基类和派生类之间。
    • 参数列表和返回类型必须相同。
    • 基类函数必须是虚函数。
    • 使用 override 关键字可以明确指出重写意图。

 隐藏(Hiding)

  • 定义:在派生类中定义一个与基类中成员(非虚函数或非静态成员变量)同名的成员,导致基类中的同名成员在派生类中不可见。
  • 特点
    • 发生在基类和派生类之间。
    • 可以是函数或变量。
    • 如果是函数,参数列表不必相同。
    • 如果派生类中的成员与基类中的成员具有相同的名称,但不同的参数列表,则基类成员被隐藏,而不是重载或重写。

 4.纯虚函数和抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。
包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
#include <iostream>
using namespace std;
class Car {
public:
	virtual void Drive() = 0;

};
class Benchi :public Car {
public:
	virtual void Drive() {
		cout << "Benchi-舒适" << endl;
	}
};

class Baoma :public Car {
public:
	virtual void Drive() {
		cout << "Baoma-上手" << endl;
	}
};
int main() {
	Car car;
	Car* b = new Benchi();
	b->Drive();
	Car* m = new Baoma();
	m->Drive();
	return 0;
}

337ab3ba1fc946b4ba2ffe85b6eb524f.png

 这里Car是抽象类,所以无法实例化对象。

结束语

本期内容就到此结束了,内容有点多,下节我们将对多态的原理进行补充讲解。

最后感谢各位友友的支持!!!

;