如何設計一個C++的類?
本文轉載自微信公眾號「程序喵大人」,作者程序喵大人。轉載本文請聯系程序喵大人公眾號。
事先聲明,本文只代表程序喵個人觀點,文中肯定會有部分或大多數觀點和大家的想法不一致,大家可以在評論區交流!
什么是類?
我理解類是現實世界的描述,是對業務的抽象,類設計的好不好多半取決于你抽象的巧不巧。
類的設計最重要的一點是要表示來自某個領域的概念,拿我最近在做的音視頻剪輯來舉例,剪輯業務中有軌道的概念,也有片段的概念,每個軌道可包含多個片段,這時候就有些問題需要考慮,在現實世界中,軌道可以復制嗎?片段可以復制嗎?軌道可以移動嗎?片段可以移動嗎?
然后我們就可以進一步將現實世界中的軌道和片段抽象成類了,可分為兩個類,一個軌道類,一個片段類,兩個類是否需要提供拷貝構造函數和移動構造函數,完全取決于它們在現實世界的樣子。
tips:類的名字應該明確告訴用戶這個類的用途。
類需要自己寫構造函數和析構函數嗎?
反正我每次定義一個類的時候都會明確把構造函數和析構函數寫出來,即便它是空實現,即便我不寫編譯器也會視情況默認生成一個,自動生成的稱為默認構造函數。但我不想依賴編譯器,也建議大家不要過度依賴編譯器,明確寫出來構造函數和析構函數也是一個好習慣,多數情況下類沒有那么簡單,多數情況下編譯器默認生成的構造函數和析構函數不一定是我們想要的。默認的構造函數不會給我們的數據成員初始化,所以需要自己寫一個構造函數,其實在構造函數里的語句也不能稱之為初始化,那是個賦值操作,真正的初始化可以通過初始化列表方式或者聲明成員時直接給初值,類似下面的代碼。如果我們的類有指針數據成員,我們在某個地方為其分配了一塊內存,編譯器自動生成的析構函數默認是不會將這塊內存釋放掉的,為了規避這潛在的風險,還是自己寫一個吧!
tips:編譯器在某些情況下會生成移動構造函數或移動賦值運算符,但記住這些情況太麻煩了,建議手動控制,明確要的時候就自己寫一個,明確不要的時候就delete掉。
- class A {
- public:
- A() : a_(2) {}// 一種初始化,標準初始化形式
- ~A() {}
- private:
- int a_;
- int b_ = 3; // 另一種初始化
- };
類需要手動聲明默認構造函數嗎?
什么是默認構造函數?看下百度百科的定義:
默認構造函數(default constructor)就是在沒有顯式提供初始化式時調用的構造函數。它由不帶參數的構造函數,或者為所有的形參提供默認實參的構造函數定義。如果定義某個類的變量時沒有提供初始化時就會使用默認構造函數。
這和上一個問題類似,首先需要了解什么時候需要默認構造函數,看下面這段代碼。當已經為一個類提供了帶有參數的構造函數,編譯器不會為該類再默認的生成構造函數,如果此時在其它地方以無參形式構造了該類的一個對象,編譯器就會報錯,找不到對應的構造函數,那怎么解決?一種方法是為類設置一個無參的默認構造函數(像下面代碼這樣),另一種方法是自己提供一個對應的構造函數。我傾向于后一種方式,前一種方式只能解決編譯上的問題,但還有可能存在潛在的bug。
- class A {
- A(int a) {}
- A() = default;
- };
數據成員是設置private還是public還是protected?
三種訪問權限就不過多介紹了,說說我平時是怎么設置數據成員權限的吧!對于普通成員變量,我全是private,除非該類作為基類,而子類也需要訪問父類的私有成員,這時候我會將父類的private改為protected。什么時候用public呢?一般情況下只會對某些靜態常量我會考慮使用public修飾,前提是外部有訪問此常量的需求。
- class A {
- public:
- constexpr static int kConstValue = 2;
- private:
- int a_;
- };
類需要虛析構函數嗎?
這個很明確,如果類會作為基類被派生時,該基類的析構函數就一定要聲明為虛函數,如果某個類確定不會被派生,那就不要聲明其析構函數為虛函數。
類需要提供拷貝構造函數嗎?
這里需要考慮清楚,需要明確究竟是否提供,這需要結合這個類在現實生活中的實際意義,類是某個領域某個業務某個實物的抽象,假設有一個試卷類,因為試卷可以拷貝,那就明確提供拷貝構造函數,假設有一個Person類,因為不允許克隆人,那就明確禁用拷貝構造函數。這里也可以參考智能指針中的unique_ptr,該智能指針就明確禁用了拷貝操作。
類需要提供移動構造函數嗎?
移動構造是C++11引入的新特性,這里涉及到左值右值等概念,具體可以看我這篇文章:《c++11新特性,所有知識點都在這了!》
一個類具有移動構造函數才具備移動語義,如果追求資源管理的效率,move資源效率一般會比拷貝一個資源高一些。
這里重點討論是否需要提供移動構造函數,答案還是,要想清楚,要結合實際情況,假設我們定義了一個美國總統的類,可以提供移動構造函數,因為美國總統幾年就會換一個,再假設我們定義了一個美國最傻吊總統的類,那就應該禁用移動構造函數,因為只有懂王一個,永遠不可移動。
排坑:賦值運算符需要考慮是否能正確的防止自身給自身賦值?
- class A {
- public:
- A();
- A(const A& rhs);
- A& operator=(const A& rhs) {
- if (this == &rhs) return *this; // 必須的
- delete m_ptr;
- m_ptr = new int[5];
- memcpy(m_ptr, rhs.m_ptr, 5);
- return *this;
- }
- private:
- int* m_ptr;
- };
成員函數什么時候使用const修飾?
這里需要知道成員函數使用const修飾代表什么意思,代表在此函數內不能修改類的數據成員,如果在const修飾的成員函數內修改了成員變量,那編譯器會編譯失敗。其實不標const也不會有任何問題,但是如果我們期望某個函數內不會修改任何成員變量時,應該把該成員函數標記為const,這樣可以防止自己或者其它程序員誤操作,當誤更改了某些成員變量時,編譯器會報錯。
如果你期望在某個成員函數內不更改成員函數,而又沒有標記為const,這時自己或者其他人在此函數內改動了某些成員變量,編譯器對此沒有任何提示,這就有可能產生潛在的bug。
tips:const對象上只能調用const成員函數,非const對象上既可以調用非const成員函數,也可以調用const成員函數。
什么時候需要加noexcept?
如果確認某個函數不會拋出異常,那就標記為noexcept,這樣編譯器可以對函數做進一步優化(具體做了什么優化,我也不知道),提供程序運行效率,總之,盡量把能標記為noexcept的都標記為noexcept。
函數傳參問題?
函數傳參無非就是傳值還是傳引用的選擇問題:
參數需要在函數內修改,并在函數外使用修改后的值時:傳引用
參數需要在函數內修改,但在函數外使用修改前的值時:傳值
參數在函數內不會修改,參數類型如果為基礎類型(int等):傳值
參數在函數內不會更改,參數類型如果為class類型:傳const引用
類的聲明和實現要分開寫到不同文件中嗎?
一般來說類的聲明會寫到頭文件,類的定義會寫到源文件中,但也有很多人會把定義寫到頭文件中,我還見過有人#include "xxx.cpp"呢,這里建議,不想讓函數內聯,那就把定義寫到源文件中。如果非內聯函數在頭文件中定義,多個源文件都引用此頭文件時編譯器就會報錯。至于類的聲明寫到頭文件還是源文件中,視情況而定,看下面這段代碼,某些類的聲明寫到了頭文件中,又有些類的聲明寫到了源文件中!
- // a.h
- class AImpl;
- class A {
- public:
- A();
- ~A();
- void func();
- private:
- AImpl *impl_;
- };
源文件如下:
- // a.cc
- class AImpl {
- public:
- void func() {
- std::cout << "real func \n";
- }
- };
- A::A() {
- impl_ = new AImpl;
- }
- A::~A() {
- delete impl_;
- }
- void A::func() {
- _impl->func();
- }
是否需要異常處理?
關于異常處理詳細的介紹可以看我這篇文章:《你的c++團隊還在禁用異常處理嗎?》
這里拋磚引玉下,如果是服務端編程,建議使用異常處理替代錯誤碼的錯誤處理方式,關于異常處理有兩個常見問題:
構造函數可以使用異常嗎
析構函數可以使用異常嗎?
結論是構造函數在處理錯誤時可以使用異常,而且建議使用異常,析構函數中也可以使用異常,但不要讓異常從析構函數中逃離,有異常要在析構函數中捕獲處理掉。
tips:異常處理方式盡量方便好用,但是它會使得程序體積增大10%-20%左右,如果對程序體積敏感的環境,我能想到的主要是嵌入式或者移動端編程環境,需要謹慎考慮下。
是否需要標記為inline?
inline的優點是可以減少函數調用的開銷,inline的缺點是容易導致代碼段體積變大,如果某個函數體非常短,比如兩三行代碼而且會被頻繁調用,可以考慮標記為inline,如果太長的且不追求極致性能的情況下,就沒必要標記為inline。
tips:inline關鍵字只是開發者給編譯器的請求,建議編譯器做內聯處理,編譯器具體做不做內聯還得看它心情。
final override virtual關鍵字的使用
如果確定某個類永遠不會被其他類繼承,那就就明確將該類標記為final,這可防止其他人繼承!
如果子類想要重寫基類某個虛函數時,可以將此函數標記為override,那該函數必須重寫父類虛函數,否則編譯器報錯。
標明某個函數是虛函數,有子類繼承時可以改寫此函數的行為。
tips:注意構造函數和析構函數中不要調用虛函數
類內考慮使用智能指針
直接看代碼:
- class A {
- public:
- A() {
- a_ = new int;
- }
- ~A() {
- delete a_;
- }
- private:
- int* a_;
- };
可以考慮改為:
- class A {
- public:
- A() {
- a_ = std::make_unique<int>();
- }
- ~A() {}
- private:
- int* a_;
- };
使用智能指針來管理類內的內存更方便且更安全。
什么時候使用explict避免隱式轉換?
explict多數情況下用于修飾只有一個參數的類構造函數,表示拒絕隱式類型轉換。那什么時候使用explict關鍵字呢,還是看情況。
比如vector的單參數構造函數就是explict,而string則不是explict。因為vector接收的單參數類型時int類型,表示vector的容量,如果希望int型隱式自動轉換成vector,那這個int是表示容量還是表示vector中的內容呢,有點牽強,所以vector中的單參數構造函數是explict的。而string接收的單參數是const char*類型,一個const char*隱式轉換string很正常,也很符合邏輯,所以不需要標記為explict。
函數參數個數多少合適?
個人習慣最多四個,超過四個我一般就會封裝到一個結構體作為參數傳遞。
類設計原則:
這里我沒有學術式的列出面向對象的幾大原則,而是把我認為重要的點都列在了這里:
接口一致原則:行為與名字相匹配
誤操作防御原則:邊界處理,能加const就加const,能用智能指針就用智能指針
依賴倒置原則:針對接口編程,依賴于抽象而不依賴于具體,抽象(穩定)不應依賴于實現細節(變化),實現細節應該依賴于抽象,因為穩定態如果依賴于變化態則會變成不穩定態。
開放封閉原則:對擴展開放,對修改關閉,業務需求是不斷變化的,當程序需要擴展的時候,不要去修改原來的代碼,而要靈活使用抽象和繼承,增加程序的擴展性,使易于維護和升級,類、模塊、函數等都是可以擴展的,但是不可修改。
單一職責原則:一個類只做一件事,一個類應該僅有一個引起它變化的原因,并且變化的方向隱含著類的責任。
里氏替換原則:子類必須能夠替換父類,任何引用基類的地方必須能透明的使用其子類的對象,開放關閉原則的具體實現手段之一。
接口隔離原則:接口最小化且完備,盡量少public來減少對外交互,只把外部需要的方法暴露出來。
最少知道原則:一個實體應該盡可能少的與其他實體發生相互作用。
將變化的點進行封裝,做好分界,保持一側變化,一側穩定,調用側永遠穩定,被調用測內部可以變化。
優先使用組合而非繼承,繼承為白箱操作,而組合為黑箱,繼承某種程度上破壞了封裝性,而且父類與子類之間耦合度比較高。
針對接口編程,而非針對實現編程,強調接口標準化。
?根據實際情況選擇遵循某些原則,完善程序。
tips:對于設計模式而言,不能一步到位,剛開始編程時不要把太多精力放到設計模式上,需求總是變化的,剛開始著重于實現,一般敏捷開發后為了應對變化重構再決定采取合適的設計模式。
注意事項
- 不要引用沒有必要的頭文件!
- 暴露給用戶的頭文件要想清楚該暴露什么,不該暴露什么,外部頭文件不要引用內部頭文件
- 類成員變量確保作保初始化工作
- 不要讓異常逃離析構函數
- 構造函數或析構函數不要調用虛函數
- 不要返回函數局部對象的指針或引用
- 盡量不要返回函數內部堆對象的指針或引用,容易產生內存泄漏,盡量遵循誰申請誰釋放的原則
參考資料
http://coder.amazingdemo.top/post/cpp_%E8%AE%BE%E8%AE%A1%E9%AB%98%E6%95%88%E7%9A%84%E7%B1%BB/