MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++类虚函数的多态实现

2021-02-287.8k 阅读

C++类虚函数的多态实现基础概念

什么是多态

多态性是面向对象编程的重要特性之一,它允许以统一的方式处理不同类型的对象。在 C++ 中,多态通过虚函数和指针或引用的组合来实现。简单来说,多态使得我们可以根据对象的实际类型,在运行时调用适当的函数版本,而不是在编译时就确定。

例如,假设有一个基类 Animal,和两个派生类 DogCat,它们都继承自 AnimalAnimal 类可能有一个函数 makeSoundDogCat 类可以重写这个函数,分别发出“汪汪”和“喵喵”的声音。当我们通过 Animal 类型的指针或引用调用 makeSound 函数时,实际调用的是 DogCat 类中重写的 makeSound 函数,这就是多态的体现。

虚函数的定义

在 C++ 中,通过在基类函数声明前加上 virtual 关键字来将其定义为虚函数。例如:

class Animal {
public:
    virtual void makeSound() {
        std::cout << "Generic animal sound" << std::endl;
    }
};

这里的 makeSound 函数就是一个虚函数。派生类可以重写这个虚函数以提供自己的实现。

重写虚函数

当派生类重写基类的虚函数时,函数的签名(参数列表和返回类型)必须与基类中的虚函数完全一致(在 C++11 及以后,协变返回类型是一个例外,即派生类重写函数可以返回基类虚函数返回类型的派生类型)。例如:

class Dog : public Animal {
public:
    void makeSound() override {
        std::cout << "Woof!" << std::endl;
    }
};

class Cat : public Animal {
public:
    void makeSound() override {
        std::cout << "Meow!" << std::endl;
    }
};

这里 DogCat 类重写了 Animal 类的 makeSound 虚函数。override 关键字是 C++11 引入的,它不是必需的,但强烈推荐使用,它可以让编译器检查派生类中的函数是否确实重写了基类的虚函数,如果函数签名不匹配,编译器会报错,有助于避免一些难以发现的错误。

多态的实现方式

多态的实现主要依赖于指针或引用。通过基类类型的指针或引用调用虚函数时,C++ 运行时系统会根据指针或引用实际指向的对象类型来决定调用哪个版本的虚函数。例如:

int main() {
    Animal* animal1 = new Dog();
    Animal* animal2 = new Cat();

    animal1->makeSound(); 
    animal2->makeSound(); 

    delete animal1;
    delete animal2;
    return 0;
}

在上述代码中,animal1Animal 类型的指针,但实际指向 Dog 对象,animal2 实际指向 Cat 对象。当调用 makeSound 函数时,会根据对象的实际类型分别调用 DogCat 类中的 makeSound 函数,输出“Woof!”和“Meow!”。

虚函数表与多态的底层实现

虚函数表(vtable)

C++ 编译器为每个包含虚函数的类创建一个虚函数表(vtable)。虚函数表是一个函数指针数组,每个元素是一个指向该类虚函数的指针。当一个类包含虚函数时,该类的每个对象都会包含一个隐藏的指针,称为虚指针(vptr),它指向该类的虚函数表。

例如,对于前面的 Animal 类,编译器会为其创建一个虚函数表,表中包含 Animal 类虚函数 makeSound 的指针。当创建 Dog 类对象时,Dog 类对象的 vptr 会指向 Dog 类的虚函数表,这个虚函数表中 makeSound 函数指针指向 Dog 类重写的 makeSound 函数版本。

动态绑定

动态绑定是实现多态的关键机制。当通过基类指针或引用调用虚函数时,C++ 运行时系统通过对象的 vptr 找到对应的虚函数表,然后根据虚函数表中函数指针调用实际的函数。这个过程是在运行时进行的,而不是编译时,因此称为动态绑定。

例如,在前面 main 函数中 animal1->makeSound() 的调用过程如下:

  1. 首先,通过 animal1 指针找到 Dog 对象。
  2. 然后,从 Dog 对象中获取 vptr,vptr 指向 Dog 类的虚函数表。
  3. Dog 类的虚函数表中找到 makeSound 函数的指针。
  4. 最后,通过该指针调用 Dog 类的 makeSound 函数。

这种动态绑定机制使得程序能够根据对象的实际类型来调用适当的函数,实现了多态性。

多重继承下的虚函数表

在多重继承的情况下,虚函数表的结构会变得更加复杂。假设一个类 Derived 继承自多个基类 Base1Base2,且这些基类都有虚函数。Derived 类会有多个虚函数表,每个基类对应一个。

例如:

class Base1 {
public:
    virtual void func1() {
        std::cout << "Base1::func1" << std::endl;
    }
};

class Base2 {
public:
    virtual void func2() {
        std::cout << "Base2::func2" << std::endl;
    }
};

