C++生成与调用dll文件初探

为什么要用dll文件

动态数据库(Dynamic Link Library, DLL)文件使程序的封装调用变得方便,项目的协同工作也能够更加独立化。仅需定义好接口,在任何需要使用该模块功能的程序都可直接引用。常与dll文件一同提及的是lib文件。lib文件分为两类,静态链接库中的lib文件实际包含了所需封装模块的代码,并将在编译时将代码加入程序一同编译;动态链接库中的lib文件则只提供一个索引功能,就像函数的声明,功能实现则有dll文件提供。这就意味着,使用dll文件至少能带来以下优势:

  1. 降低程序的耦合度,增加模块的复用效率;
  2. 模块的更新仅需替换dll文件,不必对程序进行重新编译;
  3. dll文件有利于程序代码的隐藏,调用者无法得知模块动能的具体实现。

dll文件的创建

笔者使用Visual Studio 2017开发环境为例,简单介绍下最简单的函数的导出。

首先在VS中新建一个动态链接库(dll)项目,可以看到项目默认生成了pch.h等4个文件。作为最简单的示例,可以先删除它们,暂时不用,自带的这几个文件应该是微软出于优化等目的所用。

首先建立头文件,用于声明所要导出的接口。

// myDll.h
#ifdef MY_DLL_API
#else
#define MY_DLL_API extern "C" _declspec(dllimport)
// extern "C" 以C的方式导出,保证了接口名称不变
#endif

MY_DLL_API double add(double a, double b);
MY_DLL_API double minus(double a, double b);

在建立cpp文件编写函数的实现

// myDll.cpp
#include "myDll.h"
double add(double a, double b)
{
	return add2(a) + b;
}
double minus(double a, double b)
{
	return a - b;
}

生成即可在项目路径的对应文件夹(Debug或Release)下找到所需的lib和dll文件。使用dll查看工具即可查看dll文件的接口。

P1

也可以使用vcvars32.bat,打开命令提示符转至vcvars32.bat路径下,并以此输入以下代码(当然也可以直接使用VS的开发人员命令提示符,这样就可以直接转至dll所在路径用dumpbin命令了)

cd C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build
vcvars32.bat
dumpbin -exports “D:\code\VS\20220724_testDll\myDll\Release\myDll.dll”

P2

可以看到所导出的接口名称都是正确的。若没有明确声明extern "C",则由于C和C++间对函数编译转换的不同,会改变函数名,主要可能是考虑到C++函数重载的问题。

也可以使用模块定义文件(.def)控制导出。

dll文件的调用

调用dll文件分为显式调用和隐式调用,其中前者可以不用lib文件,而后者需要lib文件。笔者在此先简要介绍下隐式调用。

新建一个控制台工程,并在导入lib文件。cpp文件代码如下

// main.cpp
#include <iostream>
#pragma comment(lib, "myDll.lib")
// 可以在项目中包含lib,此时便无需将lib和dll文件拷贝至项目路径下,也无需手动关联lib
extern "C" _declspec(dllimport) double add (double a , double b);
extern "C" _declspec(dllimport) double minus(double a, double b);
// 要有extern "C" 和原先定义的一致,否则会报错
int main()
{
	double x1, x2;
	std::cout << "x1 = ";
	std::cin >> x1;
	std::cout << "x2 = ";
	std::cin >> x2;
	std::cout << "*******************" << std::endl;
	std::cout << "x1 + x2 = " << add(x1, x2) << std::endl;
	std::cout << "x1 - x2 = " << minus(x1, x2) << std::endl;
	std::cout << "*******************" << std::endl;
	system("pause");
}

运行结果为

P3

找到程序的运行文件所在,更改dll文件可以直接更改对应的模块功能。如原先的add函数更改为

double add2(double a)
{
	return a * a;
}
double add(double a, double b)
{
	return add2(a) + b;
}

生成的新dll文件直接替换,运行main.exe,可以看到结果已经改变。

P4

类的封装

可以看到,上文为了保证函数名不变,使用了C的编译方法,然而当需要封装类时就出问题了。有人认为应避免直接导出类,不过如果确实出于各种需要,如需要保证数据结构整体性,也应当要使用的。

使用以下代码生成dll

// Cal.h
#include <iostream>
#ifdef MY_DLL_API
#else
#define MY_DLL_API _declspec(dllimport)
// extern "C" 以C的方式导出,保证了接口名称不变
#endif

