一、虚函数基础认识

大家在学 C++ 的时候,虚函数可是个挺重要的概念。简单来说,虚函数就是在基类里用 virtual 关键字声明的函数。为啥要有虚函数呢?这主要是为了实现多态。多态是啥?就是同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。

咱来看个简单的例子:

// C++ 技术栈
#include <iostream>

// 基类 Animal
class Animal {
public:
    // 虚函数 speak
    virtual void speak() {
        std::cout << "Animal speaks" << std::endl;
    }
};

// 派生类 Dog 继承自 Animal
class Dog : public Animal {
public:
    // 重写基类的虚函数 speak
    void speak() override {
        std::cout << "Dog barks" << std::endl;
    }
};

// 派生类 Cat 继承自 Animal
class Cat : public Animal {
public:
    // 重写基类的虚函数 speak
    void speak() override {
        std::cout << "Cat meows" << std::endl;
    }
};

int main() {
    // 创建基类指针
    Animal* animal1 = new Dog();
    Animal* animal2 = new Cat();

    // 调用虚函数
    animal1->speak();
    animal2->speak();

    // 释放内存
    delete animal1;
    delete animal2;

    return 0;
}

在这个例子里,Animal 是基类,DogCat 是派生类。speak 函数在基类里被声明为虚函数,在派生类里被重写。当我们用基类指针指向派生类对象时,调用 speak 函数会根据实际对象的类型来执行相应的函数,这就是多态的体现。

二、虚函数实现机制

虚函数是怎么实现的呢?其实主要靠两个东西:虚函数表(vtable)和虚表指针(vptr)。

每个包含虚函数的类都会有一个虚函数表,这个表就像是一个函数地址的列表,里面存着该类所有虚函数的地址。而每个该类的对象都会有一个虚表指针,这个指针指向该类的虚函数表。

当我们通过基类指针调用虚函数时,程序会先通过对象的虚表指针找到虚函数表,然后从虚函数表里找到对应的虚函数地址,最后调用该函数。

还是用上面的例子来说,DogCat 对象都有自己的虚表指针,分别指向 DogCat 类的虚函数表。当 animal1->speak() 被调用时,程序会通过 animal1 指向的 Dog 对象的虚表指针找到 Dog 类的虚函数表,然后从表中找到 Dog::speak 函数的地址并调用它。

三、性能优化

虚函数虽然很有用,但也有一些性能开销。因为每次调用虚函数都要通过虚表指针去找虚函数表,再从表中找函数地址,这就比普通函数调用多了一些步骤。

3.1 减少不必要的虚函数

如果一个函数不需要实现多态,就不要把它声明为虚函数。比如下面这个例子:

// C++ 技术栈
#include <iostream>

class Shape {
public:
    // 非虚函数,不需要多态
    void drawBorder() {
        std::cout << "Drawing border" << std::endl;
    }
    // 虚函数,需要多态
    virtual void drawShape() {
        std::cout << "Drawing shape" << std::endl;
    }
};

class Circle : public Shape {
public:
    // 重写虚函数
    void drawShape() override {
        std::cout << "Drawing circle" << std::endl;
    }
};

int main() {
    Shape* shape = new Circle();
    // 调用非虚函数
    shape->drawBorder();
    // 调用虚函数
    shape->drawShape();

    delete shape;
    return 0;
}

在这个例子中,drawBorder 函数不需要多态,所以没有声明为虚函数,这样可以减少性能开销。

3.2 内联虚函数

如果虚函数的代码比较简单,并且调用频繁,可以考虑将其声明为内联函数。内联函数会在调用处直接展开代码,避免了函数调用的开销。不过要注意,内联函数只是一种建议,编译器可能不会真正内联。

// C++ 技术栈
#include <iostream>

class Base {
public:
    // 内联虚函数
    inline virtual void print() {
        std::cout << "Base print" << std::endl;
    }
};

class Derived : public Base {
public:
    // 重写内联虚函数
    inline void print() override {
        std::cout << "Derived print" << std::endl;
    }
};

