基础知识 | 函数基础 3 —— 跨模块调用未导出虚函数的各种姿势

缘起

在上篇文章 《基础知识 | 函数基础 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 的指针,然后调用其虚函数。

在解答以上几个问题前,我们先查看以下几种情况编译器生成的汇编代码。

说明:

  1. 本文查看的是 release 版本关闭优化后的汇编代码,比 debug 版本更简洁明了
  2. 下文中提到的 定义 指的是函数的声明和实现在一起

情况 1

注释掉上篇文章代码中对 if1->Test4(0); 的调用。编译并查看反汇编代码,重点关注构造函数及虚表的存储位置。

case1-getinterface-constructor-vftable

可以发现 Interface1InterfaceBase 的构造函数及虚表都存储在 GetInterface 模块中。虚表中的函数也都属于 GetInterface 模块。

情况 2

情况 1 代码的基础上进行如下修改:

  • 在头文件定义构造函数(注意:头文件中既包含声明又包含实现)

  • 在头文件定义一个名为 Test5() 的虚函数

代码修改如下:

1
2
3
4
5
6
7
8
9
10
class Interface1 : public InterfaceBase
{
public:
Interface1() {}
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);
virtual void Test5() {}
};

查看构造函数及虚表

编译运行,跟踪反汇编代码。可以发现,Interface1InterfaceBase 的构造函数及虚表都存储在 GetInterface 模块中。虚表中的函数也都属于 GetInterface 模块。

case2-getinterface-constructor-vftable

查看虚函数汇编代码

挑几个典型的虚函数,查看其反汇编代码。

  • 先来看看 InterfaceBase::scalar deleting destructor 的汇编代码。如下图:
    case2-interfacebase-destructor
  • 再来看看 Interface1::scalar deleting destructor 的汇编代码。如下图:
    case2-interface1-destructor
  • 再来看看 Interface1::Test1() 的汇编代码。如下图:
    case2-interface1-test1

    可以发现,GetInterface 模块中的 Interface1::Test1() 会调用 Interface 模块导出的 Interface1::Test1()

  • 再来看看 Interface1::Test5()的汇编代码。如下图:
    case2-interface1-test5

以上几个函数有一个共同特点:所有函数的实现代码都在 GetInterface 模块中。我是怎么知道的?根据输出结果判断的,输出结果中,! 前面的部分是模块名。比如,GetInterface!Interface1::Test5,对应的模块是 GetInterface

小贴士: 还可以根据 lma address 来查找某个地址所属的模块

两种编译报错的情况

  • 去掉 Test2() 的导出标识,再编译,会报链接错误,如下图:
    case2-test2-not-export-link-error
  • 如果把 Interface1 的构造函数的定义移动到 Interface.cpp 中,头文件中只保留声明,也会报链接错误,如下图:
    case2-constructor-implement-in-cpp-link-error

其实,这两个错误有共性:

  1. 都没有导出标识

  2. 实现和声明分离

    实现在 Interface.cpp 中,最终会编译到 Interface 模块,而不是 GetInterface 模块

  3. 都会被 GetInterface 模块用到

    • new Interface() 时会调用构造函数
    • 在构造函数调用时会初始化虚表指针,指向虚表。虚表需要记录 Test2() 的地址,而 Test2()GetInterface 模块中没有实现,没办法确定其地址

说明: 情况 1 的构造函数的代码是编译器自动生成的,与 情况 2 的构造函数代码是一样的。

情况 3

情况 2 代码的基础上进行如下修改:

  • 为构造函数增加导出标识,并且把实现移动到 Interface.cpp
  • 去掉其它函数的导出标识。只保留 Test3() 的导出标识

代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
// Interface.h
class Interface1 : public InterfaceBase
{
public:
DLL_EXPORT Interface1();
virtual void Test1(int);
virtual void Test2(int);
DLL_EXPORT virtual void Test3(const std::string&);
void Test4(int);
virtual void Test5() {};
};
1
2
3
4
// Interface.cpp
Interface1::Interface1()
{
}

查看构造函数及虚表

编译运行,跟踪反汇编代码。可以发现,GetInterface!GetInterface() 调用的构造函数是从 Interface 模块导入的。

Interface1InterfaceBase 的构造函数及虚表都存储在 Interface 模块中,虚表中的函数也都属于 Interface 模块。

case3-getinterface-constructor-vftable

注意:

  • 虚表中的所有函数的地址都属于 Interface 模块,与 情况 2 不一样。

  • 虽然 Test3() 是导出的,但是虚表中保存的 Test3 的地址是 Interface 模块的。

  • 注意 Test5()。在 情况 2 中,虚表中保存的 Test5() 的地址是在 GetInterface 模块中的,而且不会像其它函数一样调用 Interface 模块中的函数。在 情况 3 中,虚表中保存的 Test5() 的地址在 Interface 模块中。

可以猜测,因为构造函数被导出了,没有必要在外部模块创建虚表了。

情况 4

情况 3 代码的基础上进行如下修改:

  • 去掉所有函数的导出标识
  • 增加一个名为 ExportInterface 的导出接口,返回 Interface1 对象指针
  • 修改 GetInterface.cpp 中的 GetInterface 接口,直接调用 ExportInterface()

代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Interface1.h 
class Interface1 : public InterfaceBase
{
public:
Interface1();
virtual void Test1(int);
virtual void Test2(int);
virtual void Test3(const std::string&);
void Test4(int);
virtual void Test5() {}
};

DLL_EXPORT InterfaceBase* ExportInterface();
1
2
3
4
5
// Interface1.cpp
InterfaceBase* ExportInterface()
{
return new Interface1;
}
1
2
3
4
5
// GetInterface.cpp
InterfaceBase* GetInterface()
{
return ExportInterface();
}

