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


























