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

C++父类虚函数与子类重写的规范

2021-12-251.2k 阅读

C++父类虚函数与子类重写的基础概念

虚函数的定义与作用

在C++中,虚函数是在基类中使用 virtual 关键字声明的成员函数。其主要作用是实现运行时多态性。当通过基类指针或引用调用虚函数时,实际调用的函数版本取决于指针或引用所指向的对象的实际类型,而不是指针或引用本身的类型。

例如,假设有一个基类 Animal 和它的派生类 DogCat

#include <iostream>

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

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

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

在上述代码中,Animal 类的 speak 函数被声明为虚函数。DogCat 类重写了这个虚函数,提供了各自特定的实现。

子类重写的概念

子类重写是指在派生类中重新定义基类中已有的虚函数。重写的函数必须与基类中的虚函数具有相同的函数签名,包括函数名、参数列表和返回类型(在C++11 及以后,返回类型可以是协变的,即返回类型可以是基类虚函数返回类型的派生类型)。

在前面的例子中,DogCat 类中的 speak 函数就是对 Animal 类中 speak 虚函数的重写。使用 override 关键字(C++11 引入)可以显式表明该函数是对基类虚函数的重写,这有助于编译器检测错误。如果派生类中函数的签名与基类虚函数不匹配,使用 override 会导致编译错误,从而避免潜在的逻辑错误。

函数签名匹配规则

函数名与参数列表

重写函数必须与基类虚函数具有相同的函数名和参数列表。例如:

class Base {
public:
    virtual void print(int num) {
        std::cout << "Base: " << num << std::endl;
    }
};

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

在这个例子中,Derived 类的 print 函数与 Base 类的 print 虚函数具有相同的函数名和参数列表,满足重写的基本要求。

如果参数列表不同,就不是重写而是重载。例如:

class Base {
public:
    virtual void print(int num) {
        std::cout << "Base: " << num << std::endl;
    }
};

class Derived : public Base {
public:
    void print(double num) {
        std::cout << "Derived: " << num << std::endl;
    }
};

这里 Derived 类的 print 函数参数类型为 double,与基类 print 函数参数类型 int 不同,这是函数重载而不是重写。

返回类型规则

在C++11 之前,重写函数的返回类型必须与基类虚函数的返回类型完全相同。例如:

class Base {
public:
    virtual int getValue() {
        return 0;
    }
};

class Derived : public Base {
public:
    int getValue() override {
        return 1;
    }
};

Derived 类的 getValue 函数返回类型与 Base 类的 getValue 虚函数返回类型均为 int,满足重写要求。

然而,从C++11 开始,允许返回类型是协变的。即如果基类虚函数返回一个指针或引用类型,派生类重写函数可以返回该指针或引用类型的派生类型。例如:

class BaseClass {
};

class DerivedClass : public BaseClass {
};

class Base {
public:
    virtual BaseClass* getObject() {
        return new BaseClass();
    }
};

class Derived : public Base {
public:
    DerivedClass* getObject() override {
        return new DerivedClass();
    }
};

这里 Derived 类的 getObject 函数返回类型 DerivedClass* 是基类 BasegetObject 虚函数返回类型 BaseClass* 的派生类型,这在C++11 及以后是合法的重写。

访问控制与虚函数重写

基类虚函数的访问权限

基类虚函数的访问权限会影响子类对其重写的方式。如果基类虚函数是 public,那么子类可以在 publicprotectedprivate 访问级别下重写该函数。例如:

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

class Derived1 : public Base {
public:
    void publicVirtual() override {
        std::cout << "Derived1 public virtual" << std::endl;
    }
};

class Derived2 : public Base {
protected:
    void publicVirtual() override {
        std::cout << "Derived2 public virtual" << std::endl;
    }
};

class Derived3 : public Base {
private:
    void publicVirtual() override {
        std::cout << "Derived3 public virtual" << std::endl;
    }
};

在这个例子中,Base 类的 publicVirtual 虚函数是 public 的,Derived1Derived2Derived3 类分别在 publicprotectedprivate 访问级别下重写了该函数。

如果基类虚函数是 protected,子类只能在 protectedprivate 访问级别下重写(因为在 public 访问级别下重写会扩大基类成员的访问范围,这是不允许的)。例如:

class Base {
protected:
    virtual void protectedVirtual() {
        std::cout << "Base protected virtual" << std::endl;
    }
};

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

如果基类虚函数是 private,虽然在技术上子类可以定义一个同名同参数列表的函数,但这不是重写,因为 private 成员对于子类是不可访问的,不能构成多态行为。例如:

class Base {
private:
    virtual void privateVirtual() {
        std::cout << "Base private virtual" << std::endl;
    }
};

class Derived : public Base {
public:
    void privateVirtual() {
        std::cout << "Derived not overriding private virtual" << std::endl;
    }
};

这里 Derived 类的 privateVirtual 函数不是对 BaseprivateVirtual 虚函数的重写。

重写函数的访问权限影响

重写函数的访问权限会影响通过基类指针或引用调用该函数的方式。例如,当基类虚函数是 public,而子类重写函数是 protected 时:

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

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

int main() {
    Base* basePtr = new Derived();
    // basePtr->virtualFunc(); // 编译错误,因为Derived::virtualFunc是protected
    delete basePtr;
    return 0;
}

在上述代码中,试图通过 Base 类指针调用 Derived 类中 protected 的重写函数 virtualFunc 会导致编译错误。

虚函数表与重写机制

虚函数表的概念

