pimpl 慣用法

GJQI12發表於2020-12-19

現在這裡有一個名為 CSocketClient 的網路通訊類,定義如下:

/**

  • 網路通訊的基礎類, SocketClient.h
  • zhangyl 2017.07.11
    */
    class CSocketClient
    {
    public:
    CSocketClient();
    ~CSocketClient();

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 這樣的標頭檔案提供給第三方使用時,都會讓庫的作者心裡隱隱不安——因為 SocketClient.h 檔案中 SocketClient 類大量的成員變數和私有函式暴露了這個類太多的實現細節,很容易讓使用者看出實現原理。這樣的標頭檔案,對於一些不想對使用者暴露核心技術實現的庫和 sdk,是非常不好的。

那有沒有什麼辦法既能保持對外的介面不變,又能儘量不暴露一些關鍵性的成員變數和私有函式的實現方法呢?有的。我們可以將程式碼稍微修改一下:

/**

  • 網路通訊的基礎類, SocketClient.h
  • zhangyl 2017.07.11
    */
    class Impl;

class CSocketClient
{
public:
CSocketClient();
~CSocketClient();

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:
Impl* m_pImpl;
};

上述程式碼中,所有的關鍵性成員變數已經沒有了,取而代之的是一個型別為 Impl 的指標成員變數 m_pImpl。

具體採用什麼名稱,讀者完全可以根據自己的實際情況來定,不一定非要使用這裡的 Impl 和 m_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 的輔助類, 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:
    void LoadConfig()
    {
    //方法的具體實現
    }

    //其他方法省略…

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;
}

現在CSocketClient 這個類除了保留對外的介面以外,其內部實現用到的變數和方法基本上對使用者不可見了。C++ 中對類的這種封裝方式,我們稱之為 pimpl 慣用法,即 Pointer to Implementation (也有人認為是 Private Implementation)。

在實際的開發中,Impl 類的宣告和定義既可以使用 class 關鍵字也可以使用 struct 關鍵字。在 C++ 語言中,struct 型別可以定義成員方法,但 struct 所有成員變數和方法預設都是 public 的。

現在來總結一下這個方法的優點:

核心資料成員被隱藏;

核心資料成員被隱藏,不必暴露在標頭檔案中,對使用者透明,提高了安全性。

降低編譯依賴,提高編譯速度;

由於原來的標頭檔案的一些私有成員變數可能是非指標非引用型別的自定義型別,需要在當前類的標頭檔案中包含這些型別的標頭檔案,使用了 pimpl 慣用法以後,這些私有成員變數被移動到當前類的 cpp 檔案中,因此標頭檔案不再需要包含這些成員變數的型別標頭檔案,當前標頭檔案變“乾淨”,這樣其他檔案在引用這個標頭檔案時,依賴的型別變少,加快了編譯速度。

介面與實現分離。

使用了 pimpl 慣用法之後,即使 CSocketClient 或者 Impl 類的實現細節發生了變化,對使用者都是透明的,對外的 CSocketClient 類宣告仍然可以保持不變。例如我們可以增刪改 Impl 的成員變數和成員方法而保持 SocketClient.h 檔案內容不變;如果不使用 pimpl 慣用法,我們做不到不改變 SocketClient.h 檔案而增刪改 CSocketClient 類的成員。

智慧指標用於 pimpl 慣用法

C++ 11 標準引入了智慧指標物件,我們可以使用 std::unique_ptr 物件來管理上述用於隱藏具體實現的 m_pImpl 指標。

SocketClient.h 檔案可以修改成如下方式:

#include //for std::unique_ptr

class CSocketClient
{
public:
CSocketClient();
~CSocketClient();

//重複的程式碼省略...

private:
struct Impl;
std::unique_ptr 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())
{
}

由於已經使用了智慧指標來管理 m_pImpl 指向的堆記憶體,因此解構函式中不再需要顯式釋放堆記憶體:

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

pimp 慣用法是 C/C++ 專案開發中一種非常實用的程式碼編寫策略,建議讀者掌握它。

相關文章