C++接口文件小技巧之PIMPL詳解
C++ 里面有一些慣用法(idioms),如 RAII,PIMPL,copy-swap、CRTP、SFINAE 等。今天要說的是 PIMPL,即 Pointer To Implementation,指向?qū)崿F(xiàn)的指針。
問題描述
在實(shí)際的項(xiàng)目中,經(jīng)常需要定義和第三方/供應(yīng)商的 C++ 接口。假如有這樣一個接口:
#include <string> #include <list> #include "dds.h" class MyInterface { public: int publicApi1(); int publicApi2(); private: int privateMethod1(); int privateMethod2(); int privateMethod3(); private: std::string name_; std::list<int> list_; DDSDomainPariciant dp_; DDSTopic topic_; DDSDataWriter dw_; };
該接口頭文件存在以下問題:
1.暴露了 MyInterface 內(nèi)部實(shí)現(xiàn)。
所有的 private/protected 的方法、成員變量都暴露給接口的使用者
2.由此帶來的另一個問題是接口不穩(wěn)定。比如我們修改類的內(nèi)部實(shí)現(xiàn),即使不改變 public 接口,接口的使用者也需要跟著更新頭文件:
- 比如 list_ 成員之前用的是 std::list 容器,現(xiàn)在打算改用 std::vector 容器
- 再比如,之前有 3 個 private 方法,現(xiàn)在重構(gòu)實(shí)現(xiàn)部分,拆成更多的小函數(shù)
3.增加了使用者的依賴。
接口的使用者想要使用上述頭文件,必須要 #include "dds.h" 這個文件,而 "dds.h" 通常又會 #include 很多其他文件。最終的結(jié)果往往是要向接口的使用者提供很多額外的頭文件。如果將來重構(gòu),不用 DDS,改用 SOME/IP 或其他中間件,接口的使用者也要跟著改變。不僅如此,為 private 成員而額外 #include 的頭文件也會增加編譯時間
解決方案 —— PIMPL
PIMPL 就是 C++ 里專門用來解決這些問題的慣用法。PIMPL 將 MyInterface 類的具體實(shí)現(xiàn)(private/protected 方法、成員)轉(zhuǎn)移到另外一個嵌套類 Impl 中,然后利用前向聲明(forward declaration)聲明 Impl,并在原有的 MyInterface 接口類中增加一個指向 Impl 對象的指針。再次強(qiáng)調(diào),在 MyInterface 中的 Impl 僅僅是一個前向聲明,MyInterface 類只知道有 Impl 這么個類,但是對 Impl 有哪些方法、哪些成員變量一無所知,因此能做的事情非常有限(聲明一個指向該類的指針就是其中之一)。而這恰恰就是 PIMPL 將接口和實(shí)現(xiàn)解耦的關(guān)鍵所在。
應(yīng)用 PIMPL 后的 MyInterface.h 文件:
class MyInterface { public: MyInterface(); ~MyInterface(); int publicApi1(); int publicApi2(); private: struct Impl; Impl* impl_; };
現(xiàn)在 MyInterface.h 接口文件變得非常清爽,看不到任何 private/protected 的方法和成員變量,也不需要 #include 任何和 private 成員相關(guān)的頭文件,隱藏實(shí)現(xiàn)細(xì)節(jié),降低使用者的依賴,提高接口穩(wěn)定性。
MyInterface.cpp
#include <string> #include <list> #include "dds.h" struct MyInterface::Impl { int publicApi1(); int publicApi2(int i); int privateMethod1(); int privateMethod2(); int privateMethod3(); std::string name_; std::list<int> list_; DDSDomainPariciant dp_; DDSTopic topic_; DDSDataWriter dw_; }; MyInterface::MyInterface() : pimpl_(new Impl()) {} MyInterface::~MyInterface() { delete pimpl_; } int MyInterface::publicApi1() { impl_->publicApi1(); } int MyInterface::publicApi2(int i) { impl_->publicApi2(i); } // 其他 MyInterface::Impl 類的方法實(shí)現(xiàn) // 原本 MyInterface 中的邏輯挪到 MyInterface::Impl 中 int MyInterface::Impl::publicApi1() {...}
可以看到,MyInterface 類的實(shí)現(xiàn)本身只是單純地將請求委托/轉(zhuǎn)發(fā)給 MyInterface::Impl 的同名方法。對于參數(shù)的傳遞,也可以適當(dāng)使用 std::move 提升效率(關(guān)于 std::move 今后也可以展開說說)。
也可以把嵌套類 MyInterface::Impl 放到單獨(dú) MyInterfaceImpl.h/cpp 中,如此一來 MyInterface.cpp 就會變得非常簡潔,就像下面這樣:
MyInterface.cpp
#include "MyInterface.h" #include "MyInterfaceImpl.h" MyInterface::MyInterface() : pimpl_(new Impl()) {} MyInterface::~MyInterface() { delete pimpl_; } int MyInterface::publicApi1() { return impl_->publicApi1(); } int MyInterface::publicApi2(int i) { return impl_->publicApi2(i); }
MyInterfaceImpl.h
#include <string> #include <list> #include "dds.h" struct MyInterface::Impl { int publicApi1(); int publicApi2(int i); int privateMethod1(); int privateMethod2(); int privateMethod3(); std::string name_; std::list<int> list_; DDSDomainPariciant dp_; DDSTopic topic_; DDSDataWriter dw_; };
MyInterfaceImpl.cpp
#include "MyInterfaceImpl.h" int MyInterface::Impl::publicApi1() { // ... } // 其他 MyInterface::Impl 類的方法定義
注意不要在 MyInterface.h 中 #include "MyInterfaceImpl.h",否則就前功盡棄了。
現(xiàn)代 C++ 中的 PIMPL
以上是傳統(tǒng) C++ 中的 PIMPL 的實(shí)現(xiàn),現(xiàn)代 C++ 應(yīng)盡量避免使用裸指針,而使用智能指針。具體的原因見文末補(bǔ)充內(nèi)容。
Impl 對象的所有權(quán)應(yīng)該是 MyInterface 獨(dú)有 ,unique_ptr 是合情合理的選擇。如果直接將上述的裸指針替換成 unique_ptr
#include <memory> class MyInterface { public: MyInterface(); int publicApi1(); int publicApi2(); private: struct Impl; std::unique_ptr<Impl> impl_; }; // main.cpp int main() { MyInterface if; }
gcc 下會看到這樣的報(bào)錯:
/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/unique_ptr.h: In instantiation of 'constexpr void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = MyInterface::Impl]':
/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/unique_ptr.h:404:17: required from 'constexpr std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = MyInterface::Impl; _Dp = std::default_delete<MyInterface::Impl>]'
<source>:118:7: required from here
/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/unique_ptr.h:97:23: error: invalid application of 'sizeof' to incomplete type 'MyInterface::Impl'
97 | static_assert(sizeof(_Tp)>0,
| ^~~~~~~~~~~
揭曉答案前,先思考一下,問題出在哪里。
問題出在 MyInterface 的析構(gòu)函數(shù)。在沒有顯式聲明析構(gòu)函數(shù)的情況下,編譯器會默認(rèn)合成一個隱式內(nèi)聯(lián)的析構(gòu)函數(shù)(編譯器在什么條件下,自動合成哪些函數(shù)也有不少學(xué)問,后面會單獨(dú)發(fā)一篇),即等效如下代碼:
class MyInterface { public: int publicApi1(); int publicApi2(); ~MyInterface(){} // 是實(shí)現(xiàn),不是聲明! private: struct Impl; std::unique_ptr<Impl> impl_; };
在 MyInterface.h 中,編譯器會自動合成 MyInterface 的析構(gòu)函數(shù)的實(shí)現(xiàn)(而非聲明),在這個析構(gòu)函數(shù)實(shí)現(xiàn)里,會進(jìn)行以下操作:
- 執(zhí)行空的析構(gòu)函數(shù)體
- 按照構(gòu)造的相反順序,依次銷毀 MyInterface 的成員
- 銷毀 unique_ptr impl_ 成員
- 調(diào)用 unique_ptr 的析構(gòu)函數(shù)
- unique_ptr 的析構(gòu)函數(shù)調(diào)用默認(rèn)的刪除器(delete),刪除指向的 Impl 對象
我們所看到報(bào)錯,就出在第 5 步。unique_ptr 的實(shí)現(xiàn)代碼在刪除前,會進(jìn)行 static_assert(sizeof(_Tp)>0
斷言,而編譯器執(zhí)行該斷言的時候,Impl 還是一個不完整類型(Incomplete Type)。因?yàn)榫幾g器此時只看到了 MyInterface::Impl 的前向聲明,還沒有看到定義,不知道 Impl 有哪些成員,也不知 Impl 類占用多大內(nèi)存,所以在進(jìn)行 sizeof(Impl) 的時候報(bào)錯。
知道了背后的原理,解決起來也很簡單,就是保證在 MyInterface 析構(gòu)函數(shù)實(shí)現(xiàn)的地方,能看到 Impl 類的定義即可:
MyInterface.h
#include <memory> class MyInterface { public: int publicApi1(); int publicApi2(); MyInterface(); ~MyInterface(); // 使用 unique_ptr 的關(guān)鍵:只聲明,不實(shí)現(xiàn)! private: struct Impl; std::unique_ptr<Impl> impl_; };
MyInterface.cpp
#include <memory> #include "MyInterface.h" #include "MyInterfaceImpl.h" MyInterface::MyInterface() : pImpl_(std::make_unique<Impl>()) {} MyInterface::~MyInterface() = default; int MyInterface::publicApi1() { return impl_->publicApi1(); } int MyInterface::publicApi2(int i) { return impl_->publicApi2(i); }
這樣,一個正確的 PIMPL 就搞定啦!雖然 PIMPL 多了一層封裝,稍微增加了一點(diǎn)點(diǎn)復(fù)雜度,但我認(rèn)為這么做是絕對的利大于弊。以一個我曾參與的項(xiàng)目為例,在將近一年的時間里,實(shí)現(xiàn)庫更新了很多版,但是接口文件從釋放以來一直沒變過,大大減少了和第三方/供應(yīng)商的溝通、調(diào)試成本。
最后,留一個思考題:為什么將 unique_ptr 換成 shared_ptr 不會遇到上面的 static_assert(sizeof(_Tp)>0
編譯錯誤?如果你能解釋其中的原因,那說明你對 shared_ptr、unique_ptr 的理解相當(dāng)深入了
知識補(bǔ)充
裸指針七宗罪
1.裸指針無法說明指向的是單個對象還是一個數(shù)組
2.裸指針無法說明使用完指針是否需要析構(gòu),即從聲明中看不出來指針是否擁有所指向的對象
3.即使知道需要析構(gòu),也不知道應(yīng)該用 delete 還是調(diào)用某個類似 deinit(p) 的函數(shù)
4.即使知道用 delete,也不知道用 delete 還是 delete[](見理由 1)
5.即使知道如何析構(gòu),還要保證在整個路徑上,剛好只調(diào)用一次析構(gòu):少調(diào)用導(dǎo)致資源泄露,調(diào)用多次將產(chǎn)生未定義行為(如同一指針 delete 兩次可能導(dǎo)致程序崩潰)
6.空懸指針(dangling pointer):對象已析構(gòu),但仍有指針指向它
7.(我自己硬湊的)使用不便:取地址、解引用、通過 -> 來訪問成員,比 . 多按兩個鍵,手指移動距離遠(yuǎn),容易按錯...
解決方案
(我自己瞎說的)能不用就不用,能用對象用對象,不要什么都無腦 new 堆上
智能指針:unique_ptr(默認(rèn)首選), shared_ptr(除非明確需要共享所有權(quán)), weak_ptr
到此這篇關(guān)于C++接口文件小技巧之PIMPL詳解的文章就介紹到這了,更多相關(guān)C++ PIMPL內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++20 新特性 協(xié)程 Coroutines(2)
上篇文章簡單給大介紹了 C++20 特性 協(xié)程 Coroutines co_yield 和 co_return 那么這篇文章繼續(xù)給大家介紹C++20 的新特性協(xié)程 Coroutines co_await,需要的朋友可以參考一下2021-10-10教你使用Matlab制作圖形驗(yàn)證碼生成器(app designer)
這篇文章主要和大家分享如何利用Matlab制作一款圖形驗(yàn)證碼生成器,文中的實(shí)現(xiàn)步驟講解詳細(xì),感興趣的小伙伴可以跟隨小編動手試一試2022-02-02C++文件關(guān)鍵詞快速定位出現(xiàn)的行號實(shí)現(xiàn)高效搜索
這篇文章主要為大家介紹了C++文件關(guān)鍵詞快速定位出現(xiàn)的行號實(shí)現(xiàn)高效搜索,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-10-10