C++生成与调用dll文件初探
为什么要用dll文件
动态数据库(Dynamic Link Library, DLL)文件使程序的封装调用变得方便,项目的协同工作也能够更加独立化。仅需定义好接口,在任何需要使用该模块功能的程序都可直接引用。常与dll文件一同提及的是lib文件。lib文件分为两类,静态链接库中的lib文件实际包含了所需封装模块的代码,并将在编译时将代码加入程序一同编译;动态链接库中的lib文件则只提供一个索引功能,就像函数的声明,功能实现则有dll文件提供。这就意味着,使用dll文件至少能带来以下优势:
- 降低程序的耦合度,增加模块的复用效率;
- 模块的更新仅需替换dll文件,不必对程序进行重新编译;
- 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文件的接口。
也可以使用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”
可以看到所导出的接口名称都是正确的。若没有明确声明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");
}
运行结果为
找到程序的运行文件所在,更改dll文件可以直接更改对应的模块功能。如原先的add函数更改为
double add2(double a)
{
return a * a;
}
double add(double a, double b)
{
return add2(a) + b;
}
生成的新dll文件直接替换,运行main.exe,可以看到结果已经改变。
类的封装
可以看到,上文为了保证函数名不变,使用了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,能够对比在工厂类中析构函数是否为虚函数时的不同。