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

C++虚拟函数与普通成员函数的本质区别

2022-01-154.9k 阅读

C++ 虚拟函数与普通成员函数的概念

普通成员函数

在 C++ 中,普通成员函数是类中定义的一种函数,它用于描述类对象的行为。普通成员函数可以访问类的私有、保护和公有成员。例如:

class Rectangle {
private:
    int width;
    int height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    int area() {
        return width * height;
    }
};

在上述代码中,area 函数就是一个普通成员函数,它计算并返回矩形的面积。当创建 Rectangle 类的对象时,可以通过对象调用这个函数:

int main() {
    Rectangle rect(5, 3);
    int result = rect.area();
    return 0;
}

虚拟函数

虚拟函数是在基类中使用 virtual 关键字声明的成员函数。它允许在派生类中被重写(override),从而实现多态性。多态性是面向对象编程的重要特性之一,它使得可以根据对象的实际类型来调用适当的函数。例如:

class Shape {
public:
    virtual double area() {
        return 0.0;
    }
};

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

class Square : public Shape {
private:
    double side;
public:
    Square(double s) : side(s) {}
    double area() override {
        return side * side;
    }
};

在上述代码中,Shape 类中的 area 函数被声明为虚拟函数。CircleSquare 类继承自 Shape 类,并各自重写了 area 函数。这样,当通过 Shape 类型的指针或引用来调用 area 函数时,会根据对象的实际类型(CircleSquare)来调用相应的 area 函数。例如:

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

    for (int i = 0; i < 2; ++i) {
        double area = shapes[i]->area();
        // 这里会根据实际对象类型调用相应的 area 函数
    }

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

    return 0;
}

函数调用机制

普通成员函数的调用机制

普通成员函数的调用是在编译时确定的,这种调用方式也称为静态绑定。编译器根据对象的声明类型来决定调用哪个函数。例如:

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

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

int main() {
    Animal* animalPtr = new Dog();
    animalPtr->makeSound(); 
    // 这里会输出 "Generic animal sound",因为编译器根据 animalPtr 的声明类型(Animal*)来调用函数
    delete animalPtr;
    return 0;
}

在上述代码中,虽然 animalPtr 实际上指向一个 Dog 对象,但由于它的声明类型是 Animal*,所以调用的是 Animal 类中的 makeSound 函数。

虚拟函数的调用机制

虚拟函数的调用是在运行时确定的,这种调用方式称为动态绑定。为了实现动态绑定,C++ 使用了一种称为虚函数表(vtable)的机制。每个包含虚拟函数的类都有一个虚函数表。当创建一个对象时,对象的内存布局中会包含一个指向虚函数表的指针(vptr)。虚函数表是一个函数指针数组,其中每个元素指向类中一个虚拟函数的实现。

当通过指针或引用来调用虚拟函数时,程序首先通过对象的 vptr 找到虚函数表,然后根据虚函数在表中的索引找到对应的函数实现并调用。例如:

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

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

int main() {
    Base* basePtr = new Derived();
    basePtr->print(); 
    // 这里会输出 "Derived class",因为在运行时根据对象的实际类型(Derived)通过虚函数表找到并调用了 Derived 类的 print 函数
    delete basePtr;
    return 0;
}

在上述代码中,Base 类包含一个虚拟函数 printDerived 类继承自 Base 类并重写了 print 函数。当通过 Base* 类型的指针 basePtr 调用 print 函数时,由于 print 是虚拟函数,程序会在运行时根据 basePtr 实际指向的对象类型(Derived),通过虚函数表找到并调用 Derived 类中的 print 函数。

内存布局差异

普通成员函数在内存中的布局

普通成员函数并不占用对象的内存空间。它们在程序的代码段(text segment)中存储,所有该类的对象共享这些函数代码。例如,对于前面定义的 Rectangle 类,area 函数的代码在代码段中只有一份,无论创建多少个 Rectangle 对象,它们都共享这一份 area 函数代码。

对象的内存布局主要包含其成员变量。例如,对于 Rectangle 类的对象,其内存布局可能如下:

