缘起
在上篇文章 《基础知识 | 函数基础 2 —— 如何不依赖外部模块却能调用它的函数?》中,我们明白了一个事实 —— 可以在不依赖外部模块的情况下通过类对象指针调用其虚函数。
但是遗留了几个问题,如下:
问题 1:
Interface1
类中没有声明构造函数,编译器生成的构造函数保存在哪里?GetInterface
模块还是Interface
模块?问题 2:
Interface1
的虚表保存在哪里?GetInterface
模块还是Interface
模块?
问题 3:如果去掉
Interface1
中虚函数的导出符号,上述代码能编译通过吗?问题 4:如果在
Interface1
中声明了未导出的构造函数,上述代码能编译通过吗?问题 5:如果
InterfaceBase::Test1()
不是纯虚函数,上述代码能编译通过吗?问题 6:如果
InterfaceBase
的析构函数不是虚函数,上述代码能编译通过吗?
本文力求把这几个问题弄清楚。如果您对以上问题已经有了答案,可以跳过本文。
我们先简单回顾一下上篇文章中的代码(可以到这里下载或查看):
Interface
模块定义了Interface1
类,并且其接口函数都是导出的。GetInterface
模块导出了一个接口,该接口内部会new Interface1()
并返回。InterfaceExe
模块通过GetInterface
模块的导出接口获得Interface1
的指针,然后调用其虚函数。
在解答以上几个问题前,我们先查看以下几种情况编译器生成的汇编代码。
说明:
- 本文查看的是
release
版本关闭优化后的汇编代码,比debug
版本更简洁明了- 下文中提到的 定义 指的是函数的声明和实现在一起
情况 1
注释掉上篇文章代码中对 if1->Test4(0);
的调用。编译并查看反汇编代码,重点关注构造函数及虚表的存储位置。
可以发现 Interface1
和 InterfaceBase
的构造函数及虚表都存储在 GetInterface
模块中。虚表中的函数也都属于 GetInterface
模块。
情况 2
在 情况 1
代码的基础上进行如下修改:
在头文件定义构造函数(注意:头文件中既包含声明又包含实现)
在头文件定义一个名为
Test5()
的虚函数
代码修改如下:
1 | class Interface1 : public InterfaceBase |
查看构造函数及虚表
编译运行,跟踪反汇编代码。可以发现,Interface1
和 InterfaceBase
的构造函数及虚表都存储在 GetInterface
模块中。虚表中的函数也都属于 GetInterface
模块。
查看虚函数汇编代码
挑几个典型的虚函数,查看其反汇编代码。
- 先来看看
InterfaceBase::scalar deleting destructor
的汇编代码。如下图:
- 再来看看
Interface1::scalar deleting destructor
的汇编代码。如下图:
再来看看
Interface1::Test1()
的汇编代码。如下图:可以发现,
GetInterface
模块中的Interface1::Test1()
会调用Interface
模块导出的Interface1::Test1()
。
- 再来看看
Interface1::Test5()
的汇编代码。如下图:
以上几个函数有一个共同特点:所有函数的实现代码都在 GetInterface
模块中。我是怎么知道的?根据输出结果判断的,输出结果中,!
前面的部分是模块名。比如,GetInterface!Interface1::Test5
,对应的模块是 GetInterface
。
小贴士: 还可以根据
lma address
来查找某个地址所属的模块
两种编译报错的情况
- 去掉
Test2()
的导出标识,再编译,会报链接错误,如下图:
- 如果把
Interface1
的构造函数的定义移动到Interface.cpp
中,头文件中只保留声明,也会报链接错误,如下图:
其实,这两个错误有共性:
都没有导出标识
实现和声明分离
实现在
Interface.cpp
中,最终会编译到Interface
模块,而不是GetInterface
模块都会被
GetInterface
模块用到- 在
new Interface()
时会调用构造函数 - 在构造函数调用时会初始化虚表指针,指向虚表。虚表需要记录
Test2()
的地址,而Test2()
在GetInterface
模块中没有实现,没办法确定其地址
- 在
说明:
情况 1
的构造函数的代码是编译器自动生成的,与情况 2
的构造函数代码是一样的。
情况 3
在 情况 2
代码的基础上进行如下修改:
- 为构造函数增加导出标识,并且把实现移动到
Interface.cpp
中 - 去掉其它函数的导出标识。只保留
Test3()
的导出标识
代码修改如下:
1 | // Interface.h |
1 | // Interface.cpp |
查看构造函数及虚表
编译运行,跟踪反汇编代码。可以发现,GetInterface!GetInterface()
调用的构造函数是从 Interface
模块导入的。
Interface1
和 InterfaceBase
的构造函数及虚表都存储在 Interface
模块中,虚表中的函数也都属于 Interface
模块。
注意:
虚表中的所有函数的地址都属于
Interface
模块,与情况 2
不一样。虽然
Test3()
是导出的,但是虚表中保存的Test3
的地址是Interface
模块的。注意
Test5()
。在情况 2
中,虚表中保存的Test5()
的地址是在GetInterface
模块中的,而且不会像其它函数一样调用Interface
模块中的函数。在情况 3
中,虚表中保存的Test5()
的地址在Interface
模块中。可以猜测,因为构造函数被导出了,没有必要在外部模块创建虚表了。
情况 4
在 情况 3
代码的基础上进行如下修改:
- 去掉所有函数的导出标识
- 增加一个名为
ExportInterface
的导出接口,返回Interface1
对象指针 - 修改
GetInterface.cpp
中的GetInterface
接口,直接调用ExportInterface()
代码修改如下:
1 | // Interface1.h |
1 | // Interface1.cpp |
1 | // GetInterface.cpp |
编译运行,跟踪反汇编代码。可以发现,与 情况 3
基本一致。Interface1
和 InterfaceBase
的构造函数及虚表都存储在 Interface
模块中,虚表中的函数也都属于 Interface
模块。
情况 5
在 情况 2
代码的基础上进行如下修改:
- 在
Interface1.h
中声明导出接口ExportInterface
- 在
Interface1.cpp
中实现ExportInterface
- 在
GetInterface.h
中增加导出接口GetInterface1
- 在
GetInterface.cpp
中实现GetInterface1
- 在
InterfaceExe.cpp
调用这两个接口得到接口指针
代码修改如下:
1 | // Interface1.h |
1 | // Interface1.cpp |
1 | // GetInterface.h |
1 | // GetInterface.cpp |
1 | int main() |
先来看看,GetInterface()
相关的反汇编代码,如下图:
从上图可以发现,与 情况 2
是一样,Interface1
和 InterfaceBase
的构造函数及虚表都存储在 GetInterface
模块中,虚表中的函数也都属于 GetInterface
模块。
再来看看 GetInterface1()
相关的反汇编代码,如下图:
从上图可以发现,Interface1
和 InterfaceBase
的构造函数及虚表都存储在 Interface
模块中,虚表中的函数也都属于 Interface
模块。
简单整理如下:
GetInterface!GetInterface()
内部会调用new Interface1
,进而导致Interface1
的构造函数在GetInterface
模块中被调用。对应的虚表及虚表中的函数地址都存储在GetInterface
模块中。GetInterface!GetInterface1()
内部会调用Interface!ExportInterface()
,而Interface!ExportInterface()
内部会调用new Interface1
, 最终Interface1
的构造函数在Interface
模块中被调用。对应的虚表及虚表中的函数地址都存储在Interface
模块中。
因此,可以得到这样的结论: 虚表及虚表中的函数地址会跟构造函数存储在同一个模块中。如果构造函数存在于多个模块中,虚表及虚表中的函数地址也会保存在多个模块中。
有了以上基础,就可以很顺利的回答之前的问题了,我们依次来看看每个问题。
答疑
问题 1:
Interface1
类中没有声明构造函数,编译器生成的构造函数保存在哪里?GetInterface
模块还是Interface
模块?答: 参考
情况 1
,Interface1
与InterfaceBase
的构造函数、虚表以及虚表中的函数都保存在GetInterface
模块中。小贴士: 可以推测,如果有另外一个类似
GetInterface
的模块,相应的内容在那个模块中也会生成一份。
问题 2:
Interface1
的虚表保存在哪里?GetInterface
模块还是Interface
模块?答: 参考
情况 1
,虚表与构造函数一样保存在GetInterface
模块中。
问题 3:如果去掉
Interface1
中虚函数的导出符号,上述代码能编译通过吗?答: 不能。参考
情况 2
中提到的两种编译报错的情况。在没有声明构造函数的情况下,编译器生成的构造函数代码和虚表都会保存在
GetInterface
模块中。在构造函数调用时会初始化虚表指针,指向虚表。虚表需要记录每个虚函数的地址,而这些虚函数在
GetInterface
模块中没有实现,没办法确定其地址,故报链接错误。
问题 4:如果在
Interface1
中声明了未导出的构造函数,上述代码能编译通过吗?答: 需要根据构造函数定义的位置判断
如果构造函数的定义也在头文件中,则可以正常编译。
因为编译后构造函数的代码保存在
GetInterface
模块中,不存在跨模块调用问题。如果构造函数的定义在源文件中而且没导出,会报链接错误。
因为在
GetInterface
模块中调用new Interface
的时候,会调用Interface
的构造函数,但是在GetInterface
模块中找不到其实现代码。
问题 5:如果
InterfaceBase::Test1()
不是纯虚函数,上述代码能编译通过吗?答: 与
问题 4
一样,需要根据InterfaceBase::Test1()
定义的位置判断如果
InterfaceBase::Test1()
的定义也在头文件中,则可以正常编译。因为编译后
InterfaceBase::Test1()
的代码保存在GetInterface
模块中,不存在跨模块调用问题。如果
InterfaceBase::Test1()
的定义在源文件中而且没导出,会报链接错误。InterfaceBase::Test1()
的实现代码保存在Interface
模块中。InterfaceBase
的虚表需要记录InterfaceBase::Test1()
的地址,而InterfaceBase::Test1()
在GetInterface
模块中没有实现,没办法确定其地址,会报链接错误。
问题 6:如果
InterfaceBase
的析构函数不是虚函数,上述代码能编译通过吗?答: 与
问题 4
、问题 5
一样,需要根据析构函数定义的位置判断如果析构函数的定义也在头文件中,则可以正常编译。
因为编译后析构函数的代码保存在
GetInterface
模块中,不存在跨模块调用问题。如果析构函数的定义在源文件中而且没导出,会报链接错误。
析构函数最终会保存在
Interface
模块中。当调用GetInterface
模块中的FreeInterface()
时,该函数内部会调用delete
,而delete
内部会调用析构函数,但是在GetInterface
模块中没有实现,会报链接错误。
友情提示:
- 如果基类的析构函数是虚函数,子类的析构函数即使不加
virtual
关键字也是虚的! - 如果一个类被设计为基类并且其析构函数是非虚的,这是一个
bad design
, 《effective c++》条款 7 中讲过,感兴趣的朋友可以参考
亲自动手
我已经将以上几种情况对应的工程源码上传到个人仓库中了,可以到这里下载。
总结
- 虚表及虚表中的函数地址会与构造函数存储在同一个模块中。如果构造函数存在于多个模块中,虚表及虚表中的函数地址也会保存在多个模块中
- 如果
classA
所有函数都定义在头文件中,那么B
模块不需要依赖A
模块,因为classA
的所有实现代码都在B
中了
在构造函数调用时会初始化虚表指针,指向虚表。必须能解析每个虚函数的地址,否则会报链接错误。
务必注意报错的时机 —— 在编译构造函数的时候报错,而不是编译虚函数调用代码的时候报错!
如果在
B
模块中已经拿到了A
模块中classA
的对象指针,可以在B
模块调用其虚函数,不需要关心虚函数是否导出。既然已经拿到了对象指针,说明构造函数已经成功执行了,虚表已经保存好了虚函数的地址。
如果想在
B
模块中获取A
模块中classA
的对象,有两种方法:A
模块导出一个返回classA
对象的指针的接口,B
模块通过A
模块的导出接口获取直接在
B
模块中实例化classA
对象。通过auto pA = new classA();
或者classA a;
如果想在
B
模块中直接实例化A
模块中classA
的对象,需要能在B
模块中访问classA
的构造函数,有两种方法:classA
的构造函数是导出的 并且B
模块依赖A
模块classA
的构造函数定义在头文件中(或者不声明构造函数,编译器会自动生成一个)
如果
A
模块中classA
的构造函数是未导出的,并且classA
中有虚函数,要想在B
模块中直接实例化classA
,需要满足:构造函数定义在头文件中
虚函数或者是导出的或者定义在头文件中
- 如果想在
B
模块中调用A
模块中classA
的成员函数(例如,delete pA
会调用classA
的析构函数),那么classA
的成员函数- 或者是虚的
- 或者是导出的
- 或者定义在头文件中
- 如果链接错误是由找不到析构函数导致的,删除导致析构函数调用的代码,就不会报错了
参考资料
《effective c++》