class Derived : public Base1, public Base2 {
public:
    void func1() override {
        std::cout << "Derived::func1" << std::endl;
    }

    void func2() override {
        std::cout << "Derived::func2" << std::endl;
    }
};

Derived 类对象会有两个 vptr,分别指向 Base1Base2 虚函数表的对应版本。当通过 Base1 类型指针调用 func1 时,会根据 Base1 虚函数表来调用;通过 Base2 类型指针调用 func2 时,会根据 Base2 虚函数表来调用。

虚继承下的虚函数表

虚继承是为了解决多重继承中的菱形继承问题。在虚继承中,虚基类的虚函数表也会有特殊的处理。

例如:

class GrandParent {
public:
    virtual void grandFunc() {
        std::cout << "GrandParent::grandFunc" << std::endl;
    }
};

class Parent1 : virtual public GrandParent {
public:
    void grandFunc() override {
        std::cout << "Parent1::grandFunc" << std::endl;
    }
};

class Parent2 : virtual public GrandParent {
public:
    void grandFunc() override {
        std::cout << "Parent2::grandFunc" << std::endl;
    }
};

class Child : public Parent1, public Parent2 {
public:
    void grandFunc() override {
        std::cout << "Child::grandFunc" << std::endl;
    }
};

在这种情况下,Child 类对象的虚函数表结构会进行调整,以确保无论通过 Parent1 还是 Parent2 路径访问虚函数,都能正确调用到 Child 类重写的版本。虚继承下的虚函数表结构会包含额外的信息来处理这种复杂的继承关系。

虚函数的特性与注意事项

虚函数与访问控制

虚函数的访问控制修饰符(publicprotectedprivate)决定了该虚函数在类外部和派生类中的可访问性。

  1. public 虚函数:可以在类外部通过对象、指针或引用调用,派生类也可以重写。例如前面的 Animal 类的 makeSound 函数就是 public 虚函数。
  2. protected 虚函数:不能在类外部通过对象调用,但可以通过指针或引用在类的成员函数和友元函数中调用。派生类可以重写,但重写后的函数访问控制级别不能低于基类中的级别,即如果基类中是 protected 虚函数,派生类重写后不能是 private
  3. private 虚函数:只能在类的成员函数和友元函数中调用,派生类不能直接访问和重写。但如果派生类不知情地定义了一个与基类 private 虚函数签名相同的函数,在运行时通过基类指针或引用调用时,仍然会调用到派生类的这个函数,这是一种比较隐晦的情况,需要注意。

构造函数与虚函数

构造函数不能是虚函数。因为在构造对象时,对象的类型还没有完全确定,虚函数的机制依赖于对象已经构造完成且 vptr 已经正确初始化。如果构造函数是虚函数,就无法确定应该调用哪个版本的构造函数,这会导致逻辑混乱。

例如:

class Base {
public:
    // 下面这种写法是错误的,构造函数不能是虚函数
    // virtual Base() {}
    virtual void func() {
        std::cout << "Base::func" << std::endl;
    }
};

如果试图将构造函数声明为虚函数,编译器会报错。

析构函数与虚函数

析构函数通常应该声明为虚函数,特别是在基类指针指向派生类对象的情况下。如果基类析构函数不是虚函数,当通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数,这会导致内存泄漏。

例如:

