2024/11/14:
在实现C++17的Any类时偶然接触到了嵌套类的实现方法以及Pimpl设计模式,遂记录。
PIMPL ( Private Implementation 或 Pointer to Implementation )是通过一个私有的成员指针,将指针所指向的类的内部实现数据进行隐藏。
通俗点来说,就是因为各种各样的原因(如:你不愿意让别人看到你的具体实现,你希望让头文件看起来更清爽)需要将底层的实现封装起来,只将API暴露出来。
举个例子:
class Line
{
public:
Line(int x1, int y1, int x2, int y2)
: _p1(x1, y1)
, _p2(x2, y2)
{}
void showLine()
{
std::cout << "Line:" << std::endl;
_p1.showPoint();
_p2.showPoint();
}
private:
// 嵌套类放入private,专门为line服务
class Point
{
public:
~Point() = default;
Point(int a, int b)
: _x(a)
, _y(b)
{}
void showPoint() const
{
std::cout << "Point:" << std::endl;
std::cout << _x << " " << _y << std::endl;
}
private:
int _x, _y;
};
Point _p1, _p2;
};
Line类对于那些只关注函数功能的用户来说可能会带来一定阅读成本。所以我们使用Pimpl设计模式,在这个类上面套一层壳子,将它隐藏起来。
具体如下面代码所示:
// Line.h
#ifndef LINE_H
#define LINE_H
#include <iostream>
#endif
class OutLine
{
public:
OutLine(int x1, int y1, int x2, int y2);
~OutLine();
void showOutLine();
private:
// 嵌套类的前向声明
class Line;
Line * _Lineptr;
};
// Line.cpp
#include "Line.h"
class OutLine::Line
{
public:
Line(int x1, int y1, int x2, int y2)
: _p1(x1, y1)
, _p2(x2, y2)
{}
void showLine()
{
std::cout << "Line:" << std::endl;
_p1.showPoint();
_p2.showPoint();
}
private:
// 嵌套类放入private,专门为line服务
class Point
{
public:
~Point() = default;
Point(int a, int b)
: _x(a)
, _y(b)
{}
void showPoint() const
{
std::cout << "Point:" << std::endl;
std::cout << _x << " " << _y << std::endl;
}
private:
int _x, _y;
};
Point _p1, _p2;
};
OutLine::OutLine(int x1, int y1, int x2, int y2)
: _Lineptr(new Line(x1, y1, x2, y2))
{
std::cout << "create outLine" << std::endl;
}
OutLine::~OutLine()
{
std::cout << "delete outLine" << std::endl;
delete _Lineptr;
_Lineptr = nullptr;
}
void OutLine::showOutLine() {
if (_Lineptr) {
_Lineptr->showLine();
}
}
可以看到,在Line.h文件中,我们使用了OutLine类作为套在Line类上的一层壳,并且成功隐藏了底层较为复杂的实现。
下列代码用于测试:
// main.cpp
int main()
{
OutLine l(1, 4, 2, 3);
l.showOutLine();
}
Pimpl设计模式主要用于将代码打包成头文件和库交给第三方使用。显而易见的,在类上再套一个类增加了一点点开销,但是相对于好处而言,这点代价是可以接受的。
除了信息的隐藏,还有几点好处:
1、只要头文件不变,可以实现对已实现的函数(参数列表不能变)功能的修改和优化。在修改之后,只需要替换动态库而不需要替换头文件。即可以实现库的平滑升级
这一点是显而易见的,比如,当我们运行上面的测试代码时,会有以下输出:
create outLine
Line:
Point:
1 4
Point:
2 3
delete outLine
可能我们觉得输出左对齐不是很美观,想美化一下输出,所以我们在Line.cpp文件中改一下Point类的输出函数:
void showPoint() const
{
std::cout << "\tPoint: " << _x << " " << _y << std::endl;
}
再运行一下测试代码,得到输出:
create outLine
Line:
Point: 1 4
Point: 2 3
delete outLine
在这个修改过程中,并没有改动Line.h头文件。
2、Pimpl最重要的功能:编译防火墙
这一点其实跟上一点是相关联的,见下图:
reference c++库文件头文件链接原理(全)
共享库(动态库)的代码是在可执行程序运行时才载入内存的,在编译过程中仅简单的引用,因此生成的可执行程序代码体积较小。
有些修改不会使头文件发生变化,只需要替换动态库(动态库是由cpp文件生成的),那么整个项目就不需要重新编译一遍。可以说这种设计模式完全发挥了动态库的优势。
可能有些人对这个编译的时间开销没什么概念,这里举个不怎么恰当的例子:有些游戏只是修了个bug,可能就要让整个游戏重新下载。而注意这方面优化的游戏,可能下个几mb的补丁就结束了。
前面的思路可能有点乱,这里总结一下采用Pimpl模式的优势与动态库的关系:
当动态库更新时,通常情况下不需要重新编译可执行文件。因为在编译生成可执行文件时,链接器并不会将动态库的代码直接复制到可执行文件中,而只是记录了对该动态库的引用。程序在执行时会根据这些引用加载相应的动态库。如果该动态库已被加载,程序则不会重复加载,从而节省内存资源。
然而,如果动态库的接口发生变化(例如函数的参数类型或返回值类型发生改变,或者函数被删除或重命名),那么可能需要重新编译可执行文件。在这种情况下,原有的可执行文件将无法正确调用动态库中的函数,可能会导致错误或异常。因此,Pimpl设计模式的编译防火墙优势就是建立在不去动这些接口的基础上的,这就要求项目有一个良好的设计思路,以尽量减少后期对接口的修改。
可见Pimpl设计模式与动态库的匹配性。