用C++编写的DLL(动态链接库)中,导出类的接口封装方法总结

注:.dll文件是Windows平台下的动态链接库文件,在Linux平台,有响应功能的文件是.so文件,.so文件接口的封装也可以参考此文的思路

目录

 一、DLL项目结构介绍

二、DLL项目示例

1.文件 My_Dll_Project.h

2.文件 My_Dll_Project.cpp

3.修改后的文件 My_Dll_Project.h

三、封装方法介绍

1.再包装法

1.1 新接口头文件 My_Dll_Project_Interface.h(给用户的头文件)

1.2 文件 My_Dll_Project_Interface.cpp (给用户头文件的实现,不公开)

1.3 封装后的原头文件 My_Dll_Project.h (不公开)

1.4 再包装法面临的难题

2.抽象类法

2.1 抽象类接口头文件 My_Dll_Project_Interface_2.h(给用户的头文件)

2.2 文件 My_Dll_Project_Interface_2.cpp (给用户头文件的实现,不公开)

1.3 封装后的原头文件 My_Dll_Project.h (不公开)

3.大小欺骗法(原创,未深度检验)

3.1 原来给用户的接口头文件(不公开)

3.2 占位+删减处理后的头文件(公开给用户)


一、DLL项目结构介绍

一个C++的DLL项目的基本结构是:

  1. 头文件(.h),写好这个库准备给人家用的函数、类、变量等的声明。
  2. 源文件(.cpp),写上面那个头文件中声明给人家用的函数等的具体实现。
  3. 库文件(.lib),用你DLL的程序员编译他的程序时会用到。
  4. 程序数据库文件(.pdb),保存这个DLL库的调试信息,只有调试这个库时才必须用到。
  5. 动态链接库文件(.dll),用户编写的程序运行时才会用到这个文件。

其中,源文件(.cpp)里写接口函数的具体实现,编译完成后一般不公开给用户,原因有很多,如,保留产权,或者为了彻底封装,甚至可能只是因为代码写得太烂不好意思公开(手动滑稽)。

头文件(.h),库文件(.lib),动态链接库文件(.dll)必须给用户,程序数据库文件(.pdb)给不给看心情,如果给了,用户就能在调试的多看几步。

头文件(.h)就是其他程序员(用户)使用你的库的接口了,也就是说,头文件告诉了用户如何使用你的库,原理参考C++教科书关于声明和定义的章节。


二、DLL项目示例

下面是用VS创建项目自动生成的代码,项目名:My_Dll_Project


1.文件 My_Dll_Project.h

// 下列 ifdef 块是创建使从 DLL 导出更简单的
// 宏的标准方法。此 DLL 中的所有文件都是用命令行上定义的 MYDLLPROJECT_EXPORTS
// 符号编译的。在使用此 DLL 的
// 任何项目上不应定义此符号。这样,源文件中包含此文件的任何其他项目都会将
// MYDLLPROJECT_API 函数视为是从 DLL 导入的,而此 DLL 则将用此宏定义的
// 符号视为是被导出的。
#ifdef MYDLLPROJECT_EXPORTS
#define MYDLLPROJECT_API __declspec(dllexport)
#else
#define MYDLLPROJECT_API __declspec(dllimport)
#endif

// 此类是从 dll 导出的
class MYDLLPROJECT_API CMyDllProject {
public:
	CMyDllProject(void);
	// TODO: 在此处添加方法。
};

extern MYDLLPROJECT_API int nMyDllProject;

MYDLLPROJECT_API int fnMyDllProject(void);

2.文件 My_Dll_Project.cpp

// My_Dll_Project.cpp : 定义 DLL 的导出函数。
//

#include "framework.h"
#include "My_Dll_Project.h"


// 这是导出变量的一个示例
MYDLLPROJECT_API int nMyDllProject=0;

// 这是导出函数的一个示例。
MYDLLPROJECT_API int fnMyDllProject(void)
{
    return 0;
}

// 这是已导出类的构造函数。
CMyDllProject::CMyDllProject()
{
    return;
}

这样的头文件给别人似乎看不出来什么问题,如果你感觉注释太多,删掉也可以,但是如果接口是下面这样,再直接给用户,就很成问题了。

3.修改后的文件 My_Dll_Project.h

#pragma once
#ifdef MYDLLPROJECT_EXPORTS
#define MYDLLPROJECT_API __declspec(dllexport)
#else
#define MYDLLPROJECT_API __declspec(dllimport)
#endif

#include 

// 此类是从 dll 导出的
class MYDLLPROJECT_API CMyDllProject
{
public:
	CMyDllProject(void);
	void this_dll_user_is_good();

private:
	int user;
	char is;
	void* bad;
	HANDLE yeah;

private:
	HANDLE user_is_bad();
	void user_is_stupid();
	void user_is_dirty();
	void user_is_nasty();
	void user_is_ugly();
	void user_is_wtf_i_cant_say_more();

};