| width | height |

这里假设 widthheight 都是 int 类型,它们按照声明的顺序在对象内存中排列。

虚拟函数对内存布局的影响

当一个类包含虚拟函数时,对象的内存布局会发生变化。除了成员变量外,对象还会包含一个指向虚函数表的指针(vptr)。例如,对于前面定义的 Shape 类的对象,其内存布局可能如下:

| vptr |

如果 Shape 类有成员变量,它们会紧接着 vptr 存储。

虚函数表是一个全局的数据结构,每个包含虚拟函数的类都有一个对应的虚函数表。虚函数表中的每个条目都是一个函数指针,指向类中一个虚拟函数的实现。例如,对于 Shape 类的虚函数表,可能如下:

| &Shape::area |

Circle 类继承自 Shape 类并重写 area 函数时,Circle 类也有自己的虚函数表。Circle 类的虚函数表中,area 函数的条目会指向 Circle::area 的实现:

| &Circle::area |

同样,Square 类的虚函数表中,area 函数的条目会指向 Square::area 的实现。

这种内存布局的差异使得虚拟函数能够实现运行时的多态性,但也增加了对象的内存开销(每个对象多了一个 vptr)以及程序的复杂性(需要管理虚函数表)。

函数重写规则

普通成员函数的重写情况(隐藏)

在 C++ 中,普通成员函数不存在严格意义上的重写。当派生类定义了一个与基类普通成员函数同名的函数时,这个函数会隐藏基类中的同名函数。例如:

class BaseClass {
public:
    void doSomething(int value) {
        std::cout << "BaseClass: doSomething with int " << value << std::endl;
    }
};

class DerivedClass : public BaseClass {
public:
    void doSomething(double value) {
        std::cout << "DerivedClass: doSomething with double " << value << std::endl;
    }
};

int main() {
    DerivedClass derived;
    derived.doSomething(5.5); 
    // 调用 DerivedClass 的 doSomething 函数
    // 下面这行代码会编译错误,因为基类的 doSomething(int) 被隐藏了
    // derived.doSomething(5); 
    return 0;
}

在上述代码中,DerivedClass 定义了一个与 BaseClassdoSomething 同名但参数类型不同的函数。此时,BaseClass 中的 doSomething(int) 函数被隐藏,通过 DerivedClass 对象无法直接调用 BaseClassdoSomething(int) 函数。

虚拟函数的重写规则

虚拟函数在派生类中重写需要满足以下规则:

  1. 函数签名必须相同:函数名、参数列表和返回类型(除了协变返回类型,即返回类型可以是基类返回类型的派生类型)必须与基类中的虚拟函数相同。例如:
class Base {
public:
    virtual Base* clone() {
        return new Base();
    }
};

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

在上述代码中,Derived 类的 clone 函数返回类型是 Derived*,它是 Base* 的派生类型,这是符合协变返回类型规则的。

  1. 使用 override 关键字(C++ 11 及以后):虽然不是必须的,但强烈建议在派生类重写虚拟函数时使用 override 关键字。这样可以让编译器检查是否真的在重写一个虚拟函数,如果函数签名不匹配,编译器会报错。例如:
class Base {
public:
    virtual void func() {}
};

class Derived : public Base {
public:
    // 下面这行代码如果没有 override 关键字,编译器可能不会报错,但这不是正确的重写
    void func(int) override { 
        // 编译器会报错,因为函数签名与 Base::func 不匹配
    }
};

遵循这些规则可以确保虚拟函数的重写正确无误,从而实现预期的多态行为。

性能影响

普通成员函数的性能特点

普通成员函数由于采用静态绑定,在编译时就确定了调用的函数,所以函数调用的开销相对较小。编译器可以对普通成员函数进行一些优化,例如内联(inline)。内联函数是一种特殊的函数,编译器会尝试将函数体直接插入到调用处,避免了函数调用的开销。例如:

class Point {
private:
    int x;
    int y;
public:
    inline int getX() {
        return x;
    }
    inline int getY() {
        return y;
    }
};

