第3章 库的开发与第三方库的使用      在开发软件的过程中,不可能所有的功能都由开发人员自己实现。一方面是因为项目的进度要求可能导致时间非常紧张,另一方面原因是新写的代码可能由于缺乏测试带来稳定性问题,这时可以选择复用项目团队内部已经开发的功能库,也可以选择第三方开发的稳定的类库。 第13天?开发一个DLL   今天要学习的案例对应的源代码目录:src/chapter03/ks03_01。本案例不依赖第三方类 库。程序运行效果如图3-1所示。 图3-1?第13天案例程序ks03_01运行效果   今天的目标是掌握如下内容。 * 开发一个DLL项目的方法。 * 在EXE中调用DLL的接口。 * 在项目开发中使用命名空间。   在软件项目开发过程中会不可避免地碰到代码复用问题。比如,在项目A中实现的功能也会在项目B中使用。这时就可以把重复的功能封装到DLL模块中。那么,用Qt怎样开发DLL呢?利用Qt开发DLL大概分为两大步:封装DLL和使用DLL。   下面介绍具体步骤。本文将DLL中供EXE调用的类或接口称作引出类、引出接口。   (1)将DLL中引出类(export)的头文件移动到公共include目录。   (2)在DLL的pro项目文件中定义宏。   (3)编写DLL引出宏的头文件。   (4)在DLL引出类的头文件中使用引出宏。   (5)在EXE项目中添加对DLL的引用。   (6)在EXE中调用DLL的接口。   (7)使用命名空间解决重名问题。   (8)使用命名空间的注意事项。   现在介绍如何把src.baseline中ks03_01项目的api_start_as_service()等相关接口封装到firstdll这个DLL项目中,firstdll位于src/base/firstdll目录。这个DLL引出的接口所在的原始头文件(src.baseline中的本节代码)api.h见代码清单3-1。 代码清单3-1 // src.baseline/src/chapter03/ks03_01/api.h #pragma once /** * @brief 让程序以服务方式运行 * @param[in] service_name 注册用的服务名,Windows用,Linux不用 * @param[in] service_showname 显示用的服务名,Windows用,Linux不用 * @return -1:创建守护程/服务失败,其他:成功 */ int api_start_as_service(const char* service_name, const char* service_ showname); /** * @brief 睡眠 * @param[in] nMSecond 睡眠时间,以毫秒为单位 * @return void */ void api_sleep(int nMSecond); // 忽略信号,否则会导致进程在收到操作系统的这些信号时退出 void api_ignore_signal();   现在介绍将这些接口封装到DLL中的详细开发步骤。在开始之前,在src目录中创建base/firstdll目录作为DLL的项目目录。将src.baseline中ks03_01项目的api_windows.cpp、api_linux.cpp、api.h、service_windows.h、service_windows.cpp这5个文件复制到该目录。   1.将引出类、引出接口的头文件移动到公共include目录   因为要把DLL作为公共模块,所以应该把DLL中的api_start_as_service()等接口所在的头文件api.h移动到公共的include目录,而不是继续放在DLL项目的源代码目录。为整个项目创建公共include目录,该目录与src目录并列。在该include目录下可以创建子目录,从而区分不同子模块的头文件。本案例中将api.h放到公共include目录的子目录base/firstdll中。为了统一头文件命名方式,将api.h改名为service_api.h,因此,需要将代码中包含api.h头文件的代码改为包含service_api.h。   2.在DLL的pro文件中定义宏   既然把头文件移动到其他目录了,那么就要把DLL的pro文件中的INCLUDEPATH配置成头文件所在的目录include/base/firstdll,否则,编译器在构建项目时就找不到这个头文件了。除此之外,还要注意在pro文件中的HEADERS配置项中把头文件的路径写全,并且把DLL的pro文件中的TEMPLATE选项设置为lib。 # src/base/firstdll/firstdll.pro TEMPLATE= lib ... INCLUDEPATH += $$PROJECT_HOME/include/base/firstdll HEADERS += $$PROJECT_HOME/src/project_base.pri \ firstdll.pro \ $$PROJECT_HOME/include/base/firstdll/base_api.h \ $$PROJECT_HOME/include/base/firstdll/service_api.h   在Linux/UNIX环境下开发DLL时,无须对引出类或者引出接口做特殊声明,但在Windows下情况有所不同。在Windows下编译引出类所在的头文件时,编译器需要明确知道自己正在构建EXE模块还是DLL模块。如果是构建EXE,编译器看到的头文件中应该对引出的类、接口用_ _declspec(dllimport)进行声明,如代码清单3-2所示。 代码清单3-2 class _ _declspec(dllimport) CPrint { ... }; _ _declspec(dllimport) void api_sleep(int nMSecond);   如果是构建DLL,编译器看到的头文件中的引出类应该用_ _declspec(dllexport)进行声明,如代码清单3-3所示。请注意是dllexport,而不是构建EXE时的dllimport。 代码清单3-3 class _ _declspec(dllexport) CPrint { ... }; _ _declspec(dllexport) void api_sleep(int nMSecond);   对比代码清单3-2和代码清单3-3后可以得知,编译器在构建EXE和构建DLL时看到的同一个头文件中的内容有些不同,这就需要编写两个头文件。这两个头文件内容基本一致,仅仅是对引出类或引出接口的定义稍有不同,即需要分别使用_ _declspec(dllimport)和 _ _declspec(dllexport)关键字。这是Windows下使用MSVC的C++编译器导致的结果。如果需要为所有引出类都提供内容基本一致的两套头文件,那么工作量就太大了,这样不但造成代码冗余,还容易引入其他问题。那该怎么解决这个问题呢?别急,现在就一步步解决它。在DLL的pro文件中定义一个宏_ _FIRSTDLL_SOURCE_ _,宏的拼写最好与项目名称有关,以防跟其他项目冲突。定义这个宏目的是为另一个宏定义做准备。在firstdll.pro中的WIN32编译分支中应该添加DEFINES -= UNICODE,该定义的作用在第10天的学习内容中已经 介绍。 // src/base/firstdll/firstdll.pro win32{ DEFINES -= UNICODE DEFINES *= _ _FIRSTDLL_SOURCE_ _ }   3.编写DLL引出宏的头文件   1)关于引出、引入类或者接口用的宏定义   既然在Windows下需要区分_ _declspec(dllimport)和_ _declspec(dllexport)这两个关键字,而且只能为EXE项目和DLL项目提供同一个头文件,那就可以把这两个关键字定义成宏,如代码清单3-4所示。编译器在构建EXE和构建该头文件所属的DLL时,再把这个宏分别解析成_ _declspec(dllimport)和_ _declspec(dllexport)。 代码清单3-4 // include/base/firstdll/firstdll_export.h #pragma once // 动态库导出宏定义 #ifdef WIN32 // windows platform # if defined _ _FIRSTDLL_SOURCE_ _ # define FIRSTDLL_API _ _declspec(dllexport) ① # else # define FIRSTDLL_API _ _declspec(dllimport) ② # endif #else // other platform # define FIRSTDLL_API ③ #endif // WIN32   在代码清单3-4中,根据操作系统的不同将BASE_API定义为不同的关键字。Windows下(WIN32分支)根据是否定义_ _FIRSTDLL_SOURCE_ _宏来进行不同的处理。因为已经在DLL的pro文件中定义_ _FIRSTDLL_SOURCE_ _,所以构建DLL时会执行标号①处的代码,即把BASE_API定义成_ _declspec(dllexport)。而在EXE项目的pro文件中并未定义_ _FIRSTDLL_SOURCE_ _,因此构建EXE时会执行标号②处的代码,即把FIRSTDLL_ API定义成_ _declspec(dllimport)。在UNIX/Linux等非Windows操作系统中构建项目时则执行标号③处的代码,也就是单纯定义FIRSTDLL_API宏,以便编译器在解析后面的代码时看到这个符号可以把它当成合法的符号。在Linux/UNIX中,这个符号没有其他含义,仅仅是个符号而已。   代码清单3-4所示的头文件firstdll_export.h在某些情况下可以删掉。比如,该DLL只提供了一个头文件用来定义引出类、引出接口,那么就不用创建firstdll_export.h文件,而是把该头文件的内容直接复制到引出类所在头文件service_api.h的开头部分即可。   2)关于接口参数的压栈顺序、栈的清理   (如果对参数压栈的知识不感兴趣,可以跳过本小节的内容)当完成DLL的开发之后,其他应用程序就可以调用该DLL中的接口了。在进行接口调用时需要用到栈(有时也称堆栈),栈是一种先入后出的数据结构,可以把栈理解成一个先入后出的队列,也就是后进入队列的数据先出来,先进入队列的后出来。栈有一个存储区和一个栈顶指针。栈顶指针指向栈中第一个可用数据项(称为栈顶)。用户可以在栈顶的上方继续向栈中添加数据,这个操作被称为压栈(Push)。压栈以后,栈顶自动变成新加入数据项的位置,栈顶指针也随之修改。用户也可以从栈中取走栈顶,这个操作被称为弹出栈(Pop),弹出栈后,原栈顶的下一个元素变成新的栈顶,栈顶指针随之修改。调用者依次把参数压栈然后调用接口,接口开始执行后在栈中取得参数数据并进行计算,接口计算结束以后,由调用者或者被调用的接口负责把栈恢复原状。因此,这就涉及关于栈的几个问题。 * 调用接口时,接口的参数列表的压栈顺序是怎样的?接口参数列表中的参数是按照从左到右的顺序压栈,还是从右到左的顺序压栈?比如对于接口int function(int a, float b)来说,是参数a先压栈,还是参数b先压栈? * 接口调用完成后,谁负责清理栈空间?即谁负责把栈恢复原状?   在高级语言中,可以通过接口调用约定来解决这些问题。只要调用者与被调用者使用相同的调用约定进行编译,双方就可以在行为上保持一致。常见的调用约定有_?_stdcall、 _ _cdcel和_ _fastcall。它们之间的区别见表3-1~表3-4。 表3-1?几种调用约定的常用场合 取 值 说 明 _ _stdcall Windows API默认的函数调用约定 _ _cdecl C/C++默认的函数调用约定 _ _fastcall 适用于对性能要求较高的场合 表3-2?接口参数的压栈顺序 取 值 说 明 _ _stdcall 接口参数入栈顺序:由右向左,如int func(int a, float b)的参数入栈顺序为b,a _ _cdecl 接口参数入栈顺序:由右向左 _ _fastcall 接口参数入栈顺序:从左开始不大于4字节的参数放入CPU的ECX和EDX寄存器,其余参数从右向左入栈。不同编译器规定的寄存器不同。因为部分参数通过寄存器来传送,所以性能高一些 表3-3?谁负责清理栈 取 值 说 明 _ _stdcall 由被调用者(即接口)负责清理栈空间,使之恢复原状 _ _cdecl 由调用者负责清理栈空间,使之恢复原状 _ _fastcall 由被调用者(即接口)负责清理栈空间,使之恢复原状   以_ _stdcall为例介绍一下用法。 int _ _stdcall function(int a, int b); // 接口声明 int _ _stdcall function(int a, int b){ // 接口实现 return a + b; }   注意:接口声明和定义(实现)处的调用约定必须要相同。   (1)错误的用法1。 int _ _stdcall function(int a, int b); // 接口声明 int function(int a, int b){ // 接口实现,默认为_ _cdecl return a + b; }   (2)错误的用法2。 int function(int a, int b); // 接口声明,默认为_ _cdecl int _ _stdcall function(int a, int b){ // 接口实现 return a + b; }   (3)错误的用法3。 int _ _stdcall function(int a, int b); // 接口声明 int _ _cdecl function(int a, int b){ // 接口实现 return a + b; } 表3-4?函数名称修饰规则(以函数functionname()为例) 取 值 编译后的被修饰的函数名 _ _stdcall ?functionname@@YG******@Z _ _cdecl ?functionname@@YA******@Z _ _fastcall ?functionname@@YI******@Z   注:表3-4中的“******”为函数返回值类型和参数类型表。   为了方便,可以定义宏FIRSTDLL_API_CALL,见代码清单3-5。在标号①处,定义宏FIRSTDLL_API_CALL,它的值为WINAPI,在Windows中,WINAPI被定义为_ _stdcall。在标号②处,其他平台只定义FIRSTDLL_API_CALL,并未给它赋值。这样做是因为在本案例介绍的方法中,调用者和被调用者使用同一个头文件进行编译,不存在调用约定不一致的情况。如果未采用本案例介绍的方法,而是为调用者和被调用者各自提供头文件进行编译,当两份头文件中对接口使用了不同的调用约定时,就可能导致问题。因此,本案例定义FIRSTDLL_API_CALL仅仅是为了介绍调用约定的知识,其实可以不用定义FIRSTDLL_ API_CALL,也就是不使用调用约定对接口进行修饰。 代码清单3-5 // include/base/firstdll/firstdll_export.h #pragma once // 动态库导出宏定义 #ifdef WIN32 // windows platform # if defined _ _FIRSTDLL_SOURCE_ _ # define FIRSTDLL_API _ _declspec(dllexport) # else # define FIRSTDLL_API _ _declspec(dllimport) # endif # include # define FIRSTDLL_API_CALL WINAPI ① #else // other platform # define FIRSTDLL_API # define FIRSTDLL_API_CALL ② #endif // WIN32   下面介绍一下FIRSTDLL_API_CALL的使用方法。接口NetworkToHost()的声明如下。 // include/base/firstdll/base_api.h FIRSTDLL_API // 代码清单3-5中定义的宏 type_uint16 // 接口返回的数据类型 FIRSTDLL_API_CALL // 调用约定写在接口返回值类型与接口名称之间 NetworkToHost(type_uint16 Data); // 接口名称、参数列表   接口NetworkToHost()的实现如下。 // src/base/firstdll/api.cpp FIRSTDLL_API // 代码清单3-5中定义的宏 type_uint16 // 接口返回的数据类型 FIRSTDLL_API_CALL // 调用约定写在接口返回值类型与接口名称之间 NetworkToHost(type_uint16 Data){ // 接口名称、参数列表 ... }   4.在DLL引出类的头文件中使用引出宏   现在只需要在引出类、引出接口的前面编写FIRSTDLL_API就可以把类或接口引出了,见代码清单3-6。在标号②处,在引出类所在的头文件中包含firstdll_export.h。然后在引出类或引出接口定义代码中增加FIRSTDLL_API字样,见标号③、标号④、标号⑤处。请注意FIRSTDLL_API宏用来定义引出类与引出接口时语法上的不同,在标号③处是在class关键字和类名之间编写FIRSTDLL_API,而标号④、标号⑤处将FIRSTDLL_API写在整个引出接口定义之前。 代码清单3-6 // include/base/firstdll/server_api.h /*! * Copyright (C) 2020 女儿叫老白 ... * please import firstdll.dll ① */ #pragma once #include "firstdll_export.h" ② /** * @brief 用来演示DLL开发的类 */ class FIRSTDLL_API CPrint { ③ public: CPrint(){} ~CPrint(){} public: void sayHello(){} }; FIRSTDLL_API int api_start_as_service(const char* service_name,const char* service_showname); ④ FIRSTDLL_API void api_sleep(int nMSecond); ⑤ ...   还有很重要的一点,标号①处的注释用来说明:在使用该头文件时需要引入哪个库文件。本案例中,如果需要用到service_api.h这个头文件,就要引入firstdll这个动态链接库。也就是说,在使用该头文件的项目的pro文件中需要引入firstdll库。这样做的目的给使用该头文件的研发人员提供方便。 # Debug版本 LIBS += -lfirstdll_d # Release版本 LIBS += -lfirstdll   如果在Windows系统中编译DLL时报错“fatal error C1083:无法打开包括文件:type_traits”,可以在系统环境变量PATH中添加下面内容:%SystemRoot%\system32。   5.在EXE项目中添加对DLL的引用   完成DLL的编写后,需要在EXE中或者其他DLL中引入这个DLL。这需要修改调用者的pro文件,在其LIBS配置项中添加对DLL的引用,如代码清单3-7所示。 代码清单3-7 # src/chapter03/ks03_01/ks03_01.pro ... debug_and_release { CONFIG(debug, debug|release) { LIBS += -lfirstdll_d ① TARGET = ks03_01_d } CONFIG(release, debug|release) { LIBS += -lfirstdll ② TARGET = ks03_01 } } else { debug { LIBS += -lfirstdll_d ③ TARGET = ks03_01_d } release { LIBS+= -lfirstdll ④ TARGET = ks03_01 } }   在代码清单3-6中的标号①、标号③处,对构建Debug版的项目进行配置,在标号②、标号④处,对Release版进行配置。这样就能保证编译器在构建项目时去链接对应版本的lib文件。   6.在EXE中调用DLL的接口   现在进入最后一个环节,在EXE或者其他DLL中调用本案例DLL的接口。其实这跟调用同一个项目中的接口没什么区别。在ks03_01项目中调用firstdll库中的接口一共分两步:第一步,编写include语句包含被调用者所在的头文件;第二步,使用引出类定义对象或调用引出接口。   (1)编写include语句包含引出类所在的头文件。这里使用了相对路径的描述,指的是相对于pro中INCLUDEPATH配置项中的目录。在ks03_01.pro中并未单独配置INCLUDEPATH,INCLUDEPATH的值其实来自ks03_01.pro引用的project_base.pri文件。 // src/chapter03/ks03_01/main.cpp #include "base/firstdll/service_api.h" // 相当于: $$INCLUDEPATH/base/firstdll/service_api.h   (2)使用引出类定义对象或调用引出接口,见代码清单3-8中标号①、标号②、标号 ③处。 代码清单3-8 // src/chapter03/ks03_01/main.cpp int main(int argc, char * argv[]){ ... // 解析命令参数, 所有命令参数均以"-"开头 for (int i = 1; i < argc; i++) { if (_stricmp(argv[i], "-term") == 0){ bTerminal = true; } ... #ifdef WIN32 else if (stricmp(argv[i], "-regist") == 0){ std::cout << ">>>模块注册。" << std::endl; regist("ks03_01", "ks03_01", "老鸟日记"); ① return 0; } else if (stricmp(argv[i], "-unregist") == 0){ std::cout << ">>>模块注销。" << std::endl; unregist("ks03_01"); ② return 0; } #endif } ... if (bTerminal){ // 进程以终端方式运行 CommandProc(); // 交互命令处理 } else { // 进程以服务方式运行 api_start_as_service("ks03_01", "ks03_01"); ③ } ... }      注意:本节介绍的方案用于开发静态链接的DLL。使用这种方案开发DLL时,在构建EXE时依赖DLL的lib文件(如a.lib);当EXE构建成功后,在EXE运行时仅仅依赖DLL本身(比如,在Windows上为a.dll,在Linux上可能为a.so.1.0.0),不再依赖DLL的lib 文件。   有时候,在编译EXE时可能碰到链接错误。比如,在编译a.exe时,该模块依赖b.dll,并且在a.exe中调用了b.dll中的接口func(),那么就有可能碰到如下的链接错误。 Error LNK2019: 无法解析的外部符号"_ _declspec(dllimport) void xxx.obj _ _cdecl func()"(_ _imp_?func@@YAXADN11@Z),该符号在函数xxx中被引用   以Windows平台为例,在a.exe项目进行编译时,因为a.exe需要依赖b.dll中的接口 func(),所以需要链接b.dll对应的链接库文件b.lib。上述错误指的是,当执行到链接这一步骤时出现链接错误,编译器找不到b.lib文件,或者在b.lib文件中找不到func()这个接口。可能的原因如下。 * 未生成或编译器未找到b.lib。此时,应检查b.dll项目的pro文件,检查TEMPLATE配置项是否为lib。如果已经生成了b.lib,那么就要检查a.exe项目的pro中是否已经把b.lib所在目录添加到a.exe项目pro的QMAKE_LIBDIR配置项中。 * 在b.dll项目中,未引出接口func()。这时,需要将func()从b.dll项目引出。 * b.dll编译位数与a.exe不一致。比如,a.exe编译成64位,而b.dll被编译成32位,这样肯定无法链接成功。此时需要将两个项目按照相同的位数进行编译。   7.使用命名空间解决重名问题   下面要学习的案例对应的源代码目录:src/chapter03/ks03_02。本案例不依赖第三方类库。程序运行效果如图3-2所示。 图3-2?案例程序ks03_02运行效果   无论是进行项目研发还是产品研发,都不可避免会碰到重名问题:头文件名重名、模块名重名、类/结构体重名、接口重名、全局变量重名等。对于头文件名重名和模块名重名的情况,软件开发组织需要制定软件研发管理规范进行制度上的约束,而且还要建立专门的组织进行落地管理。解决类重名、接口重名、全局变量重名问题的方法也很简单:使用命名空间进行管理。下面将介绍如何在Qt开发中使用命名空间解决重名问题。   前面介绍了怎样开发一个DLL,下面在之前的基础上增加命名空间的使用。一般情况下只为DLL代码设置命名空间,不为EXE代码设置命名空间(当需要把EXE与DLL设置为同一个命名空间时除外)。对于某一个DLL项目,一般也只设置一个命名空间。那么,具体该怎样使用命名空间呢?本节的DLL仍然以src/base/basedll项目为例。使用命名空间进行管理一共分为以下两大步。   (1)在DLL中将代码写到命名空间中。   (2)在其他代码中使用命名空间中的类或接口。   下面进行详细介绍。   1)在DLL中将代码写到命名空间中   在DLL中使用命名空间的语法如下: namespace 命名空间名称 { ... }   将命名空间内的代码写在{}内。请注意命名空间不是类定义,因此在{}结束后不写“;”。本案例使用ns_train作为DLL的命名空间。将DLL的h文件和cpp文件的对外引出类和接口写到命名空间ns_train中,见代码清单3-9中标号①处。在命名空间结束时不写“;”,见标号②处。建议软件开发组织建立专门的命名空间管理机构并发布《命名空间管理规范》,以便对新增命名空间进行审批、登记。软件开发组织应该只允许使用批准后的命名空间。 代码清单3-9 // include/base/basedll/base_api.h #pragma once #include "base_export.h" namespace ns_train { ① ... class BASE_API CClassInNameSpace { public: CClassInNameSpace() {} ~CClassInNameSpace() {} public: void sayHello() { } }; BASE_API std::string getPath(const std::string& strPath); ... } ②      注意:命名空间的保护范围应该仅仅是需要引出的类或接口,因此需要把#include "xxx.h"语句排除在外。如果是类的前向声明,那么要区分对待: * 如果不是该DLL中定义的类,需要把它排除在命名空间之外。 * 如果是该DLL中定义的类,需要把它包含到命名空间之内。   在DLL的cpp文件中用同样的方式把代码写到命名空间里,如代码清单3-10所示。 代码清单3-10 // src/base/basedll/basedll.cpp #include "base_api.h" ... namespace ns_train { std::string getPath(const std::string& strInputPath){ ... } ... }   2)在代码中使用命名空间中的类或接口   在EXE或其他DLL中使用basedll中定义的类或接口时,需要使用命名空间,见代码清单3-11。在标号①、标号②处采用了命名空间的语法,即“命名空间名称::类名”“命名空间名称::接口名”的写法。 代码清单3-11 // src/chapter03/ks03_02/main.cpp ... #include "base/basedll/base_api.h" bool initialize() { // do initialize work, { ns_train::CClassInNameSpace obj; ① obj.sayHello(); std::string str = ns_train::getPath("$PROJECT_DEV_HOME/test/ chapter03/ks03_02/test.txt"); ② std::cout << "通过ns_train::getPath()解析指定字符串后得到:" << std::endl; std::cout << str << std::endl; // 如果失败,则返回false } return true; }   8.使用命名空间的注意事项   目前为止,已在DLL中定义了命名空间并在EXE中使用了DLL中的引出类、引出接口。下面介绍几点注意事项。   1)不在头文件中使用using namespace xxx这种代码   在头文件中使用using namespace xxx的代码可能导致命名空间污染。使用命名空间的示意代码见代码清单3-12。 代码清单3-12 // 推荐的写法 ns_train::CClassInNameSpace printObject; ① // 不推荐的写法 using ns_train::CClassInNameSpace; ② CClassInNameSpace printObject; ③   推荐使用标号①处的写法,不推荐标号②处的写法。采用标号②处的写法时,虽然标号③处用CClassInNameSpace定义对象printObject时可以不写“ns_train::”了,但是,如果在同一个文件中包含的其他头文件(属于别的DLL)中存在另一个叫CClassInNameSpace(类名相同)的类时,就会有问题了。所以,建议采用“ns_train::CClassInNameSpace printObject”来定义变量的写法。   2)当需要为EXE项目设置命名空间时,不要把main()函数放到命名空间里   有时候EXE和DLL同属一个大项目,为了方便调用DLL中的类,就会把EXE项目的代码也设置到跟DLL相同的命名空间中。这种情况下应该把main()函数排除在外,否则编译器会认为main()函数属于命名空间,而不会把它当作正常的main()函数入口。因为正常的main()函数入口应该是全局的,所以会导致编译错误。代码清单3-13是无法编译通过的,需要把main()函数从命名空间的范围中排除才行。 代码清单3-13 //main.cpp // 错误的代码 namespace ns_train { int main(int argc, char* argv[]){ } }   3)用了命名空间也不是一劳永逸   软件开发组织应制定软件研发管理制度并且严格执行。比如,制定《命名空间管理规范》,规定对外引出的类或接口必须提供命名空间保护、命名空间的名称需要提请相关机构审核等。在规范的软件研发活动中,使用命名空间进行管理是最基础的工作,因为这会避免很多不必要的问题。即使认为目前开发的类不会跟别人重名,也应该从一开始就养成使用命名空间的良好习惯。因为良好的习惯会潜移默化地影响软件研发活动,对软件研发人员的未来之路肯定会产生有益的影响。 第14天?可动态加载的DLL   今天要学习的案例对应的源代码目录:src/chapter03/ks03_03。本案例不依赖第三方类库。程序运行效果如图3-3所示。 图3-3?第14天案例程序运行效果   今天的目标是掌握如下内容。 * 动态加载DLL的含义与作用。 * 实现动态加载的DLL的方法。   在进行软件开发活动时,软件开发人员可能无法预测未来会碰到什么样的需求,当需求发生变化时,原来的软件有可能难以适应这种变化从而导致无法满足新的需求。但是,通过需求分析和软件设计工作,开发人员可以有效降低这种变化带来的风险,对软件进行插件化设计就是一种非常有效的解决方案。所谓插件化设计,简单来讲,就是为软件中的某项功能制定设计规范,只要新开发的软件遵循这种规范,就可以在不改动原软件的前提下,将新软件提供的功能添加到系统中,从而满足新的需求。在第13天的学习内容中介绍过DLL开发技术,当EXE使用DLL时,需要在构建时链接DLL的lib文件,用这种方式开发的DLL叫静态链接的DLL。本节将介绍动态加载DLL的技术,这种技术可以用来开发插件。动态加载的DLL和静态链接的DLL有什么区别呢?区别有以下两点。   (1)构建过程中对DLL的lib文件的依赖不同。如果使用静态链接的DLL,在构建EXE项目时需要用到DLL的lib文件,以便链接DLL中的符号,如引出类、引出接口;如果使用动态加载的DLL,在构建EXE项目时不需要DLL的lib文件。   (2)在运行过程中,对DLL的依赖时间不同。如果使用静态链接的DLL,EXE整个运行过程中都要依赖DLL库文件(如a.dll);如果使用动态加载的DLL,EXE只有在加载该DLL后才依赖DLL库文件,而在卸载DLL后就不再依赖DLL库文件了,这时即使删除该DLL库文件也不影响EXE的正常运行。   怎样开发可以动态加载的DLL呢?开发动态加载的DLL分为两步,第一步是开发可动态加载的DLL,第二步是动态加载DLL。下面进行详细介绍。   1.开发可动态加载的DLL   可动态加载的DLL的开发技术与用于静态链接的DLL类似,不同之处在于需要把引出的接口用extern "C"进行声明。extern "C"是让C++代码能够调用C代码写的接口而采用的一种语法形式。如代码清单3-14所示,在标号①处,定义该DLL的引出宏KS03_03_DLL_API。在第13天的学习内容中介绍DLL开发技术时,编写了专门定义引出宏的头文件base_export.h,使用本节的写法可以省去这个头文件,因为该头文件中的内容被转移到引出类所在的头文件了。当DLL中有多个头文件需要引出时,本节的这种方法就不适合了,因为仍然需要编写专门用于定义引出宏的头文件,如base_export.h。在标号②处,使用extern "C" 声明一个引出接口function_test(int)。如果有多个引出接口,可以将引出接口写到extern "C" {}的花括号内部,如标号③处所示。在标号④处、标号⑤处定义了两个引出接口,可以看出,这些接口跟普通DLL中的引出接口的唯一区别就是被写在了extern "C" {}的花括号内部。 代码清单3-14 // src/chapter03/ks03_03/ks03_03_dll/ks03_03_dll.h #pragma once // 动态库导出宏定义 ① #ifdef WIN32 // windows platform # if defined _ _ks03_03_DLL_SOURCE_ _ # define KS03_03_DLL_API _ _declspec(dllexport) # else # define KS03_03_DLL_API _ _declspec(dllimport) # endif #else // other platform # define KS03_03_DLL_API #endif // WIN32 // 获取插件的导出函数 extern "C" KS03_03_DLL_API int function_test(int); ② extern "C" { ③ /* 如果删除接口前面的KS03_03_DLL_API,将导致该接口无法被找到。*/ KS03_03_DLL_API const char* getComputerGeneration(); ④ KS03_03_DLL_API int calculate(int a, int b); ⑤ };   下面看一下这几个接口的实现。如代码清单3-15所示,这几个接口的实现见标号①、标号②、标号③处。它们与普通DLL接口的区别在于把接口实现写在了extern "C"后面的花括号中。 代码清单3-15 // src/chapter03/ks03_03/ks03_03_dll/ks03_03_dll.cpp #include "ks03_03_dll.h" // 获取插件的引出函数 extern "C" { int function_test(int a) { ① return a; } const char* getComputerGeneration() { ② const char *szInfo = "我是世界上第一台电子计算机\"ENIAC\"。"; return szInfo; } int calculate(int a, int b) { ③ return a + b; } };   2.在EXE中动态加载某个DLL   完成DLL的开发后,就可以在EXE项目中加载DLL并调用其中的接口了。这里只介绍与普通DLL开发的不同之处。Windows系统与Linux系统对于动态加载DLL提供了不同的接口,但是都包含加载DLL、查找DLL中的接口、调用DLL中的接口、卸载DLL这四个步骤。下面分别进行介绍。   1)在Windows中动态加载DLL并调用DLL中的接口   (1)如果要调用DLL中的接口,首先需要加载DLL。在Windows中加载DLL的接口为LoadLibrary(),其原型如下。 HMODULE WINAPI LoadLibrary(_In_ LPCTSTR lpFileName);   其中_In_表示后面的参数是输入参数,也就是在接口内部只会引用传入的参数,而不会修改它。参数lpFileName表示要加载的DLL名称,如果DLL所在路径已经配置到PATH环境变量,就可以不写全路径而只写DLL文件名。HMODULE是返回值类型,它是一个句柄,用来操作打开的DLL。WINAPI宏在WIN32中被定义为_ _stdcall,在第13天的学习内容中已经介绍过。调用LoadLibrary()的示例代码如下。该例子表示加载的DLL为"my_dll.dll"。 HMODULE hDll = LoadLibrary("my_dll.dll");   (2)在Windows系统中加载DLL后,可以用GetProcAddress()查找DLL中的接口,其原型如下。 WINBASEAPI FARPROC WINAPI GetProcAddress(_In_ HMODULE hModule, _In_ LPCSTR lpProcName);   WINBASEAPI宏用来表明后面是一个引出接口。FARPROC表示该接口的返回值类型是一个函数地址,也就是DLL中接口的地址。hModule是指向DLL的句柄,hModule可以取LoadLibrary()的返回值。lpProcName表示要查找的接口名。调用GetProcAddress()的示例代码如下。该例子表示在hDll句柄所指向的DLL中查找函数(或称作符号)function_test并将找到的函数地址保存到pFuncAddress中。 void *pFuncAddress = GetProcAddress(hDll, "function_test");   (3)找到DLL中的接口后,就可以调用它了。function_test()的定义见代码清单3-14中标号②处,调用它的代码见代码清单3-16。在标号①处定义一个函数指针pFunction,为了跟function_test()的定义保持一致,要把它定义成不带参数并且返回值类型为int的函数指针。在标号②处,将GetProcAddress()返回的函数指针转换为期望的函数指针类型,其中int(*)()表示返回值为int类型的函数指针,int(*)后面的()表示该函数不带参数,如果有参数就把参数列表写在int(*)后面的()里。在标号③处,完成了对pFunction()的调用,也就是对DLL中function_test()的调用。 代码清单3-16 int(*pFunction)() = NULL ; // 定义一个函数指针 ① pFunction = (int(*)())pFuncAddress; // 将函数指针转换为实际类型 ② pFunction(); // 调用function_test() ③   (4)完成接口调用后,如果不需要再调用该DLL中的接口,可以在适当的时机卸载 DLL。但是,如果仍然需要调用其中的接口,就不能卸载DLL,否则将导致调用异常。在Windows中卸载DLL的接口为FreeLibrary(),其原型如下。 WINBASEAPI BOOL WINAPI FreeLibrary(In_ HMODULE hLibModule);   其中hLibModule表示DLL的句柄,该句柄可以由LoadLibrary()得到。FreeLibrary()返回BOOL类型的值,用来表示卸载成功与否。调用FreeLibrary()的示例代码如下。 FreeLibrary(hDll);   本节的案例中,在Windows中加载DLL的完整代码见代码清单3-17。在Windows版的演示代码中,为了演示不同类型的函数调用,调用了DLL中的两个带有不同参数的接口getComputerGeneration()、calculate(int, int)。如标号①处所示,定义两个变量strFunctionName、strFunctionName2,这两个变量用来存储DLL中的函数名称。在标号②处,定义一个void*类型的变量pFuncAddress,用来存放GetProcAddress()返回的函数地址。在标号③处分别针对函数getComputerGeneration()、calculate(int, int)定义了函数指针,在定义函数指针时,它的参数表、返回值类型必须与指向的函数完全一致,如果不明白,可以查看代码清单3-14中getComputerGeneration()、calculate(int, int)的定义。在标号④、标号⑤处为strDllPath赋值,该变量用来表示待加载的DLL名称,可以看出在Windows、Linux系统中DLL的命名方式有所不同。如标号⑥处所示,调用LoadLibrary()加载指定DLL,需要注意将参数strDllPath.c_str()转换为LPCTSTR类型,以便保证跟LoadLibrary()的参数类型一致。在标号⑦处,将GetProcAddress()返回的函数指针转换为和getComputerGeneration()定义一致的函数指针,请注意其具体语法,可以跟标号⑧处指向calculate(int, int)函数的指针进行对比,以便加深理解。在标号⑨处,调用pFunction2(1, 2)就相当于调用calculate(1, 2)。在标号⑩处,当不再使用DLL中的接口时,卸载hDll所指向的DLL。