int main() {
    Base* base = new Derived();
    base->print();
    delete base;
    return 0;
}

四、使用建议

4.1 正确使用 override 关键字

在派生类中重写虚函数时,使用 override 关键字可以让编译器帮我们检查是否正确重写了基类的虚函数。如果没有正确重写,编译器会报错。

// C++ 技术栈
#include <iostream>

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

class Derived : public Base {
public:
    // 使用 override 关键字重写虚函数
    void func() override {
        std::cout << "Derived func" << std::endl;
    }
};

int main() {
    Base* base = new Derived();
    base->func();
    delete base;
    return 0;
}

4.2 避免在构造函数和析构函数中调用虚函数

在构造函数和析构函数中调用虚函数可能会导致意外的结果。因为在构造函数执行时,派生类的部分还没有完全构造好;在析构函数执行时,派生类的部分已经被销毁了。

// C++ 技术栈
#include <iostream>

class Base {
public:
    Base() {
        // 不要在构造函数中调用虚函数
        // this->print(); 
    }
    virtual void print() {
        std::cout << "Base print" << std::endl;
    }
    ~Base() {
        // 不要在析构函数中调用虚函数
        // this->print(); 
    }
};

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

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

五、应用场景

虚函数在很多场景下都很有用。比如在游戏开发中,不同的角色可能有不同的攻击方式,我们可以用虚函数来实现多态。

// C++ 技术栈
#include <iostream>

// 基类 Character
class Character {
public:
    // 虚函数 attack
    virtual void attack() {
        std::cout << "Character attacks" << std::endl;
    }
};

// 派生类 Warrior 继承自 Character
class Warrior : public Character {
public:
    // 重写基类的虚函数 attack
    void attack() override {
        std::cout << "Warrior swings a sword" << std::endl;
    }
};

// 派生类 Mage 继承自 Character
class Mage : public Character {
public:
    // 重写基类的虚函数 attack
    void attack() override {
        std::cout << "Mage casts a spell" << std::endl;
    }
};

int main() {
    Character* character1 = new Warrior();
    Character* character2 = new Mage();

    character1->attack();
    character2->attack();

    delete character1;
    delete character2;

    return 0;
}

在这个游戏角色的例子中,不同的角色有不同的攻击方式,通过虚函数我们可以方便地实现多态,让代码更灵活。

六、技术优缺点

6.1 优点

  • 实现多态:虚函数可以让我们通过基类指针或引用调用派生类的函数,实现多态,提高代码的灵活性和可扩展性。
  • 代码复用:基类定义虚函数,派生类重写虚函数,避免了代码的重复编写。

6.2 缺点

  • 性能开销:虚函数调用需要通过虚表指针和虚函数表,比普通函数调用多了一些步骤,会有一定的性能开销。
  • 增加内存占用:每个包含虚函数的类都有一个虚函数表,每个该类的对象都有一个虚表指针,会增加内存占用。

七、注意事项

  • 虚析构函数:如果基类有虚函数,建议将基类的析构函数声明为虚析构函数,这样在通过基类指针删除派生类对象时,能正确调用派生类的析构函数。
// C++ 技术栈
#include <iostream>

class Base {
public:
    // 虚析构函数
    virtual ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
    virtual void print() {
        std::cout << "Base print" << std::endl;
    }
};

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

int main() {
    Base* base = new Derived();
    base->print();
    delete base;
    return 0;
}
  • 函数签名要一致:派生类重写虚函数时,函数签名(函数名、参数列表、返回类型)要和基类的虚函数一致,否则就不是重写,而是隐藏。

八、文章总结

虚函数是 C++ 中实现多态的重要手段,通过虚函数表和虚表指针来实现。虽然虚函数有一些性能开销,但在很多场景下能提高代码的灵活性和可扩展性。在使用虚函数时,我们要注意合理使用,减少不必要的虚函数,正确使用 override 关键字,避免在构造函数和析构函数中调用虚函数等。同时,要根据具体情况权衡虚函数的优缺点,选择合适的应用场景。