class MY_DLL_API Cal
{
public:
	Cal();
	~Cal();
	void SetPara(unsigned int _n,double *_x);
	double Add();
	double Minus();

private:
	double *x;
	unsigned int n;
};
// Cal.cpp
#include "Cal.h"

Cal::Cal()
{
	x = nullptr;
	n = 0;
	std::cout << "creat Cal class" << std::endl;
}

Cal::~Cal()
{
	std::cout << "distory Cal class" << std::endl;
}

void Cal::SetPara(unsigned int _n, double *_x)
{
	if (n != _n)
	{
		if (x)
			delete x;
		x = new double[_n];
		n = _n;
	}
	for (int ii = 0; ii < n; ii++)
	{
		*(x + ii) = _x[ii];
	}
}

double Cal::Add()
{
	double result = 0;
	for (int ii = 0; ii < n; ii++)
	{
		result += x[ii];
	}
	return result;
}

double Cal::Minus()
{
	double result = 0;
	for (int ii = 0; ii < n; ii++)
	{
		result -= x[ii];
	}
	return result;
}

在调用时有一些些不同,除了lib文件和dll文件,还需要使用h文件。由于C++特性丰富,接口需要根据头文件明确。

#include <iostream>
#include "Cal.h"
// class MY_DLL_API Cal;		// 该句在h文件中已声明,因此无需
int main()
{
	Cal t;
	double x[] = { 1, 3, 5 };
	t.SetPara(3, x);
	std::cout << "add: " << t.Add() << std::endl;
	std::cout << "minus: " << t.Minus() << std::endl;
	std::cout << "*******************" << std::endl;
	double y[] = { 1, 2};
	t.SetPara(2, y);
	std::cout << "add: " << t.Add() << std::endl;
	std::cout << "minus: " << t.Minus() << std::endl;
	std::cout << "*******************" << std::endl;
	system("pause");
	return 0;
}

然而,这样的封装和调用需要暴露出私有成员,这有时是开发者不愿意的,因此需要采取一定手段来隐藏私有成员。常用的方法可以有工厂模式和Pimpl风格,其中工厂模式又有简单工厂、工厂方法、抽象工厂等诸多类别,此外也有网友提出了一些自己的方法,都是值得借鉴的。此处笔者介绍一个基于简单工厂模式的封装。

首先定义一个工厂类作为基类,这是一个可以给其他使用者看到的源代码,其中仅定义了需要开放的接口,并将接口定义为虚函数。此外又定义了一个函数以获取对象地址。值得注意的是,基类的析构函数使用了虚析构函数,这是因为对象实际是由子类实例化的,然而返回给用户的还是基类类型,若不使用虚析构函数则销毁对象时仅调用基类的析构函数,反之才能调用子类的析构函数,正确释放内存空间。

// Cal_public.h
#pragma once
#ifdef MY_DLL_API
#else
#define MY_DLL_API _declspec(dllimport)
#endif

class Cal_base
{
public:
	virtual ~Cal_base() {};
		// 虚析构函数使得:在删除指向子类对象的基类指针时调用子类析构,防止内存泄露
	virtual void SetPara(unsigned int _n, double *_x) = 0;
	virtual double Add() = 0;
	virtual double Minus() = 0;
};

// 也可以单独定义一个类以获取接口
//class MY_DLL_API Cal_factor
//{
//public:
//	Cal_base * Get_Cal();
//};

// 使用函数获取接口
MY_DLL_API Cal_base *  Get_Cal();

Cal.h文件也要做出相应的改变:

// Cal.h
#pragma once
#include <iostream>
#include "Cal_public.h"

class Cal : public Cal_base
{
	// ...
};

Cal_base * Get_Cal()
{
	return new Cal();
}

这样就只需将编译所得的lib文件、dll文件并Cal_public.h一块给使用者即可。此时dll的调用变为:

// main.cpp
#include <iostream>
#include "Cal_public.h"

int main()
{
	//Cal_factor tt;
	//Cal_base *t = tt.Get_Cal();
	Cal_base *t = Get_Cal();
	double x[] = { 1, 3, 5 };
	// ...以下类似...
	delete t;
	system("pause");
	return 0;
}

此处显式地销毁了t,能够对比在工厂类中析构函数是否为虚函数时的不同。

P5

posted @ 2022-07-25 13:31  缤纷若风  阅读(1601)  评论(0编辑  收藏  举报