深入理解virtual关键字

2022 年 10 月 19 日 阿里技术



引言


为什么会写这篇文章?主要是因为项目中的代码大量使用了带virtual关键字的类,想通过本文浅谈一下。virtual并没有什么超能力可以化腐朽为神奇,它有其存在的理由,但滥用它是一种非常不可取的错误行为。本文将带你一步一步了解virtual机制,为你揭开virtual的神秘面纱。

为什么需要virtual


假设我们正在进行一个公共图形化库的设计实现,其中涉及2d和3d坐标点的打印,设计出Point2d和Point3d的实现如下:
#include <stdio.h>class Point2d {public:  Point2d(int x = 0int y = 0): _x(x), _y(y) {}  void print() const printf("Point2d(%d, %d)\n", _x, _y); }protected:  int _x;  int _y;};class Point3d : public Point2d {public:  Point3d(int x = 0int y = 0int z = 0):Point2d(x, y), _z(z) {}  void print() const printf("Point3d(%d, %d, %d)\n", _x, _y, _z); }protected:  int _z;};int main() {  Point2d point2d;  Point3d point3d;  point2d.print();        //outputs: Point2d(0, 0)  point3d.print();        //outputs: Point3d(0, 0, 0)  return 0;}
完美,一切都符合预期。既然如此,我们为什么需要virtual?让我们提个新需求:封装一个坐标点打印接口,输入是坐标点实例,输出是坐标点的值。很快,我们实现了代码:
void print(const Point2d &point) {  point.print();}int main() {  Point2d point2d;  Point3d point3d;  print(point2d);       //outputs: Point2d(0, 0)  print(point3d);       //outputs: Point2d(0, 0)  return 0;}
问题来了,当我们传入3d坐标点实例时,我们的期望是打印3d坐标点的值,而实际只能打印2d坐标点的值。现在的程序分不清坐标点是2d还是3d,为了让程序变得更聪明,需要对症下药,而virtual正是该症的药方。只需要更新Point2d接口print的声明即可:
class Point2d {public:  virtual void print() const { printf("Point2d(%d, %d)\n", _x, _y); }};int main() {  Point2d point2d;  Point3d point3d;  print(point2d);       //outputs: Point2d(0, 0)  print(point3d);       //outputs: Point3d(0, 0, 0)  return 0;}
干的漂亮,一切又恢复完美如初。在c++继承关系中实现多态的威力,正是需要virtual的地方。那么它的神奇魔力究竟从何而来呢?一切要从类数据成员内存布局说起。

类的内存布局


在c++对象模型中,非静态数据成员被配置于每一个类对象之内,静态数据成员则被存放在类对象之外。静态和非静态函数成员也被存放在类对象之外。大多数编译器对类的内存布局方式是按成员的声明顺序依次排列,本文的所有例子都是在mac环境下,使用x86_64-apple-darwin21.6.0/clang-1300.0.29.3编译,非virtual版本的Point2d内存布局:


内存布局需要我们注意的是编译器对内存的对齐方式,内存对齐一般分两步:其一是类成员先按自身大小对齐,其二是类按最大成员大小对齐。我们在安排类成员的时候,应该遵循成员从大到小的顺序声明,这样可以避免不必要的内存填充,节省内存占用。


派生类的内存布局


在c++的继承模型中,一个子类的内存大小,是其基类的数据成员加上其自己的数据成员大小的总和。大多数编译器对子类的内存布局是先安排基类的数据成员,然后是本身的数据成员。非virtual版本的Point3d的内存布局:



virtual类的内存布局


当Point2d声明了virtual函数后,对类对象产生了两点重大影响:一是类将产生一系列指向virtual functions的指针,放在表格之中,这个表格被称之为virtual table(vtbl)。二是类实例都被安插一个指针指向相关的virtual table,通常这个指针被称为vptr。为了示例需要,我们重新设计Point2d和Point3d实现:

class Point2d {public:  Point2d(int x = 0int y = 0): _x(x), _y(y) {}  virtual void print() const printf("Point2d(%d, %d)\n", _x, _y); }  virtual int z() const printf("Point2d get z: 0\n"); return 0; }  virtual void z(int z) printf("Point2d set z: %d\n", z); }protected:  int _x;  int _y;};class Point3d : public Point2d {public:  Point3d(int x = 0int y = 0int z = 0):Point2d(x, y), _z(z) {}  void print() const printf("Point3d(%d, %d, %d)\n", _x, _y, _z); }  int z() const printf("Point3d get z: %d\n", _z); return _z; }  void z(int z) printf("Point3d set z: %d\n", z); _z = z; }protected:  int _z;};

