多态性 (Polymorphism)
在C++中,多态性是其作为面向对象编程语言的重要特性。所谓多态(Polymorphism),我们对其简单的概括性理解可以认为是使用同样的函数名称可以调用其多种不同的具体实现逻辑。根据函数和其具体实现逻辑的绑定时机,我们可以将这种多态性分为编译时多态(Compile-time Polymorphism,也被叫做静态多态),和运行时多态(Runtime Polymorphism,也被叫做动态多态)。
编译时多态(Compile-time Polymorphism)
编译时多态,即是在代码编译阶段确定函数和其具体实现逻辑的绑定,主要是通过函数重载(Function Overloading)来实现。函数重载即是使用相同的函数名和不同的函数参数形式来定义多个函数,不同的函数参数形式可以是不同的参数类型、不同的参数数量或者参数类型和数量皆不同。当使用相应的函数调用语句时,编译器会根据语句使用的具体参数形式将该调用与对应的函数实现逻辑绑定。
一个简单的函数重载示例:
class Calculator { public: // 整数加法 int add(int a, int b) { return a + b; } // 浮点数加法 double add(double a, double b) { return a + b; } // 三个整数加法 int add(int a, int b, int c) { return a + b + c; } }; int main() { Calculator calc; // 编译器根据参数类型和数量选择对应的函数 int sum1 = calc.add(5, 3); // 调用第一个add double sum2 = calc.add(3.14, 2.86); // 调用第二个add int sum3 = calc.add(1, 2, 3); // 调用第三个add return 0; }
在这个例子中,Calculator类中定义了三个同名的add函数,它们的参数列表各不相同。编译器会在编译时根据函数调用时传入的参数类型和数量来确定应该调用哪个具体的add函数实现。这就是典型的编译时多态的应用。
编译时多态性除了一般函数重载能体现之外,还有操作符重载(Operator Overloading),操作符如+、-、*、/、=等其本质是一类特殊的函数,只不过其函数名称和调用语句形式较为特殊,所以本质上也属于函数重载。
下面是一个简单的操作符重载示例:
class Complex { private: double real; double imag; public: Complex(double r = 0, double i = 0) : real(r), imag(i) {} // 重载加法运算符 Complex operator+(const Complex& other) { return Complex(real + other.real, imag + other.imag); } }; int main() { int a = 1; int b = 41; Complex c1(3.0, 2.0); // 3+2i Complex c2(1.0, 4.0); // 1+4i int sum_i = a + b; Complex sum_c = c1 + c2; // 使用重载的+运算符 return 0; }
在这个例子中,我们定义了一个Complex类来表示复数,并重载了+。通过运算符重载,我们可以使用自然的数学表达式语法来操作复数对象。编译器在编译时会将这些运算符表达式转换为相应的函数调用。这是另一种形式的编译时多态。
此外,在C++中还可以基于模板函数来简化编译时多态的实现,当相同名称的函数面对不同类型的参数具有相似逻辑实现时,定义使用模板函数能让编译期自动帮我们实现多态性,避免手动编写相似逻辑代码的冗余工作,这时模板函数就像是一种有力的语法糖工具能加速我们的编码工作。
下面是一个使用模板函数实现编译时多态的示例:
template<typename T> T getMax(T a, T b) { return (a > b) ? a : b; } int main() { // 整数比较 int i1 = 42, i2 = 21; std::cout << "Max of " << i1 << " and " << i2 << " is: " << getMax(i1, i2) << std::endl; // 浮点数比较 double d1 = 3.14, d2 = 2.72; std::cout << "Max of " << d1 << " and " << d2 << " is: " << getMax(d1, d2) << std::endl; // 字符比较 char c1 = 'a', c2 = 'z'; std::cout << "Max of " << c1 << " and " << c2 << " is: " << getMax(c1, c2) << std::endl; return 0; }
在这个例子中,我们定义了一个模板函数getMax,它可以比较任何支持>运算符的数据类型。编译器会在编译时根据传入的参数类型自动生成相应的函数实现。这样,我们就不需要为每种数据类型都手动编写一个getMax函数,而是让编译器帮我们自动完成这项工作。这种方式不仅减少了代码重复,还保持了类型安全性。
当这段代码编译时,编译器会根据实际使用的类型自动实例化生成三个不同的函数定义:一个用于int类型,一个用于double类型,还有一个用于char类型。
上述所有方式实现的函数多态性,其本质都是对函数进行重载实现的,由编译器在代码编译期间确定性地完成。
除了上述方式之外,还有一种称为CRTP( Curiously Recurring Template Pattern )基于类继承的编译时多态实现方式。CRTP模式中基类是一个模板类,其模板参数类型比较特殊,是该基类的派生类。
下面是一个CRTP实现编译时多态的例子:
#include <iostream> // 基类模板,其中派生类作为模板参数传入 template <typename Derived> class Base { public: // 调用派生类实现的函数 void interface() { // 通过静态转换访问派生类的方法,这里转换是安全的 // 因为调用该方法的实例被保证一定是派生类实例而不是基类实例 static_cast<Derived*>(this)->implementation(); } // 提供默认实现 void implementation() { std::cout << "Base 的默认实现" << std::endl; } }; // 派生类1,继承自 Base 并将自身作为模板参数 class Derived1 : public Base<Derived1> { public: // 覆盖基类的实现 void implementation() { std::cout << "Derived1 的具体实现" << std::endl; } }; // 派生类2,继承自 Base 并将自身作为模板参数 class Derived2 : public Base<Derived2> { public: // 覆盖基类的实现 void implementation() { std::cout << "Derived2 的具体实现" << std::endl; } }; // 派生类3,继承自 Base 但不覆盖实现方法(将使用基类的默认实现) class Derived3 : public Base<Derived3> { // 没有覆盖 implementation(),将使用基类版本 }; int main() { // 创建不同派生类的实例 Derived1 d1; Derived2 d2; Derived3 d3; std::cout << "调用 Derived1::interface(): "; d1.interface(); // 将调用 Derived1::implementation() std::cout << "调用 Derived2::interface(): "; d2.interface(); // 将调用 Derived2::implementation() std::cout << "调用 Derived3::interface(): "; d3.interface(); // 将调用 Base::implementation() return 0; }
这个例子展示了 CRTP 的核心概念:
- 基类 Base 是一个模板类,它接受派生类类型作为模板参数
- 派生类继承自特化的基类模板,并将自身作为模板参数传递
- 基类通过 static_cast 在编译时将 this 指针转换为派生类类型
这种方式实现了静态多态(编译时多态),不需要虚函数和运行时开销与运行时多态相比,CRTP 提供了更好的性能,因为所有绑定都在编译时解析,避免了虚函数表查找的开销。
运行时多态(Runtime Polymorphism)
提到C++中的多态性,更为人所知的其实是其运行时多态性,其主要通过虚函数表(vTable)、虚函数表指针(vPtr)和继承(Inheritance)来实现的。运行时多态的主要含义是通过基类类型的指针或引用,根据运行时该指针或引用所指向的具体派生类,可以调用派生类的方法。
下面通过一个示例来展示运行时多态的实现机制:
class Shape { // 基类 public: virtual void draw() { // 虚函数 std::cout << "Drawing a shape" << std::endl; } virtual ~Shape() {} // 虚析构函数 }; /* Shape's vTable: * - Shape::draw() * - Shape::~Shape() */ class Circle : public Shape { // 派生类 public: void draw() override { // 覆写虚函数 std::cout << "Drawing a circle" << std::endl; } }; /* Circle's vTable: * - Circle::draw() // 覆写了Shape::draw() * - Circle::~Circle() // 继承的析构函数 */ class Rectangle : public Shape { // 另一个派生类 public: void draw() override { // 覆写虚函数 std::cout << "Drawing a rectangle" << std::endl; } }; /* [vptr] -> Rectangle's vTable: * - Rectangle::draw() // 覆写了Shape::draw() * - Rectangle::~Rectangle() // 继承的析构函数 */ int main() { Shape* shapes[3]; // 基类指针数组 // 每个实例对象都包含一个vptr指针变量,其由编译器编译构造函数时添加语句生成 // ,指向其对应类的vTable shapes[0] = new Shape(); // vptr -> Shape's VTable shapes[1] = new Circle(); // vptr -> Circle's VTable shapes[2] = new Rectangle(); // vptr -> Rectangle's VTable // 运行时多态:通过基类指针调用draw() // 系统会查找对象的vptr指向的vtable // 然后调用vtable中对应的函数实现 for(int i = 0; i < 3; i++) { shapes[i]->draw(); // 动态绑定,调用相应类的draw()实现 } // 清理内存 for(int i = 0; i < 3; i++) { delete shapes[i]; // 通过虚析构函数确保正确释放内存 } return 0; }
在这个例子中:
- 每个包含虚函数的类(虚函数为自声明或继承而来)都有一个虚函数表(vTable),存储着该类的虚函数指针,由于每个类的vTable内容是固定不变的,编译器生成vTable后将其存储于程序虚拟内存的只读数据段(如在Linux下为
.rodata
)。
- 每个类的实例都包含一个虚函数表指针(vptr),指向该类的vTable,vptr作为一个隐含成员变量存储于该实例对象中,影响该实例对象的体积大小。
- 当通过基类指针调用虚函数时,系统会:
- 找到对象的vptr
- 通过vptr找到对应的vTable
- 在vTable中查找并调用正确的函数实现
这就是C++运行时多态的实现机制。当我们通过基类指针调用draw()函数时,程序会在运行时根据指针指向的实际对象类型,调用相应的draw()实现,这种动态绑定机制即是主要通过vtable和vptr来实现的。
其中值得关注的一点是用于实现多态的基类的析构函数需要是虚函数,因为如果不是,那么在使用
delete
操作符处理基类指针时,只会调用基类本身的析构函数,而不会从实例对象的vptr所指向的vTable中找到派生类的虚构函数来调用,可能引起资源泄漏等后果。以下示例代码展示了为什么基类析构函数必须是虚函数:
class Resource { public: Resource() { std::cout << "Resource acquired\n"; } // 错误示范:非虚析构函数 ~Resource() { std::cout << "Base resource released\n"; } }; class DerivedResource : public Resource { private: int* array; public: DerivedResource() { array = new int[100]; std::cout << "Derived resource acquired (allocated array)\n"; } ~DerivedResource() { delete[] array; std::cout << "Derived resource released (freed array)\n"; } }; void demonstrate_memory_leak() { std::cout << "Creating resource via base class pointer...\n"; Resource* ptr = new DerivedResource(); std::cout << "Deleting resource...\n"; delete ptr; // 会导致内存泄漏!只调用基类析构函数 } // 正确的实现方式: class ResourceCorrect { public: ResourceCorrect() { std::cout << "Resource acquired\n"; } // 正确示范:虚析构函数 virtual ~ResourceCorrect() { std::cout << "Base resource released\n"; } }; class DerivedResourceCorrect : public ResourceCorrect { private: int* array; public: DerivedResourceCorrect() { array = new int[100]; std::cout << "Derived resource acquired (allocated array)\n"; } ~DerivedResourceCorrect() { delete[] array; std::cout << "Derived resource released (freed array)\n"; } }; void demonstrate_proper_cleanup() { std::cout << "\nCreating resource via base class pointer (correct version)...\n"; ResourceCorrect* ptr = new DerivedResourceCorrect(); std::cout << "Deleting resource...\n"; delete ptr; // 正确:会调用派生类的析构函数 }
在第一个错误示例中,当通过基类指针删除派生类对象时,由于基类的析构函数不是虚函数,只会调用基类的析构函数,而不会调用派生类的析构函数。这导致派生类中分配的array数组内存永远不会被释放,造成内存泄漏。
而在第二个正确示例中,由于基类的析构函数被声明为virtual,当删除对象时,会首先调用派生类的析构函数释放array数组,然后再调用基类的析构函数,从而正确地清理了所有资源。
总结
以上内容是本人在学习过程中对C++多态性的一些总结,包括编译时多态和运行时多态,编译时多态主要通过函数重载来实现,运行时多态通过虚函数表、虚函数表指针及继承实现。基于多态性,使用相同函数名称可以灵活地调用不同的具体函数实现逻辑,也有利于项目代码的可扩展性和可维护性。
参考链接
- C++ Polymorphism - GeeksforGeeks, https://www.geeksforgeeks.org/cpp-polymorphism/
- vTable And vPtr in C++ - GeeksforGeeks, https://www.geeksforgeeks.org/vtable-and-vptr-in-cpp/
- Virtual Functions and Runtime Polymorphism in C++ - GeeksforGeeks, https://www.geeksforgeeks.org/virtual-functions-and-runtime-polymorphism-in-cpp/
- Exploring C++ Virtual Functions and Polymorphism | Medium,https://medium.com/@AlexanderObregon/exploring-c-virtual-functions-and-polymorphism-65cd38fbb70b
版权声明:本文为作者beaclnd的原创文章,遵循版权协议署名-非商业性使用-禁止演绎 4.0 (CC BY-NC-ND 4.0) ,若转载请附上原文出处链接和本声明。