Pimpl 模式(d-pointer)详解:如何解决 C++ 头文件过大、编译依赖和 ABI 兼容问题

文章目录

  • [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++ 技术。

相关推荐
无小道11 分钟前
C/C++——异步编程小记
开发语言·c++·c++11
shylyly_17 分钟前
stack/queue中的deque
数据结构·c++·deque·双端队列·queue·stack·容器适配器
DevOpenClub22 分钟前
Markdown、HTML 和 PPT 如何稳定交付:文档转换任务的幂等发布流程
开发语言·前端·c#·html·powerpoint
raindayinrain27 分钟前
c++泛型编程
c++·函数模板·类模板·泛型编程
程序员夏洛31 分钟前
谈谈你了解的最常见的几种设计模式,说说他们的应用场景
开发语言·python·模板方法模式
OPEN-F33 分钟前
C++综合实战:网络编程入门与HTTP客户端
网络·c++·http
小灰灰搞电子37 分钟前
Rust+Slint 实现侧滑菜单栏源码分享
开发语言·rust·抽屉式菜单栏
十五年专注C++开发38 分钟前
CMake基础:BUILD_INTERFACE 与 INSTALL_INTERFACE 完全解析
c++·cmake·build_interface
JavaEdge.1 小时前
scoped 样式与 data-v-* 属性选择器(写给 Javaer)
开发语言·vue