一篇文章带你掌握C++虚函数的来龙去脉

C/C++
230
0
0
2023-06-08
目录
  • 一切从继承讲起
  • 继承的语义是什么
  • std::vector
  • 虚函数登场
  • 虚函数定义
  • 子类中如何改变一个虚函数的行为
  • override 限定符
  • final 限定符
  • covariant 返回类型
  • virtual destructor 虚析构函数
  • 虚函数如何实现的
  • 函数指针
  • 虚函数表
  • 虚函数表的概念
  • vtable指针
  • 虚函数的消耗
  • 总结

一切从继承讲起

我们有一个基类 Animal。

有一个 Dog 类继承了 Animal。

有一个 Fish 类也继承了 Animal。

一切从上面的小例子开始讲起。

假设 Animal 有一个成员函数 print,可以打印自己是什么物种,在 Animal类中,可以这么写:

class Animal
{
    public:
    void print()
    {
        std::cout << "我是 Animal" << std::endl;
    }
};
class Dog: public Animal{};
class Fish: public Animal{};

上面代码里Dog和Fish没有任何新的改动,仅仅继承了Animal而已。所以当实例化Dog或者Fish的时候,将生成的对象调用print函数,只能显示出"我是 Animal"。

Dog d;
d.print(); // 打印 我是 Animal

Fish f;
f.print(); // 打印 我是 Animal

这样不好,我们想要更精确的打印物种信息,所以我们在子类中重定义print函数:

class Dog: public Animal
{
    public:
    void print()
    {
        std::cout << "我是 Dog" << std::endl;
    }
}

class Fish: public Animal
{
    public:
    void print()
    {
        std::cout << "我是 Fish" << std::endl;
    }
}

这样的话,Dog类和Fish类的变量调用print函数的时候,就会打印相应的信息了:

Dog d;
d.print(); // 打印 我是 Dog

Fish f;
f.print(); // 打印 我是 Fish

到目前为止,一切都是顺理成章。

继承的语义是什么

请思考一下这个问题:Dog 和 Animal 之间是什么关系?

在C++里,Dog继承自Animal,我们就说,Dog就是Animal。

就是说,子类就是父类。

不是谁包含谁的关系。

这很重要,但是还是需要进一步分析,【子类就是父类】这种关系到底在哪里能体现出来。

举一个例子,我们有一个函数,参数是 Animal*, 如下:

void foo(Animal* a)
{

}

由于C++是一个强类型系统,大部分语法都是来限制类型的。所以我们经常可以从函数传参来试图理解一些比较难理解的概念,比如说【子类就是父类】这个概念。

Animal a;
Dog d;

foo(&a); // 这个天经地义,完美匹配类型系统

foo(&d); // ????? 这个行不行呢

上面代码最后一句到底行不行?

根据【子类就是父类】 -> 【Dog 就是 Animal】。答案很明显,行!

我们再来看一个例子:

Animal a;
Animal* pa {&a}; // 依然天经地义

Dog d;
Animal* pd {&d}; // 依然?????

上面的代码不是函数传参,却与函数传参无二,花括号里需要填一个东西,来匹配前面的类型声明。

很明显,&d的类型是Dog*类型,完全可以当做Animal*来使用。

小总结,【子类就是父类】这个东西,在实践里,就是说,当我们需要一个【父类指针】的变量的时候,我们完全可以把一个【子类指针变量】丢进去。

上面的总结不仅仅对于指针来说,对于引用也是同样的。毕竟C++里,引用本身的概念与指针类似。

这里给个例子:

Dog d;
Animal& r{d}; // 完全可以

这是为什么呢,为什么可以这么做呢?

这是因为,子类对象的内存里,确实包含了完整的基类对象。

注意,对象之间的关系可以说包含与被包含了。

std::vector

我们在使用std::vector的时候,只能存储同种类型的变量,比如说,我们要存的是Animal*类型的变量,根据上面的说法,我们不仅仅能存Animal对象的指针,也可以存Dog对象 或者 Fish对象的指针。

这就给我们的代码带来了便利,一个std::vector可以来存储所有Animal子类的指针了。

否则,我们需要给每一个子类声明一个std::vector变量。

接着往下说,我们考虑下面的例子:

std::vector<Animal*> list;
Animal a;
Dog d;
Fish f;
list.push_back(&a);
list.push_back(&d);
list.push_back(&f);

for (auto e : list)
{
    e->print();
}