extern MYDLLPROJECT_API int nMyDllProject;

MYDLLPROJECT_API int fnMyDllProject(void);

当然这里的说用户坏话的函数名只是开个玩笑,认真地说,这个头文件有以下两个问题:

  1. private 标签标识这个这个函数是私有的,也就是说这个类的用户被阻止使用这些函数(原因不解释),也就是说这些函数、成员变量是用户绝对用不到的,不需要暴露给用户。而私有成员部分暴露了类的实现方式,削弱了库的封装性和保密性,用户可以获得内部实现的线索,同时,如果后续修改私有成员,那么用户必须连头文件都一起替换。
  2. 这个库的实现用到了头文件,但是这个头文件并不是接口所必须的,用户#include这个头文件时,会把一并包含进来,会污染用户的命名空间。

函数和变量是独立的,可以分别决定每个函数和变量是否导出,但是类的定义一定是所有成员都写在一起的,这才导致了问题,下面就来介绍常用的类的两种深度隔离的封装方法。

三、封装方法介绍

1.再包装法

就是不导出原本要导出的类,重写一个新类,新类中数据成员只有一根指向原先类的指针,新类的所有普通函数都是通过指针对原先类的调用,就好像在商品外面重新包装了一样,再包装类可以完全隔绝原有类的副影响,而且可以给原先不支持移动语义(C++11)的类附加移动语义支持。


1.1 新接口头文件 My_Dll_Project_Interface.h(给用户的头文件)

#pragma once
#ifdef MYDLLPROJECT_EXPORTS
#define MYDLLPROJECT_API __declspec(dllexport)
#else
#define MYDLLPROJECT_API __declspec(dllimport)
#endif

class CMyDllProject;	// 这里写声明

class MYDLLPROJECT_API CMyDllProjectInterface
{
public:
	CMyDllProjectInterface(void);
	CMyDllProjectInterface(CMyDllProjectInterface&& mov) noexcept;	// 添加移动语义
	~CMyDllProjectInterface();
	
public:
	CMyDllProjectInterface& operator=(CMyDllProjectInterface&& mov) noexcept;	// 添加移动语义

public:
	void this_dll_user_is_good();

private:
	CMyDllProject* _self;	// 只暴露一根指针,甚至不暴露,直接声明为void*,然后实现代码使用强制类型转换也可以
};

1.2 文件 My_Dll_Project_Interface.cpp (给用户头文件的实现,不公开)

#include "My_Dll_Project_Interface.h"
#include "My_Dll_Project.h"

CMyDllProjectInterface::CMyDllProjectInterface(void) :
	_self(new CMyDllProject)
{}

CMyDllProjectInterface::CMyDllProjectInterface(CMyDllProjectInterface&& mov) noexcept:
	_self(mov._self)
{
	mov._self = nullptr;
}

CMyDllProjectInterface::~CMyDllProjectInterface()
{
	if (_self) delete _self;
}

CMyDllProjectInterface& CMyDllProjectInterface::operator=(CMyDllProjectInterface&& mov) noexcept
{
	this->~CMyDllProjectInterface();
	_self = mov._self;
	mov._self = nullptr;
	return *this;
}

void CMyDllProjectInterface::this_dll_user_is_good()
{
	return _self->this_dll_user_is_good();	// 这个类的函数其实就是对原类的简单调用
}

1.3 封装后的原头文件 My_Dll_Project.h (不公开)

#include 	// 这个头文件就不用再暴露给用户了

class CMyDllProject
{
public:
	CMyDllProject(void);
	void this_dll_user_is_good();

private:
	int user;
	char is;
	void* bad;
	HANDLE yeah;

private:
	HANDLE user_is_bad();
	void user_is_stupid();
	void user_is_dirty();
	void user_is_nasty();
	void user_is_ugly();
	void user_is_wtf_i_cant_say_more();

};

完美封装+添加移动语义

这种封装的优点:

  1. 直接封装成类的形式,用户可以直接按类的方式使用
  2. 可以自动管理资源,无需智能指针
  3. 可以添加额外的移动语义,同时对象的移动速度非常快。

缺点:

  1. 从理论上来说,每次调用原函数都要进栈出栈,可能会显著降低性能(测试结果:Debug模式下,方法一比下面的方法二慢了77%,Release版本下,完全优化,方法一比方法二反而快了2%)
  2. 不容易反映原接口头文件中类的继承关系
  3. 写包装接口类的工作量有点大

反映类的继承关系就是说下面这样:

1.4 再包装法面临的难题

class Interface // 这是要直接公开的抽象类接口
{
public:
	virtual int help() = 0;
};

class A :public Interface // 这是要包装的导出类
{
public:
	virtual int help();
	void yes();
};

class B :public A // 这是要包装的导出类
{
public:
	void yes();
};