在上述代码中,getXgetY 函数被声明为内联函数。如果编译器支持并选择对其进行内联优化,那么在调用 getXgetY 函数时,函数体的代码会直接插入到调用处,而不是进行常规的函数调用操作,从而提高了性能。

虚拟函数的性能影响

虚拟函数由于采用动态绑定,在运行时需要通过虚函数表来确定调用的函数,这增加了函数调用的开销。每次通过指针或引用来调用虚拟函数时,都需要先通过 vptr 找到虚函数表,再根据表中的索引找到对应的函数实现。此外,虚函数表和 vptr 的存在也增加了程序的内存开销。

然而,在很多情况下,虚拟函数带来的性能损失是可以接受的,尤其是在需要实现多态性的场景中。并且,现代编译器也会对虚拟函数调用进行一些优化,例如在某些情况下,编译器可以通过分析代码来确定对象的实际类型,从而将动态绑定优化为静态绑定,减少性能损失。但总体来说,虚拟函数的性能通常不如普通成员函数,在性能敏感的代码中使用虚拟函数时需要谨慎考虑。

应用场景

普通成员函数的应用场景

  1. 类的内部实现:当函数只用于类的内部逻辑,不需要在派生类中进行重写时,适合使用普通成员函数。例如,一个链表类的内部节点操作函数,如插入节点、删除节点等,这些操作通常是链表类特有的,不需要在派生类中改变行为,就可以定义为普通成员函数。
class LinkedList {
private:
    struct Node {
        int data;
        Node* next;
        Node(int d) : data(d), next(nullptr) {}
    };
    Node* head;
public:
    LinkedList() : head(nullptr) {}
    void insert(int value) {
        Node* newNode = new Node(value);
        newNode->next = head;
        head = newNode;
    }
    // 这里的 insert 函数就是一个普通成员函数,用于链表类的内部操作
};
  1. 不涉及多态性的行为:如果类的行为不依赖于对象的实际类型,不需要体现多态性,普通成员函数是更好的选择。比如一个数学计算类,其计算函数只根据传入的参数进行固定的计算,不因为对象类型的不同而改变计算逻辑。
class MathUtils {
public:
    double add(double a, double b) {
        return a + b;
    }
};

虚拟函数的应用场景

  1. 实现多态性:这是虚拟函数最主要的应用场景。当需要根据对象的实际类型来执行不同的操作时,就需要使用虚拟函数。例如在一个图形绘制系统中,不同类型的图形(如圆形、矩形、三角形等)都继承自一个基类 ShapeShape 类中定义了一个虚拟函数 draw,每个派生类重写 draw 函数来实现自己的绘制逻辑。通过 Shape 类型的指针或引用来调用 draw 函数时,就可以根据实际对象类型绘制不同的图形。
class Shape {
public:
    virtual void draw() = 0; 
    // 纯虚函数,要求派生类必须重写
};

class Circle : public Shape {
private:
    int radius;
public:
    Circle(int r) : radius(r) {}
    void draw() override {
        // 实现圆形的绘制逻辑
    }
};

class Rectangle : public Shape {
private:
    int width;
    int height;
public:
    Rectangle(int w, int h) : width(w), height(h) {}
    void draw() override {
        // 实现矩形的绘制逻辑
    }
};
  1. 设计框架和插件系统:在设计软件框架或插件系统时,虚拟函数可以提供一种灵活的扩展机制。框架定义一些虚拟函数作为扩展点,插件开发者通过继承框架类并重写这些虚拟函数来实现特定的功能。例如,一个游戏引擎框架可以定义一些虚拟函数用于处理游戏事件,游戏开发者可以通过继承相关类并重写这些虚拟函数来为游戏添加自定义的事件处理逻辑。

通过以上对 C++ 虚拟函数与普通成员函数在概念、调用机制、内存布局、重写规则、性能影响及应用场景等方面的深入分析,可以清晰地了解它们之间的本质区别,从而在实际编程中根据需求做出正确的选择。