编译运行,跟踪反汇编代码。可以发现,与 情况 3 基本一致。Interface1InterfaceBase 的构造函数及虚表都存储在 Interface 模块中,虚表中的函数也都属于 Interface 模块。

case4-getinterface-constructor-vftable

情况 5

情况 2 代码的基础上进行如下修改:

  • Interface1.h 中声明导出接口 ExportInterface
  • Interface1.cpp 中实现 ExportInterface
  • GetInterface.h 中增加导出接口 GetInterface1
  • GetInterface.cpp 中实现 GetInterface1
  • InterfaceExe.cpp 调用这两个接口得到接口指针

代码修改如下:

1
2
// Interface1.h 
DLL_EXPORT InterfaceBase* ExportInterface();
1
2
3
4
5
// Interface1.cpp
InterfaceBase* ExportInterface()
{
return new Interface1;
}
1
2
// GetInterface.h 
DLL_EXPORT_GET InterfaceBase* GetInterface1();
1
2
3
4
5
6
7
8
9
10
// GetInterface.cpp
InterfaceBase* GetInterface()
{
return new Interface1;
}

InterfaceBase* GetInterface1()
{
return ExportInterface();
}
1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
InterfaceBase* base = GetInterface();
InterfaceBase* base1 = GetInterface1();
Interface1* if1 = dynamic_cast<Interface1*>(base);
if1->Test1(0);

//if1->Test4(0);

FreeInterface(if1);
return 0;
}

先来看看,GetInterface() 相关的反汇编代码,如下图:

case5-getinterface-constructor-vftable

从上图可以发现,与 情况 2 是一样,Interface1InterfaceBase 的构造函数及虚表都存储在 GetInterface 模块中,虚表中的函数也都属于 GetInterface 模块。

再来看看 GetInterface1() 相关的反汇编代码,如下图:

case5-getinterface1-constructor-vftable

从上图可以发现,Interface1InterfaceBase 的构造函数及虚表都存储在 Interface 模块中,虚表中的函数也都属于 Interface 模块。

简单整理如下:

  • GetInterface!GetInterface() 内部会调用 new Interface1,进而导致 Interface1 的构造函数在 GetInterface 模块中被调用。对应的虚表及虚表中的函数地址都存储在 GetInterface 模块中。

  • GetInterface!GetInterface1() 内部会调用 Interface!ExportInterface(),而 Interface!ExportInterface() 内部会调用 new Interface1, 最终 Interface1 的构造函数在 Interface 模块中被调用。对应的虚表及虚表中的函数地址都存储在 Interface 模块中。

因此,可以得到这样的结论: 虚表及虚表中的函数地址会跟构造函数存储在同一个模块中。如果构造函数存在于多个模块中,虚表及虚表中的函数地址也会保存在多个模块中。

有了以上基础,就可以很顺利的回答之前的问题了,我们依次来看看每个问题。

答疑

  • 问题 1:Interface1 类中没有声明构造函数,编译器生成的构造函数保存在哪里?GetInterface 模块还是 Interface 模块?

    答: 参考 情况 1Interface1InterfaceBase 的构造函数、虚表以及虚表中的函数都保存在 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 模块中没有实现,会报链接错误。

    友情提示:

    1. 如果基类的析构函数是虚函数,子类的析构函数即使不加 virtual 关键字也是虚的!
    2. 如果一个类被设计为基类并且其析构函数是非虚的,这是一个 bad design, 《effective c++》条款 7 中讲过,感兴趣的朋友可以参考

亲自动手

我已经将以上几种情况对应的工程源码上传到个人仓库中了,可以到这里下载。

总结

  1. 虚表及虚表中的函数地址会与构造函数存储在同一个模块中。如果构造函数存在于多个模块中,虚表及虚表中的函数地址也会保存在多个模块中
  1. 如果 classA 所有函数都定义在头文件中,那么 B 模块不需要依赖 A 模块,因为 classA 的所有实现代码都在 B 中了
  1. 在构造函数调用时会初始化虚表指针,指向虚表。必须能解析每个虚函数的地址,否则会报链接错误。

    务必注意报错的时机 —— 在编译构造函数的时候报错,而不是编译虚函数调用代码的时候报错!

  1. 如果在 B 模块中已经拿到了 A 模块中 classA 的对象指针,可以在 B 模块调用其虚函数,不需要关心虚函数是否导出。

    既然已经拿到了对象指针,说明构造函数已经成功执行了,虚表已经保存好了虚函数的地址。

  1. 如果想在 B 模块中获取 A 模块中 classA 的对象,有两种方法:

    1. A 模块导出一个返回 classA 对象的指针的接口,B 模块通过 A 模块的导出接口获取

    2. 直接在 B 模块中实例化 classA 对象。通过 auto pA = new classA(); 或者 classA a;

  1. 如果想在 B 模块中直接实例化 A 模块中 classA 的对象,需要能在 B 模块中访问 classA 的构造函数,有两种方法:

    1. classA 的构造函数是导出的 并且 B 模块依赖 A 模块

    2. classA 的构造函数定义在头文件中(或者不声明构造函数,编译器会自动生成一个)

  1. 如果 A 模块中 classA 的构造函数是未导出的,并且 classA 中有虚函数,要想在 B 模块中直接实例化 classA,需要满足:

    1. 构造函数定义在头文件中

    2. 虚函数或者是导出的或者定义在头文件中

  1. 如果想在 B 模块中调用 A 模块中 classA 的成员函数(例如, delete pA 会调用 classA 的析构函数),那么 classA 的成员函数
    • 或者是虚的
    • 或者是导出的
    • 或者定义在头文件中
  1. 如果链接错误是由找不到析构函数导致的,删除导致析构函数调用的代码,就不会报错了

参考资料

《effective c++》

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%