本文为 C++ 学习笔记,参考《Sams Teach Yourself C++ in One Hour a Day》第 8 版、《C++ Primer》第 5 版、《代码大全》第 2 版。
多态(Polymorphism)是面向对象语言的一种特征,可以使用相似的方式(基类中的接口)处理不同类型的对象。在编码时,我们将不同类型(具有继承层次关系的基类和派生类)的对象视为基类对象进行统一处理,不必关注各派生类的细节,在运行时,将会通过相应机制执行各对象所属的类中的方法,基类对象执行基类方法,派生类对象执行派生类方法。多态是一种非常强大的机制,我们考虑这种情况,基类早已写好并定义了良好的接口,基类的使用者编写代码时,将能通过基类的接口来调用派生类中的方法,也就是说,后写的代码能被先写的代码调用,这使程序具有很强的复用性和扩展性。
1. 使用虚函数实现多态
例程 1 源码:
#include <iostream>
using namespace std;
class Fish
{
public:
/* virtual */ void Swim() { cout << "Fish swims!" << endl; }
};
class Tuna : public Fish
{
public:
void Swim() { cout << "Tuna swims!" << endl; }
};
class Carp : public Fish
{
public:
void Swim() { cout << "Carp swims!" << endl; }
};
void FishSwim(Fish &fish)
{
fish.Swim();
}
int main()
{
// 引用形式
Fish myFish;
Tuna myTuna;
Carp myCarp;
FishSwim(myFish);
FishSwim(myTuna);
FishSwim(myCarp);
// 引用形式
Fish &rFish1 = myFish;
Fish &rFish2 = myTuna;
Fish &rFish3 = myCarp;
rFish1.Swim();
rFish2.Swim();
rFish3.Swim();
// 指针形式
Fish *pFish1 = new Fish();
Fish *pFish2 = new Tuna();
Fish *pFish3 = new Carp();
pFish1->Swim();
pFish2->Swim();
pFish3->Swim();
return 0;
}
直接编译运行代码,得到如下结果:
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
Fish swims!
将第 8 行的 virtual 关键字取消注释,再次编译运行代码,得到如下结果:
Fish swims!
Tuna swims!
Carp swims!
Fish swims!
Tuna swims!
Carp swims!
Fish swims!
Tuna swims!
Carp swims!
分析上述例程:
- 派生类对象可以赋值给基类对象(这里对象是广义称法,代指对象、指针、引用),例程中使用基类引用或指针指向派生类对象。基类与派生类赋值关系的说明可参考《C++基础-继承》一文第 3 节。
- 如果基类中的 Swim() 不是虚函数,那么无论基类引用(或指针)指向何种类型的对象,运行时都调用基类中的方法。这种情况未启用多态机制。
- 如果基类中的 Swim() 是虚函数,那么运行时会根据基类引用(或指针)指向的具体对象,调用对象所属的类中的方法。若指向派生类对象则调用派生类方法,若指向基类对象则调用基类方法。这种情况使用了多态机制。
使用基类指针或引用指向基类或派生类对象,运行时调用对象所属的类中的方法,就是多态。在编写代码时,可将派生类对象视为基类对象进行统一处理,据此我们可以先实现一个通用接口,如第 31 行 FishSwim() 函数所示,运行时具体调用哪个方法由传入的参数决定。我们以后可以根据需要新增派生类,并实现派生类中的 Swim() 方法,而早已写好的 FishSwim() 函数不需要修改任何代码就能调用新派生类中的方法,这种机制使得代码具有极好的可扩展性。
编程实践:对于将被派生类覆盖的基类方法,务必将其声明为虚函数,以使其支持多态。
2. 虚析构函数
虚析构函数与普通虚函数机制并无不同。我们分析一下例程 2 源码:
#include <iostream>
using namespace std;
class Fish
{
public:
Fish()
{
cout << "Constructed Fish" << endl;
}
virtual ~Fish()
{
cout << "Destroyed Fish" << endl;
}
};
class Tuna : public Fish
{
public:
Tuna()
{
cout << "Constructed Tuna" << endl;
}
~Tuna()
{
cout << "Destroyed Tuna" << endl;
}
};
void DeleteFishMemory(Fish *pFish)
{
// 若第 12 行删除 virtual 关键字,则传入实参是派生类对象时,也被当作其基类对象进行析构
// 若第 12 行携带 virtual 关键字,则传入实参是派生类对象时,将被当作派生类对象进行析构
delete pFish;
}
int main()
{
cout << "Allocating a Tuna on the free store:" << endl;
Tuna *pTuna = new Tuna;
cout << "Deleting the Tuna: " << endl;
DeleteFishMemory(pTuna);
cout << "Instantiating a Tuna on the stack:" << endl;
Tuna tuna;
cout << "Automatic destruction as it goes out of scope: " << endl;
return 0;
}
运行结果如下:(//后不是打印内容,是说明语句)
Allocating a Tuna on the free store: // 在堆上创建派生类对象 pTuna
Constructed Fish // 调用基类构造函数
Constructed Tuna // 调用派生类构造函数
Deleting the Tuna: // 调用自定义函数以销毁派生类对象
Destroyed Tuna // 调用派生类析构函数
Destroyed Fish // 调用基类析构函数
Instantiating a Tuna on the stack: // 在栈上创建派生类对象 tuna
Constructed Fish // 调用基类构造函数
Constructed Tuna // 调用派生类构造函数
Automatic destruction as it goes out of scope: // 提示 main() 结束后自动销毁栈上对象 tuna
Destroyed Tuna // 调用派生类析构函数
Destroyed Fish // 调用基类析构函数
如果我们将第 12 行 virtual 关键字注释掉,再次编译运行,结果如下:
Allocating a Tuna on the free store:
Constructed Fish
Constructed Tuna
Deleting the Tuna:
Destroyed Fish // 堆上的派生类对象被当作基类对象析构,只调用了基类析构函数,堆内存未销毁干净
Instantiating a Tuna on the stack:
Constructed Fish
Constructed Tuna
Automatic destruction as it goes out of scope:
Destroyed Tuna
Destroyed Fish
和前一次运行结果对比,很容易看出,析构函数未使能多态时,void DeleteFishMemory(Fish *pFish)
传入实参为派生类对象时,delete 操作只会把实参当作基类对象处理,只调用基类的析构函数,从而导致堆上的派生类对象无法销毁干净。
总结如下:
如果不将析构函数声明为虚函数,那么如果一个函数的形参是基类指针,实参是指向堆内存的派生类指针时,函数返回时作为实参的派生类指针将被当作基类指针进行析构,这会导致资源释放不完全和内存泄漏;要避免这一问题,可将基类的析构函数声明为虚函数,那么函数返回时,作为实参的派生类指针就会被当作派生类指针进行析构。
换句话说,对于使用 new 在堆内存中实例化的派生类对象,如果将其赋给基类指针,并通过基类指针调用 delete,如果基类析构函数不是虚函数,delete 将按基类析构的方式来析构此指针,如果基类析构函数是虚函数,delete 将按派生类析构的方式来析构此指针(调用派生类析构函数和基类析构函数)。
编程实践:务必要将基类的析构函数声明为虚函数,以避免派生类实例未被妥善销毁的情况发生。
3. 多态机制的工作原理-虚函数表
为方便说明,将第一节代码加以修改,例程 3 源码如下:
#include <iostream>
using namespace std;
class Fish
{
public:
virtual void Swim() { cout << "Fish swims!" << endl; }
};
class Tuna : public Fish
{
public:
void Swim() { cout << "Tuna swims!" << endl; }
};
class Carp:public Fish
{
public:
void Swim() { cout << "Carp swims!" << endl; }
};
int main()
{
Fish *pFish = NULL;
pFish = new Fish();
pFish->Swim();
pFish = new Tuna();
pFish->Swim();
pFish = new Carp();
pFish->Swim();
return 0;
}
编译运行的输出结果为:
Fish swims!
Tuna swims!
Carp swims!
例程中使用统一类型(基类)的指针 pFish 指向不同类型(基类或派生类)的对象,指针的赋值是在运行阶段执行的,在编译阶段,编译器把 pFish 认作 Fish 类型的指针,而并不知道 pFish 指向的是哪种类型的对象,无法确定将执行哪个类中的 Swim() 方法。调用哪个类中的 Swim() 方法显然是在运行阶段决定的,这是使用实现多态的逻辑完成的,而这种逻辑由编译器在编译阶段提供。
3.1 虚函数表机制
如下 Base 类声明了 N 个虚函数:
class Base
{
public:
virtual void Func1() { // Func1 implementation }
virtual void Func2() { // Func2 implementation }
// .. so on and so forth
virtual void FuncN() { // FuncN implementation }
};
如下 Derived 类继承自 Base 类,并覆盖了除 Base::Func2() 外的其他所有虚函数。
class Derived: public Base
{
public:
virtual void Func1() { // Func2 overrides Base::Func2() }
// no implementation for Func2()
// .. so on and so forth
virtual void FuncN() { // FuncN overrides Base::FuncN() }
};
编译器见到这种继承层次结构后,知道 Base 定义了一些虚函数,并在 Derived 中覆盖了它们。在这种情况下,编译器将为实现了虚函数的基类和覆盖了虚函数的派生类分别创建一个虚函数表(Virtual Function Table, VFT)。换句话说,Base 和 Derived 类都将有自己的虚函数表。实例化这些类的对象时,会为每个对象创建一个隐藏的指针(我们称之为 VFT*),它指向相应的 VFT。可将 VFT 视为一个包含函数指针的静态数组,其中每个指针都指向相应的虚函数,如下图所示:
每个虚函数表都由函数指针组成,其中每个指针都指向相应虚函数的实现。在类 Derived 的虚函数表中,除 Func2 函数指针外,其他所有函数指针都指向 Derived 本地的虚函数实现。Derived 没有覆盖 Base::Func2(),因此相应的函数指针指向 Base 类的 Func2() 实现。
下述代码调用未覆盖的虚函数,编译器将查找 Derived 类的 VFT,最终调用的是 Base::Func2() 的实现:
Derived objDerived;
objDerived.Func2();
调用被覆盖的虚函数时,也是类似的机制:
void DoSomething(Base& objBase)
{
objBase.Func1(); // invoke Derived::Func1
}
int main()
{
Derived objDerived;
DoSomething(objDerived);
};
在这种情况下,虽然将 objDerived 传递给了 objBase,进而被解读为一个 Base 实例,但该实例的 VFT 指针仍指向 Derived 类的虚函数表,因此通过该 VTF 执行的是 Derived::Func1()。
C++ 就是通过虚函数表实现多态的。
3.2 类实例中的 VFT 指针
例程 3.2 源码如下:
#include <iostream>
using namespace std;
class Class1
{
private:
int a, b;
public:
void DoSomething() {}
};
class Class2
{
private:
int a, b;
public:
virtual void DoSomething() {}
};
int main()
{
cout << "sizeof(Class1) = " << sizeof(Class1) << endl;
cout << "sizeof(Class2) = " << sizeof(Class2) << endl;
return 0;
}
在 64 位系统下编译并运行,结果为:
sizeof(Class1) = 8
sizeof(Class2) = 16
Class2 中将函数声明为虚函数,因此类的成员多了一个 VFT 指针,64 位系统中,指针变量占用 8 字节空间,因此 Class2 比 Class1 多占用了 8 个字节。
3.3 继承关系中的 VFT 指针
#include <iostream>
using namespace std;
class Base
{
private:
int x = 1;
int y = 2;
const static int z = 3;
/* 注释1
public:
virtual void test() {};
*/
};
class Derived : public Base
{
private:
int u = 11;
int v = 22;
const static int w = 33;
/* 注释2
public:
virtual void test() {};
*/
};
int main()
{
Base base;
Derived derived;
cout << "sizeof(Base) = " << sizeof(Base) << endl;
cout << "sizeof(Derived) = " << sizeof(Derived) << endl;
return 0;
}
上述代码运行结果为:
sizeof(Base) = 8
sizeof(Derived) = 16
使用 gdb 查看变量值:
(gdb) p base
$1 = {x = 1, y = 2, static z = 3}
(gdb) p derived
$2 = {<Base> = {x = 1, y = 2, static z = 3}, u = 11, v = 22, static w = 33}
取消“注释1”处的注释,运行结果为:
sizeof(Base) = 16
sizeof(Derived) = 24
使用 gdb 查看变量值:
(gdb) p base
$1 = {_vptr.Base = 0x400b10 <vtable for Base+16>, x = 1, y = 2, static z = 3}
(gdb) p derived
$2 = {<Base> = {_vptr.Base = 0x400af0 <vtable for Derived+16>, x = 1, y = 2, static z = 3}, u = 11, v = 22, static w = 33}
再取消“注释2”处的注释,运行结果为:
sizeof(Base) = 16
sizeof(Derived) = 24
使用 gdb 查看变量值:
(gdb) p base
$1 = {_vptr.Base = 0x400b10 <vtable for Base+16>, x = 1, y = 2, static z = 3}
(gdb) p derived
$2 = {<Base> = {_vptr.Base = 0x400af0 <vtable for Derived+16>, x = 1, y = 2, static z = 3}, u = 11, v = 22, static w = 33}
根据上述实验结果,给出结论:
- static 数据成员属于整个类,不占用某一具体对象的内存空间。
- 基类的所有非 static 数据成员(包括私有数据成员)都会在派生类对象中占用内存。
- 只要基类含有虚函数,基类和派生类对象都会含有各自的 VFT 指针,即使派生类没有虚函数。如果派生类没有虚函数,那么派生类虚函数表中的每个元素都指向基类的的虚函数。
- 派生类对象只含一份 VFT 指针,基类的 VFT 指针不会在派生类中占用内存。从打印可以看出,VFT 指针为 _vptr.Base,派生类的 VFT 指针存在在派生类的 Base 部分,也可以认为派生类的 VFT 指针覆盖了基类的 VFT 指针,指向自己的虚函数表。
4. 纯虚函数和抽象基类
在 C++ 中,包含纯虚函数的类是抽象基类。抽象基类用于定义接口(只声明形式,不实现内容),在派生类中实现接口,这样可以实现接口与实现的分离。抽象基类不能被实例化。抽象基类提供了一种非常好的机制,可在基类声明所有派生类都必须实现的函数接口,将这些派生类中必须实现的接口声明为纯虚函数即可。
纯虚函数写法如下:
class AbstractBase
{
public:
virtual void DoSomething() = 0; // pure virtual method
};
其派生类中必须实现此函数。
例程 4 源码如下:
#include <iostream>
#include <stdio.h>
using namespace std;
class B
{
public:
virtual void func1() = 0; // 纯虚函数不能在基类中实现,一定要在派生类中实现
virtual void func2() = 0; // 纯虚函数不能在基类中实现,一定要在派生类中实现
virtual void func3() { cout << "B::func3" << endl; } // 此虚函数被派生类中函数覆盖
virtual void func4() { cout << "B::func4" << endl; } // 此虚函数在派生类中无覆盖
void func5() { cout << "B::func5" << endl; } // 此函数被派生类中函数覆盖
void func6() { cout << "B::func6" << endl; } // 此函数在派生类中无覆盖
private:
int x = 1;
int y = 2;
static int z;
};
class D : public B
{
public:
virtual void func1() override { cout << "D::func1" << endl; }
virtual void func2() override { cout << "D::func2" << endl; }
virtual void func3() override { cout << "D::func3" << endl; }
void func5() { cout << "D::func5" << endl; } // 不能带 override
private:
int u = 11;
int v = 22;
static int w;
};
int main()
{
// B b; // 编译错误,抽象基类不能被实例化
D d;
cout << "sizeof(d) = " << sizeof(d) << endl;
d.func1(); // 访问派生类中的覆盖函数(覆盖纯虚函数)
d.func2(); // 访问派生类中的覆盖函数(覆盖纯虚函数)
d.func3(); // 访问派生类中的覆盖函数(覆盖虚函数)
d.func5(); // 访问派生类中的覆盖函数(覆盖普通函数)
d.B::func3(); // 访问基类中的虚函数
d.B::func4(); // 访问基类中的虚函数
d.B::func5(); // 访问基类中的普通函数
return 0;
}
上述代码运行结果:
sizeof(d) = 24
D::func1
D::func2
D::func3
D::func5
B::func3
B::func4
B::func5
结论如下:
- 类中只要有一个纯虚函数,这个类就是抽象基类,不能被实例化。
- 基类中的纯虚函数,基类不能给出实现,必须在派生类中实现,即一定要在派生类中覆盖基类的纯虚函数。
- 基类中的虚函数,基类中要给出实现,派生类可实现也可不实现,即派生类需要覆盖基类中的虚函数。
- 基类中的普通函数,被派生类中的同名同参的函数覆盖,这种情况下,此函数既不能被继承,也不支持多态。所以要避免这种情况,避免派生类中某一函数与基类某普通函数同名。
- 普通函数不支持多态,所以需要继承的函数应声明为虚函数,不应使用普通函数。
5. 使用虚继承解决菱形问题
一个类继承多个父类,而这多个父类又继承一个更高层次的父类时,会引发菱形问题。例如,鸭嘴兽具备哺乳动物、鸟类和爬行动物的特征,这意味着 Platypus 类需要继承 Mammal、 Bird 和 Reptile 三个类。然而,这些类都从同一个 Animal 类派生而来,如下图所示:
例程如下:
#include <iostream>
using namespace std;
class Animal
{
public:
Animal() { cout << "Animal constructor" << endl; }
int age;
};
class Mammal : public /* virtual */ Animal
{
};
class Bird : public /* virtual */ Animal
{
};
class Reptile : public /* virtual */ Animal
{
};
class Platypus : public Mammal, public Bird, public Reptile
{
public:
Platypus() { cout << "Platypus constructor" << endl; }
};
int main()
{
Platypus duckBilledP;
// uncomment next line to see compile failure
// age is ambiguous as there are three instances of base Animal
// duckBilledP.age = 25;
duckBilledP.Mammal::age = 25;
duckBilledP.Bird::age = 25;
duckBilledP.Reptile::age = 25;
return 0;
}
编译并运行,输出结果如下:
Animal constructor
Animal constructor
Animal constructor
Platypus constructor
可见,Platypus 有三个 Animal 实例。如果取消第 35 行的注释,编译无法通过,因为无法确定是要设置哪个 Animal 实例中的 age 成员。
如果取消第 11、15、19 行对关键字 virtual 的注释,再次编译运行,可看到如下输出结果:
Animal constructor
Platypus constructor
此时,Platypus 只有一个 Animal 实例。
在继承层次结构中,继承多个从同一个类派生而来的基类时,如果这些基类没有采用虚继承,将导致二义性。这种二义性被称为菱形问题(Diamond Problem)。使用虚继承可以解决多继承时的菱形问题。
C++关键字 virtual 被用于实现两个不同的概念,其含义随上下文而异,如下:
- 在函数声明中, virtual 意味着当基类指针指向派生对象时,通过它可调用派生类的相应函数。
- 从 Base 类派生出 Derived1 和 Derived2 类时,如果使用了关键字 virtual,则意味着再从 Derived1 和 Derived2 派生出 Derived3 时,每个 Derived3 实例只包含一个 Base 实例。
6. 使用 override 明确表明覆盖意图
从 C++11 起,程序员可使用限定符 override 来核实被覆盖的函数在基类中是否被声明为虚函数。形式如下:
class Fish
{
public:
virtual void Swim()
{
cout << "Fish swims!" << endl;
}
};
class Tuna : public Fish
{
public:
void Swim() const override // Error: no virtual fn with this sig in Fish
{
cout << "Tuna swims!" << endl;
}
void Swim() override // Right: has virtual fn with this sig in Fish
{
cout << "Tuna swims!" << endl;
}
};
换而言之, override 提供了一种强大的途径,让程序员能够明确地表达对基类的虚函数进行覆盖的意图,进而让编译器做如下检查:
• 基类函数是否是虚函数?
• 派生类中被声明为 override 的函数是否是基类中对应虚函数的覆盖?确保没有有手误写错。
编程实践:在派生类中声明要覆盖基类函数的函数时,务必使用关键字 override
7. 使用 final 禁止覆盖
被声明为 final 的类禁止继承,不能用作基类。而被声明为 final 的虚函数,不能在派生类中进行覆盖。
因此,要在 Tuna 类中禁止进一步定制虚函数 Swim(),可像下面这样做:
class Tuna : public Fish
{
public:
// override Fish::Swim and make this final
void Swim() override final // 我覆盖父类中的函数,但我的子类不要覆盖我
{
cout << "Tuna swims!" << endl;
}
};
Tuna 类可以被继承,但 Swim() 函数不能派生类中的实现覆盖。
8. 可将复制构造函数声明为虚函数吗
答案是不可以。不可能实现虚复制构造函数,因为在基类方法声明中使用关键字 virtual 时,表示它将被派生类的实现覆盖,这种多态行为是在运行阶段实现的。而构造函数只能创建固定类型的对象,不具备多态性,因此 C++不允许使用虚复制构造函数。
虽然如此,但存在一种不错的解决方案,就是定义自己的克隆函数来实现上述目的。这部分内容有些复杂,待用到时再作补充。
修改记录
2019-05-28 V1.0 初稿
2020-03-01 V1.1 修改错误,整理文档