C++虚函数详解

讲虚函数之前先讲讲面向对象的三大特性:封装、继承、多态。

1、封装

封装是指将数据(属性)和操作数据的方法(函数)封装在一个单元中,这个单元就是类。封装的主要目的是隐藏类的内部实现细节,只暴露必要的接口给外部使用者。

优点:

  • 信息隐藏: 封装可以将类的内部细节隐藏起来,不暴露给外部,提高了安全性和防止误用。
  • 简化接口: 封装通过提供清晰的接口简化了类的使用,使用者只需关注如何使用接口而不需要了解内部实现。
  • 提高可维护性: 内部实现的修改不会影响外部使用者,从而提高了代码的可维护性。

示例:

c 复制代码
#include <iostream>
#include <string>

class Student {
private:
    std::string name;
    int age;

public:
    // 构造函数
    Student(const std::string& n, int a) : name(n), age(a) {}

    // 获取姓名
    std::string getName() const {
        return name;
    }

    // 设置年龄
    void setAge(int a) {
        if (a >= 0) {
            age = a;
        }
    }

    // 显示学生信息
    void displayInfo() const {
        std::cout << "Name: " << name << ", Age: " << age << std::endl;
    }
};

int main() {
    Student student("Alice", 20);
    
    // 使用公有接口获取和设置信息
    student.setAge(21);
    std::cout << "Student Name: " << student.getName() << std::endl;
    student.displayInfo();

    return 0;
}

2、继承

继承允许一个类(子类或派生类)继承另一个类(父类或基类)的属性和方法。通过继承,子类可以获得父类的特征,并可以添加新的特征或修改继承的特征。

优点:

  • 代码重用: 继承允许在不重复编写代码的情况下扩展和修改现有类,提高了代码的重用性。
  • 层次结构: 继承可以创建类的层次结构,使得代码更有组织性和可扩展性。
  • 多态性支持: 继承是多态性的基础,通过基类指针或引用调用派生类的方法实现多态行为。

示例:

c 复制代码
#include <iostream>
#include <string>

// 基类
class Animal {
protected:
    std::string name;

public:
    Animal(const std::string& n) : name(n) {}

    void eat() {
        std::cout << name << " is eating." << std::endl;
    }
};

// 派生类
class Dog : public Animal {
public:
    Dog(const std::string& n) : Animal(n) {}

    void bark() {
        std::cout << name << " is barking." << std::endl;
    }
};

int main() {
    Dog myDog("Buddy");

    myDog.eat();  // 继承自基类
    myDog.bark(); // 派生类自己的方法

    return 0;
}

3、多态

多态性是指同一个操作可以作用于不同类型的对象,并且可以根据对象的类型执行不同的行为。多态性通过虚函数和函数重载实现。

  • 编译时多态性(静态多态性): 通过函数重载实现,编译器在编译时根据函数参数的类型和数量来选择调用合适的函数。这种多态性是在编译时解析的。
  • 运行时多态性(动态多态性): 通过虚函数和继承实现,允许在运行时根据对象的实际类型来调用适当的函数。这种多态性是在运行时解析的。

优点:

  • 灵活性: 多态性允许在不同的情境下以通用的方式处理不同类型的对象,提高了代码的灵活性。
  • 可扩展性: 可以轻松地添加新的派生类而不影响现有的代码,增加了系统的可扩展性。
  • 简化接口: 多态性简化了代码的接口,允许使用者按统一的方式与不同类型的对象交互。

示例:

c 复制代码
#include <iostream>
#include <vector>

class Shape {
public:
    virtual void draw() {
        std::cout << "Drawing a shape." << std::endl;
    }
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Square : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a square." << std::endl;
    }
};

int main() {
    std::vector<Shape*> shapes;
    shapes.push_back(new Circle());
    shapes.push_back(new Square());

    for (Shape* shape : shapes) {
        shape->draw(); // 多态性:根据对象的实际类型调用适当的方法
    }

    // 释放内存
    for (Shape* shape : shapes) {
        delete shape;
    }

    return 0;
}

讲了这么多,进入今天主题吧,C++实现多态的虚函数。

在C++中,函数继承的方法可以让我们快速开发,为了满足多态和泛型编程,C++允许用户使用虚函数来完成运行时解析,与一般的编译时解析也有着本质区别。

4、虚函数在内存中的分布

对于C++了解的人都应该知道虚函数是通过一个虚函数表来实现的。在这个表中,主要是一个类的虚函数的地址表,这个表解决了继承、覆盖的问题,保证其真实反应实际的函数。这样的话,在有虚函数的类的实例中,这个表被分配在这个实例的内存中。当我们用父类的指针操作其子类的时候,这个虚表就非常重要了,它指明了实际所应该调用的函数。

csharp 复制代码
class A {
public:
    virtual void v_a(){}
    virtual ~A(){}
    int64_t _m_a;
};
int main()
{
    A* a = new A();
    return 0;
}

