基础知识 | 函数基础 2 —— 如何不依赖外部模块却能调用它的函数?

缘起

前一段日子,同事遇到了一个奇怪的现象 —— 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 模块?

约定:

  1. 本文不考虑通过 GetProcAddress() 获取函数指针后再调用的情况

  2. 虚函数表在本文中简称虚表,指向虚表的指针简称虚表指针

为了更好的研究这个问题,我特意写了示例程序

示例程序

示例程序由三个工程组成:接口模块、获取接口模块和主模块。

  • 接口模块

    对应的工程是 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
    #pragma once

    #include <string>

    #ifndef DLL_INTERFACE_EXPORT
    #define DLL_EXPORT __declspec(dllimport)
    #else
    #define DLL_EXPORT __declspec(dllexport)
    #endif

    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
    #include <iostream>

    #define DLL_INTERFACE_EXPORT

    #include "Interface.h"

    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
    #pragma once
    #include "../Interface/Interface.h"

    #ifndef DLL_GETINTERFACE_EXPORT
    #define DLL_EXPORT_GET __declspec(dllimport)
    #else
    #define DLL_EXPORT_GET __declspec(dllexport)
    #endif

    DLL_EXPORT_GET InterfaceBase* GetInterface();
    DLL_EXPORT_GET void FreeInterface(InterfaceBase*);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// GetInterface.cpp
#include <iostream>

#define DLL_GETINTERFACE_EXPORT

#include "GetInterface.h"

InterfaceBase* GetInterface()
{
return new Interface1();
}
void FreeInterface(InterfaceBase* if1)
{
delete if1;
}
  • 主模块

    对应的工程是 InterfaceExe.vcxproj,只依赖获取接口模块,不依赖接口模块

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // InterfaceExe.cpp
    #include "../GetInterface/GetInterface.h"

    int main()
    {
    InterfaceBase* base = GetInterface();
    Interface1* if1 = dynamic_cast<Interface1*>(base);
    if1->Test1(0);

    if1->Test4(0);

    FreeInterface(if1);
    return 0;
    }

示例工程已经上传到本篇博客对应的资料仓库里,感兴趣的小伙伴儿可以自行到这里下载验证。

在以上代码中:

  1. if1->Test4() 导致链接错误

    报错信息如下:

    LNK2019 无法解析的外部符号 "__declspec(dllimport) public: void __cdecl Interface1::Test4(int)" (__imp_?Test4@Interface1@@QEAAXH@Z),该符号在函数 main 中被引用

  1. if1->Test1() 不会导致链接错误。惊不惊喜?意不意外?

    注释掉 if1-> Test4(); 即可顺利编译,所以可以确定 if1->Test1() 不会导致链接错误

在解释之前,先回顾一下基础。

基础回顾

我之前写过一篇关于函数基础知识的总结—— 《基础知识 | 函数基础 1 —— 基本概念 & 如何调用外部模块的函数》

与当前问题相关的内容整理如下:

  • B 模块要想调用 A 模块中的函数,最重要的原则是:B 模块要能找到 A 模块函数的地址,一般情况下需要做两件事:

    首先A 模块中的函数需要声明为导出的。导出的函数会记录在 A 模块的导出表

    其次,通过头文件 + 库文件,让 B 模块依赖 A 模块。编译器会在 B 模块的导入表中添加对应项

  • 每个包含虚函数的类对象都有一个虚表指针,该指针指向了虚表,会在类对象的构造函数中初始化

  • 虚表中按顺序存放了虚函数地址

  • 虚函数调用是通过虚表实现的。大体调用过程如下:

    • 通过类对象找到虚表指针,进而找到虚表

    • 根据头文件中虚函数的顺序得到索引

    • 根据索引从虚表中取出函数地址进行调用

简单解释

下面我们尝试从虚函数的调用机制来理解编译器的行为:

  1. if1->Test4() 导致链接错误
    因为 Test4() 是普通成员函数,调用的时候,需要找到其地址。接口模块虽然导出了 Test4(),但是主模块并没有依赖接口模块。所以报链接错误很正常!
  1. 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~

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