OOP 学习笔记(6)——虚函数与多态
Contents
虚函数与多态
函数调用捆绑
把函数体与函数调用相联系称为捆绑(binding)。
- 也就是将函数体实现代码的入口地址与调用的函数名绑定,执行到调用代码时进入函数体内部。
两种类型
当捆绑在程序运行之前(编译器、链接器)完成时,称为早捆绑(early binding)。
- 运行之前已经决定了函数调用代码进入哪个函数。
- 比如上节中,即便使用对象的引用或者指针,调用的也还是基类函数的问题,就是编译器提前将函数绑定。
当捆绑根据对象的实际类型(上例中即子类而非基类),发生在程序运行时,称为晚捆绑(late binding),又称动态捆绑或运行时捆绑。
- 要求在运行时能确定对象实际类型,并绑定正确的函数(也就是对象需要包含自己实际类型的信息)。
- 晚捆绑只对类中的虚函数起作用,使用
virtual
关键字声明虚函数。
虚函数
对于被派生类重新定义的成员函数,若它在基类中被声明为虚函数时:
class Base
{
public:
virtual ReturnType FuncName(parameters); // 虚函数
//...
};
则通过基类指针/引用调用该成员函数时,编译器将根据所指/引用对象的实际类型决定是调用基类中的函数还是调用派生类重写的函数。
若某成员函数在基类中声明为虚函数,当派生类重写覆盖其时,无论是否声明为虚函数,该成员函数都仍然是虚函数。
虚函数实例
因此上讲例子可以改为:
#include <iostream>
using namespace std;
class Base
{
public:
virtual void print()
{
cout << "Base::print()" << endl;
}
};
class Derive : public Base
{
public:
void print()
{
cout << "Derive::print()" << endl;
}
};
void fun(Base &obj)
{
obj.print();
}
int main()
{
Derive d;
d.print();
fun(d);
return 0;
}
此时运行结果为:
Derive::print()
Derive::print()
对于指针同理,但对于对象即便声明了虚函数还是无效。
虚函数表
对象自身要包含自己实际类型的信息:用虚函数表表示。
运行时通过虚函数表确定对象的实际类型。
虚函数表(VTABLE):每个包含虚函数的类用于存储虚函数地址的表(虚函数表有唯一性,即使没有重写虚函数)。
每个包含虚函数的类对象中,编译器秘密地放一个指针,称为虚函数指针(vpointer/VPTR),指向这个类的虚函数。
当通过基类指针做函数调用时,编译器静态地插入能取得这个 VPTR 并在 VTABLE 表中查找函数地址的代码,这样就能调用正确的函数并引起晚捆绑的发生。
- 编译期间:建立虚函数表(VTABLE),记录每个类或该类的基类中所有已声明的虚函数入口地址。
- 运行期间:建立虚函数指针(VPTR),在构造函数中发生,指向相应的虚函数表(VTABLE)。
虚函数指针示例
#include <iostream>
using namespace std;
#pragma pack(4) //按照4字节进行内存对齐
class NoVirtual //没有虚函数
{
int a;
public:
void x() const {}
int i() const
{
return 1;
}
};
class OneVirtual //一个虚函数
{
int a;
public:
virtual void x() const {}
int i() const
{
return 1;
}
};
class TwoVirtual //两个虚函数
{
int a;
public:
virtual void x() const {}
virtual int i() const
{
return 1;
}
};
int main()
{
cout << "int: " << sizeof(int) << endl;
cout << "NoVirtual: " << sizeof(NoVirtual) << endl;
cout << "void* : " << sizeof(void*) << endl;
cout << "OneVirtual: " << sizeof(OneVirtual) << endl;
cout << "TwoVirtual: " << sizeof(TwoVirtual) << endl;
return 0;
}
$64$ 位机器上运行结果:
int: 4
NoVirtual: 4
void* : 8
OneVirtual: 12
TwoVirtual: 12
后两个类各自带一个指向虚函数表的虚函数指针(void*
),故多了 $8$ 个字节。
使用 #pragma pack(4)
进行内存对齐的原因主要有平台原因和性能原因:
- 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
- 数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
编译器一般按照几个字节对齐呢?本文中两个编译器默认按照类中最大类型长度来对齐。
虚函数与构造函数
上面说了,编译器会秘密插入初始化 VPTR 的代码,这段代码插入在构造函数的开头。
除此之外,虚函数不能也不必是虚函数,考虑到创建对象需要知道 VPTR 而构造函数调用前 VPTR 未初始化,以及构造函数的调用时需要明确指定创建对象的类型。
而如果在构造函数中调用虚函数,被调用的只是此函数的本地版本,也就是虚机制在构造函数中不工作。(可以考虑派生类特有数据成员的状态,以下面为例)
而除此之外,即便是在基类特有的函数中调用虚函数,虚机制依然有效。
#include <iostream>
using namespace std;
class Base
{
public:
virtual void foo()
{
cout << "Base::foo" << endl;
}
Base() // 在构造函数中调用虚函数foo
{
foo();
}
void bar() // 在普通函数中调用虚函数foo
{
foo();
}
};
class Derived : public Base
{
public:
int i;
void foo()
{
cout << "Derived::foo" << i << endl;
}
Derived(int j) : Base(), i(j) {}
};
int main()
{
Derived d(0);
Base &b = d;
b.bar();
b.foo();
return 0;
}
运行结果为:
Base::foo
Derived::foo0
Derived::foo0
虚函数与析构函数
析构函数可以且常常是虚的,虚析构函数仍需要定义函数体。
考虑到删除基类对象指针时,编译器应当根据所指对象的实际类型,调用对应的析构函数,这样才能防止内存泄漏,所以析构函数往往需要是虚函数,这也是一个重要原则。
同样,在析构函数中调用一个虚函数,被调用的也只是本地版本,虚机制在析构函数中同样不工作。
这也是考虑到,先执行派生类的析构,那么调用可能发生在一个已经被删除的对象上,导致非法调用。
重写覆盖
重写覆盖(override)
派生类重新定义基类中的虚函数,函数名必须相同,函数参数列表必须相同,返回值一般情况应当相同。
派生类的虚函数表中原基类的虚函数指针会被派生类中重新定义的虚函数指针覆盖掉。
重写隐藏(redefining)
派生类重新定义基类中的函数,函数名相同,但参数列表不同或者基类的函数不是虚函数。
虚函数表不会发生覆盖。
重写覆盖示例
#include <iostream>
using namespace std;
class Base
{
public:
virtual void foo()
{
cout << "Base::foo()" << endl;
}
virtual void foo(int) // 重载
{
cout << "Base::foo(int)" << endl;
}
void bar() {}
};
class Derived1 : public Base
{
public:
void foo(int) // 重写覆盖
{
cout << "Derived1::foo(int)" << endl;
}
};
class Derived2 : public Base
{
public:
void foo(float) // 误把参数写错了,不是重写覆盖,是重写隐藏
{
cout << "Derived2::foo(float )" << endl;
}
};
int main()
{
Derived1 d1;
Derived2 d2;
Base *p1 = &d1;
Base *p2 = &d2;
// d1.foo(); 由于派生类都定义了带参数的foo,基类foo()对实例不可见
// d2.foo();
p1->foo(); // 但是虚函数表中有继承自基类的 foo() 虚函数
p2->foo();
d1.foo(3); // 重写覆盖
d2.foo(3.0); // 调用的是派生类 foo(float)
p1->foo(3); // 重写覆盖,虚函数表中是派生类 foo(int )
p2->foo(3.0); // 重写隐藏,虚函数表中是继承自基类 foo(int)
return 0;
}
override
重写覆盖要满足的条件很多,可以用 override
关键字辅助检查。
override
关键字明确告诉编译器一个函数是对基类中一个虚函数的重写覆盖,编译器将限制只有正确的重写覆盖才能通过编译。
当然这个关键字只是起到辅助检查的作用,并不是必要条件。
final
- 在虚函数声明或定义中使用时,
final
关键字确保函数为虚且不可被派生类重写。可在继承关系链的中途进行设定,禁止后续派生类对指定虚函数重写(最终覆盖)。 - 在类定义中使用时,
final
关键字指定此类不可被继承。
class Base
{
virtual void foo() {}
};
class A : public Base
{
void foo() final {}
void bar() final {}
};
class B final : public A
{
// void foo() override {} 已有最终覆盖 A::foo,编译错误
};
// class C : public B {}; B 不能被继承,编译错误
一般类被 final
修饰是因为某些类不允许有子类,实现者不希望客户端从这些类派生新类而修改它们。
纯虚函数
虚函数还可以进一步声明为纯虚函数(可以有函数体),包含纯虚函数的类,通常称为抽象类,以下为声明格式:
virtual ReturnType FuncName(parameters) = 0;
抽象类不允许定义对象,定义基类为抽象类的主要用途是为派生类规定共性接口。
class A
{
public:
virtual void f() = 0; // 可在类外定义函数体提供默认实现。派生类通过 A::f() 调用。
};
此时不能用 A
定义对象。
抽象类
定义:含有至少一个纯虚函数。
特点:
- 不允许定义对象。
- 只能为派生类提供接口。(可以有静态成员数据)
- 能避免对象切片:保证只有指针和引用能被向上类型转换。
纯虚函数继承
基类纯虚函数若不被重写覆盖,则派生类继承后仍为纯虚函数。
所以继承一个抽象类并需要实例化时,需要实现所有的纯虚函数。
纯虚析构函数除外。
纯虚析构函数
纯虚析构函数必须有函数体。
class A
{
public:
virtual ~A() = 0;
};
A::~A {} // 必须有函数体
纯虚析构函数可以使基类称为抽象类。
而继承时,编译器自动生成默认析构函数,不必显式实现,所以派生类无须实现析构函数也可实例化。
向下类型转换
基类指针/引用转换成派生类指针/引用,被称为向下类型转换(向下同样是因为在继承图中向下)。
向下类型转换是为了调用原来向上转换时丢失了特性的基类指针/引用的特性数据和接口。
同样是通过虚函数表(VTABLE)确保转换正确性。
转换实例
需要用到前面说的强制转换中的动态类型转换。
class Pet
{
public:
virtual ~Pet() = 0;
};
class Dog : public Pet
{
public:
void run()
{
cout << "dog run" << endl;
}
};
class Bird : public Pet
{
public:
void fly()
{
cout << "bird fly" << endl;
}
};
void action(Pet *p)
{
auto d = dynamic_cast<Dog*>(p); //向下类型转换
auto b = dynamic_cast<Bird*>(p);
if (d) d->run();
else
if (b) b->fly();
}
int main()
{
Pet *p[2];
p[0] = new Dog;
p[1] = new Bird;
for (int i = 0; i < 2; i++)
action(p[i]);
return 0;
}
输出为:
dog run
bird fly
对于引用同理将 *
改为 &
即可。
动态类型转换
dynamic_cast
是一种安全类型向下类型转换。
使用中,被强制转换的对象必须是多态类型(声明或继承了至少一个虚函数的类),否则会编译错误,但是转换至的类型则不需要。
转换失败时,如果是指针类型,会返回空指针 nullptr
,如果是引用类型会报 bad_cast
异常。
除此之外,如果知道正在处理的是哪些类型,也可使用 static_cast
来避免这种开销。
但这样不能保证转换后的目标是对应类型的,也就是不安全。
dynamic_cast
运行时需要额外的检查 RTTI(Run-Time Type Identification),因此会慢一些。
重要原则(了解指针所指向的真正对象)
- 指针或引用的向上转换总是安全的;
- 向下转换时用
dynamic_cast
,安全检查; - 避免对象之间的转换。
多态
按照基类的接口定义,调用指针或引用所指对象的接口函数,函数执行过程因对象实际所属派生类的不同而呈现不同的效果(表现),这个现象被称为多态。
当利用基类指针/引用调用函数时:
- 虚函数在运行时确定执行哪个版本,取决于引用/指针对象的真实类型。
- 非虚函数在编译时绑定。
当利用类的对象直接调用函数时:
- 所有函数在编译时绑定。
所以,产生多态效果的条件是继承,虚函数和引用或指针。
优点
多态使得 C++ 语言可以用同一段代码,在运行时完成不同的任务,这些不同运行结果的差异由派生类之间的差异决定。
- 通过基类定好接口之后,只需调用抽象基类的接口即可,大大提高程序的可复用性。
- 不同派生类对同一接口的实现不同,能达到不同的效果,提高了程序可拓展性和可维护性。
应用:模板方法(TEMPLATE METHOD)设计模式
- 在接口的一个方法中定义算法骨架。
- 将一些步骤的实现延迟到子类中。
- 使得子类可以在不改变算法结构的前提下,重新定义算法中的某些步骤。
模板方法是一种源代码重用的基本技术,在类库的设计实现中应用十分广泛——因为此设计模式能有效解决类库提供公共行为和用户定制特殊细节之间的折衷平衡。
多重继承
多重继承会有多个虚函数表,几重继承,就会有几个虚函数表。
这些表按照派生的顺序依次排列。
如果子类改写了父类的虚函数,就会用子类虚函数覆盖虚函数表相应位置,子类中新的虚函数则会加到第一个虚函数表末尾。
利与弊
- 利:
- 清晰,符合直觉。
- 结合多个接口。
- 弊:
- 二义性,见上讲。
- 钻石型继承树(DOD:Diamond Of Death)带来的数据冗余,见上讲。
一种解决方案——Best Practice
- 最多继承一个非抽象类。
- 可以继承多个抽象类(接口)。
这样可以避免二义性,并且利用一个对象实现多个接口。
OOP 核心思想
OOP 核心思想是数据抽象、继承和动态绑定。
数据抽象
类的接口与实现分离。
抽象类定义接口,具体实现在子类中。
继承
建立相关类型的层次关系,也就是相互之间的基类与派生类关系。
包括函数重写、虚函数等等。
动态绑定
统一使用基类指针,实现多态行为。
包括类型转换、模板等等。
No Comments