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

C++父类虚函数与子类多态实现的条件

2022-07-134.0k 阅读

C++ 父类虚函数与子类多态实现的条件

多态的基本概念

在面向对象编程中,多态是一个核心概念。它允许我们以统一的方式处理不同类型的对象,使得代码更加灵活、可维护和可扩展。多态性提供了一种机制,使得在运行时能够根据对象的实际类型来决定调用哪个函数,而不是在编译时就确定。这意味着,同样的函数调用在不同的对象上可以产生不同的行为。

C++ 中多态的实现方式

在 C++ 中,多态主要通过虚函数和指针或引用(指向或引用对象)来实现。虚函数是在基类中声明,在派生类中可以被重写的函数。当通过基类指针或引用调用虚函数时,C++ 会在运行时根据对象的实际类型来决定调用哪个版本的虚函数,这就是所谓的动态绑定。

父类虚函数的定义

在 C++ 中,要定义一个虚函数,只需要在基类函数声明前加上 virtual 关键字。例如:

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

在上述代码中,Animal 类中的 makeSound 函数被声明为虚函数。当一个函数被声明为虚函数后,它就为子类提供了重写的机会,以实现不同的行为。

子类对虚函数的重写

子类要重写父类的虚函数,函数的签名(包括函数名、参数列表和返回类型)必须与父类中的虚函数完全一致(除了协变返回类型的情况,后面会详细讨论)。例如:

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

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

DogCat 类中,makeSound 函数重写了 Animal 类中的虚函数。注意,在 C++11 及以后,使用 override 关键字可以显式表明该函数是重写父类的虚函数,这有助于编译器检查函数签名是否正确匹配。如果不小心写错了函数签名,编译器会报错。

多态调用的条件

  1. 通过基类指针或引用调用:要实现多态,必须通过基类的指针或引用来调用虚函数。例如:
int main() {
    Animal* animal1 = new Dog();
    Animal* animal2 = new Cat();

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

    delete animal1;
    delete animal2;
    return 0;
}

在上述代码中,animal1animal2Animal 类型的指针,分别指向 DogCat 对象。当调用 makeSound 函数时,实际调用的是 DogCat 类中重写的版本,这就是多态的体现。如果直接通过对象调用虚函数,例如:

Dog dog;
dog.makeSound();

这里调用的 makeSound 函数是编译时就确定的 Dog 类中的版本,不会体现多态性。因为直接通过对象调用函数时,编译器在编译阶段就知道对象的具体类型,不需要在运行时进行动态绑定。

  1. 虚函数必须在基类中声明:如果基类中的函数没有声明为虚函数,子类中即使有同名同参数列表的函数,也不会构成重写,也就无法实现多态。例如:
class Base {
public:
    void nonVirtualFunction() {
        std::cout << "Base non - virtual function" << std::endl;
    }
};

class Derived : public Base {
public:
    void nonVirtualFunction() {
        std::cout << "Derived non - virtual function" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    basePtr->nonVirtualFunction();

    delete basePtr;
    return 0;
}

在上述代码中,Base 类中的 nonVirtualFunction 没有声明为虚函数,Derived 类中的 nonVirtualFunction 不会重写基类的函数。当通过 Base 指针调用 nonVirtualFunction 时,调用的是 Base 类中的版本,而不是 Derived 类中的版本,这不是多态的行为。

  1. 函数签名匹配:如前文所述,子类重写虚函数时,函数签名必须与父类虚函数的签名完全一致(除了协变返回类型的情况)。函数签名包括函数名、参数列表和返回类型。如果参数列表不同,那么子类中的函数将被视为新的函数,而不是重写父类的虚函数。例如:
class Shape {
public:
    virtual void draw() {
        std::cout << "Drawing a shape" << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw(int x, int y) { // 参数列表不同,不是重写
        std::cout << "Drawing a rectangle at (" << x << ", " << y << ")" << std::endl;
    }
};

在上述代码中,Rectangle 类中的 draw 函数由于参数列表与 Shape 类中的 draw 函数不同,它不是重写 Shape 类的虚函数。

协变返回类型

协变返回类型是 C++ 中一个特殊的规则,允许子类重写虚函数时返回类型可以是父类虚函数返回类型的派生类型。例如:

class BaseObject {
public:
    virtual BaseObject* clone() {
        return new BaseObject();
    }
};

class DerivedObject : public BaseObject {
public:
    DerivedObject* clone() override {
        return new DerivedObject();
    }
};

在上述代码中,BaseObject 类的 clone 函数返回 BaseObject*,而 DerivedObject 类重写的 clone 函数返回 DerivedObject*,这是符合协变返回类型规则的。这种机制在实现对象克隆等功能时非常有用,它允许更精确地返回具体类型的对象,而不需要进行额外的类型转换。

纯虚函数与抽象类

纯虚函数是一种特殊的虚函数,在基类中只声明不定义,并且要求子类必须重写。纯虚函数的声明形式是在函数声明后加上 = 0。例如:

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

包含纯虚函数的类被称为抽象类。抽象类不能被实例化,它主要作为其他类的基类,为子类提供一个通用的接口。例如,Shape 类是一个抽象类,任何试图实例化 Shape 对象的操作都会导致编译错误。

// Shape shape; // 编译错误,不能实例化抽象类

子类必须重写抽象类中的所有纯虚函数才能被实例化。例如:

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

Circle 类重写了 Shape 类中的纯虚函数 areaperimeter,因此 Circle 类可以被实例化。

析构函数与多态

当涉及到继承和动态内存分配时,析构函数的处理非常重要。如果基类的析构函数不是虚函数,在通过基类指针删除派生类对象时,可能不会调用派生类的析构函数,从而导致内存泄漏。例如:

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

class Derived : public Base {
private:
    int* data;
public:
    Derived() {
        data = new int[10];
        std::cout << "Derived constructor" << std::endl;
    }
    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor" << std::endl;
    }
};

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

在上述代码中,Base 类的析构函数不是虚函数。当 delete basePtr 执行时,只会调用 Base 类的析构函数,而不会调用 Derived 类的析构函数,导致 Derived 类中动态分配的数组 data 没有被释放,造成内存泄漏。

为了避免这种情况,基类的析构函数应该声明为虚函数。例如:

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

class Derived : public Base {
private:
    int* data;
public:
    Derived() {
        data = new int[10];
        std::cout << "Derived constructor" << std::endl;
    }
    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor" << std::endl;
    }
};

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