定义一个类A,那么它在内存中分布的情况是什么样的呢?接下来一起看看

  • 首先在主函数的栈帧上有一个 A 类型的指针指向堆里面分配好的对象 A 实例。
  • 对象 A 实例的头部是一个 vtable 指针,紧接着是 A 对象按照声明顺序排列的成员变量。(当我们创建一个对象时,便可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针。)
  • vtable 指针指向的是代码段中的 A 类型的虚函数表中的第一个虚函数起始地址。
  • 虚函数表的结构其实是有一个头部的,叫做 vtable_prefix ,紧接着是按照声明顺序排列的虚函数。
  • 注意到这里有两个虚析构函数,因为对象有两种构造方式,栈构造和堆构造,所以对应的,对象会有两种析构方式,其中堆上对象的析构和栈上对象的析构不同之处在于,栈内存的析构不需要执行 delete 函数,会自动被回收。
  • typeinfo 存储着 A 的类基础信息,包括父类与类名称,C++关键字 typeid 返回的就是这个对象。
  • typeinfo 也是一个类,对于没有父类的 A 来说,当前 tinfo 是 class_type_info 类型的,从虚函数指针指向的vtable 起始位置可以看出。

5、虚函数表实现原理

虚函数表是一个指向虚函数的指针数组,每个带有虚函数的类都有一个对应的虚函数表。

虚函数指针

其本质就是一个指向函数的指针,与普通的函数指针并没有什么大的区别。它指向程序员自己定义的虚函数,当子类调用虚函数的时候,实际上就是通过调用这个虚函数指针从而找到接口。

虚函数指针是一个真实存在的数据类型,在对象实例化的时候,放在这个对象地址的首位,目的就是为了保证运行的快速性。与对象的成员函数不一样的是,虚函数指针对外部是完全不可见的,除非直接访问地址或者是debug模式,否则它是不能被外部调用的。

只有拥有虚函数的类才能拥有虚函数指针,每个虚函数都会对应一个虚函数指针。那么,拥有虚函数的类都会产生额外的开销,并且也会在一定程度上影响程序的运行速度。

虚函数表

当一个类包含虚函数时,编译器会在该类的对象中添加一个指向虚函数表的指针。这个指针通常位于对象的内存布局的开头(虚指针),它们按照一定的顺序组织起来就会构成一个表状结构,叫做虚函数表。虚函数表本身是一个全局的、类特定的数组,其中包含了该类中所有虚函数的地址。

先来定义一个基类:

csharp 复制代码
class Panent
{
public:
    virtual void A(){cout<<"Panent::A"<<endl;}
    virtual void B(){cout<<"Panent::B"<<endl;}
    virtual void C(){cout<<"Panent::C"<<endl;}
};

对于基类Base的虚函数表记录的只有自己定义的虚函数。

下来再看看子类:

csharp 复制代码
class Children: public Panent
{
public:
    virtual void A(){cout<<"Children::f"<<endl;}
    virtual void B1(){cout<<"Children::B1"<<endl;}
    virtual void C1(){cout<<"Children::C1"<<endl;}
}

最常见的继承,就是子类对基类的虚函数进行覆盖继承。

此时的虚函数表:

基函数的表项仍然会保留,而得到正确继承的虚函数的指针将会被覆盖,而子类自己的虚函数将跟在表后。

当多继承的时候,表项将会增多,顺序将会体现为继承的顺序,那么子类的虚函数就跟在第一个表项后。

C++中一个类是公用一个虚函数表的,基类有基类的虚函数表,子类有子类的虚函数表,这样极大的节省了内存。

虚表指针

为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,在编译阶段,编译器在类中添加了一个指针 __vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表, __vptr一般在对象内存分布的最前面。

虚表指针的初始化确实发生在构造函数的调用过程中, 但是在执行构造函数体之前,即进入到构造函数的"{"和"}"之前。 为了更好的理解这一问题, 我们可以把构造函数的调用过程细分为两个阶段,即:

  • 进入到构造函数体之前。在这个阶段如果存在虚函数的话,虚表指针被初始化。如果存在构造函数的初始化列表的话,初始化列表也会被执行。
  • 进入到构造函数体内。这一阶段是我们通常意义上说的构造函数。

带缺省参数的虚函数

当缺省参数和虚函数一起出现的时候情况有点复杂,极易出错。我们知道,虚函数是动态绑定的,但是为了执行效率,缺省参数是静态绑定的。

参考:cloud.tencent.com/developer/a...

相关推荐
轩辰~10 分钟前
网络协议入门
linux·服务器·开发语言·网络·arm开发·c++·网络协议
小_太_阳19 分钟前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
智慧老师28 分钟前
Spring基础分析13-Spring Security框架
java·后端·spring
lxyzcm30 分钟前
C++23新特性解析:[[assume]]属性
java·c++·spring boot·c++23
蜀黍@猿1 小时前
C/C++基础错题归纳
c++
雨中rain1 小时前
Linux -- 从抢票逻辑理解线程互斥
linux·运维·c++
搬码后生仔2 小时前
asp.net core webapi项目中 在生产环境中 进不去swagger
chrome·后端·asp.net
凡人的AI工具箱2 小时前
每天40分玩转Django:Django国际化
数据库·人工智能·后端·python·django·sqlite
ALISHENGYA2 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(实战项目二)
数据结构·c++·算法
arong_xu2 小时前
现代C++锁介绍
c++·多线程·mutex