缘起
前一段日子,同事遇到了一个奇怪的现象 —— B
模块调用了 A
模块某个类的成员函数,没有依赖 A
模块,编译时没有报错。而 C
模块也调用了 A
模块中同一个类的成员函数,没有依赖 A
模块,编译时却报了链接错误。
简单语音沟通后觉得不太可能。用了 A
模块的函数,却不依赖 A
模块,有点儿不讲道理!
因为当时我没在电脑前,跟同事简单沟通了几个可以设置库依赖的位置,结果都没有发现对应的依赖项。
心里越发觉得不可思议,难道 B
模块是通过其它方式依赖 A
模块的?正常情况下,如果 B
模块依赖 A
模块,一定可以在 B
模块的导入表中看到 A
模块相关的记录。于是建议同事查看 B
模块的导入表,但是同事不太熟悉。因为项目比较急,遂建议同事在 C
模块中添加对 A
模块的依赖,先解决项目问题,后面有机会再调查具体原因。
直到最近才有时间调查这个问题,结果发现这个问题非常有意思 —— B
模块确实没有依赖 A
模块(B
模块的导入表中确实没发现 A
模块的相关项),但是 B
模块确实调用了 A
模块中的函数,而且不是通过 LoadLibrary() + GetProcAddress()
的方式调用的。
本文主要关注以下问题,如果你已经有了答案,可以跳过本文。
B
模块在什么情况下可以调用A
模块中的函数,但是却不依赖A
模块?
约定:
本文不考虑通过
GetProcAddress()
获取函数指针后再调用的情况虚函数表在本文中简称虚表,指向虚表的指针简称虚表指针
为了更好的研究这个问题,我特意写了示例程序
示例程序
示例程序由三个工程组成:接口模块、获取接口模块和主模块。
接口模块
对应的工程是
Interface.vcxproj
,代码很简单,声明并实现了一些接口1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26// Interface.h
class InterfaceBase
{
public:
virtual void Test1(int) = 0;
virtual ~InterfaceBase() {}
};
class Interface1 : public InterfaceBase
{
public:
DLL_EXPORT virtual void Test1(int);
DLL_EXPORT virtual void Test2(int);
DLL_EXPORT virtual void Test3(const std::string&);
DLL_EXPORT void Test4(int);
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42// Interface.cpp
void Interface1::Test1(int a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}
void Interface1::Test2(int a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}
void Interface1::Test3(const std::string& a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}
void Interface1::Test4(int a)
{
std::cout << __FUNCTION__ << " : " << a << std::endl;
}
- 获取接口模块
对应的工程是 `GetInterface.vcxproj`,依赖**接口模块**。代码非常简单,只暴露了一个接口,用来获取接口对象指针
```c++
// GetInterface.h
DLL_EXPORT_GET InterfaceBase* GetInterface();
DLL_EXPORT_GET void FreeInterface(InterfaceBase*);
1 | // GetInterface.cpp |
主模块
对应的工程是
InterfaceExe.vcxproj
,只依赖获取接口模块,不依赖接口模块1
2
3
4
5
6
7
8
9
10
11
12
13
14// InterfaceExe.cpp
int main()
{
InterfaceBase* base = GetInterface();
Interface1* if1 = dynamic_cast<Interface1*>(base);
if1->Test1(0);
if1->Test4(0);
FreeInterface(if1);
return 0;
}
示例工程已经上传到本篇博客对应的资料仓库里,感兴趣的小伙伴儿可以自行到这里下载验证。
在以上代码中:
if1->Test4()
会导致链接错误报错信息如下:
LNK2019 无法解析的外部符号 "__declspec(dllimport) public: void __cdecl Interface1::Test4(int)" (__imp_?Test4@Interface1@@QEAAXH@Z),该符号在函数 main 中被引用
if1->Test1()
不会导致链接错误。惊不惊喜?意不意外?注释掉
if1-> Test4();
即可顺利编译,所以可以确定if1->Test1()
不会导致链接错误
在解释之前,先回顾一下基础。
基础回顾
我之前写过一篇关于函数基础知识的总结—— 《基础知识 | 函数基础 1 —— 基本概念 & 如何调用外部模块的函数》
与当前问题相关的内容整理如下:
B
模块要想调用A
模块中的函数,最重要的原则是:B
模块要能找到A
模块函数的地址,一般情况下需要做两件事:首先,
A
模块中的函数需要声明为导出的。导出的函数会记录在A
模块的导出表中其次,通过头文件 + 库文件,让
B
模块依赖A
模块。编译器会在B
模块的导入表中添加对应项
每个包含虚函数的类对象都有一个虚表指针,该指针指向了虚表,会在类对象的构造函数中初始化
虚表中按顺序存放了虚函数地址
虚函数调用是通过虚表实现的。大体调用过程如下:
通过类对象找到虚表指针,进而找到虚表
根据头文件中虚函数的顺序得到索引
根据索引从虚表中取出函数地址进行调用
简单解释
下面我们尝试从虚函数的调用机制来理解编译器的行为:
if1->Test4()
会导致链接错误
因为Test4()
是普通成员函数,调用的时候,需要找到其地址。接口模块虽然导出了Test4()
,但是主模块并没有依赖接口模块。所以报链接错误很正常!
if1->Test1()
不会导致链接错误已经得到了对象指针(
if1
),说明对象已经被构造好了,虚表指针已经指向了正确的虚表。因为Test1()
是虚函数,调用Test1()
是通过虚表进行的,直接到虚表对应的位置获取函数地址即可。 所以不会产生链接错误。
总结
如果
B
模块已经拿到了A
模块中的类对象指针,通过该指针调用的类成员函数,如果调用的成员函数是普通函数,则
B
模块需要依赖A
模块如果调用的成员函数是虚函数,则
B
模块不需要依赖A
模块
实际项目中遇到的正是这种情况:B
模块调用的是 A
模块中的虚函数,所以不需要依赖 A
模块;而 C
模块调用的是 A
模块中的普通成员函数,需要依赖 A
模块。至此,项目中的疑问算是彻底解开了。
这就完了? 还有很多问题需要继续深挖……
More
问题 1:
Interface1
类中没有声明构造函数,编译器生成的构造函数保存在哪里?GetInterface
模块还是Interface
模块?问题 2:
Interface1
的虚表保存在哪里?GetInterface
模块还是Interface
模块?问题 3:如果去掉
Interface1
中虚函数的导出符号,上述代码能编译通过吗?问题 4:如果在
Interface1
中声明了未导出的构造函数,上述代码能编译通过吗?问题 5:如果
InterfaceBase::Test1()
不是纯虚函数,上述代码能编译通过吗?问题 6:如果
InterfaceBase
的析构函数不是虚函数,上述代码能编译通过吗?
争取在下一篇文章中把上面的坑都填上,stay tuned~