现在,当 delete basePtr 执行时,会先调用 Derived 类的析构函数,再调用 Base 类的析构函数,确保动态分配的内存被正确释放。

虚函数表与动态绑定原理

C++ 的多态实现背后依赖于虚函数表(vtable)机制。每个包含虚函数的类都有一个虚函数表。虚函数表是一个函数指针数组,其中每个元素指向该类的一个虚函数的实现。当一个类从包含虚函数的基类派生时,它会继承基类的虚函数表,并根据需要修改其中的指针,以指向自己重写的虚函数版本。

当通过基类指针或引用调用虚函数时,C++ 运行时系统首先通过对象的隐藏指针(通常称为 vptr,虚指针)找到对应的虚函数表,然后根据虚函数在表中的索引找到实际要调用的函数。这种机制使得在运行时能够根据对象的实际类型动态决定调用哪个虚函数。

例如,假设有如下代码:

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

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

在内存中,Base 类的对象会包含一个 vptr 指针,指向 Base 类的虚函数表。Base 类的虚函数表中,第一个元素指向 Base::func1,第二个元素指向 Base::func2

当创建一个 Derived 类的对象时,它也会包含一个 vptr 指针,指向 Derived 类的虚函数表。Derived 类的虚函数表继承自 Base 类的虚函数表,但由于 Derived 类重写了 func1,所以虚函数表中第一个元素指向 Derived::func1,第二个元素仍然指向 Base::func2(因为 Derived 类没有重写 func2)。

当通过 Base 指针调用虚函数时,运行时系统会根据对象的 vptr 找到对应的虚函数表,然后根据调用的虚函数在表中的索引来调用实际的函数。

多重继承与多态

在 C++ 中,一个类可以从多个基类派生,这就是多重继承。在多重继承的情况下,多态的实现会更加复杂。

例如:

class A {
public:
    virtual void func() { std::cout << "A::func" << std::endl; }
};

class B {
public:
    virtual void func() { std::cout << "B::func" << std::endl; }
};

class C : public A, public B {
public:
    void func() override { std::cout << "C::func" << std::endl; }
};

在上述代码中,C 类从 AB 类多重继承,并且重写了 func 函数。当通过 AB 类的指针或引用调用 func 函数时,会根据对象的实际类型(C 类对象)调用 C 类中重写的 func 函数。

然而,多重继承可能会带来一些问题,比如菱形继承问题(也称为钻石问题)。考虑如下代码:

class X {
public:
    int value;
};

class A : public X {
public:
    virtual void func() { std::cout << "A::func" << std::endl; }
};

class B : public X {
public:
    virtual void func() { std::cout << "B::func" << std::endl; }
};

class C : public A, public B {
public:
    void func() override { std::cout << "C::func" << std::endl; }
};

在上述代码中,C 类从 AB 类多重继承,而 AB 又都从 X 类继承。这就导致 C 类中会有两份 X 类的成员,包括 value 成员,这会造成数据冗余和访问歧义。为了解决菱形继承问题,可以使用虚继承。

class X {
public:
    int value;
};

class A : virtual public X {
public:
    virtual void func() { std::cout << "A::func" << std::endl; }
};

class B : virtual public X {
public:
    virtual void func() { std::cout << "B::func" << std::endl; }
};

class C : public A, public B {
public:
    void func() override { std::cout << "C::func" << std::endl; }
};

通过虚继承,C 类中只会有一份 X 类的成员,避免了数据冗余和访问歧义。在多态方面,虚继承不会影响虚函数的动态绑定机制,通过 ABC 类的指针或引用调用虚函数仍然会根据对象的实际类型进行正确的动态绑定。

总结多态实现条件及注意事项

  1. 虚函数声明:基类中必须将相关函数声明为虚函数,这样子类才有机会重写以实现多态。
  2. 函数签名匹配:子类重写虚函数时,函数签名(包括返回类型,除协变返回类型情况)必须与父类虚函数一致。
  3. 通过指针或引用调用:多态调用必须通过基类指针或引用来实现,直接通过对象调用虚函数不会体现多态性。
  4. 析构函数的虚属性:如果基类涉及动态内存分配或者有派生类可能需要进行特殊清理操作,基类的析构函数必须声明为虚函数,以确保在删除对象时能够正确调用所有相关的析构函数,避免内存泄漏。
  5. 纯虚函数与抽象类:当基类的虚函数没有实际意义,需要子类强制重写时,可以将其定义为纯虚函数,使基类成为抽象类。抽象类不能被实例化,子类必须重写所有纯虚函数才能被实例化。
  6. 多重继承中的多态:在多重继承场景下,多态同样依赖虚函数和指针或引用调用。但要注意菱形继承等问题,可通过虚继承来解决。

理解和掌握这些条件对于在 C++ 中正确实现多态至关重要,它能使代码更加灵活、可维护,充分发挥面向对象编程的优势。无论是开发小型项目还是大型系统,多态机制都为代码的设计和扩展提供了强大的支持。