C++通过虚函数表(vtable)来实现运行时多态性。当一个类包含虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个函数指针数组,每个元素指向类中的一个虚函数。

对于基类和派生类,它们各自有自己的虚函数表。当创建一个对象时,对象内部会包含一个指向其所属类虚函数表的指针(通常称为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 类有一个虚函数表,其中包含 func1func2 的函数指针。Derived 类也有一个虚函数表,由于重写了 func1,其虚函数表中 func1 的指针指向 Derived::func1,而 func2 的指针仍然指向 Base::func2(因为 Derived 类没有重写 func2)。

重写机制在虚函数表中的体现

当通过基类指针或引用调用虚函数时,实际调用的过程如下:首先,根据对象的vptr找到对应的虚函数表;然后,在虚函数表中找到与所调用函数对应的函数指针;最后,通过该函数指针调用实际的函数。

例如,当有 Base* ptr = new Derived(); ptr->func1(); 这样的代码时,ptr 指向 Derived 对象,通过 Derived 对象的vptr找到 Derived 类的虚函数表,在虚函数表中找到 func1 的函数指针并调用 Derived::func1。这就是为什么通过基类指针或引用能够调用到派生类中重写的虚函数,实现运行时多态性。

纯虚函数与抽象类

纯虚函数的定义

纯虚函数是在基类中声明的虚函数,它没有函数体,通过在声明中使用 = 0 来表示。例如:

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

在这个例子中,Shape 类的 area 函数是纯虚函数。纯虚函数的目的是为派生类提供一个统一的接口,要求派生类必须重写该函数。

抽象类的概念与特点

包含至少一个纯虚函数的类称为抽象类。抽象类不能被实例化,其主要作用是作为其他派生类的基类,为它们提供一个通用的接口框架。例如:

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

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

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

在上述代码中,Shape 类是抽象类,CircleRectangle 类继承自 Shape 类并实现了纯虚函数 areadraw。如果派生类没有重写所有的纯虚函数,那么该派生类也仍然是抽象类,不能被实例化。

多重继承与虚函数重写

多重继承下的虚函数重写问题

在多重继承中,一个类可以从多个基类继承。当涉及虚函数重写时,可能会出现一些复杂的情况。例如:

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

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

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

在这个例子中,Derived 类从 Base1Base2 继承,并且重写了它们各自的 func 虚函数。这里 Derived 类中的 func 函数同时重写了 Base1Base2func 函数,可能会导致一些混淆,尤其是在调用时需要明确指定是通过 Base1 还是 Base2 的接口来调用。

解决多重继承虚函数重写的混淆

为了避免在多重继承中虚函数重写的混淆,可以使用作用域解析运算符 :: 来明确指定调用的基类版本。例如:

int main() {
    Derived d;
    d.Base1::func(); // 调用Base1的func版本
    d.Base2::func(); // 调用Base2的func版本
    d.func(); // 调用Derived重写的func版本
    return 0;
}

此外,在设计时应尽量避免复杂的多重继承结构,以减少潜在的问题。如果确实需要多重继承,可以考虑使用接口类(只包含纯虚函数的抽象类)来分离不同的功能,降低耦合度。

虚析构函数与重写

虚析构函数的必要性

当基类指针指向派生类对象,并且通过基类指针删除该对象时,如果基类析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这会导致资源泄漏。例如:

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

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

int main() {
    Base* basePtr = new Derived();
    delete basePtr; // 只调用Base的析构函数,导致Derived中data内存泄漏
    return 0;
}

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

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

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

int main() {
    Base* basePtr = new Derived();
    delete basePtr; // 先调用Derived析构函数,再调用Base析构函数
    return 0;
}

虚析构函数的重写规则

派生类的析构函数会自动重写基类的虚析构函数,不需要显式使用 override 关键字。当通过基类指针删除派生类对象时,会先调用派生类的析构函数,然后调用基类的析构函数,确保资源的正确释放。

总结与最佳实践

总结虚函数与子类重写的要点

  1. 虚函数在基类中用 virtual 关键字声明,用于实现运行时多态性。
  2. 子类重写虚函数时,函数签名(函数名、参数列表、返回类型遵循协变规则)必须与基类虚函数匹配,C++11 引入 override 关键字用于显式表明重写,有助于编译器检测错误。
  3. 基类虚函数的访问权限影响子类重写的访问级别,重写函数的访问权限也会影响通过基类指针或引用的调用。
  4. 虚函数表是C++实现运行时多态性的机制,对象通过vptr指向所属类的虚函数表。
  5. 纯虚函数定义在抽象类中,要求派生类必须重写,抽象类不能被实例化。
  6. 多重继承下虚函数重写可能会导致混淆,可通过作用域解析运算符明确调用版本。
  7. 基类析构函数应声明为虚函数,以确保通过基类指针删除派生类对象时资源能正确释放。

最佳实践建议

  1. 在设计类层次结构时,合理使用虚函数和子类重写来实现多态行为,提高代码的可扩展性和灵活性。
  2. 对于可能被继承的类,将析构函数声明为虚函数,避免资源泄漏。
  3. 使用 override 关键字显式标记重写函数,使代码更清晰并帮助编译器检测错误。
  4. 在多重继承时,尽量简化继承结构,避免复杂的虚函数重写情况,可考虑使用接口类来分离功能。
  5. 注意虚函数的性能开销,虽然运行时多态性很强大,但虚函数调用会比普通函数调用有一定的性能损失,在性能敏感的代码中需谨慎使用。

通过遵循这些规范和最佳实践,可以编写出更健壮、可维护的C++代码,充分利用虚函数和子类重写带来的优势。