class Base {
public:
    ~Base() {
        std::cout << "Base::~Base" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived::~Derived" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    delete basePtr; 
    return 0;
}

在上述代码中,由于 Base 类析构函数不是虚函数,delete basePtr 只会调用 Base 类的析构函数,Derived 类的析构函数不会被调用。如果 Derived 类在析构函数中有动态分配的资源需要释放,就会导致内存泄漏。

Base 类析构函数声明为虚函数:

class Base {
public:
    virtual ~Base() {
        std::cout << "Base::~Base" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived::~Derived" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    delete basePtr; 
    return 0;
}

这样,delete basePtr 时会先调用 Derived 类的析构函数,再调用 Base 类的析构函数,避免了内存泄漏。

纯虚函数与抽象类

纯虚函数是一种特殊的虚函数,它没有函数体,在声明时通过 = 0 来指定。包含纯虚函数的类称为抽象类,抽象类不能实例化对象。

例如:

class Shape {
public:
    virtual double area() = 0; 
};

class Circle : public Shape {
public:
    double radius;
    Circle(double r) : radius(r) {}
    double area() override {
        return 3.14159 * radius * radius;
    }
};

class Rectangle : public Shape {
public:
    double width, height;
    Rectangle(double w, double h) : width(w), height(h) {}
    double area() override {
        return width * height;
    }
};

这里 Shape 类是抽象类,因为它包含纯虚函数 areaCircleRectangle 类继承自 Shape 并实现了 area 函数,所以它们可以实例化对象。

抽象类的主要作用是为派生类提供一个通用的接口,强制派生类实现某些函数,从而实现更严格的多态规范。

虚函数在实际项目中的应用

图形绘制系统

在一个简单的图形绘制系统中,我们可以定义一个基类 Shape,并将 draw 函数定义为虚函数。然后派生出不同的图形类,如 CircleRectangle 等,每个派生类重写 draw 函数以实现自己的绘制逻辑。

class Shape {
public:
    virtual void draw() = 0; 
};

class Circle : public Shape {
public:
    double radius;
    Circle(double r) : radius(r) {}
    void draw() override {
        std::cout << "Drawing a circle with radius " << radius << std::endl;
    }
};

class Rectangle : public Shape {
public:
    double width, height;
    Rectangle(double w, double h) : width(w), height(h) {}
    void draw() override {
        std::cout << "Drawing a rectangle with width " << width << " and height " << height << std::endl;
    }
};

void drawShapes(Shape** shapes, int count) {
    for (int i = 0; i < count; ++i) {
        shapes[i]->draw();
    }
}

int main() {
    Shape* shapes[2];
    shapes[0] = new Circle(5.0);
    shapes[1] = new Rectangle(4.0, 3.0);

    drawShapes(shapes, 2);

    for (int i = 0; i < 2; ++i) {
        delete shapes[i];
    }
    return 0;
}

在这个例子中,drawShapes 函数接受一个 Shape 指针数组,可以绘制不同类型的图形,这体现了多态的优势,使得代码更加灵活和可扩展。

游戏开发中的角色行为

在游戏开发中,假设有一个基类 Character,包含虚函数 performAction。不同类型的角色,如 WarriorMage 等继承自 Character 并重写 performAction 函数来实现各自的行为。

class Character {
public:
    virtual void performAction() = 0; 
};

class Warrior : public Character {
public:
    void performAction() override {
        std::cout << "Warrior is attacking with a sword!" << std::endl;
    }
};

class Mage : public Character {
public:
    void performAction() override {
        std::cout << "Mage is casting a spell!" << std::endl;
    }
};

void controlCharacters(Character** characters, int count) {
    for (int i = 0; i < count; ++i) {
        characters[i]->performAction();
    }
}

int main() {
    Character* characters[2];
    characters[0] = new Warrior();
    characters[1] = new Mage();

    controlCharacters(characters, 2);

    for (int i = 0; i < 2; ++i) {
        delete characters[i];
    }
    return 0;
}

这样,游戏中的角色可以根据自身类型执行不同的动作,通过多态实现了游戏逻辑的丰富性和灵活性。

插件系统的设计

在一个插件系统中,可以定义一个基类 Plugin,包含虚函数 execute。不同的插件类继承自 Plugin 并实现 execute 函数。主程序可以通过加载不同的插件对象,并通过 Plugin 指针调用 execute 函数来执行插件的功能。

class Plugin {
public:
    virtual void execute() = 0; 
};

class MathPlugin : public Plugin {
public:
    void execute() override {
        std::cout << "MathPlugin is performing a calculation." << std::endl;
    }
};

class GraphicsPlugin : public Plugin {
public:
    void execute() override {
        std::cout << "GraphicsPlugin is rendering an image." << std::endl;
    }
};

void runPlugins(Plugin** plugins, int count) {
    for (int i = 0; i < count; ++i) {
        plugins[i]->execute();
    }
}

int main() {
    Plugin* plugins[2];
    plugins[0] = new MathPlugin();
    plugins[1] = new GraphicsPlugin();

    runPlugins(plugins, 2);

    for (int i = 0; i < 2; ++i) {
        delete plugins[i];
    }
    return 0;
}

这种设计使得插件系统易于扩展,只需要编写新的插件类继承自 Plugin 并实现 execute 函数,主程序就可以动态加载和执行新的插件功能。

通过以上实际项目应用案例可以看出,虚函数和多态在提高代码的灵活性、可扩展性和可维护性方面具有重要作用。它们使得代码能够更好地适应不断变化的需求,是 C++ 面向对象编程的核心技术之一。在实际编程中,合理运用虚函数和多态可以设计出更加优雅和高效的软件系统。同时,深入理解虚函数的底层实现和各种特性,有助于我们避免在使用过程中出现各种潜在的问题,编写出健壮可靠的代码。

在处理复杂的继承体系,如多重继承和虚继承时,要特别注意虚函数表的结构变化以及动态绑定的实现细节,确保程序的正确性。在设计类的接口时,要仔细考虑虚函数的访问控制、构造函数和析构函数与虚函数的关系,以及纯虚函数和抽象类的使用,以构建出层次清晰、功能明确的软件架构。总之,虚函数和多态是 C++ 编程中强大而又复杂的特性,需要开发者不断实践和深入理解,才能发挥出它们的最大优势。