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

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

2024-06-127.0k 阅读

一、函数调用机制的基础理解

在深入探讨C++虚函数与普通成员函数的区别之前,我们先来了解一下C++中函数调用的基本机制。

当我们调用一个普通成员函数时,编译器在编译阶段就已经确定了要调用的函数的具体地址。这意味着,无论对象的实际类型是什么,只要它属于某个特定的类,调用该类的普通成员函数时,编译器会直接跳转到该函数的固定地址执行。例如:

#include <iostream>

class Base {
public:
    void normalFunction() {
        std::cout << "This is Base's normal function." << std::endl;
    }
};

class Derived : public Base {
public:
    void normalFunction() {
        std::cout << "This is Derived's normal function." << std::endl;
    }
};

int main() {
    Base baseObj;
    Derived derivedObj;

    baseObj.normalFunction();
    derivedObj.normalFunction();

    Base* basePtr = &derivedObj;
    basePtr->normalFunction();

    return 0;
}

在上述代码中,baseObj.normalFunction()会调用Base类的normalFunctionderivedObj.normalFunction()会调用Derived类的normalFunction。而当我们通过Base类型的指针basePtr指向Derived对象并调用normalFunction时,由于普通成员函数的调用在编译期确定,所以仍然调用的是Base类的normalFunction

二、虚函数的引入与动态绑定机制

虚函数的出现打破了这种编译期确定函数调用地址的规则。当一个函数被声明为虚函数时,编译器会为包含该虚函数的类创建一个虚函数表(Virtual Table,简称vtable)。每个对象会包含一个指向这个虚函数表的指针(通常称为vptr)。

在运行时,根据对象的实际类型,通过vptr找到对应的虚函数表,进而确定要调用的虚函数的实际地址。这种机制被称为动态绑定(Dynamic Binding)或运行时多态(Runtime Polymorphism)。例如:

#include <iostream>

class Base {
public:
    virtual void virtualFunction() {
        std::cout << "This is Base's virtual function." << std::endl;
    }
};

class Derived : public Base {
public:
    void virtualFunction() override {
        std::cout << "This is Derived's virtual function." << std::endl;
    }
};

int main() {
    Base baseObj;
    Derived derivedObj;

    baseObj.virtualFunction();
    derivedObj.virtualFunction();

    Base* basePtr = &derivedObj;
    basePtr->virtualFunction();

    return 0;
}

在这段代码中,baseObj.virtualFunction()会调用Base类的virtualFunctionderivedObj.virtualFunction()会调用Derived类的virtualFunction。关键在于,当通过Base类型的指针basePtr指向Derived对象并调用virtualFunction时,由于动态绑定机制,会调用Derived类的virtualFunction,而不是Base类的。

三、虚函数与普通成员函数在继承体系中的表现差异

  1. 函数重写(Override)
    • 虚函数:在派生类中,如果一个函数与基类中的虚函数具有相同的函数名、参数列表和返回类型(协变返回类型除外),则该函数会自动重写基类的虚函数。C++11引入了override关键字,建议在派生类中重写虚函数时使用,以增强代码的可读性和可维护性。例如:
class Base {
public:
    virtual void print() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived" << std::endl;
    }
};
- **普通成员函数**:普通成员函数不存在重写的概念。在派生类中定义与基类普通成员函数同名的函数,会导致隐藏(Hide)基类的函数。例如:
class Base {
public:
    void print() {
        std::cout << "Base" << std::endl;
    }
};

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

在上述代码中,Derived类的print(int num)函数隐藏了Base类的print()函数。即使通过Base指针或引用指向Derived对象,调用print()仍然会调用Base类的版本。

  1. 访问权限与函数调用
    • 虚函数:虚函数的访问权限会影响通过对象、指针或引用调用虚函数的行为。例如:
class Base {
protected:
    virtual void protectedVirtualFunction() {
        std::cout << "Base's protected virtual function" << std::endl;
    }
public:
    void callProtectedVirtual() {
        protectedVirtualFunction();
    }
};

class Derived : public Base {
public:
    void protectedVirtualFunction() override {
        std::cout << "Derived's protected virtual function" << std::endl;
    }
};

在这段代码中,Base类的protectedVirtualFunction是受保护的虚函数。Base类的callProtectedVirtual函数可以调用protectedVirtualFunction。在Derived类中重写了该虚函数,当通过Base类的callProtectedVirtual调用时,会调用Derived类的重写版本,因为动态绑定不受访问权限在调用点的限制,只要在定义点可访问即可。 - 普通成员函数:普通成员函数的访问权限直接决定了能否在特定的作用域内调用该函数。例如:

class Base {
protected:
    void protectedNormalFunction() {
        std::cout << "Base's protected normal function" << std::endl;
    }
};

class Derived : public Base {
public:
    void callBaseProtectedNormal() {
        // 这里可以调用Base的protectedNormalFunction,因为Derived是Base的派生类
        protectedNormalFunction();
    }
};

在上述代码中,Base类的protectedNormalFunction是受保护的普通成员函数。Derived类可以在其成员函数中调用它,因为派生类可以访问基类的受保护成员。但如果在类外部尝试通过BaseDerived对象调用protectedNormalFunction,则会导致编译错误。

四、虚函数与普通成员函数在内存布局上的差异

  1. 普通成员函数 普通成员函数并不占用对象的内存空间。因为普通成员函数的地址在编译期就已经确定,多个对象共享同一份函数代码。例如:
class Simple {
public:
    void simpleFunction() {
        std::cout << "Simple function" << std::cout;
    }
    int data;
};