我们知道,这三个类,都有自己定义的print函数,那么这个for循环执行的时候,到底怎么打印呢?

我是 Animal
我是 Animal
我是 Animal

这种结果是出乎意料,还是不出所料呢,不同的人有不同的见解。

这里应该是不出所料的,因为,c++是一个静态类型的语言,大部分特性都是静态的,所谓静态,就是编译的时候就能确定一些事情,比如说,调用哪个函数。

由于e的类型是Animal*, 所以在编译的时候,就已经确定好了,for循环里的print是Animal::print。这就是所谓静态。

我们发现,这个std::vector确实能存储Animal对象指针、 Dog对象指针、 Fish对象指针, 但好像一旦存储进去了,就无法区分,谁是谁了。

这怎么行,有一些行为,确实在子类里覆盖了,比如说print的行为。

如何让静态的c++编译器生成一些看起来动态的机器码呢,比如说,上面的循环里,能够调用各自类里面重新定义的print函数,而不简单粗暴的直接使用Animal::print呢?

虚函数登场

虚函数定义

虚函数是一种特殊的类成员函数, 这种函数在编译器,无法确定真正的函数地址在哪里,所以称之为虚函数。

程序运行的时候,根据具体的对象是什么,就调用什么相应的版本。

用严格一点的话来说:调用该虚函数出现的那个类和当前对象的类,这两个类之间,最靠下的那个版本的函数。

如何让一个普通成员函数成为一个虚函数呢,在声明的时候,前面加上virtual就行了。

话太绕了,我们来看例子:

class L
{
};
class L: public L1
{
    public:
    virtual void print()
    {
        std::cout << "L" << std::endl;
    }
};
class L: public L2
{
}
class L: public L3
{
    public:
    virtual void print()
    {
        std::cout << "L" << std::endl;
    }
}
///
void test()
{
    L l4;
    L* pL1 {&l4};
    pL->print(); // 1. 打印什么
    
    L* pL2 {&l4};
    pL->print(); // 2. 打印什么
    
    L l3;
    pL = &l3;
    pL->print(); // 3. 打印什么
     
}

我们来看上面的三个问题.

  • 问题1: pL1->print();。这句话其实很简单,压根就不能编译,因为pL1的类型是L1*, 而L1类里面根本就没有print函数。
  • 问题2: pL2->print();。L2*的身子装了L4指针,这就很明显了,L2和L4之间,最靠下的print,出现在L4中,所以这里应该打印L4。
  • 问题3: pL2->print();。L2*的身子装了L3指针,根据我们的说法,也是很明显的,L2和L3之间,最靠下的,还是L2,所以这里应该打印L2。

通过这三个小问题,应该稍微了解虚函数到底调用哪一个的问题了。

子类中如何改变一个虚函数的行为

如果想要在子类中改变一个虚函数的行为,那么就必须严格按照基类中该虚函数的函数签名,重新实现这个虚函数:

class A
{
    public:
    virtual void print(){}
}
class B: public A
{
    public:
    virtual void print(int a){}
}

来看看上面的子类B中,我们给print加了一个参数,此时B中的print还是A中的那个print吗?

答案是否定的,

  • 首先这个代码是能编译过的
  • 只不过,B::print和A::print压根就没啥联系,在具体的搜索虚函数进行调用的时候,他们被看做完全不同的两个函数。

再来看看虚函数的返回值类型所带来的问题:

class A
{
    public:
    virtual void print(){}
}
class B: public A
{
    public:
    virtual int print(){return;}
}

问,此时B::print还是A::print吗?

答案,是的。。。。只不过,这个直接编译不过。

编译不过是好的,为什么,因为在编译的时候,就告诉你错在哪了。

上面那个由于疏忽或者别的原因,给原本的虚函数多加了一个参数,这种才可怕呢,因为编译通过了。

那怎么防范生成了一个新的函数?

override 限定符

如果在子类里面,我们确定要重新实现一个虚函数,那么我们就在函数签名的后面加上这个override限定符。

class A
{
    public:
    virtual void print(){};
};
class B: public A
{
    public:
    void print(int a) override {};
}

看上面代码,B这个子类中print函数前面前面,我们去掉了virtual, 而在花括号前面加了override。

此时,编译器就报错了,逻辑是这样的:

  • 编译器看到override,它就认为print是从基类继承而来的一个虚函数,所以它去看看A::print, 发现这个函数没有参数。
  • 回过头来,发现B::print(int) 带了一个参数,编译器直接报错。

