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