文章目录
- [Pimpl 模式(d-pointer)详解:如何解决 C++ 头文件过大、编译依赖和 ABI 兼容问题](#Pimpl 模式(d-pointer)详解:如何解决 C++ 头文件过大、编译依赖和 ABI 兼容问题)
- [1. 普通 C++ 类的问题](#1. 普通 C++ 类的问题)
- [2. C++ 为什么头文件大会导致问题?](#2. C++ 为什么头文件大会导致问题?)
- [3. Pimpl 的核心思想](#3. Pimpl 的核心思想)
- [4. 最简单 Pimpl 实现](#4. 最简单 Pimpl 实现)
- [5. 实现放入 cpp](#5. 实现放入 cpp)
- [6. Pimpl 如何解决头文件过大的问题?](#6. Pimpl 如何解决头文件过大的问题?)
- [7. Pimpl 的第二个作用:隐藏 ABI](#7. Pimpl 的第二个作用:隐藏 ABI)
- [8. Qt 的 d-pointer 实现方式](#8. Qt 的 d-pointer 实现方式)
- [9. Pimpl 与普通封装有什么区别?](#9. Pimpl 与普通封装有什么区别?)
- [10. C++11 推荐写法](#10. C++11 推荐写法)
- [11. 为什么析构函数必须放 cpp?](#11. 为什么析构函数必须放 cpp?)
- [12. Pimpl 的缺点](#12. Pimpl 的缺点)
- [13. 什么场景适合 Pimpl?](#13. 什么场景适合 Pimpl?)
-
- [1. 开发大型库](#1. 开发大型库)
- [2. 大型 C++ 工程](#2. 大型 C++ 工程)
- [3. 第三方 SDK](#3. 第三方 SDK)
- [14. Pimpl 实际工程结构](#14. Pimpl 实际工程结构)
- [15. 总结](#15. 总结)
-
- [1. 减少头文件依赖](#1. 减少头文件依赖)
- [2. 加快编译速度](#2. 加快编译速度)
- [3. 保持 ABI 稳定](#3. 保持 ABI 稳定)
Pimpl 模式(d-pointer)详解:如何解决 C++ 头文件过大、编译依赖和 ABI 兼容问题
在大型 C++ 项目中,经常会遇到这样的问题:
一个简单的类:
cpp
class Camera
{
public:
void open();
void close();
private:
cv::Mat image_;
std::vector<Device> devices_;
std::mutex mutex_;
NetworkClient client_;
AlgorithmEngine engine_;
};
看起来只是一个 Camera 类,但是它的头文件可能需要:
cpp
#include <opencv2/opencv.hpp>
#include <vector>
#include <mutex>
#include "Device.h"
#include "NetworkClient.h"
#include "AlgorithmEngine.h"
结果:
Camera.h变得巨大- 每个包含
Camera.h的文件都会间接包含大量头文件 - 修改一个私有成员可能导致整个工程重新编译
- 动态库升级容易破坏 ABI 兼容
为了解决这些问题,C++ 中出现了一种经典设计:
Pimpl(Pointer to implementation)模式,也叫 d-pointer、opaque pointer、Compiler Firewall。
它通过一个指针,把类的实现细节隐藏到 .cpp 文件中。(cppreference)
Qt 大量使用这种设计,例如 Qt 源码中常见:
cpp
Q_D(QWidget);
Q_Q(QWidget);
这些宏就是 d-pointer 模式的一部分。(Qt Wiki)
1. 普通 C++ 类的问题
假设我们有:
Camera.h
cpp
#pragma once
#include <opencv2/opencv.hpp>
#include <vector>
#include <mutex>
#include "Device.h"
#include "NetworkClient.h"
#include "AlgorithmEngine.h"
class Camera
{
public:
Camera();
void open();
private:
cv::Mat image_;
std::vector<Device> devices_;
std::mutex mutex_;
NetworkClient client_;
AlgorithmEngine engine_;
};
Camera.cpp
cpp
#include "Camera.h"
Camera::Camera()
{
}
void Camera::open()
{
}
表面上:
cpp
Camera
|
|-- open()
实际上:
Camera.h
|
|-- OpenCV
|
|-- Device
|
|-- NetworkClient
|
|-- AlgorithmEngine
|
|-- mutex
|
|-- vector
形成巨大依赖树。
2. C++ 为什么头文件大会导致问题?
C++ 的编译模型:
main.cpp
#include "Camera.h"
|
|
v
展开 Camera.h
|
|
v
编译整个文件
如果:
100 个 cpp:
A.cpp
B.cpp
C.cpp
...
都:
cpp
#include "Camera.h"
那么:
Camera.h修改
|
|
v
100个cpp全部重新编译
例如:今天增加一个成员:
cpp
private:
AlgorithmEngine engine_;
改成:
cpp
private:
AlgorithmEngine engine_;
NewAIModel model_;
虽然只是内部实现变化。但是:
Camera.h改变
↓
所有include Camera.h的文件重新编译
大型工程:
5000 cpp
重新编译
30分钟
这就是 C++ 大项目常见的编译瓶颈。
3. Pimpl 的核心思想
Pimpl:
Pointer to implementation
意思:
不直接保存实现,而保存一个指向实现的指针。
结构变成:
Camera
+----------------+
| public API |
| |
| Impl* d |
+----------------+
|
|
v
+----------------+
| private data |
| |
| OpenCV |
| Algorithm |
| Network |
+----------------+
4. 最简单 Pimpl 实现
Camera.h
变成:
cpp
#pragma once
#include <memory>
class CameraPrivate;
class Camera
{
public:
Camera();
~Camera();
void open();
private:
std::unique_ptr<CameraPrivate> d;
};
注意这里没有:
cpp
#include <opencv2/opencv.hpp>
#include "AlgorithmEngine.h"
#include "NetworkClient.h"
只有:
cpp
class CameraPrivate;
这叫:
前向声明(forward declaration)
5. 实现放入 cpp
Camera.cpp
cpp
#include "Camera.h"
#include <opencv2/opencv.hpp>
#include "AlgorithmEngine.h"
#include "NetworkClient.h"
class CameraPrivate
{
public:
cv::Mat image_;
AlgorithmEngine engine_;
NetworkClient client_;
};
Camera::Camera()
:
d(new CameraPrivate)
{
}
Camera::~Camera() = default;
void Camera::open()
{
d->engine_.start();
}
现在:
Camera.h
只知道:
CameraPrivate*
但是:
Camera.cpp
知道:
CameraPrivate全部内容
6. Pimpl 如何解决头文件过大的问题?
对比:
普通方式
Camera.h
#include OpenCV
#include Network
#include Algorithm
class Camera
{
cv::Mat image;
NetworkClient client;
AlgorithmEngine engine;
};
依赖:
Camera.h
|
+-- OpenCV
|
+-- Network
|
+-- Algorithm
|
+-- STL
Pimpl方式
Camera.h
class CameraPrivate;
class Camera
{
CameraPrivate* d;
};
依赖:
Camera.h
|
|
+-- CameraPrivate声明
真正依赖:
Camera.cpp
|
+-- OpenCV
|
+-- Network
|
+-- Algorithm
结果:
头文件从:
几十KB
甚至几百KB
降低到:
几KB
7. Pimpl 的第二个作用:隐藏 ABI
这是 Qt 最看重的地方。
假设发布一个动态库:
libcamera.dll
用户代码:
cpp
Camera cam;
编译时编译器需要知道:
cpp
sizeof(Camera)
例如:
Camera:
image_
64 bytes
client_
128 bytes
engine_
256 bytes
sizeof(Camera)=448
如果升级增加:
cpp
NewAIModel model_;
变成:
sizeof(Camera)=1024
旧程序:不知道。产生 ABI 崩溃。
Pimpl
用户看到:
cpp
class Camera
{
CameraPrivate* d;
};
大小:
sizeof(Camera)
=
sizeof(pointer)
=8 bytes
以后内部变化:
版本1:
CameraPrivate
image
client
版本2:
CameraPrivate
image
client
AIModel
GPUContext
Cache
但是外部:
Camera
永远:
8 bytes
所以ABI 不变。
这也是 Qt 能长期保持二进制兼容的重要原因。(Qt Wiki)
8. Qt 的 d-pointer 实现方式
Qt 类例如:
cpp
class QWidget
{
public:
void show();
private:
QWidgetPrivate *d_ptr;
};
内部:
cpp
class QWidgetPrivate
{
public:
QWidget *q_ptr;
QRect geometry;
QWidgetData data;
};
关系:
QWidget
|
|
v
QWidgetPrivate
|
|
v
QWidget
所以 Qt 中:
cpp
Q_D(QWidget);
实际上:
cpp
QWidgetPrivate *d =
d_func();
9. Pimpl 与普通封装有什么区别?
很多人认为private 成员已经隐藏了。
例如:
cpp
class Person
{
private:
std::string name_;
};
为什么还需要 Pimpl?
原因private只能防止:
用户访问
不能防止:
编译依赖
ABI变化
比较:
| private | Pimpl | |
|---|---|---|
| 隐藏数据 | √ | √ |
| 减少头文件依赖 | × | √ |
| 减少编译时间 | × | √ |
| ABI稳定 | × | √ |
| 增加间接访问 | × | √ |
10. C++11 推荐写法
现代 C++推荐:
cpp
class Widget
{
public:
Widget();
~Widget();
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
cpp:
cpp
class Widget::Impl
{
public:
std::string name;
int value;
};
Widget::Widget()
:
impl_(std::make_unique<Impl>())
{
}
11. 为什么析构函数必须放 cpp?
错误:
cpp
class Widget
{
private:
std::unique_ptr<Impl> impl_;
};
可能出现:
invalid application of sizeof
incomplete type
原因unique_ptr 析构时需要知道:
cpp
sizeof(Impl)
解决头文件:
cpp
class Widget
{
public:
~Widget();
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
cpp:
cpp
Widget::~Widget() = default;
12. Pimpl 的缺点
不是所有类都应该使用。
缺点1:一次额外间接访问
普通:
object.member
一次访问
Pimpl:
object
|
pointer
|
member
多一次指针跳转。
缺点2:增加动态内存
通常:
cpp
new Impl
导致:
heap allocation
缺点3:代码复杂度增加
简单类:
cpp
class Point
{
int x;
int y;
};
没有必要:
PointPrivate
13. 什么场景适合 Pimpl?
1. 开发大型库
例如:
Qt
OpenCV模块
商业SDK
插件系统
需要:
- ABI稳定
- 隐藏实现
2. 大型 C++ 工程
例如你的工程:
ProtocolManager
DeviceManager
AlgorithmEngine
Renderer
Controller
如果头文件:
cpp
#include
FastDDS
OpenCV
VTK
CUDA
Boost
建议Pimpl。
3. 第三方 SDK
例如你的接口:
cpp
class RobotSDK
{
};
内部:
DDS
TCP
Camera
GPU
算法
用户只需要:
cpp
#include "RobotSDK.h"
14. Pimpl 实际工程结构
推荐:
Camera/
Camera.h
Camera.cpp
CameraPrivate.h
Camera.h:
cpp
class CameraPrivate;
class Camera
{
CameraPrivate* d;
};
CameraPrivate.h:
cpp
class CameraPrivate
{
cv::Mat image;
vtkRenderer renderer;
};
15. 总结
Pimpl(d-pointer)的核心:
用一个稳定的小接口类,持有一个指向真实实现的指针,把复杂实现移动到 cpp。
它解决三个核心问题:
1. 减少头文件依赖
以前:
Camera.h
|
+ OpenCV
+ VTK
+ DDS
+ Boost
之后:
Camera.h
|
+ CameraPrivate*
2. 加快编译速度
修改:
CameraPrivate
只重新编译:
Camera.cpp
而不是:
整个工程
3. 保持 ABI 稳定
对象布局以前:
Camera大小随成员变化
之后:
Camera = 一个指针大小
内部可以无限扩展因此:
- Qt
- 大型 C++ SDK
- 跨版本动态库
- 工业软件
大量采用 Pimpl / d-pointer 设计。(Qt Wiki)
对于 Qt + CMake + MSVC + 大型视觉/工业软件项目,Pimpl 是非常值得掌握的架构级 C++ 技术。