从名字上来看,多态不就是多种形态吗?接下来看看多态的概念~
概念: 多态(polymorphism)是一个在面向对象编程中非常重要的概念,它通俗地可以理解为“多种形态”。多态性允许我们在程序中通过统一的接口来调用不同的实现,从而提高了代码的灵活性和可扩展性。
类型: 多态主要分为两种类型:编译时多态(静态多态)和运行时多态(动态多态)。
编译时多态(静态多态)主要通过函数重载和函数模板来实现 函数重载允许在同一个作用域内定义多个同名但参数类型或数量不同的函数,这样在编译时就可以根据传入的参数类型或数量来确定调用哪个函数。函数模板则是一种更通用的方式,它允许我们编写与类型无关的代码,编译器在编译时会根据传入的类型来生成相应的函数实现。由于这两种多态性的实现都是在编译时完成的参数匹配,因此它们被称为编译时多态或静态多态。
运行时多态(动态多态)则是通过虚函数和继承关系来实现的 在运行时多态中,基类指针或引用可以指向派生类对象,并通过基类指针或引用来调用虚函数。此时,调用的虚函数是派生类中的实现,而不是基类中的实现。这种多态性的实现是在运行时完成的,因为只有在运行时才能确定基类指针或引用实际指向的是哪个类的对象。因此,运行时多态也被称为动态多态。
具体来说,运行时多态允许我们在完成某个行为(函数)时,传入不同的对象就会完成不同的行为,从而达到多种形态。假设我们正在开发一个图形绘制应用程序,该程序需要支持多种图形对象(如圆形、矩形和三角形)的绘制。我们可以设计一个基类Shape
,并在其中定义一个虚函数draw()
用于绘制图形。不同的图形对象(圆形、矩形和三角形)将作为Shape
的派生类,并在这些派生类中重写draw()
函数以实现各自的绘制逻辑。同样地,在动物叫声的模拟中,传入猫对象时发出“喵”的叫声,传入狗对象时发出“汪汪”的叫声,这也是通过多态性来实现的。
总之,多态性是面向对象编程中一个非常重要的特性,它允许我们在程序中通过统一的接口来调用不同的实现,从而提高了代码的灵活性和可扩展性。无论是编译时多态还是运行时多态,都在不同的场景下发挥着重要的作用。
当一个类成员函数的前面被添加了virtual
关键字进行修饰时,这个成员函数就被称为虚函数。值得注意的是,非成员函数(即不属于任何类的函数)是不能被virtual
关键字修饰的。
虚函数的重写,简单来说,就是子类里有一个和父类一模一样的虚函数(它们的函数名、参数列表以及返回值的类型都完全相同)。所以有时候我们说子类重写了父类的虚函数。这里有个小细节要注意:子类在重写父类的虚函数时,即使不写virtual
关键字,因为继承的关系,这个函数在子类里还是会被当作虚函数来处理。但是这种做法不太规范,我们最好还是写上virtual
,这样代码看起来更清晰,大家也更明白你的意图。
多态是指在继承关系中,不同的类对象通过调用同一个函数而展现出不同的行为。这种特性使得程序能够根据不同的对象类型来执行不同的操作,从而提高了程序的灵活性和可扩展性。
要实现多态效果,必须满足以下两个重要条件:
综上所述,要实现多态效果,首先需要一个基类,并在其中声明虚函数;其次,需要有一个或多个派生类,它们重写基类的虚函数以实现不同的行为;最后,通过基类的指针或引用来调用这些虚函数,以实现多态性。这种机制使得程序能够根据对象的实际类型来执行不同的操作,从而提高了程序的灵活性和可扩展性。
说了这么多,我们来看看代码,就以图像编辑系统为例
假设我们正在开发一个图形绘制应用程序,该程序需要支持多种图形对象(如圆形、矩形和三角形)的绘制。我们可以设计一个基类Shape
,并在其中定义一个虚函数draw()
用于绘制图形。不同的图形对象(圆形、矩形和三角形)将作为Shape
的派生类,并在这些派生类中重写draw()
函数以实现各自的绘制逻辑。
#include<iostream>
using namespace std;
class Shape
{
public:
virtual void draw()
{
cout << "Shape draw()" << endl;
}
};
class Circle : public Shape
{
public:
//虚函数的重写,可以不写virtual关键字
void draw()
{
// 实现圆形的绘制逻辑
std::cout << "Drawing a circle." << std::endl;
}
};
class Rectangle : public Shape
{
public:
//最好还是写上virtual,这样代码看起来更清晰
virtual void draw()
{
// 实现矩形的绘制逻辑
std::cout << "Drawing a rectangle." << std::endl;
}
};
class Triangle : public Shape
{
public:
void draw()
{
// 实现三角形的绘制逻辑
std::cout << "Drawing a triangle." << std::endl;
}
};
int main()
{
//1.必须是基类的指针或者引用调用虚函数
//2.被调用的函数必须是虚函数,并且派生类完成了对基类的虚函数的重写/覆盖
Circle c;
Shape* s1 = &c;
s1->draw();
Shape& s2 = c;
s2.draw();
Rectangle r;
Shape* s3 = &r;
s3->draw();
Shape& s4 = r;
s4.draw();
Triangle t;
Shape* s5 = &t;
s5->draw();
Shape& s6 = t;
s6.draw();
//根据不同对象类型来执行不同的操作,从而提高了程序的灵活性和可扩展性
Shape s7;
s7.draw();
return 0;
}
上面的这段代码就体现了C++面向对象编程中的多态性特性。通过定义一个基类Shape
及其多个派生类(如Circle
、Rectangle
、Triangle
),并在这些派生类中重写基类中的虚函数draw()
,实现了多态性。当使用基类Shape
的指针或引用来指向不同的派生类对象并调用draw()
函数时,会根据对象的实际类型执行相应的派生类中的函数实现。这种机制极大地增强了程序的灵活性和可扩展性,使得我们可以在不修改现有代码的情况下,轻松添加新的形状类并自动适应这些新类。
了解了多态之后,我们来看看一个选择题
以下程序输出结果是什么()
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
解析:
是不是很多小伙伴认为是B->0,这里我们就需要考虑到基类和派生类的默认参数了
当执行 p->test();
时,以下事情发生:
p
是一个指向 B
类对象的指针。test
函数在 A
类中定义,但在 B
类中没有重写。因此,调用的是 A
类中的 test
函数。A
类的 test
函数中,func
被调用,没有提供参数。因此,将使用 A
类(父类)中 func
函数的默认参数值,即 1
。func
是虚函数,并且 p
指向一个 B
类的对象,因此 B
类的 func
函数将被调用。B
类 func
函数的参数值是 1
,而不是 B
类中定义的默认参数值 0
。这是因为默认参数值是在编译时确定的,基于的是调用该函数时所使用的函数签名(在这里是 A
类中的 func
函数签名)总结: 在C++中,当通过基类指针或引用调用虚函数时,虽然函数解析会基于对象的实际类型(即多态性),但默认参数值是基于函数声明的类型(即编译时类型)来确定的。
由于C++语言的灵活性,有时候程序员可能会因为疏忽而未能正确地重写虚函数,比如函数名拼写错误、参数列表不匹配、或者返回类型不符合要求等。这些错误在编译期间可能不会立即被检测出来,因为C++编译器在默认情况下并不会对虚函数的重写进行严格的类型检查。
为了解决这个问题,C++11标准引入了override
和final
这两个关键字,它们提供了更强的类型检查和更清晰的代码意图表达。
override
关键字:
当派生类中的函数意图重写基类中的虚函数时,可以在派生类函数声明后加上override
关键字。这样做有两个好处: override
关键字清晰地表明了派生类函数的意图,即它是为了重写基类中的某个虚函数。这有助于其他程序员理解代码的结构和意图。final
关键字:
如果不希望某个类虚函数在派生类中被重写,或者某个类不希望被其他类继承,可以使用final
关键字来修饰该类或该函数。 final
修饰一个虚函数时,表示该函数在派生类中不能被重写。final
修饰一个类时,表示该类不能被继承。例:
#include<iostream>
using namespace std;
class A
{
public:
virtual void test1()
{
cout << "A test1()" << endl;
}
virtual void test2()final//该函数在派生类中不能被重写
{
cout << "A test2()" << endl;
}
};
class B :public A
{
public:
//virtual void test1(int a)override//err——错误重写,编译报错
// //添加override关键字,在编译期间检查是否正确地重写虚函数
//{
// cout << "B test1()" << endl;
//}
//正确重写:
virtual void test1()override
{
cout << "B test1()" << endl;
}
//virtual void test2()//err——无法重写final test2函数
//{
// cout << "B test2()" << endl;
//}
};
class Base final
//final修饰一个类时,表示该类不能被继承
{
public:
int _a;
void test()
{
}
};
//class Derive :public Base//err——不能将final类类型作为基类
//{
//
//};
int main()
{
B b;
A& a = b;
a.test1();
a.test2();
return 0;
}
协变是指在C++继承中,派生类重写基类虚函数时,返回类型可以是基类返回类型的派生类类型(指针或引用)。这增强了类型安全性,但需确保返回类型间存在继承关系,并遵循C++的虚函数重写规则,C++11的override
关键字有助于此。
析构函数是一个特殊的成员函数,它在对象的生命周期结束时被自动调用,用于执行清理操作,比如释放对象占用的资源。析构函数的名称与类名相同,但前面有一个波浪号(~
)
当基类指针指向派生类对象时(多态性),如果基类的析构函数不是虚函数,那么在删除这个指针时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致资源泄露,因为派生类可能分配了需要在析构时释放的资源。
有人会说,基类和派生类名字不是不相同吗?怎么只调用基类的析构函数呢?
虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor~
为了解决这个问题,基类的析构函数通常被声明为虚函数。这样,当通过基类指针删除对象时,会首先调用派生类的析构函数,然后是基类的析构函数,确保资源被正确释放。
在C++中,析构函数的名字在编译时会被特殊处理,以支持多态性。即使派生类的析构函数没有显式地使用virtual
关键字(在C++11及之后的标准中,这是允许的,因为析构函数自动继承基类的虚属性),它仍然被视为对基类虚析构函数的重写。这是因为编译器知道当对象通过基类指针删除时,需要调用正确的析构函数链。
我们首先来看看如果不将析构函数写成虚函数是什么样子?
class A
{
public:
A()
{
cout << "A()" << endl;
}
~A()
{
cout << "~A()" << endl;
}
};
class B :public A
{
public:
B()
{
cout << "B()" << endl;
}
~B()
{
cout << "~B()" << endl;
}
};
int main()
{
A* p = new B(); // 基类指针指向派生类对象
delete p; // 只会调用调用A的析构函数,如果B类中有内存管理,容易造成内存泄漏
return 0;
}
我们可以发现,只会调用调用A的析构函数,如果B类中有内存管理,容易造成内存泄漏~所以我们要把析构函数设置为虚函数~接下来看看正确的写法
class A
{
public:
A()
{
cout << "A()" << endl;
}
virtual ~A()//将析构函数写成虚函数
{
cout << "~A()" << endl;
}
};
class B :public A
{
public:
B()
{
cout << "B()" << endl;
}
~B()//派生类的析构函数可以不显式地使用virtual关键字
{
cout << "~B()" << endl;
}
};
int main()
{
A* p = new B(); // 基类指针指向派生类对象
delete p; // 首先调用B的析构函数,然后调用A的析构函数
return 0;
}
这里我们来简单进行一下重载/重写/隐藏的对比
virtual
。这里出现了新的概念动态绑定与静态绑定
我们来看看下面的例子:
class A
{
public:
//函数重载
virtual void test1()
{
cout << "A test1()" << endl;
}
void test1(int a)
{
cout << "A test1(int a)" << endl;
}
};
class B :public A
{
public:
//函数重写
virtual void test1()
{
cout << "B test1()" << endl;
}
};
int main()
{
A* a = new B;
//动态绑定——满足多态,在运行时查找对象的虚函数表来实现的
a->test1();
//静态绑定——在编译时确定函数调用的方式
a->test1(1);
return 0;
}
我们可以通过反汇编来进行查看区分:
纯虚函数是一个没有具体实现的虚函数,它在类中被声明为
= 0
包含纯虚函数的类被称为抽象类,抽象类不能创建对象,它们的主要作用是提供一个接口或规范,要求继承它们的子类必须实现这些纯虚函数。这样,抽象类就强制了子类必须按照一定的规则来行事。通过纯虚函数和抽象类,C++实现了多态性的一种形式,允许我们用基类指针或引用来操作不同的子类对象,而不需要知道具体的子类类型。这使得代码更加灵活和可扩展。
就比如前面的图形编辑系统,我们可以不需要实现Shape类里面的draw()函数,因为这是没有实际意义的,我们可以把它设计成纯虚函数,让派生类进行实现就好了~
例:
class Shape //抽象类
{
public:
virtual void draw() = 0; // 纯虚函数,强制派生类实现
};
class Circle : public Shape
{
public:
void draw() override
{
// 实现圆形的绘制逻辑
std::cout << "Drawing a circle." << std::endl;
}
};
class Rectangle : public Shape
{
public:
void draw() override
{
// 实现矩形的绘制逻辑
std::cout << "Drawing a rectangle." << std::endl;
}
};
class Triangle : public Shape
{
public:
void draw() override
{
// 实现三角形的绘制逻辑
std::cout << "Drawing a triangle." << std::endl;
}
};
int main()
{
// Shape s;//err 无法实例化抽象类类型
Rectangle r;
r.draw();
Shape* s1 = &r;
Shape& s2 = r;
s1->draw();
s2.draw();
return 0;
}
前面说了这么多多态的概念以及使用,那么多态的底层原理是什么呢?为什么通过多态会达到这么神奇的效果呢?我们一起来看看接下来的内容,多态的原理~
多态的原理,简单来说就是运行时到指向的对象的虚表中找到对应的虚函数的地址进行调用~
有人可能就会好奇,虚表?什么是虚表?我们怎么知道虚表的存在呢?别急,我们慢慢来看~
我们首先来看看下面的这道题:
下面编译为32位程序的运行结果是什么()
A. 编译报错 B. 运行报错 C. 8 D. 12
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _a = 1;
char _ch = 'd';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
有的人会说这个题不是送分题吗?这里的代码没有什么问题,所以首先排除A、B,前面说过一个类的字节大小需要看这个类里面的成员变量,再根据内存对齐就可以了,所以答案是C。
我知道你很急,但是先别急,我们来看看跑出来的结果
显然,正确答案:D 有人会说怎么会这样?我说伙计,这里有虚函数啊,肯定有坑啊! 我们再来看看在64位环境下是什么结果?
是16,相信到这里,大家心里面就有谱了,一个跟我们想象的多了4个字节,一个跟我们想象的多了8个字节,这多的不正好是指针在不同环境下的字节大小嘛~所以多的这个就是指针,那么这个指针里面隐藏着什么内容呢?
接下来我们在x86(也就是32位环境)下观察:
我们可以发现,对象b里面不仅仅有_a和_ch,还有一个__vfptr放在前面(这个顺序与平台有关,有的平台可能放在后面),这个又是什么呢?
这个是指向虚函数表的指针我们通常称之为虚函数表指针(其中“v”代表virtual,即“虚”,“f”代表function,即“函数”)。任何一个包含虚函数的类中,都至少包含一个这样的虚函数表指针。原因在于,一个类的所有虚函数的地址需要被存储在这个类的对象的虚函数表(简称虚表)中~,前面的字节大小就解释得通了~
如果一个类里面有多个虚函数,字节大小会不会不一样呢?
我们可以看到是没有变化的,那它的派生类大小又是多少呢?
#include<iostream>
using namespace std;
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
protected:
int _a = 1;
char _ch = 'd';
};
class Derive :public Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
protected:
int _d = 2;
char _chd = 'dd';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
Derive d;
cout << sizeof(d) << endl;
return 0;
}
解释:(在32位环境下)
Base
类的对象 b
时,对象 b
的大小至少包括一个指向虚函数表的指针(__vfptr)的大小,以及 Base
类自己的成员变量 _a
和 _ch
的大小。由于虚指针的存在,即使 Base
类的成员变量很小,对象的大小也会比成员变量总和大为12Derive
类的对象 d
时,对象 d
的大小包括从 Base
类继承的虚指针(因为 Derive
类也有虚函数,它可能会使用自己的虚表,但虚指针本身通常只存储一次,在继承体系的最顶层类中),Base
类的成员变量 _a
和 _ch
,以及 Derive
类自己的成员变量 _d
和 _chd
为20那么知道了这是虚函数表指针,那么多态又是怎么实现的呢?我们一点点来看
我们结合下面的代码来看看是怎么样实现多态的?
class Shape //抽象类
{
public:
virtual void draw() = 0; // 纯虚函数,强制派生类实现
};
class Circle : public Shape
{
public:
void draw() override
{
// 实现圆形的绘制逻辑
std::cout << "Drawing a circle." << std::endl;
}
};
class Rectangle : public Shape
{
public:
void draw() override
{
// 实现矩形的绘制逻辑
std::cout << "Drawing a rectangle." << std::endl;
}
};
class Triangle : public Shape
{
public:
void draw() override
{
// 实现三角形的绘制逻辑
std::cout << "Drawing a triangle." << std::endl;
}
};
int main()
{
// Shape s;//err 无法实例化抽象类类型
Rectangle r;
r.draw();
Shape* s1 = &r;
Shape& s2 = r;
s1->draw();
s2.draw();
cout << endl;
Triangle t;
t.draw();
Shape* s3 = &r;
Shape& s4 = r;
s3->draw();
s4.draw();
return 0;
}
当满足多态条件后,底层机制转变为在运行时而非编译时确定函数调用。具体而言,程序会访问指向对象的虚表(vtable),根据该虚表来确定对应虚函数的地址。这一转变使得,当指针或引用指向基类对象时,会调用基类的虚函数;而当它们指向派生类对象时,则会调用派生类中对应的重写虚函数。这样,多态性得以实现,允许通过基类类型的指针或引用来透明地调用派生类中重写的方法。
虚函数表这么神奇,接下来,我们来看看虚函数表更加详细的内容~
通过虚函数表机制,C++实现了多态性,允许通过基类指针或引用来调用派生类中重写的虚函数,从而实现灵活的面向对象编程。
编译报错发生在源代码被编译器处理时。这些错误通常是由于语法错误、类型不匹配、缺少头文件、未定义的标识符等原因引起的。编译器在尝试将源代码转换为可执行文件时,如果发现这些问题,就会停止编译并输出错误信息。
特点:
运行报错发生在程序执行时。这些错误通常是由于内存访问违规(如空指针解引用)、数组越界、除零错误、资源泄漏等原因引起的。编译器在编译时无法检测到这些错误,因为它们在程序运行时才会发生。
特点: