文章目录
- 前言
- 重载
- 继承
- 虚函数
- 虚函数表
- 动态绑定的实现
- 析构函数
- 构造函数
- 多态
- 子类直接继承父类的方法,不覆盖
- 多重继承
- 纯虚函数
前言
C++在C语言的基础上增加了类的概念,而类的最关键的特性就是三个:
- 继承
- 多态
- 重载
这篇文章想接着上两篇C++相关的文章,从内存分布,符号表等更底层的角度来看一下C++语言的实现逻辑,以便更好的理解C++程序设计的底层逻辑。
重载
重载在了解了符号表,特别是函数签名之后是特别好理解的,同样的函数名,但是返回值,行参类型不同的情况下,在符号表中的函数签名是不一样的。
通过编译和链接后,加载在不同的内存区域,就实现了函数的重载逻辑。
继承
继承的概念就是,子类可以沿用父类的成员变量与函数。但是什么时候会用到,什么时候有不会用到,特别是在通过指针在不同类之间进行转换的时候,是特别容易出错的。
虚函数
类之间的继承是需要通过虚函数来实现的,也就是C++中的virtual关键字。
在说虚函数之前,先来看两段代码:
demo1:
class base{
public:
int x=1;
int addx(){return x+1;}
};
class childA: public base{
public:
int addx(){return x+2;}
};
int main()
{
base* ptr = new childA();
base* ptr_base = new base();
printf("hello world: %d\n", ptr->addx());
printf("hello world: %d\n", ptr_base->addx());
return 0;
}
demo2:
class base{
public:
int x=1;
virtual int addx(){return x+1;}
};
class childA: public base{
public:
virtual int addx(){return x+2;}
};
int main()
{
base* ptr = new childA();
base* ptr_base = new base();
printf("hello world: %d\n", ptr->addx());
printf("hello world: %d\n", ptr_base->addx());
return 0;
}
写这段代码的逻辑是需要使用一个基类指针,可以在运行过程中,根据需要指向不同的子类,来执行不同的业务逻辑。
从上面两端代码中,唯一的区别是成员函数前有没有virtual关键字。
这里注意,使用到继承和多态的时候,最好不要用gcc编译器,会出现链接失败的情况,最好使用g++编译器进行编译链接。
编译链接后执行:
demo1的输出结果:
hello world: 2
hello world: 2
demo2的输出结果是:
hello world: 3
hello world: 2
很明显,demo2才是我们想要的结果。那为什么demo1没有达到我们想要的效果呢,我们可以看一下符号表来找一下这里面的逻辑。
使用nm来观察其符号表:
demo1的符号表:
0000000100003f10 T __ZN4base4addxEv
0000000100003ef0 t __ZN4baseC1Ev
0000000100003f50 t __ZN4baseC2Ev
0000000100003ed0 t __ZN6childAC1Ev
0000000100003f30 t __ZN6childAC2Ev
U __Znwm
0000000100008020 d __dyld_private
0000000100000000 T __mh_execute_header
0000000100003e10 T _main
U _memset
U _printf
U dyld_stub_binder
demo2的符号表:
0000000100003f40 t __ZN4base4addxEv
0000000100003e90 t __ZN4baseC1Ev
0000000100003ef0 t __ZN4baseC2Ev
0000000100003f20 t __ZN6childA4addxEv
0000000100003e70 t __ZN6childAC1Ev
0000000100003eb0 t __ZN6childAC2Ev
0000000100004030 S __ZTI4base
0000000100004040 S __ZTI6childA
0000000100003fa5 S __ZTS4base
0000000100003f9d S __ZTS6childA
0000000100004058 s __ZTV4base
0000000100004018 s __ZTV6childA
U __ZTVN10__cxxabiv117__class_type_infoE
U __ZTVN10__cxxabiv120__si_class_type_infoE
U __Znwm
0000000100008018 d __dyld_private
0000000100000000 T __mh_execute_header
0000000100003db0 T _main
U _memset
U _printf
U dyld_stub_binder
从demo1的符号表中可以看到,在符号表中只有base类的add函数签名,也就是编译器分析代码后发现只用到了base类的add函数。实际上,如果不带virtual关键字的话,编译器会认为这是完全没关系的两个函数,只是说childA和base之间有个继承关系而已。
base的add是base的成员函数,childA的add是childA的成员函数,着两个函数之间没有任何的关系。在代码base* ptr = new childA()执行的时候,编译器做完类型转换后会判断就是要执行base类的成员函数add,所以符号表中就只有base类的add函数签名,最终输出也就是上面的结果了。
而如果是加了virtual关键字的话,就需要把childA和父类的base相关符号全部链接到符号表中。
虚函数表
virtual关键字实际上是告诉编译器,我们需要在父类和子类的两个成员函数见建立继承的关系。这种关系实际上是比较复杂的,这个复杂的关系,编译器是通过维护一个叫做“虚函数表”的指针来实现的。
- 每个类对象都维护了一个虚函数指针,实际上就是一个链表结构,这个链表按顺序保存了每个对象中虚函数的地址。
- 虚函数指针位于符号表的最前面
- 虚函数按照其声明顺序放于表中
- 父类的虚函数在子类的虚函数前面
我们先看上面代码中这种比较简单的情况,也就是子类简单的覆盖了父类的虚函数。
比如我们有两个对象:
base b;
childA c;
两个对象都会有一个虚表指针vptr,都在对象内存的最前面。vptr就是一个链表头,后面接着的就是一个一个的虚函数地址:
base的内存分布:
child的内存分布:
我们可以通过一段代码来验证这个过程:
class base{
public:
int x=1;
virtual int addx(){printf("first func\n");return x+1;}
};
int main()
{
base b;
Fun pFun = NULL;
// &b为类对象的首地址,把这个地址转换成int64位地址,记得在64位机器上一定是int64,因为大部分int只是一个32位地址,那样地址的取值就会不对了。
// 上面提到,虚表指针就是放在类对象的最前面,所以这个地址也是虚表指针的地址。
printf("虚函数表地址:%x\n", (int64_t*)(&b));
// 上面的虚表指针地址就是vptr的地址,对这个地址进行取值计算,也就是*运算符,得到的就是vptr指向的链表的第一个元素的地址。
printf("a:%10x\n", *(int64_t*)(&b));
// 和上面的地址是一样的,只是做了一个int64的转换
printf("b:%10x\n", (int64_t*)*(int64_t*)(&b));
// 对链表的第一个地址再进行取值运算,就是这个函数的地址了
printf("c:%x\n", *(int64_t*)*(int64_t*)(&b));
// 用一个函数指针指向这个地址,直接执行,就相当于执行了这个函数指针
pFun = (Fun)*(int64_t*)*(int64_t*)(&b);
pFun();
return 0;
}
输出结果就是:
虚函数表地址:d7bd868
a: 4b23068
b: 4b23068
c:4b22ee0
first func
动态绑定的实现
根据上面的代码,我们在程序的运行过程中,可以用父类的对象指针指向不同的子类的对象地址,从而实现不同业务逻辑的执行,这个过程称之为动态绑定。
参考代码:
class base{
public:
int x=1;
virtual int addx(){printf("base func\n");return x+1;}
};
class childA: public base{
public:
virtual int addx(){printf("child func\n");return x+2;}
};
typedef int(*Fun)();
int main()
{
base* ptr_base = new base();
Fun pFun = NULL;
printf("虚函数表地址:%x\n", (int64_t*)(ptr_base));
// 再次取址就可以得到第一个虚函数的地址了
printf("a:%10x\n", *(int64_t*)(ptr_base));
printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_base));
printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_base));
pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_base);
pFun();
childA c;
ptr_base = &c;
printf("虚函数表地址:%x\n", (int64_t*)(ptr_base));
// 再次取址就可以得到第一个虚函数的地址了
printf("a:%10x\n", *(int64_t*)(ptr_base));
printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_base));
printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_base));
pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_base);
pFun();
return 0;
}
输出结果是:
虚函数表地址:7ac05990
a: 4947028
b: 4947028
c:4946e60
base func
虚函数表地址:d6b2868
a: 4947050
b: 4947050
c:4946ee0
child func
实际上,我们应该理解,c++的指针都是一个64位的地址,指针类型只是标记它指向的地址类型,或者说移动的大小之类。在上面这个例子中,从父类对象指向了一个子类的对象。相应的vptr,虚函数地址都发生了变化。
析构函数
在有虚函数和继承关系的情况下,一般建议父类和每个子类都需要在析构函数上标记为virtual。
原因在于:如果没有标记为虚函数,在使用动态绑定来实现不同的逻辑的时候,父类对象在被释放的时候,调用的都是父类的析构函数,造成的后果就是该释放的没释放(子类中的内存),不该释放的释放了,就会造成内存泄漏和访问越界。
构造函数
构造函数不能是虚函数,因为构造函数就是为内存中的各种值赋值的,此时还没有初始化vptr这个虚指针。每个子类自己定义一下就可以了。
多态
父类和子类之间有继承关系之后,子类和父类可以表现出不同的逻辑,实际上上面已经提到了一种情况的多态,也就是子类直接覆盖了父类的虚函数,再这一节我们一起看看其他几种情况。
子类直接继承父类的方法,不覆盖
把上面的例子中子类的方法去掉:
class base{
public:
int x=1;
virtual int addx(){printf("base func\n");return x+1;}
};
class childA: public base{
public:
// virtual int addx(){printf("child func\n");return x+2;}
};
还是使用上面的输出,我们会发现,链表第一个指针的内容是一样的,也就是说子类的虚表指针直接指向了父类的函数地址,调用的也就是调用的父类的函数了。
虚函数表地址:51405990
a: 42fe028
b: 42fe028
c:42fdeb0
base func
虚函数表地址:ca83868
a: 42fe050
b: 42fe050
c:42fdeb0
base func
多重继承
在C++中,子类是可以继承多个类的。那么子类继承自两个类以上的情况,实际上就是上面几种情况的扩展,有覆盖的就是指向自己的函数地址,无覆盖的就指向相应的父类的地址。
和单一继承的情况不一样的是, 多继承的子类中有多个虚表指针,每个指针指向不同的继承线上的虚表。
如代码:
class base{
public:
int x=1;
virtual int addx(){printf("base func\n");return x+1;}
};
class baseB
{
public:
int y=100;
virtual int minus(){printf("base minus func\n"); return y-1;}
};
class childA: public baseB, public base{
public:
// virtual int addx(){printf("child func\n");return x+2;}
virtual int minus(){printf("child minus func\n");return y-2;}
};
typedef int(*Fun)();
int main()
{
base* ptr_base = new base();
Fun pFun = NULL;
printf("虚函数表地址:%x\n", (int64_t*)(ptr_base));
// 再次取址就可以得到第一个虚函数的地址了
printf("a:%10x\n", *(int64_t*)(ptr_base));
printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_base));
printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_base));
pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_base);
pFun();
childA c;
baseB* ptr_baseB = &c;
printf("虚函数表地址:%x\n", (int64_t*)(ptr_baseB));
// 再次取址就可以得到第一个虚函数的地址了
printf("a:%10x\n", *(int64_t*)(ptr_baseB));
printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_baseB));
printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_baseB));
pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_baseB);
pFun();
return 0;
}
继承了两个类,内存中虚指针和虚表的情况就是:
在使用代码baseB* ptr_baseB = &c;时, 编译器就会使用baseB的虚表指针。
最终的输出结果就是:
虚函数表地址:8ac05990
a: 4ae5030
b: 4ae5030
c:4ae4dc0
base func
虚函数表地址:d856858
a: 4ae5058
b: 4ae5058
c:4ae4e90
child minus func
纯虚函数
纯虚函数就类似于java里面的接口类了,定义了纯虚函数的类是不能被实例化的,只能通过被子类继承之后实例化。
在虚表里面的话,就是父类的虚表里面没有这样一项而已。