这就让错误尽早出现在编译时期,棒!

final 限定符

可能会有这么一种情况,有一个类A,里面有一个虚函数print,你写了一个类B,继承了类A,然后override了这个print函数。然后别人写了一个类C继承了类B,你不想类C拥有override这个print函数的权限。

此时,在类B中,override print 函数的地方,可以加一个final:

class A
{
    public:
    virtual void print(){};
}
class B: public A
{
    public:
    void print() override final {}; // 注意看,加了final
}
class C: public B
{
    public:
    void print() override {}; // 编译报错
}

上面的代码演示了,class C中无法继续override print的写法。

还有一种极端的情况,你写了一个类A,你压根就不想别人去继承这个类A:

class A final
{
};
class B: public A // oh, 直接报错
{
};

加了final之后,就可以阻止别的类来继承了。

covariant 返回类型

上面讲过,一个虚函数,想要在子类里override,那么函数签名必须一模一样,包括返回值类型。但是有一种特殊的情况,需要考虑。看下面的例子

class A
{
    public:
    void print()
    {
        std::cout << "This is A" << std::endl;
    }
};
class B: public A
{
    public:
    void print()
    {
        std::cout << "This is B" << std::endl;
    }
};

class L
{
    public:
    virtual A* get()
    {
        return new A{};
    }
};
class L: public L1
{
    public:
    B* get() override
    {
        return new B{};
    }
};

我们先注意到,B和A就是两个普通的有继承关系的类,里面并没有出现virtual函数。

真正要研究的是L2和L1,get 函数是一个virtual函数,但是L2里get返回值类型是B*。

这似乎违反了virtual函数的规定,那就是函数签名必须一致。

但是又能说的通:【子类就是父类】。

所以上面的代码能编译过吗?

答案是能。这种特殊的情况被称之为covariant 返回类型,有的地方翻译成协变返回类型。

接着看如下的代码:

void test()
{
    L l2;
    l.get()->print(); // 问题1,这里打印什么?
    
    L& rl1{l2};
    rl.get()->print(); // 问题2,这里打印什么?
}
  • 问题1:这个地方不难,就是打印This is B。
  • 问题2:我们来慢慢分析,rl1 声明的类型是 L1& ,但是引用了一个子类对象l2。此时 rl2.get()是遵循虚函数的调用逻辑,也就是肯定调用的是L2::get。L2::get的返回类型是什么,是B*,所以直接得出结果应该是 B::print, 打印This is B。

不好意思,问题2的结论是错的。

虚函数不会改变原本的函数返回类型,在L1这个基类中,返回类型就是A*,即使调用了L2::get,仍然返回了A*这个类型,如果你有IDE,你可以将鼠标悬停在

rl1.get()->print();

get这个地方,会显示出,返回类型是A*, 于是乎,最后的print其实是A::print, 所以打印了

This is A。

virtual destructor 虚析构函数

在大部分时候,我们都无需为自定的class提供一个析构函数, 因为大部分时候自定义的class里面不包含需要释放的资源,比如说内存,文件等等。此时c++会提供一个默认的析构函数。

但是,如果我们的class里有这种动态的资源,那么就不得不提供一个自定义的析构函数,来针对这些动态资源进行释放。

更进一步的是,如果一个拥有动态资源的class同时继承了别的class,此时最好小心一点:

这是啥意思, 来看例子:

class L
{
    public:
    ~L()
    {
        std::cout << "L 正在析构" << std::endl;
    }
};
class L: public L1
{
    int* resource;
    public:
    L():resource{new int}
    {
    }
    ~L()
    {
        delete resource;
    }
};
void test()
{
    L* l2{new L2};
    L* pl1{l2};
    
    delete pl;
}

分析以上代码,pl1 指向了一个子类L2的对象,在delete pl1的时候,编译器发现,L1 的析构函数是正常函数,所以编译器在这里指定决定调用L1::~L1这个函数,然后就结束了。

我们会发现,L2 的析构函数并没有被调用到,也就是说, resource 所指向的资源没有被回收!!!

怎么办呢,将 L1 中的析构函数标记成virtual:

class L
{
    public:
    virtual ~L(){};
}

这样才能保证,任何继承自L1的类中的动态资源被回收。

结论:如果写了一个类,这个类有可能被别的类继承的话,那么最好将这个类的析构函数标记成virtual的:

