多态是面向对象编程的核心特性之一,它允许不同派生类对同一接口作出不同实现。在C++中,多态主要依靠虚函数和基类指针或引用来实现。理解对象指针和引用在多态场景下的行为,是编写健壮、可扩展C++代码的基础。本文将结合具体代码,逐步讲解虚函数机制、指针与引用的用法差异、对象切片陷阱以及资源管理最佳实践。

一、虚函数与动态绑定
在C++中,如果基类中的成员函数被声明为虚函数(使用virtual关键字),那么派生类可以重写该函数。当通过基类指针或引用调用这个虚函数时,编译器不会在编译期决定具体执行哪个版本的函数,而是等到运行时根据指针或引用所指向对象的实际类型来选择合适的函数,这个过程称为动态绑定。动态绑定依赖虚函数表(vtable)实现,每个包含虚函数的类都有一张虚表,对象内部隐含一个指向虚表的指针。
例如定义一个图形基类Shape,其中area函数被声明为虚函数,然后派生Circle和Rectangle分别重写area。当我们使用基类指针指向派生类对象并调用area时,实际执行的是派生类中的版本。这正是多态的核心表现。如果没有虚函数,即使基类指针指向派生类对象,调用的仍然是基类版本,无法实现运行时多态。
下面的代码展示了基本的虚函数定义和派生类重写:
class Shape {
public:
virtual double area() const { return 0.0; }
virtual ~Shape() {}
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
class Rectangle : public Shape {
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override { return width * height; }
};
二、基类指针和引用的声明与使用
基类指针可以指向任意派生类对象,这是多态最常见的用法。声明一个基类指针Shape* p后,可以令它指向Circle对象或Rectangle对象。通过指针调用虚函数使用箭头运算符->。指针本身可以为空,也可以在不同时刻重新指向不同的派生类对象,灵活性较高。需要注意的是,动态分配的对象在使用完毕后必须通过delete释放,否则会造成内存泄漏。
基类引用则必须在定义时初始化,并且只能绑定到一个对象,不能重新绑定。引用使用点运算符.调用成员函数。引用同样能够触发动态绑定,例如Shape& ref = circ;之后,ref.area()调用的是Circle的area。引用不能为空,因此在使用前不需要进行空指针检查,语法上更加安全和简洁。
下面的代码同时演示了指针和引用的多态调用方式:
Circle circ(5.0);
Rectangle rect(4.0, 6.0);
Shape* shapes[] = { &circ, &rect };
for (Shape* s : shapes) {
std::cout << s->area() << std::endl;
}
Shape& ref1 = circ;
Shape& ref2 = rect;
std::cout << ref1.area() << std::endl;
std::cout << ref2.area() << std::endl;
运行这段代码会依次输出78.53975和24,说明无论通过指针数组遍历调用,还是通过引用直接调用,都正确执行了派生类中重写的area函数。
三、对象切片与值传递的陷阱
使用基类对象直接接收派生类对象时会引发对象切片。例如写出Shape s = circ;,编译器只会复制circ对象中属于Shape的那部分数据,Circle新增的成员和虚函数信息都会被丢弃。此时s.area()调用的仍然是Shape的版本,即使area是虚函数也不会发生动态绑定,因为s本身就是一个完整的Shape对象,而不是通过指针或引用间接访问派生类对象。
函数参数按值传递同样会造成切片。如果一个函数写成void printArea(Shape s),调用时传入Circle对象,参数s会是Shape类型的临时对象,虚函数调用被静态绑定到基类版本。为了让多态生效,函数参数应该使用基类指针或引用,例如const Shape& s。
下面的代码对比了按值传递和按引用传递的差异:
void printAreaByValue(Shape s) {
std::cout << "area: " << s.area() << std::endl;
}
void printAreaByRef(const Shape& s) {
std::cout << "area: " << s.area() << std::endl;
}
Circle c(5.0);
printAreaByValue(c); // 调用Shape::area,结果为0
printAreaByRef(c); // 调用Circle::area,结果为78.53975
从输出结果可以看出,按值传递丢失了对象的动态类型信息,这是多态编程中需要特别避免的误区。
四、虚析构函数与资源释放
当使用基类指针指向动态分配的派生类对象并通过delete释放时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,派生类中分配的资源将无法得到正确释放,导致内存泄漏。例如派生类在构造函数中申请了动态数组,析构函数中释放该数组,但通过基类指针删除时若析构不是虚函数,派生类的析构根本不会执行,数组内存就泄漏了。
正确的做法是将基类的析构函数声明为virtual。这样通过基类指针删除派生类对象时,会先调用派生类的析构函数,再调用基类的析构函数,保证资源被完整释放。任何可能作为多态基类的类都应该提供虚析构函数,即使不进行动态分配,这也是良好的设计习惯。
class Base {
public:
virtual ~Base() { }
};
class Derived : public Base {
int* data;
public:
Derived() : data(new int[100]) { }
~Derived() override { delete[] data; }
};
Base* p = new Derived();
delete p; // 正确调用Derived析构,因为Base析构是虚函数
如果Base的析构函数没有virtual,那么delete p;只会调用Base的析构,data数组不会被释放。使用智能指针管理多态对象时同样依赖虚析构,例如std::unique_ptr<Base>在析构时会调用Base的析构,如果它不是虚函数,同样无法释放派生类资源。
五、纯虚函数与抽象基类
纯虚函数是在基类中只有声明而没有实现的虚函数,写法是在函数声明末尾加上= 0。包含纯虚函数的类称为抽象基类,它不能直接实例化,但可以定义指针或引用指向派生类对象。抽象基类通常用于描述一组派生类的公共接口,强制派生类必须实现这些纯虚函数,否则派生类也会成为抽象类。
下面的例子将Shape设计为抽象基类,area函数是纯虚函数。Circle继承并实现了area,因此可以进行实例化。通过Shape*指针操作Circle对象,仍然可以触发动态绑定,调用Circle的area实现。
class Shape {
public:
virtual double area() const = 0; // 纯虚函数
virtual ~Shape() {}
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
Shape* s = new Circle(8.0);
std::cout << s->area() << std::endl;
delete s;
采用抽象基类的设计可以让代码只依赖接口而不依赖具体实现,将来增加新的图形类型时,只需要派生新的类并实现纯虚函数,使用基类指针或引用的代码无需修改。这是多态设计的重要价值。
六、智能指针与多态
现代C++推荐使用智能指针管理动态对象,避免手动delete带来的内存泄漏和异常安全问题。在std::unique_ptr中存储基类类型,可以指向派生类对象,并在智能指针销毁时自动释放对象。例如std::unique_ptr<Shape> p = std::make_unique<Circle>(3.0);,当p离开作用域时,会自动调用Shape的虚析构函数,从而正确释放Circle对象。
在实际开发中,常使用容器管理一组多态对象。下面的代码使用了std::vector存储多个基类智能指针,并遍历调用虚函数。这样可以安全地处理不同类型的图形对象,而无需手动管理每个对象的生命周期。
#include <memory>
#include <vector>
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));
shapes.push_back(std::make_unique<Rectangle>(2.0, 5.0));
for (const auto& s : shapes) {
std::cout << s->area() << std::endl;
} // 自动释放
使用智能指针时仍要确保基类析构函数是虚函数,否则unique_ptr<Shape>在释放对象时只会调用基类析构,派生类资源依然会泄漏。此外,std::shared_ptr也支持类似的多态管理,原理相同。
七、指针与引用的选择
指针和引用在多态中都能触发动态绑定,但选择时需要根据具体场景权衡。指针可以为空,可以重新指向不同对象,适合表达“可能没有对象”或者需要在多个对象之间切换的情况。引用必须在定义时初始化,不能为空,也不能改变绑定对象,适合表达“一定存在且不会更换”的对象关系。
函数参数方面,如果函数需要修改传入对象或者只想读取其状态,优先使用const Shape&引用参数。引用语法简洁,不需要进行空指针检查,而且避免了值传递带来的切片问题。如果调用方可能传递空对象,则只能使用指针Shape*,并在函数内部检查指针有效性。
总之,在C++多态编程中,应避免使用基类对象直接存储派生类对象,优先使用指针或引用来操作派生类对象,同时结合虚析构函数和智能指针来管理资源。掌握这些用法,能够帮助你编写出更安全、更灵活、更易于扩展的面向对象代码。