目录
前言
一、子类型
二、静态联编和动态联编
三、虚函数
四、纯虚函数和抽象类
五、虚析构函数
六、重载,重定义与重写的异同
前言
面向对象程序设计语言的三大核心特性是封装性、继承性和多态性。封装性奠定了基础,继承性是实现代码重用和扩展的关键,而多态性则是功能的扩充。多态性体现在对不同类的对象发送相同的消息时,会产生不同的行为。这里所说的消息主要是指对类成员函数的调用,而不同的行为则对应着不同的实现方式。在C++中,实现多态性的方法包括:
-
函数重载
-
运算符重载
-
模板
-
虚函数
函数重载是多态性的一种基本形式,它允许在同一作用域内,相同的函数名对应不同的实现。函数重载的实现条件是函数参数的类型或个数必须有所区别。
除了函数重载这种简单的多态形式,C++还提供了更为灵活的特性——虚函数。虚函数使得函数调用与函数体的绑定可以在运行时动态确定,这对于实现同一接口、多种实现的场景尤为重要。在深入探讨虚函数的概念之前,我们需要先了解子类型、静态联编和动态联编等相关概念。
一、子类型
在继承的框架下,如果类B以公有继承的方式从类A派生而来,那么类B不仅继承了类A的行为,还可能拥有自身独特的行为。在这种情况下,我们称类B为类A的一个子类型。具体来说,如果存在一个类型S,它至少提供了类型T的行为,那么我们称类型S是类型T的子类型。
当类B是类A的子类型时,类A对象能够调用的函数,类B的对象同样能够调用。这种情况下,我们称类B与类A兼容,或者说类B适应于类A。子类型的一个重要作用是实现类型兼容性,即在公有继承的模式下,派生类的对象、指向对象的指针以及对象的引用,都能够无缝地适应于基类的对象、指向对象的指针和对象引用所适用的场合。
需要注意的是,子类型关系是单向且不可逆的。如果已知类B是类A的子类型,那么认为类A也是类B的子类型是不正确的。子类型的概念强调的是派生类对基类的兼容性和扩展性,而不是基类对派生类的依赖。
二、静态联编和动态联编
联编是程序中各个部分相互关联的过程。根据联编发生的时机,它可以分为静态联编和动态联编两种类型。静态联编,又称为早期联编,发生在程序的编译和链接阶段。在这种联编方式中,函数调用与执行该函数的代码之间的对应关系在程序运行之前就已经确定,这意味着所有关联工作都在程序执行前完成。
示例代码:
class CBase {
public:
void fun() {
cout << "CBase:fun" << endl;
}
};
class CMyClass :public CBase {
public:
void fun() { cout << "CMyClass:fun" << endl; }
};
int main() {
CBase* p;
CBase objA;
CMyClass objB;
p = &objA;
p->fun();
p = &objB;
p->fun();
return 0;
}
在静态联编的情况下,如果存在一个指向基类的指针p,那么在程序运行之前,p->fun()就已经被确定为调用基类的成员函数fun()。因此,无论指针p指向的是基类对象还是派生类对象,p->fun()都将调用基类的成员函数,并且结果保持一致。这是静态联编的特性。
相比之下,动态联编,又称为晚期联编,是在程序运行时进行的联编过程。动态联编要求在运行时确定函数调用与执行该函数代码之间的对应关系。以之前的例子为例,如果采用动态联编,那么随着指针pobjA指向的对象不同,pobjA->fun_a()将能够调用不同类中fun_a()的不同版本。这意味着,通过一个统一的接口pobjA->fun_a(),可以访问多个不同的实现版本,即函数调用取决于运行时pobjA所指向的对象,从而展现出多态性。使用虚函数可以实现动态联编,允许在不同的联编情况下选择不同的实现,这正是多态性的体现。
继承是实现动态联编的基础,而虚函数则是动态联编的关键所在。通过虚函数,可以在运行时根据对象的实际类型来调用相应的函数版本,从而实现多态行为。
三、虚函数
虚函数的概念:在基类中冠以关键字 virtual 的成员函数
虚函数的定义:
virtual<类型说明符>函数名>(<参数表>)
{
//<函数体>
}
virtual void fun a()
{
//<函数体>
}
虚函数的定义与特性如下:
-
若在基类中将某一成员函数声明为虚函数,则该函数在所有派生类中均保持其虚函数的属性,即使派生类中未显式使用
virtual
关键字。 -
动态绑定(或动态联编)仅在通过基类指针或引用调用虚函数时发生,这是实现多态性的关键机制。
-
虚函数不能被声明为静态函数,也不能是友元函数,因为这些类型的函数不支持动态绑定。
-
在基类中声明为虚函数的成员函数,在派生类中即便没有使用
virtual
关键字,仍然保持其虚函数的特性。 -
当一个成员函数在基类中被声明为虚函数时,它允许在派生类中拥有不同的实现版本,这为多态性的实现提供了可能。
-
由于虚函数的存在,编译器会在运行时进行动态联编,确保调用虚函数的对象在运行时根据实际对象类型来确定,从而实现动态联编的多态性。
说了半天虚函数,它到底有什么特性??特性如下:
当一个父类指针指向子类对象的时候,调用一个虚函数,将调用子类的虚函数。
示例代码:
#include <iostream>
using namespace std;
class Base {
public:
virtual void Fun1() {
cout << "Base::Funl ..."<<endl ;
}
virtual void Fun2() {
cout << "Base::Fun2 ..." << endl;
}
void Fun3() {
cout << "Base::Fun3 ..." << endl;
}
};
class Derived : public Base {
public:
/*virtual*/void Fun1()//不加virtual Fun1 也是虚函数。
{
cout << "Derived::Fun1 ..."<< endl;
}
/*virtual*/void Fun2()
{
cout << "Derived::Fun2 ..." << endl;
}
void Fun3()
{
cout << "Derived::Fun3 ..." << endl;
}
};
int main() {
Base* p;
Derived d;
p = &d;
p->Fun1(); //Fun1是虚函数,基类之指针指向派生类对象,调用的是派生类对象的虚函数
p->Fun2();
p->Fun3(); //Fun3非虚函数,根据p指针实际类型来调用相应类的成员函数
return 0;
}
运行结果:
这是一个很厉害的特性,我们知道调用什么函数,一般是和类型绑定的,类A的指针调用fun这个函数,本身调用的应该是类A的函数。但是有了虚函数之后,看这个指针指向哪一个子类对象了。
这给我们提供了很多想象力,比如一个函数的参数是父类指针,我们往里面传递不同的子类对象,就可以在函数中调用到不同的虚函数。
再比如,我们在一个数组中存储不同的子类对象,统一的去调用虚函数,大家的行为都是不相同的。
四、纯虚函数和抽象类
虚函数机制赋予了基类指针指向派生类对象的能力,并确保调用的是派生类中相应的虚函数,这一特性使得我们能够以统一的方式处理不同派生类的对象。这种动态绑定确保了函数入口在运行时才得以确定。然而,当面临基类接口无法实现的情况时,我们该如何应对?以形状类为例,它定义了一个求面积的函数,而圆形和矩形作为其派生类,各自拥有计算面积的方法。但形状本身作为一个抽象概念,并不具备计算面积的具体方法。在这种情况下,纯虚函数便派上了用场。包含纯虚函数的类被称为抽象类,它们不能被实例化。纯虚函数是一种特殊的虚函数,它没有具体的实现,仅作为接口存在,强制派生类提供必要的实现细节。
其定义格式如下:
class <类名>
{
virtual <函数类型> <函数名>(<参数表>)=0;
//...
}
class CClassA
{
virtual <函数类型> <函数名>(<参数表>)=0;
//...
}
在众多场景中,基类可能无法为虚函数提供实质性的实现,此时将其声明为纯虚函数,将其实现的责任转交给派生类,这正是纯虚函数的核心作用。当一个类中包含了纯虚函数,它便成为了抽象类。根据C++的规范,抽象类无法直接创建对象。
由于纯虚函数缺乏具体的实现,包含此类函数的类自然无法直接实例化,这一点显而易见——因为无法调用未实现的纯虚函数。因此,这类类被形象地称为抽象类。抽象类若要摆脱其抽象的本质,唯有依赖派生类来充实这些虚函数的具体实现。
示例代码:
#include <iostream>
using namespace std;
class Shape {
public:
virtual void Draw() = 0;
virtual ~Shape() {}
};
class Circle :public Shape{
public:
void Draw() {
cout << "Circle::Draw()..." << endl;
}
~Circle() {
cout << "~Circle ..." << endl;
}
};
class Square : public Shape {
public:
void Draw() {
cout << "Square::Draw()" << endl;
}
~Square() {
cout << "~Square ..." << endl;
}
};
int main() {
//Shape obj; //错误的,抽象类不能定义对象
Shape* pobj = NULL;
Circle objcirele;
pobj = &objcirele;
pobj->Draw();
return 0;
}
抽象类仅能作为基类被继承,而不能直接声明抽象类的实例。在类的构造与析构过程中,构造函数不可设为虚函数,而析构函数则允许为虚函数。
抽象类本身不具备直接创建对象实例的能力,但可以声明抽象类的指针或引用。通过指向抽象类的指针,我们能够实现运行时的多态性。派生类有义务实现基类中的纯虚函数,若未能履行这一职责,该派生类仍将被视为抽象类。
五、虚析构函数
构造函数不可声明为虚函数,而析构函数则具备这一特性,通过在析构函数前添加关键字virtual
来实现。一旦基类的析构函数被声明为虚函数,其派生类的析构函数默认也成为虚析构函数,此时可省略virtual
关键字。
将析构函数声明为虚函数的原因在于,当基类指针指向派生类对象时,这是多态性的常见应用场景。在释放内存时,若通过delete
操作符删除基类指针,通常只会触发基类的析构函数,而派生类的析构函数则不会被调用,这可能导致内存泄漏。
然而,若基类的析构函数是虚函数,且派生类提供了自定义的析构函数实现,那么在delete
基类指针时,将同时调用派生类的析构函数。在派生类执行析构过程时,会自动调用基类的析构函数,确保所有相关资源得到妥善清理。
示例代码:
#include <iostream>
using namespace std;
class CClassA{
public :
CClassA(){
cout << "CClassA" << endl;
}
virtual ~CClassA() {
cout << "~CClassA" << endl;
}
};
class CClassB : public CClassA {
public:
CClassB() { cout << "CClassB" << endl; }
virtual ~CClassB() { cout << "~CClassB" << endl; }
};
int main() {
CClassA* pobjA = new CClassB;
delete pobjA;
return 0;
}
六、重载,重定义与重写的异同
在面向对象编程中,"重载"(overload)、"重写"(override)和"重定义"(redefine)是三个重要的概念,它们在处理成员函数时有着不同的应用和特征。
重载(Overload):
-
发生在同一个类中。
-
函数名称相同。
-
参数列表必须不同。
-
是否使用
virtual
关键字是可选的。
重写(Override):
-
发生在派生类与基类之间。
-
函数名称相同。
-
参数列表相同。
-
基类中的函数必须声明为
virtual
。
重定义(Redefine):
-
发生在派生类与基类之间。
-
当函数名和参数都相同时,基类函数不需要
virtual
关键字。 -
当函数名相同但参数不同时,是否使用
virtual
关键字是可选的。
这些概念的理解和正确应用对于掌握面向对象编程至关重要,它们帮助开发者以更加灵活和高效的方式设计和实现类和对象。