最近刷了一些題,也面試了一些公司,把關於c++中關於類的一些概念總結了一下。
在這里也反思一下,面試前信心滿滿自以為什么都懂,毫無准備就大膽得去了,然后就覺得自己臉都被打腫了。回來認認真真刷題,這陣子都不敢再去面試了~~。
1. 類的訪問屬性:public,protect,private
C++中類的成員變量和函數都帶有三種屬性中的一種,假如沒有特別聲明,那么就默認是私有的(除了構造函數)。public表示是公開的,對象可以直接調用的變量或者函數;protect表示是保護性的,只有本類和子類函數能夠訪問(注意只是訪問,本類對象和子類對象都不可以直接調用),而私有變量和函數是只有在本類中能夠訪問(有個例外就是友元函數,這個后面會詳細說)。
class A { public: A(int b):m_public(b),m_protected(1), m_private(2){} int m_public; protected: int m_protected; private: int m_private; }; int main( void ) { A a(10); cout<<a.m_public<<endl; //正確,可以直接調用 cout<<a.m_protected<<endl; //錯誤,不可以直接調用 cout<<a.m_private<<endl;//錯誤,不可能直接調用 }
而子類對父類的繼承類型也有這三種屬性,分別為公開繼承,保護繼承和私有繼承。
class A { public: A(int b):m_public(b),m_protected(1), m_private(2){} int m_public; protected: int m_protected; private: int m_private; }; class B: public A{} //公有繼承 class C: protected A{} //保護繼承 class D:private A{} //私有繼承
於是問題來了,父類成員的公開屬性有三種,子類的繼承屬性也有同樣的三種,那么一共就有九種搭配(例如公開繼承父類公開成員,私有繼承父類公開成員,保護繼承父類私有成員等等)。
我們只需要記住這兩個里取嚴格的那一種。例如私有繼承父類公開成員,那么在子類里父類的所有屬性都變成子類私有的了。
public繼承 public protected 不可用
protected繼承 protected protected 不可用
private繼承 private private 不可用
class A { public: A(int b):m_public(b){} int m_public; }; class B:private A { public: B(int num):A(num){} }; int main() { B b(10); cout<<b.m_public<<endl; //錯誤,無法直接調用私有成員 }
2. 類的四個默認函數:構造,拷貝構造,賦值,析構(這一點要背下來,面試直接被問了)
- 每當構建一個新的類,編譯器都會給每個類生成以上四個默認的無參構造函數,並且這四個函數都是默認public的。
class A { public: double m_a; }; int main( void ) { A a; //調用默認無參構造函數,此時m_a的值是不確定的,不能用 //離開主函數前調用析構函數,釋放a的內存 }
但是一旦程序員自己定了帶參數的構造函數,那么編譯器就不會再生成默認的無參構造函數了,但是還是有默認的拷貝和賦值構造函數。因此假如只定義了有參數的構造函數,那么這個類就沒有無參構造函數。
class A { public: A(int i):m_a(i){} int m_a; }; int main( void ) { A a; //錯誤!沒有無參構造函數 A a1(5); // 調用了A中程序員定義的有參構造函數 A a2(6); // 調用了A中程序員定義的有參構造函數 A a3 = a1; //此處調用默認的拷貝構造函數 a2 = a1; //此處調用默認的賦值函數 }
上面的程序中尤其需要注意的是 A a3 = a1這一句,雖然有等號,但是仍然是拷貝構造函數。拷貝構造函數和賦值函數的區別在於等式左邊的對象是否已經存在。a2 = a1這一句執行的時候,a2已經存在,因此是賦值函數,而執行A a3 = a1這一句的時候,a3還不存在,因此為拷貝構造函數。
默認的賦值和拷貝構造函數一般只是簡單的拷貝類中成員的值,這一點當類中存在指針成員和靜態成員變量的時候就非常危險。例如以下一種情況:
class A { public: A(int i, int* p):m_a(i), m_ptr(p){}int m_a; int *m_ptr; }; int main( void ) { int m = 10, *p = &m; A a1(3, p); A a2 = a1; //a2 和 a1的m_ptr都指向了同一地址 *p = 100; cout<<*(a2.m_ptr)<<endl; //輸出為100 }
這也就是C++中由於指針帶來的淺拷貝的問題,只賦值了地址,而沒有新建對象。因此假如類中存在靜態變量或者指針成員變量時一定要自己手動定義賦值、拷貝構造、析構函數。
class A { public: A(int i):m_a(i), m_ptr(new int(10)){} A(const A &a) //拷貝構造函數 { m_a = a.m_a; m_ptr = new int(*(a.m_ptr)); } ~A(){ assert(m_ptr!=nullptr); delete m_ptr; } int m_a; int *m_ptr; }; int main( void ) { A a1(3); A a2 = a1; //a2 和 a1的m_ptr指向了不同的地址 *a1.m_ptr = 100; cout<<*(a2.m_ptr)<<endl; //輸出為10 }
- 子類會繼承父類定義的構造函數嗎?
可以理解成不能。子類會繼承父類所有的函數,包括構造函數,但是子類的構造函數會把父類的構造函數覆蓋了,所以看起來就是沒有繼承。假如子類不定義任何構造函數,那么子類只會默認地調用父類的無參構造函數。當父類中只定義了有參構造函數,從而不存在無參構造函數的話,子類就無法創建對象。
class A { public: A(int b):m_public(b){} int m_public; }; class B:public A { }; int main() { B b; //出錯,因為父類沒有無參構造函數 }
因此在這種情況必須要顯示定義子類的構造函數,並且在子類構造函數中顯示調用父類的構造函數。
class A { public: A(int b):m_public(b){} int m_public; }; class B:public A { public: B(int num):A(num){} }; int main() { B b1; //出錯,由於父類沒有無參構造函數,因此B也不存在無參構造 B b2(5); //正確 }
- 構造函數的構造順序:先構造基類,再構造子類中的成員,再構造子類
class A { public: A(){cout<<"constructing A"<<endl;} }; class B { public: B(){cout<<"constructing B"<<endl;} }; class C:public A { public: B b; A a; int num; C(int n):num(n){cout<<"constructing C"<<endl;} }; int main() { C c(1); }
運行結果為:
第一行的constructingA就是在構建基類,然后構建b對象,再構建a對象,最后構建c本身。
而析構的順序就正好是完全反過來,先析構子類,再析構子類中的對象,最后析構基類。
- 假如把構造函數和析構函數定義成私有的會怎樣?(被問的時候真的一臉懵,-_-//)
假如把構造函數定義為私有,那么類就無法直接實例化(還是可以實例化的,只是要轉個彎)。來看下面這個例子:
class A { public: int m_public; static A* getInstance(int num) { return new A(num); } private: A(int b):m_public(b){} }; int main() { A a1(4); //錯誤 A* pa = A::getInstance(5); //正確 }
有些時候,我們不希望一個類被過多地被實例化,比如有關全局的類、路由類等。這時候,我們就可以用這種方法為類設置構造函數並提供靜態方法。
假如把類的析構函數定義為私有,那么就無法在棧中生成對象,而必須要通過new來在堆中生成對象。
另外在這里提及一點,對應的,如何讓類只能在棧中生成,而不能new呢?就是將new 和delete重載為私有。
原因是C++是一個靜態綁定的語言。在編譯過程中,所有的非虛函數調用都必須分析完成。即使是虛函數,也需檢查可訪問性。因些,當在棧上生成對象時,對象會自動析構,也就說析構函數必須可以訪問。而堆上生成對象,由於析構時機由程序員控制,所以不一定需要析構函數。
class A { public: int m_public; static A* getInstance(int num) { return new A(num); } A(int b):m_public(b){} private: ~A(){} }; int main() { A a(5); //錯誤,因為系統無法自動調用析構函數 A *p_a = new A(5); //正確,此時p_a指向的是堆中的內存 }
- 構造函數的初始化列表(就是構造函數冒號后面的東西,叫初始化列表,需要與{}中的函數內容區分開)
有幾種情況必須要使用初始化列表:
常量成員
引用類型
沒有默認構造函數的類類型
class A { public: int m_a; A(int num):m_a(num){} }; class B { public: const int m_const; int &m_ref; A a; B(int num, int b):m_ref(b), m_const(1), a(num) //初始化列表 { cout<<"constructing B"<<endl; } }; int main() { int n = 5; B b(1, n); }
還需要注意的一點是,初始化列表里的真正賦值的順序其實是按照成員變量的聲明順序,而不是初始化列表中顯示的順序。例如這里是先初始化m_const,然后是m_ref,最后是a。
- 隱式轉換與explicit關鍵字的使用
來看下面一個例子:
class A { public: int m_a; A(int num):m_a(num){cout<<"constructing A"<<endl;} }; void test(A a) { cout<<a.m_a+1<<endl; } int main() { A a1= 6; //此時等式右邊的6隱式生成了一個以6為參數的A類對象 test(6); //輸出7,入參也隱式生成了一個以6為參數的A類對象 cout<<a1.m_a<<endl; //輸出6 }
從下面的運行結果可以看出,A的構造函數被調用了兩次。
這種隱式轉換有時候神不知鬼不覺,為了避免這種情況,於是有了explicit關鍵字。當構造函數被聲明為explicit后,就必須要顯式調用構造方法來生成對象了,而無法進行隱式轉換。
class A { public: int m_a; explicit A(int num):m_a(num){cout<<"constructing A"<<endl;} }; void test(A a) { cout<<a.m_a+1<<endl; } int main() { A a1= 6; //錯誤 test(6); //錯誤 }
3. 虛函數
虛函數是C++實現多態的方法。 虛函數和普通函數沒有什么區別,只有當用基類指針調用子類對象的方法時才能夠真正發揮它的作用,也只有在這種情況下,才能真正體現出C++面對對象編程的多態性質。
先來了解一下綁定的概念。函數體與函數調用關聯起來叫做綁定。
- 早綁定:早綁定發送在程序運行之前,也是編譯和鏈接階段。
class A
{
public: int m_a; A(int num):m_a(num){} int add(int n) { return m_a + n; } }; int main() { A a(1); cout<<a.add(5); }
在上面的代碼中,函數add在編譯期間就已經確定了實現。這就是早綁定。所有的非虛函數都是早綁定。
- 晚綁定:晚綁定發生在程序運行期間,主要體現在繼承的多態方面。
引用一句Bruce Eckel的話:“不要犯傻,如果它不是晚邦定,它就不是多態。”
class A { public: int m_a; A(int num):m_a(num){} void virtual show() { cout<<"base function"<<endl; } }; class B:public A { public: B(int n):A(n){} void show() { cout<<"derived function"<<endl; } }; int main() { A *pa = new B(1); pa->show(); //輸出 derived function }
父類和子類都定義了show方法,在繼承的過程中,由於父類show方法是虛函數,而父類指針指向的是子類對象,所以會在子類對象中去找show函數的實現。假如子類中沒有show,那么就還是會調用父類的show。
這個晚綁定的過程是通過虛指針實現的。只要一個類中聲明了有虛函數,那么編譯器就會自動生成一個虛函數表。雖然名字叫表,但本質是一個存放虛函數指針的函數指針數組。一個虛表對應一個指針。當該類作為基類,其派生類對基類的(一個或者多個)虛函數進行重寫時,派生類的虛函數表中,相應的函數指針的值就會發生變化。
構造函數不能聲明為虛函數。虛函數是晚綁定,一定是先有了基類對象,才會有對應的虛指針,再去本類或者子類對象中去找對應的實現。所以一定要先通過構造函數創建了對象,才能去實現虛函數的作用。
而析構函數,則常常被聲明為虛函數。(記得當時面試官問我,析構函數能不能是虛的,我當時斬釘截鐵得回答,不能!-_-//)
先看下面這個例子。
class A
{
public: int m_a; A(int num):m_a(num){cout<<"constructing A"<<endl;} ~A(){cout<<"destructing A"<<endl;} }; class B:public A { public: B(int n):A(n){cout<<"constructing B"<<endl;} ~B(){cout<<"destructing B"<<endl;} }; int main() { A* pa= new B(1); delete pa; }
執行結果為:
可以看到是先構建了A對象,然后構建了B對象。可是卻只析構了A對象,B對象的內存空間就泄漏了。
現在把A的析構函數置為虛函數的話,
class A
{
public: int m_a; A(int num):m_a(num){cout<<"constructing A"<<endl;} virtual ~A(){cout<<"destructing A"<<endl;} }; class B:public A { public: B(int n):A(n){cout<<"constructing B"<<endl;} ~B(){cout<<"destructing B"<<endl;} }; int main() { A* pa= new B(1); delete pa; }
運行結果為:
可以看到這時B對象也被析構了。這正是利用了虛函數晚綁定的特點,當調用基類指針析構函數的時候,先調用B的析構函數,再調用A的析構函數。
對於虛函數始終要注意只有用指針調用的時候才會有作用,假如只是普通的對象調用,虛函數是不起作用的。
class A { public: int m_a; A(int num):m_a(num){} virtual void show() { cout<<"A::show()"<<endl; } virtual ~A(){} }; class B:public A { public: B(int n):A(n){} void show() { cout<<"B::show()"<<endl; } ~B(){} }; int main() { A a(1); a.show(); //輸出A::show() B b(1); b.show(); //輸出B::show() }
4. 成員函數的重載、隱藏與覆蓋
- 成員函數的重載
(1)相同的范圍(在同一個類中)
(2)函數名字相同
(3)參數不同 ,也可以僅僅是順序不同
(4)virtual 關鍵字可有可無
class A { public: int m_a; A(int num):m_a(num){} void show(int n){} //(1) virtual void show(int n){} //(2) 錯誤!!不是重載,重復定義了(1),因為virtual關鍵字不能重載函數 void show(double d){} //(3)show函數的重載 void show(int a, double b){} //(4)show函數的重載 void show(double b, int a){} //(5)show函數的重載 void show(int a, double b) const {} //(6)show函數的重載,const關鍵可以作為重載的依據 void show(const int a, double b){} //(7)錯誤!! 不是重載, 頂層const不可以作為重載的依據,重復定義了(6) void show(int *a){} //(8)show函數的重載 void show(const int *a){} //(9)show函數的重載,只有底層const才可以作為重載的依據 void show(int * const a){} //(10) 錯誤!!不是重載,重復定義了(8),因為這里也使用了頂層const };
至於const能不能成為重載的依據取決於是頂層const還是底層const。頂層const是指對象本身是常量,而底層const是指指向或引用的對象才是常量。
- 成員函數的隱藏,這里“隱藏”是指派生類的函數屏蔽了與其同名的基類函數
只要子類函數的名字與基類的相同,那么不管參數相同與否,都會直接屏蔽基類的同名函數。
class A { public: void show(int a) {cout<<"A::show()"<<endl;}//(1) }; class B:public A { public: void show(){cout<<"B::show()"<<endl;} //(2)將(1)屏蔽了 };
- 假如在子類中仍舊需要用到基類的同名函數,就要用using關鍵字顯式聲明。
class A { public: void show(int) {cout<<"A::show()"<<endl;} }; class B:public A { public: using A::show; void show(){ show(0); //一定要在前面顯式聲明using A中的show函數,否則此句會編譯錯誤 cout<<"B::show()"<<endl; } }; int main() { B b; b.show(); }
輸出結果為:
- 成員函數的覆蓋
(1)不同的范圍(分別位於派生類與基類)
(2)函數名字相同
(3)參數相同
(4) 基類函數必須有virtual 關鍵字
這其實就是多態的實現過程。注意參數必須要完全一致。之前講過,在此不再贅述。
5. inline關鍵字
定義在類中的成員函數默認都是內聯的。內聯函數和普通函數的區別在於:當編譯器處理調用內聯函數的語句時,不會將該語句編譯成函數調用的指令,而是直接將整個函數體的代碼插人調用語句處,就像整個函數體在調用處被重寫了一遍一樣。這有助於提高程序的運行效率。但是要注意inline函數僅僅是一個對編譯器的建議,所以最后能否真正內聯,看編譯器的意思,它如果認為函數不復雜,能在調用點展開,就會真正內聯,並不是說聲明了內聯就會內聯,聲明內聯只是一個建議而已。
這也是為什么虛函數可以是內連函數。因為僅僅是建議,當虛函數需要表現出多態性質的時候,編譯器會選擇不內連。
6. 友元函數、友元類
類的友元函數是定義在類外部,但有權訪問類的所有私有(private)成員和保護(protected)成員。
class A { public: A(int n):m_a(n){} friend class B; //聲明B為A的友元類 private: int m_a; }; class B { public: B(A a){cout<<a.m_a<<endl;} //由於B是A的友元類,所以可以直接調用a的私有m_a成員 }; int main() { A a(1); B b = B(a); //輸出1 }
要注意盡管友元類很強大,但是友元類和類本身並沒有任何繼承關系和成員關系。友元類或友元函數都不是本類的成員類和成員函數。
就如名字定義的那樣,只是朋友,不具有任何親屬關系,因此無法使用this指針進行調用。
友元函數常用在重載運算符。因為通常重載運算符的時候都要用到私有變量,所以用友元函數來重載是非常合適的。
7. 運算符的重載
- 首先要明確,有6個運算符是不可以被重載的。
. (成員訪問運算符)
.*, ->* (成員指針訪問運算符)
:: (域運算符)
sizeof (長度運算符)
?: (條件運算符)
# (預處理符號)
- =, [] ,() ,-> 四個符號只能通過成員函數來重載,不能通過友元函數來定義
=,->, [], () 為什么不能重載為友元函數,是因為當編譯器發現當類中沒有定義這4個運算符的重載成員函數時,就會自己加入默認的運算符重載成員函數。而如果這四個運算符寫成友元函數時會報錯,產生矛盾。
- 不允許用戶定義新的運算符作為重載運算符,不能修改原來運算符的優先級和結合性,不能改變操作對象等等限制
- 重載原則如下:
如果是一元操作,就用成員函數去實現
如果是二元操作,就盡量用友元函數去實現
如果是二元操作,但是對兩個操作對象的處理不同,那么就盡可能用成員函數去實現
- 運算符的重載
class A { public: A(int n):m_a(n){} int m_a; friend A operator+(A const& a1, A const & a2); }; A operator+(A const& a1, A const & a2) { A res(0); res.m_a = 1 + a1.m_a + a2.m_a; return res; } int main() { A a1(1), a2(2); A a3 = a1 + a2; cout<<a3.m_a; //輸出4 }
8. 再談const關鍵字
- 常量指針指向常對象, 常對象只能調用其常成員函數
class A { public: A(int n):m_a(n){} int m_a; void show(){cout<<"A::show()"<<endl;} }; int main() { A a1(1); const A a2(2); a1.show(); //正確 a2.show(); //錯誤 }
假如增加const函數后,就可以正常運行。
class A { public: A(int n):m_a(n){} int m_a; void show(){cout<<"A::show()"<<endl;} void show() const{cout<<"A::show() const"<<endl;} }; int main() { A a1(1); const A a2(2); a1.show(); //正確 輸出A::show() a2.show(); //正確 輸出A::show() const,自動調用const函數 }
- A const* 和const A*等價,允許用A* 賦值A const*,但是不允許用A const* 賦值A*
class A { public: A(int n):m_a(n){} int m_a; void changeValue(int *p){cout<<"changeValue"<<endl;} void changeValue2(int const *p){cout<<"changeValue2"<<endl;} }; int main() { int n = 10; int const *p_n_const = &n; int *p_n = &n; A a(1); a.changeValue(p_n_const); //錯誤,無法把int const*類型,轉成int *類型,但是反之可以 a.changeValue2(p_n); //正確,輸出changeValue2 }
這是因為形參const A*表示指向的對象不能改變,所以如果傳入A*實參,只要不改變對象的值就不會有問題。但是如果形參為A*,則有可能改變A*指向的對象,這是const A*辦不到的,所以編譯器不允許傳入const A*作為實參傳入。
暫時總結到這里。正好最近工作中也寫了不少類的繼承,也實現了一些類的封裝,所以這里也包含了我踩過的一些坑。
參考:
https://github.com/huihut/interview
https://blog.csdn.net/zbc415766331/article/details/83826953
https://www.cnblogs.com/darrenji/p/3907448.html
https://blog.csdn.net/IOT_SHUN/article/details/79674323
https://blog.csdn.net/liuboqiang2588/article/details/82260841
https://blog.csdn.net/weixin_42205987/article/details/81569744
https://blog.csdn.net/cb673335723/article/details/81231974