class A
{
    public:
    virtual ~A() = default;
}

关于这一点,有很多大师级人物都讨论过,不同的人有不同的看法,不过,上面的结论还是稳妥的,虽然有一点性能消耗。

虚函数如何实现的

为什么要有这个疑问,难道这种实现不正常吗?

不正常,非常不正常,C++是一个静态语言,必须先编译再运行,执行什么函数,一定是编译时就决定好的。

而虚函数打破了这种既有的规则,而这种规则的打破依赖于函数指针。

下面来讲讲虚函数这一套逻辑到底是怎么跑起来的。

函数指针

这是一种指针,这个指针指向的是一块代码,用这个指针可以进行函数调用:

void print_v()
{
    std::cout << "print_v" <<std::endl;
}

void print_v()
{
    std::cout << "print_v" <<std::endl;
}

void test()
{
    auto f {print_v};
    f(); // 打印 print_v
    f = print_v;
    f(); // 打印 print_v
}

观察上面的代码,发现,两个f()调用了不同的函数,这是一种动态行为。也就是说,程序运行的时候,根据f本身的指向,才能决定真正调用哪一块代码。

虚函数表

有了函数指针,使得动态行为有了可能,剩下的就是奇思妙想,让虚函数逻辑跑起来。

大部分编译器采用了所谓虚函数表的东西来实现虚函数逻辑。

这种东西文字描述不清,直接看例子:

class L
{
    public:
    virtual void func()
    {
    }
    virtual void func()
    {
    }
};
class Sub: public L1
{
    public:
    void func() override
    {
    }
};
class Sub: public L1
{
    public:
    void func() override
    {}
};

先描述一下,上面有三个class,L1是一个基类,里面有两个virtual 函数:

  • func1
  • func2

然后

  • Sub1继承了L1, 然后override了 func1
  • Sub2继承了L1, 然后override了 func2

此时,先来考虑一个小问题,sizeof 三个 class,应该是多大呢,假如是64bit机器。

答案是都是占8字节,也就是64bit。

那么这8字节存了啥东西?

答案就是,这8字节其实是一个指针,指向哪,先不说,一会再来说明。

虚函数表的概念

对于上面的例子来说,编译器生成了三个虚函数表,也就是L1、Sub1、Sub2每个class,各一个。

注意这个虚函数表是每个class一个,而不是每个对象一个,一定要搞明白。

这很类似于 class 里的静态成员,这么说就好理解了。

那虚函数表长啥样?

其实虚函数表就是一个数组,数组里的每一项就是一个简单的函数指针。

我们来画一画上面例子的虚函数表:

在右边的代码段里,我们可以看见,一共有四个不同的函数,这与我们的代码是一致的。

再来看左边的虚函数表,可以清晰的看出来,每个类里的两个虚函数都真实地指向了正确的版本。

光有这个虚函数表,是没用的,在调用虚函数的地方,必须与这个虚函数表联系起来。

还记得刚才说的那个8字节的指针吗。

那个指针就是起到这种关联的。

我们看下面的例子:

void test()
{
    L l2;
    L* p{&l2};
    p->func();
}

我们画出上面的整个关系图:

此时用 p->func1() 的时候,为什么会调用到Sub1::func1就一目了然了,一直跟着指针往下走就明白了!

vtable指针

我们将上面的那个8字节指针称做vtable指针,它的作用就是来指向相应的class的虚函数表的。

一般而言,这个变量是在基类里声明的,子类是继承了这个变量。

在对象初始化的时候,这个指针会指向真正的本class的虚函数表。

比如说

  • Sub1对象里的vtable就会指向Sub1的虚函数表
  • Sub2对象里的vtable就会指向Sub2的虚函数表

虚函数的消耗

我们从上面的实现可以看出,在使用虚函数的class里,强行塞入了一个vtable指针,占了8字节,这无疑会增加内存的消耗。

其次,调用虚函数的时候,需要三步走。

  • 从vtable找到虚函数表
  • 从虚函数表找到真正的函数指针
  • 然后由函数指针找到函数,进行调用

而一般的函数只有最后一步,这无疑也是增加了一些步骤的,不过这种消耗不怎么明显,所以该用虚函数,还是尽量用吧,不要有什么心理负担,然后搞什么静多态。

对了,我们把整个虚函数所进行的行为称之为多态,这是一种动态多态,因为这是运行时的行为。

至于什么叫静多态,那就不属于本文所讨论的了。