这三个类想导出,封装类可不好写,读者可以自行试试如何解决这个棘手的问题,先告诉大家这个问题并不是无解。

2.抽象类法

这个方法的基本原理就是利用抽象类作接口,给用户提供工厂函数(Factory Method),或者把所有工厂函数打包在一起做个工具箱类,让用户使用工厂函数创建实例。


2.1 抽象类接口头文件 My_Dll_Project_Interface_2.h(给用户的头文件)

#ifdef MYDLLPROJECT2_EXPORTS
#define MYDLLPROJECT2_API __declspec(dllexport)
#else
#define MYDLLPROJECT2_API __declspec(dllimport)
#endif

class MYDLLPROJECT2_API CMyDllProjectInterface
{
public:
	virtual void this_dll_user_is_good() = 0;
};

MYDLLPROJECT2_API CMyDllProjectInterface* factory_function();	// 导出工厂函数

2.2 文件 My_Dll_Project_Interface_2.cpp (给用户头文件的实现,不公开)

#include "My_Dll_Project.h"
#include "My_Dll_Project_Interface_2.h"

CMyDllProjectInterface* factory_function()
{
	return new CMyDllProject();
}

1.3 封装后的原头文件 My_Dll_Project.h (不公开)

#include 
#include "My_Dll_Project_Interface_2.h"

class CMyDllProject : public CMyDllProjectInterface
{
public:
	CMyDllProject(void);
	void this_dll_user_is_good();

private:
	int user;
	char is;
	void* bad;
	HANDLE yeah;

private:
	HANDLE user_is_bad();
	void user_is_stupid();
	void user_is_dirty();
	void user_is_nasty();
	void user_is_ugly();
	void user_is_wtf_i_cant_say_more();

};

这种封装的优点:

  1. 理论上,直接使用指针可以比方法一更快
  2. 不必编写封装类,工作量少

缺点:

  1. 要求用户管理资源
  2. 不能直接套用一般类的形式(构造函数、拷贝函数、移动、复制)

这种方法多适用于接口先于代码确定好的情况


3.大小欺骗法(原创,未深度检验)

没有点创新怎好意思投原创,这种方法的原理非常简单,就是原来接口头文件在编译好后、交给用户前时,把类中的私有成员尽管删除,然后用char数组填充类的大小,数组长度为sizeof(类)的大小,说白了就是占位的作用。

3.1 原来给用户的接口头文件(不公开)

#pragma once
#ifdef MYDLLPROJECT_EXPORTS
#define MYDLLPROJECT_API __declspec(dllexport)
#else
#define MYDLLPROJECT_API __declspec(dllimport)
#endif

#include 

class MYDLLPROJECT_API CMyDllProject
{
public:
	CMyDllProject(void);
	void this_dll_user_is_good();

private:
	int user;
	char is;
	void* bad;
	HANDLE yeah;

private:
	HANDLE user_is_bad();
	void user_is_stupid();
	void user_is_dirty();
	void user_is_nasty();
	void user_is_ugly();
	void user_is_wtf_i_cant_say_more();

};

3.2 占位+删减处理后的头文件(公开给用户)

#pragma once
#ifdef MYDLLPROJECT_EXPORTS
#define MYDLLPROJECT_API __declspec(dllexport)
#else
#define MYDLLPROJECT_API __declspec(dllimport)
#endif

// 使用占位可以不包含了

class MYDLLPROJECT_API CMyDllProject
{
public:
	CMyDllProject(void);
	void this_dll_user_is_good();
private:
        // 函数尽管删除,数据成员反应在占位数组的大小里
	char _place_hoder[20];    // 占位20
};

包不包含的问题:如果类没有返回别的头文件中的数据类型的公共函数,就可以实现不包含,其他头文件中的数据类型的数据成员,可以直接用占位数组占位就行了。

这种封装的好处:

  1. 简单粗暴,而且不会带来额外的性能开销
  2. 直接反应导出类间的继承等关系
  3. woc,还列个啥,最简单的就是最好的,能直接用了难道不爽吗?

缺点:

  1. 简直就是野路子方法(没错就是野路子,原创)
  2. 兼容性可能有所欠缺,可能会出错(虽然我自己用了好几次没有出任何错误)
  3. 私有函数其实并没有真正意义上的封装在内部(使用dumpbin仍能在dll中看到导出的私有函数)

之所以想出这种方法,是基于我的一个假设:类的定义体在编译过程中只起到内存大小指示的作用,内存本身并不划分为不同类型。我认为基于这种方法并不能完全封装,我觉得它的用处可能更多在于团队内部合作用到。

另外我认为,完全封装并不是不行,只是需要编译器支持,让编译器不导出私有函数就行了。

你可能感兴趣的:(用C++编写的DLL(动态链接库)中,导出类的接口封装方法总结)