C++ 必须掌握的 pimpl 惯用法

假设现在有这样一段代码:

/** * 网络通信的基础类, SocketClient.h * zhangyl 2017.07.11 */class CSocketClient{public:    CSocketClient();    ~CSocketClient();
private: CSocketClient(const CSocketClient& rhs) = delete; CSocketClient& operator=(const CSocketClient& rhs) = delete;
public: void SetProxyWnd(HWND hProxyWnd);
bool Init(CNetProxy* pNetProxy); bool Uninit();
int Register(const char* pszUser, const char* pszPassword); void GuestLogin();
BOOL IsClosed(); BOOL Connect(int timeout = 3); void AddData(int cmd, const std::string& strBuffer); void AddData(int cmd, const char* pszBuff, int nBuffLen); void Close();
BOOL ConnectServer(int timeout = 3); BOOL SendLoginMsg(); BOOL RecvLoginMsg(int& nRet); BOOL Login(int& nRet);
private: void LoadConfig(); static UINT CALLBACK SendDataThreadProc(LPVOID lpParam); static UINT CALLBACK RecvDataThreadProc(LPVOID lpParam); bool Send(); bool Recv(); bool CheckReceivedData(); void SendHeartbeatPackage();
private: SOCKET m_hSocket; short m_nPort; char m_szServer[64]; long m_nLastDataTime; //最近一次收发数据的时间 long m_nHeartbeatInterval; //心跳包时间间隔,单位秒 CRITICAL_SECTION m_csLastDataTime; //保护m_nLastDataTime的互斥体 HANDLE m_hSendDataThread; //发送数据线程 HANDLE m_hRecvDataThread; //接收数据线程 std::string m_strSendBuf; std::string m_strRecvBuf; HANDLE m_hExitEvent; bool m_bConnected; CRITICAL_SECTION m_csSendBuf; HANDLE m_hSemaphoreSendBuf; HWND m_hProxyWnd; CNetProxy* m_pNetProxy; int m_nReconnectTimeInterval; //重连时间间隔 time_t m_nLastReconnectTime; //上次重连时刻 CFlowStatistics* m_pFlowStatistics;};

这段代码来源于笔者实际项目中开发的一个股票客户端的软件。CSocketClient 类的 public 方法提供对外的接口供第三方使用,每个函数的具体实现在 SocketClient.cpp 中,对第三方使用者是透明的。一般在 Windows 系统上作为提供给第三方使用的库,一般需要提供给使用者 .h、*.lib.dll 文件等,在 Linux 系统上需要提供 .h、*.a.so** 文件。不管是在哪个操作系统平台上,这样的头文件提供给第三方,都让提供者心里隐隐不安——因为类的成员变量(和私有函数)暴露了太多的实现细节,很容易让使用者看出实现原理。这在一些不想对使用者暴露关键性的核心实现技术的应用中,这样的头文件是非常不好的。那有没有什么办法既能保持对外的接口不变,又能不暴露那些关键性的成员变量呢?有的。我们可以将代码稍微修改一下:

/** * 网络通信的基础类, SocketClient.h * zhangyl 2017.07.11 */class Impl;
class CSocketClient{public: CSocketClient(); ~CSocketClient();
private: CSocketClient(const CSocketClient& rhs) = delete; CSocketClient& operator=(const CSocketClient& rhs) = delete;
public: //TODO: 暂且保留这个接口,用于将来向UI层反馈当前网络状态 void SetProxyWnd(HWND hProxyWnd);
bool Init(CNetProxy* pNetProxy); bool Uninit();
int Register(const char* pszUser, const char* pszPassword); void GuestLogin();
BOOL IsClosed(); BOOL Connect(int timeout = 3); void AddData(int cmd, const std::string& strBuffer); void AddData(int cmd, const char* pszBuff, int nBuffLen); void Close();
BOOL ConnectServer(int timeout = 3); BOOL SendLoginMsg(); BOOL RecvLoginMsg(int& nRet); BOOL Login(int& nRet);
//启动发送和接收数据工作线程 //bool Startup();
private: void LoadConfig(); static UINT CALLBACK SendDataThreadProc(LPVOID lpParam); static UINT CALLBACK RecvDataThreadProc(LPVOID lpParam); bool Send(); bool Recv(); bool CheckReceivedData(); void SendHeartbeatPackage();
private: Impl* m_pImpl;};

上述代码中,所有的关键性成员变量已经没有了,取而代之的是一个类型为 Impl 的指针变量 m_pImpl

具体采用什么名称,读者完全可以根据自己的实际情况来定,不一定非要使用这里的 Implm_pImpl

Impl 类型现在是完全对使用者透明,为了在当前类中可以使用 Impl,使用了一个前置申明:

//原代码第5行class Impl;

然后我们就可以将刚才隐藏的成员变量放到这个类中去:

class Impl{public:	Impl()	{        //TODO: 你可以在这里对成员变量做一些初始化工作	}
~Impl() { //TODO: 你可以在这里做一些清理工作 }
public: SOCKET m_hSocket; short m_nPort; char m_szServer[64]; long m_nLastDataTime; //最近一次收发数据的时间 long m_nHeartbeatInterval; //心跳包时间间隔,单位秒 CRITICAL_SECTION m_csLastDataTime; //保护m_nLastDataTime的互斥体 HANDLE m_hSendDataThread; //发送数据线程 HANDLE m_hRecvDataThread; //接收数据线程 std::string m_strSendBuf; std::string m_strRecvBuf; HANDLE m_hExitEvent; bool m_bConnected; CRITICAL_SECTION m_csSendBuf; HANDLE m_hSemaphoreSendBuf; HWND m_hProxyWnd; CNetProxy* m_pNetProxy; int m_nReconnectTimeInterval; //重连时间间隔 time_t m_nLastReconnectTime; //上次重连时刻 CFlowStatistics* m_pFlowStatistics;};

接着我们在 CSocketClient 的构造函数中创建这个 m_pImpl 对象,在 CSocketClient 析构函数中释放这个对象。

CSocketClient::CSocketClient(){	m_pImpl = new Impl();}
CSocketClient::~CSocketClient(){ delete m_pImpl;}

这样,原来需要引用的成员变量,可以在 CSocketClient 内部使用 m_pImpl->变量名 来引用了。

这里仅仅以演示隐藏 CSocketClient 的成员变量为例,隐藏其私有方法与此类似,都是变成类 Impl 的方法。

需要强调的是,在实际开发中,由于 Impl 类是 CSocketClient 的辅助类,离开了 CSocketClient 类, Impl 也没有独立存在的意义,所以一般会将 Impl 类定义成 CSocketClient 的内部类。即采用如下形式:

/** * 网络通信的基础类, SocketClient.h * zhangyl 2017.07.11 */class CSocketClient{public:    CSocketClient();    ~CSocketClient();
//重复的代码省略...
private: class Impl; Impl* m_pImpl;};

然后在 ClientSocket.cpp 中定义 Impl 类的实现:

/** * 网络通信的基础类, SocketClient.cpp * zhangyl 2017.07.11 */class  CSocketClient::Impl{public:	SOCKET                          m_hSocket;    short                           m_nPort;    char                            m_szServer[64];    long                            m_nLastDataTime;        //最近一次收发数据的时间    long                            m_nHeartbeatInterval;   //心跳包时间间隔,单位秒    CRITICAL_SECTION                m_csLastDataTime;       //保护m_nLastDataTime的互斥体     HANDLE                          m_hSendDataThread;      //发送数据线程    HANDLE                          m_hRecvDataThread;      //接收数据线程    std::string                     m_strSendBuf;    std::string                     m_strRecvBuf;    HANDLE                          m_hExitEvent;    bool                            m_bConnected;    CRITICAL_SECTION                m_csSendBuf;    HANDLE                          m_hSemaphoreSendBuf;    HWND                            m_hProxyWnd;    CNetProxy*                      m_pNetProxy;    int                             m_nReconnectTimeInterval;    //重连时间间隔    time_t                          m_nLastReconnectTime;        //上次重连时刻    CFlowStatistics*                m_pFlowStatistics;}
CSocketClient::CSocketClient(){ m_pImpl = new Impl();}
CSocketClient::~CSocketClient(){ delete m_pImpl;}

在实际的开发中,如果使用的是 C++ 语法,Impl 类的声明和定义不仅可以使用 class 关键字,也可以使用 struct 关键字,在 C++ 语法中,struct 类型也可以定义成员方法,同时默认所有成员变量和方法都是 public 的。当然,如果使用的 C 语法,由于C不支持 class 关键字且不支持 struct 中定义成员方法,因此只能使用 struct 类型,也不能用于隐藏成员方法。如果使用 struct 上述代码可以这么改写:

/*** 网络通信的基础类, SocketClient.h* zhangyl 2017.07.11*/class CSocketClient{public:   CSocketClient();   ~CSocketClient();
//重复的代码省略...
private: struct Impl; Impl* m_pImpl;};
/** * 网络通信的基础类, SocketClient.cpp * zhangyl 2017.07.11 */struct  CSocketClient::Impl{	SOCKET                          m_hSocket;    short                           m_nPort;    char                            m_szServer[64];    long                            m_nLastDataTime;        //最近一次收发数据的时间    long                            m_nHeartbeatInterval;   //心跳包时间间隔,单位秒    CRITICAL_SECTION                m_csLastDataTime;       //保护m_nLastDataTime的互斥体     HANDLE                          m_hSendDataThread;      //发送数据线程    HANDLE                          m_hRecvDataThread;      //接收数据线程    std::string                     m_strSendBuf;    std::string                     m_strRecvBuf;    HANDLE                          m_hExitEvent;    bool                            m_bConnected;    CRITICAL_SECTION                m_csSendBuf;    HANDLE                          m_hSemaphoreSendBuf;    HWND                            m_hProxyWnd;    CNetProxy*                      m_pNetProxy;    int                             m_nReconnectTimeInterval;    //重连时间间隔    time_t                          m_nLastReconnectTime;        //上次重连时刻    CFlowStatistics*                m_pFlowStatistics;}
CSocketClient::CSocketClient(){ m_pImpl = new Impl();}
CSocketClient::~CSocketClient(){ delete m_pImpl;}

使用 struct 写法更简洁了

按上面所说的方式,CSocketClient 这个类除了保留对外的接口,其内部实现用到的变量和方法基本上对使用者就不可见了。这种做法,我们称为 pimpl 惯用法,即 Pointer to Implementation (也有人认为是 Private Implementation)。

现在来总结一下这个方法的优点:

  • 保护核心数据和实现原理;

    核心实现细节被隐藏,不必暴露在外,对使用者透明,因此保护了核心数据和实现原理。

  • 降低编译依赖,提高编译速度;

    由于原来的头文件的成员变量可能是一些复合类型,可能会递归地调用其他构造函数,使用了 pimpl 惯用法以后,原来的头文件变得更“干净”,这样其他的类在引用这个头文件时,依赖的类型就更少,因此加快了编译速度。

  • 接口与实现分离。

    使用了 pimpl 惯用法之后,即使 CSocketClient 或者 Impl 类的实现细节发生了变化,对使用者都是透明的,对外的 CSocketClient 类声明仍然可以保持不变。

智能指针用于 pimpl 惯用法

C++ 11 标准引入了智能指针对象,我们可以使用 std::unique_ptr 对象来管理上述用于隐藏具体实现的 m_pImpl 指针。

SocketClient.h 文件可以修改成如下方式:

#include <memory> //for std::unique_ptr
class CSocketClient{public: CSocketClient(); ~CSocketClient();
//重复的代码省略...
private: struct Impl; std::unique_ptr<Impl> m_pImpl;};

SocketClient.cpp 中修改 CSocketClient 对象的构造函数和析构函数的实现如下:

构造函数

如果你的编译器仅支持 C++ 11 标准,我们可以按如下修改:

CSocketClient::CSocketClient(){    //C++11 标准并未提供 std::make_unique(),该方法是 C++14 提供的    m_pImpl.reset(new Impl());}

如果你的编译器支持 C++14 及以上标准,可以这么修改:

CSocketClient::CSocketClient() : m_pImpl(std::make_unique<Impl>()){    }

由于已经使用了智能指针管理了 m_pImpl 指向的堆内存,析构函数中不再需要显式释放堆内存:

CSocketClient::~CSocketClient(){    //不再需要显式 delete 了     //delete m_pImpl;}

pimp 惯用法是实际 C/C++ 项目开发一种非常有用的代码编写策略,希望读者可以掌握其实现方法和用途。

推荐阅读

聊聊技术人员的常见职业问题

分享电驴等一些优质的开源软件的源码

侵入式服务与非侵入式服务结构

2019 年终总结

高性能服务器开发 2019 年原创汇总


我建立了专门的微信交流群,如果读者想加群,与 QQ 群不同的是,群内要求实名,方便大家交流技术,由于是实名,也可以进行内推、找房等信息恭喜是哪个,需要加群的小伙伴可以加小编微信号 Balloonwj,我拉你入群,记得备注“你的姓名 + 职业 + 加微信群”哦。


评论