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

2022-11-13 19:11:05 带你 一篇文章 来龙去脉

一切从继承讲起

我们有一个基类 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循环里的printAnimal::print。这就是所谓静态。

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

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

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

虚函数登场

虚函数定义

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

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

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

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

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

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

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

  • 问题1: pL1->print();。这句话其实很简单,压根就不能编译,因为pL1的类型是L1*, 而L1类里面根本就没有print函数。

  • 问题2: pL2->print();L2*的身子装了L4指针,这就很明显了,L2和L4之间,最靠下的print,出现在L4中,所以这里应该打印L4

  • 问题3: pL2->print();L2*的身子装了L3指针,根据我们的说法,也是很明显的,L2L3之间,最靠下的,还是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::printA::print压根就没啥联系,在具体的搜索虚函数进行调用的时候,他们被看做完全不同的两个函数。

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

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

问,此时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 L1
{
    public:
    virtual A* get()
    {
        return new A{};
    }
};
class L2: public L1
{
    public:
    B* get() override
    {
        return new B{};
    }
};

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

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

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

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

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

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

接着看如下的代码:

void test()
{
    L2 l2;
    l2.get()->print(); // 问题1,这里打印什么?
    
    L1& rl1{l2};
    rl1.get()->print(); // 问题2,这里打印什么?
}
  • 问题1:这个地方不难,就是打印This is B
  • 问题2:我们来慢慢分析,rl1 声明的类型是 L1& ,但是引用了一个子类对象l2。此时 rl2.get()是遵循虚函数的调用逻辑,也就是肯定调用的是L2::getL2::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 L1
{
    public:
    ~L1()
    {
        std::cout << "L1 正在析构" << std::endl;
    }
};
class L2: public L1
{
    int* resource;
    public:
    L2():resource{new int}
    {
    }
    ~L2()
    {
        delete resource;
    }
};
void test()
{
    L2* l2{new L2};
    L1* pl1{l2};
    
    delete pl1;
}

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

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

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

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

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

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

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

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

虚函数如何实现的

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

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

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

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

函数指针

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

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

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

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

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

虚函数表

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

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

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

class L1
{
    public:
    virtual void func1()
    {
    }
    virtual void func2()
    {
    }
};
class Sub1: public L1
{
    public:
    void func1() override
    {
    }
};
class Sub2: public L1
{
    public:
    void func2() 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()
{
    L2 l2;
    L1* p{&l2};
    p->func1();
}

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

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

vtable指针

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

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

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

比如说

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

虚函数的消耗

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

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

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

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

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

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

总结

到此这篇关于C++虚函数的文章就介绍到这了,更多相关掌握C++虚函数内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

相关文章