大多数编译器把vptr安插在类实例的开始处,现在我们来看看virtual版本的Point2d和Point3d的内存布局:

真实内存布局是否如上图所示,很简单,我们一验便知:

int main() {  typedef void (*VF1) (Point2d*);  typedef void (*VF2) (Point2d*, int);  Point2d point2d(1122);  intptr_t *vtbl2d = (intptr_t*)*(intptr_t*)&point2d;  ((VF1)vtbl2d[0])(&point2d);       //outputs: Point2d(11, 22)  ((VF1)vtbl2d[1])(&point2d);       //outputs: Point2d get z: 0  ((VF2)vtbl2d[2])(&point2d, 33);   //outputs: Point2d set z: 33  Point3d point3d(445566);  intptr_t *vtbl3d = (intptr_t*)*(intptr_t*)&point3d;  ((VF1)vtbl3d[0])(&point3d);       //outputs: Point3d(44, 55, 66)  ((VF1)vtbl3d[1])(&point3d);       //outputs: Point3d get z: 66  ((VF2)vtbl3d[2])(&point3d, 77);   //outputs: Point3d set z: 77  return 0;}

关键核心virtual table的获取在第5行,其实可以看成两步操作:intptr_t vptr2d = *(intptr_t*)&point2d;intptr_t *vtbl2d = (intptr_t*)vptr2d;第一步使vptr2d指向virtual table,第二步将指针转换为数组首地址。然后就可以用vtbl2d逐个调用虚函数。从输出结果看,程序确实逐个调用到对应的虚函数,virtual类的内存布局和先前我们所画结构图一致。

另一个有趣的地方是虚函数指针的定义,有没有让你联想到什么?你没想错,正是c++类this指针的存在:类成员函数里的this指针,其实是编译器将类实例的地址以第一个参数的形式传递进去的。和其他任何参数一样,this指针没有任何特别之处!


virtual析构函数


前文中我们都没设计析构函数,是因为要在这里单独讲解。让我们重新设计下继承体系,加入Point类:

class Point {public:  ~Point() { printf("~Point\n"); }};class Point2d : public Point {public:  ~Point2d() { printf("~Point2d"); }};class Point3d : public Point2d {public:  ~Point3d() { printf("~Point3d"); }};int main() {  Point *p1 = new Point();  Point *p2 = new Point2d();  Point2d *p3 = new Point2d();  Point2d *p4 = new Point3d();  Point3d *p5 = new Point3d();  delete p1;      //outputs: ~Point  delete p2;      //outputs: ~Point  delete p3;      //outputs: ~Point2d~Point  delete p4;      //outputs: ~Point2d~Point  delete p5;      //outputs: ~Point3d~Point2d~Point  return 0;}

可以看到,非virtual析构函数版本,决定继承体系中析构函数链调用的因素是指针的声明类型:析构函数的调用从声明指针类型的类开始,依次调用其父类析构函数。现在我们把Point的析构函数声明为virtual,来看下同样调用的结果:

//除Point析构声明为virtual外,其余均不变int main() {  Point *p1 = new Point();  Point *p2 = new Point2d();  Point2d *p3 = new Point2d();  Point2d *p4 = new Point3d();  Point3d *p5 = new Point3d();  delete p1;      //outputs: ~Point  delete p2;      //outputs: ~Point2d~Point  delete p3;      //outputs: ~Point2d~Point  delete p4;      //outputs: ~Point3d~Point2d~Point  delete p5;      //outputs: ~Point3d~Point2d~Point  return 0;}

virtual析构函数版本,决定继承体系中析构函数链调用的因素是指针的实际类型:析构函数的调用从指针指向的实际类型的类开始,依次调用其父类析构函数。


什么时候需要virtual


我看过项目中很多模块的代码,大量的类不管三七二十一都把析构函数声明为virtual。关键是这样的类既不是设计用于基类继承,也不是设计要使用多态能力,简直让人哭笑不得。现在你能理解为啥滥用virtual是不对的吗?因为在非必需的情况下,引入virtual实在不是一个明智的选择,它会带来两个明显的副作用:其一是每个类额外增加一个指针大小的内存占用,其二是函数调用多一层间接性。这两个特性会带来内存与性能的双重消耗。

其中内存的消耗是固定的一个指针大小,似乎看起来不起眼,但在类没有成员或者成员很少的情况下,就会带来100%以上的内存膨胀。性能的消耗则更加隐蔽,virtual会带来构造函数的强制合成,这点可能出乎很多人的意料。为何呢?因为虚表指针需要被安插妥当,因此编译器需要在类构造的时候做好这项工作。如果我们再声明一个虚析构函数,那将再引入一个非必要的合成函数,造成性能的双杀。让我们来瞧瞧这样做的后果:

#include <stdio.h>#include <time.h>struct Point2d {    int _x, _y;};struct VPoint2d {    virtual ~VPoint2d() {}    int _x, _y;};template <typename T>T sum(const T &a, const T &b) {
    T result;    result._x = a._x + b._x;    result._y = a._y + b._y;    return result;}template <typename T>void test(int times) {    clock_t t1 = clock();    for (int i = 0; i < times; ++i) {        sum(T(), T());    }    clock_t t2 = clock();    printf("clocks: %lu\n", t2 - t1);}int main() {    test<Point2d>(1000000);    test<VPoint2d>(1000000);    return 0;}

假设将上面的代码存为demo.cpp,用clang++ -o demo demo.cpp将代码编译成demo,使用nm demo|grep Point2d查看所有相关符号:

可以看到VPoint2d自动合成了构造和析构函数,以及typeinfo信息。作为对比Point2d则没有合成任何函数,我们看下两者的执行效率:在作者mac机器上,三次demo执行的结果取中间值是Point2d:12819,VPoint2d:21833,VPoint2d性能耗时增加了9014次clock,增幅达70.32%。

因此,一定不要随意引入virtual,一定不要随意引入virtual,一定不要随意引入virtual,除非你真正需要它:

1、在继承中使用多态能力的时候,需要使用virtual functions机制;

2、基类指针指向子类实例的时候,需要使用virtual析构函数;

任何其他时候,virtual并没有其他你想要的任何魔力且会有反噬作用。其实还有一种情况需要virtual,就是virtual base class,由于这种情况太过于复杂,建议任何时候都不要去尝试它(可能需要另外一篇长文来解释为何不建议使用,本文暂且不表)。

结语


关于virtual的讲解至此结束,不多不少,不知对你来说是否够用。希望本文对你了解和使用virtual可以起到帮助作用。c++复杂且庞大,很多特性都有它使用的场景和限制,我们只有深入了解其背后的机制,才能做到"宠辱不惊,看庭前花开花落;去留无意,望天上云卷云舒;"。

最后,本文参考了《深度探索c++对象模型》一书。毋须多言,我觉得这是一本关于c++的必读书籍。希望大家有空都可以看看,一定会让你开卷有益、相见恨晚。


往期推荐

1.阿里云块存储团队软件工程实践

2.地图作业平台低代码实战(搭建能力提升)


GTS云巧乘风者征文大赛等你来


投稿GTS云巧乘风者征文活动,有机会获得888元猫超卡和天猫精灵Sound~文章内容不限于云巧,只要运用了【组装式应用的理念】就可以投稿!


云巧及组装式应用理念是什么?云巧是“组装式应用”理念的落地,助力大家提升交付速度,提高交付质量,降低用工成本。


点击阅读原文查看详情。

登录查看更多
0

相关内容

【2022新书】数据可视化手册,443页pdf
专知会员服务
194+阅读 · 2022年5月7日
人工智能应用落地的关键成功要素
专知会员服务
20+阅读 · 2022年4月29日
Web3.0(元宇宙)概念下的场景生态简析
专知会员服务
215+阅读 · 2022年4月23日
【2021新书】ApachePulsar 实战,402页pdf
专知会员服务
69+阅读 · 2021年12月29日
专知会员服务
37+阅读 · 2021年5月29日
【经典书】精通Linux,394页pdf
专知会员服务
92+阅读 · 2021年2月19日
【深度学习视频分析/多模态学习资源大列表】
专知会员服务
91+阅读 · 2019年10月16日
基于Pytorch的卷积算子的推导和实现
极市平台
0+阅读 · 2022年10月29日
令人沮丧的C++性能调试
InfoQ
0+阅读 · 2022年10月24日
在.NET 6 中如何创建和使用 HTTP 客户端 SDK
一文读懂 Pytorch 中的 Tensor View 机制
极市平台
0+阅读 · 2022年1月30日
如何理解 Flutter 路由源码设计?| 开发者说·DTalk
谷歌开发者
1+阅读 · 2022年1月28日
「现代C++设计魅力」虚函数继承-thunk技术初探
阿里技术
0+阅读 · 2022年1月26日
一种命令行解析的新思路(Go 语言描述)
阿里技术
0+阅读 · 2021年11月9日
理解 YOLO 目标检测
AI研习社
20+阅读 · 2018年11月5日
设计和实现一款轻量级的爬虫框架
架构文摘
13+阅读 · 2018年1月17日
国家自然科学基金
0+阅读 · 2015年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2008年12月31日
Arxiv
0+阅读 · 2022年12月15日
Arxiv
0+阅读 · 2022年12月14日
Arxiv
0+阅读 · 2022年12月13日
Arxiv
0+阅读 · 2022年12月13日
VIP会员
相关VIP内容
【2022新书】数据可视化手册,443页pdf
专知会员服务
194+阅读 · 2022年5月7日
人工智能应用落地的关键成功要素
专知会员服务
20+阅读 · 2022年4月29日
Web3.0(元宇宙)概念下的场景生态简析
专知会员服务
215+阅读 · 2022年4月23日
【2021新书】ApachePulsar 实战,402页pdf
专知会员服务
69+阅读 · 2021年12月29日
专知会员服务
37+阅读 · 2021年5月29日
【经典书】精通Linux,394页pdf
专知会员服务
92+阅读 · 2021年2月19日
【深度学习视频分析/多模态学习资源大列表】
专知会员服务
91+阅读 · 2019年10月16日
相关资讯
基于Pytorch的卷积算子的推导和实现
极市平台
0+阅读 · 2022年10月29日
令人沮丧的C++性能调试
InfoQ
0+阅读 · 2022年10月24日
在.NET 6 中如何创建和使用 HTTP 客户端 SDK
一文读懂 Pytorch 中的 Tensor View 机制
极市平台
0+阅读 · 2022年1月30日
如何理解 Flutter 路由源码设计?| 开发者说·DTalk
谷歌开发者
1+阅读 · 2022年1月28日
「现代C++设计魅力」虚函数继承-thunk技术初探
阿里技术
0+阅读 · 2022年1月26日
一种命令行解析的新思路(Go 语言描述)
阿里技术
0+阅读 · 2021年11月9日
理解 YOLO 目标检测
AI研习社
20+阅读 · 2018年11月5日
设计和实现一款轻量级的爬虫框架
架构文摘
13+阅读 · 2018年1月17日
相关基金
国家自然科学基金
0+阅读 · 2015年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2008年12月31日
Top
微信扫码咨询专知VIP会员