在上述代码中,Simple类对象的内存布局只包含data成员变量。simpleFunction的代码存储在代码段,并不属于对象的一部分。

  1. 虚函数 对于包含虚函数的类,每个对象会额外包含一个指向虚函数表的指针(vptr)。虚函数表存储了类中虚函数的地址。例如:
class VirtualClass {
public:
    virtual void virtualFunction() {
        std::cout << "Virtual function" << std::cout;
    }
    int data;
};

VirtualClass类对象的内存布局包含data成员变量以及vptr。虚函数表则存储在程序的静态数据区。当类有多个虚函数时,虚函数表会按顺序存储这些虚函数的地址。

五、性能方面的考虑

  1. 普通成员函数 由于普通成员函数的调用在编译期确定,没有额外的间接寻址开销。所以在性能上,普通成员函数的调用相对较快,尤其是对于那些不需要动态多态特性的函数。例如,一些工具类的辅助函数,它们的行为不依赖于对象的实际类型,使用普通成员函数可以获得更好的性能。

  2. 虚函数 虚函数的动态绑定机制带来了灵活性,但也引入了一定的性能开销。每次通过指针或引用调用虚函数时,需要先通过vptr找到虚函数表,再从虚函数表中获取函数地址,这涉及到额外的内存访问。不过,现代编译器在很多情况下可以对虚函数调用进行优化,例如在一些确定的场景下进行内联展开等。但总体来说,虚函数调用的性能略低于普通成员函数调用。

六、构造函数和析构函数中的虚函数与普通成员函数

  1. 构造函数中的情况
    • 虚函数:在构造函数中调用虚函数,不会发生动态绑定。这是因为在构造函数执行期间,对象的类型还没有完全确定。例如:
class Base {
public:
    Base() {
        virtualFunction();
    }
    virtual void virtualFunction() {
        std::cout << "Base's virtual function in constructor" << std::endl;
    }
};

class Derived : public Base {
public:
    Derived() : Base() {
    }
    void virtualFunction() override {
        std::cout << "Derived's virtual function in constructor" << std::endl;
    }
};

在上述代码中,当创建Derived对象时,先调用Base的构造函数,在Base构造函数中调用virtualFunction,此时调用的是Base类的virtualFunction,而不是Derived类的重写版本。 - 普通成员函数:在构造函数中调用普通成员函数与在其他成员函数中调用普通成员函数没有本质区别,都是直接调用编译期确定的函数版本。

  1. 析构函数中的情况
    • 虚函数:析构函数可以声明为虚函数。当一个基类指针指向派生类对象,并且通过该指针删除对象时,如果基类析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致内存泄漏等问题。例如:
class Base {
public:
    ~Base() {
        std::cout << "Base's destructor" << std::endl;
    }
};

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

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

在上述代码中,由于Base的析构函数不是虚函数,delete basePtr只会调用Base的析构函数,而不会调用Derived的析构函数。如果将Base的析构函数声明为虚函数:

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

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

此时delete basePtr会先调用Derived的析构函数,再调用Base的析构函数,确保资源正确释放。 - 普通成员函数:在析构函数中调用普通成员函数,同样是调用编译期确定的版本,与在其他成员函数中调用普通成员函数类似。但需要注意,在析构函数中调用普通成员函数时,对象的状态可能已经发生变化,要确保函数操作的安全性。

七、多重继承与虚函数、普通成员函数

  1. 多重继承下的普通成员函数 在多重继承中,普通成员函数的调用遵循普通的隐藏和访问规则。例如:
class A {
public:
    void func() {
        std::cout << "A's func" << std::endl;
    }
};

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

class C : public A, public B {
public:
    void callFuncs() {
        A::func();
        B::func();
    }
};

在上述代码中,C类从AB多重继承,并且AB都有func函数。在C类的callFuncs函数中,可以通过作用域限定符明确调用ABfunc函数。

  1. 多重继承下的虚函数 多重继承下的虚函数情况更为复杂。每个基类可能有自己的虚函数表,派生类对象可能包含多个vptr,分别指向不同基类的虚函数表。例如:
class A {
public:
    virtual void func() {
        std::cout << "A's virtual func" << std::endl;
    }
};

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

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

在上述代码中,C类重写了AB的虚函数funcC类对象会包含两个vptr,分别指向AB的虚函数表。当通过AB类型的指针或引用调用func时,会根据动态绑定机制调用C类的重写版本。

八、纯虚函数与普通成员函数的特殊关系

  1. 纯虚函数的定义与特性 纯虚函数是一种特殊的虚函数,它在基类中只声明而不定义,并且要求派生类必须重写该函数。例如:
class Shape {
public:
    virtual double area() = 0;
};

class Circle : public Shape {
public:
    double area() override {
        // 计算圆面积的代码
        return 0.0;
    }
};

在上述代码中,Shape类的area函数是纯虚函数,Circle类继承自Shape并必须重写area函数。包含纯虚函数的类被称为抽象类,不能实例化对象。

  1. 与普通成员函数的对比 纯虚函数与普通成员函数有很大的不同。普通成员函数提供了具体的实现,而纯虚函数只是定义了接口,强制派生类提供具体实现。从调用角度看,纯虚函数不能直接调用(除非在基类的构造函数或析构函数中),而普通成员函数可以直接通过对象调用。同时,纯虚函数体现了一种更严格的抽象和多态机制,用于定义一些具有共性接口但具体实现因派生类而异的函数。

通过以上详细的对比和分析,我们对C++中虚函数与普通成员函数的区别有了深入的理解。在实际编程中,应根据具体的需求和场景,合理选择使用虚函数或普通成员函数,以实现高效、可维护的代码。