[GeekBand] C++學習筆記(2)——BigThree、OOP

 

本篇筆記主要分爲三個部分,第一部分是以String類爲例的基於對象的編程,重點在於構造與析構、拷貝構造函數、拷貝賦值函數三個重要函數。這一部分與筆記(1)中的內容結合起來就是基於對象編程的主要內容。第二部分是在掌握了基於對象編程的基礎上的面向對象編程(OOP)學習,講解了類之間的組合、繼承、委託關係。最後一部分則是一些關於面向對象編程的一點補充,包括內存空間、生命週期、new和delete等,以及幾種綜合利用組合、繼承、委託的設計模式簡介。node

 

第一部分、以String類(有指針類)爲例講解關鍵函數「Big Three」程序員

 

 1 class String
 2 {
 3 public:                                 
 4    String(const char* cstr=0);                     
 5    String(const String& str);                    
 6    String& operator=(const String& str);         
 7    ~String();                                    
 8    char* get_c_str() const { return m_data; }
 9 private:
10    char* m_data;
11 };
  1. 構造函數與析構函數

  如筆記(1)中描述的,構造函數是在對象生成時被調用的特殊函數;相應的,析構函數是變量的生命結束時被調用的特殊函數。對於Complex類的對象來講,析構函數不須要進行特殊的操做;這時,編譯器會自動提供一個默認的析構函數,其函數體爲空。編程

  爲什麼String類須要特殊的析構函數? 設計模式

    首先看String類在產生時候進行了什麼樣的操做:數組

inline
String::String(const char* cstr)
{
   if (cstr) {
      m_data = new char[strlen(cstr)+1];
      strcpy(m_data, cstr);
   }
   else {   
      m_data = new char[1];
      *m_data = '\0';
   }
}

  能夠看到,在構造String類的對象時,依靠指針,運用new方法在堆中申請了一塊空間。若是不採用特殊的析構函數的話,成員指針變量的生命週期已經結束,可是指針變量所申請的內存並無被歸還,從而致使內存溢出。所以,必須有相應的析構函數。析構函數的操做很簡單,只須要delete [] m_data;一個操做便可。cookie

  須要注意的是,構造函數是先進行分配成員內存和賦初值的工做再進入函數體;而析構函數是先進入函數體執行操做,退出函數體後編譯器自動歸還成員變量的內存。函數

  有此例可知,在構造函數中存在申請堆空間的new操做時,必需要在析構時進行delete操做。學習

 

  2. 默認拷貝構造函數和默認拷貝賦值函數this

  所謂拷貝構造函數,是指當使用一個對象去初始化同類對象時會進行調用的函數。spa

  例如:

1 String A;
2 String B(A);
3 String C = A;

  String對象B和C產生時就調用了複製構造函數。其函數聲明爲

  String(const String& str); 


 所謂拷貝賦值函數,就是用一個對象去給另外一個對象賦值時所用到的函數,也就是‘=’運算符的運算符重載。

  默認狀況下,C++所提供的拷貝構造函數和拷貝賦值函數就是簡單的位複製,將成員變量一位一位的進行復制,全部數據徹底相同,稱這種複製方式叫作淺複製。對於純數據類,並不須要設計者提供拷貝構造函數和拷貝賦值函數便可,然而對於有指針的類來講,淺複製有這樣的問題:

  通過淺複製a=b,指針的地址進行了位拷貝,就會變成

  這種狀況下,堆空間下的「WORLD\0」字符串將永遠不會被delete,產生內存泄露。對於拷貝構造的操做(即String b = a);位複製不會給b多分配一個內存,可是它會致使「HELLO\0」字符串被兩個指針同時指向,當其中一個析構時,指針所指向的區域被delete;剩下的指針成爲了野指針。此時若是另外一個也發生了析構,則此在野指針上進行delete操做,會發生內存溢出。

  這些問題的核心就在於淺複製上,將淺複製轉化爲深複製,即複製時爲b中的指針分配一段堆內存,而後在這段堆內存裏面寫上a對象的對應字符串便可。

  

  

3.拷貝賦值與拷貝構造的處理邏輯

  拷貝賦值:

  1.  
    1. 檢查是否爲自我賦值,若是是,直接返回。
    2. 歸還原內存
    3. 申請新內存
    4.       深度複製內容

  注意第一步檢查是否爲自我賦值的工做不可省略。由於若是是自我賦值,即產生了"a=a"這樣的語句時,若是不進行自我賦值檢查,第一步將直接歸還a的內存形成錯誤。

 1 inline

 2 String& String::operator=(const String& str)

 3 {

 4    if (this == &str)//自我賦值檢測

 5       return *this;

 6

 7    delete[] m_data;//歸還原內存

 8    m_data = new char[ strlen(str.m_data) + 1 ];//申請新內存

 9    strcpy(m_data, str.m_data);//深度複製內容

10    return *this;//返回對象自己

11 }

 

 

  拷貝構造:

  1.  
    1. 申請內存
    2. 深度複製內容

  拷貝構造的任務比較簡單,只需申請內存並深度複製便可。在拷貝構造函數中也可使用拷貝賦值函數。

1 inline

2 String::String(const String& str)

3 {

4    m_data = new char[ strlen(str.m_data) + 1 ];

5    strcpy(m_data, str.m_data);

6 }

 

第二部分、面向對象的設計(OOP)

  

這一部分主要關注類和類之間的三種關係:組合、繼承、委託;

  1.組合(has-a關係)

    組合關係描述的是一種「包含關係」;Container由Component組成,但Container不是Component,如圖所示。

  

    例如,人體和四肢、森林和樹木、職員信息和工資帳單……使用類的表示法描述組合關係時以下圖。

    

     一個具體的例子以下:

      queue 模板類是由deque模板類(deque:雙向隊列)派生出來的單向隊列。queue的全部功能都是基於deque的功能的特化,queue自己並不提供其餘的功能。通常管這種程序設計模式叫作Adapter(適配),是功能由徹底到特化的一種設計模式。

    

 

 

 

 

 

 

 

 

 

 

    組合關係下的構造與析構

  •   構造由內而外
    •   編譯器先執行Component的無參構造,而後才執行本身的構造函數。Component的構造函數由編譯器自動執行,無需顯式執行。
    •   若是不想使用無參構造函數,則應採起列表初始化語法。e.g. Container():Component(1,2){......}
  •   析構由外而內
    •       先析構本身,再析構本身的Component。

 

2.委託關係(Composition by reference)

  所謂委託關係,就是在類中包含了指向一個類的對象的指針,用類的關係圖描述以下:

  這種特殊的String中,包含了一個指向StringRep的指針,StringRep包含是字符串的實體,關係以下圖所示:

 

  這種手法叫作「編譯防火牆」,在普通的字符串上又加了一層API。StringRep提供基礎功能,而且將String聲明爲友元。在編譯時,StringRep永遠不須要被從新編譯。String的功能是在StringRep的功能之上的拓展。

  

  (注:pImpl:pointer to implementation)

  StringRep會對指向本身的String數目進行計數。事實上,這就是在Python中的垃圾回收機制。

  對於相等的元素,Python只保留一個實體,另外的都以引用的方式存在着。採用「寫時複製」的方法,保證了各個元素之間不會相互影響,同時又能儘可能節省內存空間並提升速度。當引用數目爲0時,實體將自動被釋放,使程序員從內存分配的難題中解放出來。

 

3.繼承關係(is-a關係)

  繼承關係是一種is-a關係,A繼承於B表示A是一種B,例如:蘋果是水果,香蕉是水果,水果又是物品……,採用類的關係圖表示以下圖左:

   

  一個簡單的例子以下:

  這裏須要說明的是雖然例子中使用的是Struct,可是在C++中,與C不一樣,Struct中也能夠含有函數。在這種狀況下,Struct的功能和Class幾乎是一致的,惟一的區別就是Struct默認是Public,而class默認是private。在例子的關係圖中,_List_node右上角的T表示這是一個函數模板。

  最經常使用的繼承關係是公有繼承關係,_List_node是由_List_node_base派生獲得的。在公有繼承的關係中,基類的public成員變爲派生類的public成員、基類的protected成員變成派生類的private成員、基類的private成員不可見。也就是說,在_List_node中,存在着_M_next這個public成員。

  除了public繼承以外,還存在着private繼承和protected繼承。

 

繼承關係下的指針、重寫、虛函數

  因爲繼承表現的是一種「is-a」關係,在指針的使用上就應該有如下的特色:

  • 因爲「蘋果是水果」,因此指向水果的指針應該能夠指向蘋果。
  • 因爲「水果並非蘋果」,因此指向蘋果的指針不能指向水果。

  也就是說,基類指針能夠指向派生類;而派生類指針不能夠指向基類。

  當咱們使用基類指針指向派生類對象並對其進行操做時,咱們只能採用基類對象定義的方法進行操做,由於基類指針沒法知道派生類新添加了什麼樣的方法。另一種狀況是,基類已經定義了一種操做,好比「水果」定義了一種操做「腐爛」。任何水果都會腐爛,可是腐爛的時間函數又各不相同,所以對每一種具體的水果,都要有一個具體的「腐爛」操做。這種在派生類中改寫基類函數的行爲就叫作重寫。在這種狀況下,若是用「水果」指針指向「蘋果」並調用「腐爛」函數,C++會自動選取最爲直接的「腐爛」函數,也就是「水果」的「腐爛」函數,而不是在「蘋果」類中改寫的新函數,這顯然不符合咱們的須要。

  爲了解決上面的兩個問題,引入虛函數——virtual關鍵字

  • 非虛函數,默認的函數類型。你不但願子類覆寫他。                                                                                                            int func(){......}
  • 虛函數。你但願子類覆寫他,而且在使用基類指針指向派生類時,你但願先檢測是否發生了重寫,若是有重寫,則使用重寫後的新函數。          virtual int func(){.......}
  • 純虛函數。這個功能應該在派生類中存在,然而做爲基類沒法得知具體的實現方法。所以,你要求派生類必須重寫,而且在基類裏不進行定義。 virtual int func()=0;

  注1:使用純虛函數的設計模式稱爲Template Method

  將設計分爲Framework和Application兩個部分,在Framework層中構思好所有的功能,但不考慮如何去實現,也就是使用純虛函數;在第二層使用繼承,去進行具體的實現。

  注2:即便函數被重寫,也能夠顯式的調用重寫前的函數(若是有權限)。例如class A:public B{...}  A a;

  在A中,對B中的func()方法進行了重寫。則a.func()調用的是新的func方法。若是想調用原來的func()方法,則能夠採用a.B::func()的方法去調用原來的函數。

  注3:含有純虛函數的類是虛基類,虛基類不能聲明出對象(由於他還有一部分紅員沒有實現),只能是由實現了那部分函數的虛基類的派生類建立對象。

 

繼承關係下的構造與析構

  • 與組合關係同樣,構造由內而外,析構由外而內;構造和析構函數中不須要顯式對基類進行操做,編譯器自動執行。
  • 基類的析構函數必須是virtual。其目的在於,若是用父類指針指向子類對象,若經過父類指針對其進行delete操做,則會使用父類的析構函數致使析構不徹底。所以在通常的設計中,都要把基類設計爲虛函數。
  • 同時存在組合和繼承關係似的構造順序:基類(繼承)->成分(組合)->本身;析構順序與此正好相反。

 

繼承、組合下的複製構造函數與拷貝賦值函數:

  • 拷貝構造函數和拷貝賦值函數不像基類的構造函數能夠自動被調用,必需要顯式地進行調用。
  • 顯示調用拷貝構造函數的方法是,使用初始化列表。Derived::Derived(const Derived& other):Base(other)。
  • 顯式調用拷貝賦值函數的方法是,直接在函數中使用Base::operator=(other)。
  • 若是沒有顯示調用拷貝構造函數,則其會自動調用無參構造函數。

 

 

第三部分、一些補充

 1、變量的生命週期

  1)stack object

    即默認的對象類型,在做用域(一個{...}成爲一個做用域;特別地,對於臨時對象,當前行就是它的做用域)結束時,object的生命週期就結束

  2)static object

    在程序結束時,變量的生命週期才結束。可是static變量的調用時緊緊限制在做用域之中的,你沒法在{...}以外的地方調用大括號內定義的static變量,儘管他們其實是存在的。

  3)global object

    在全部的{}以外定義的變量,成爲全局變量,在任何地方均可以調用它。若是想使其只可以在當前文件中使用,能夠加static關鍵字,使其變爲靜態全局變量,做用域爲當前的文件。

  4)heap object

    在變量被delete或程序結束時結束,有new必需要delete。

2、類和對象的內存空間

  當定義了一個類時,咱們爲類分配了一塊存儲空間,裏邊含有的是:靜態數據成員、非靜態函數、靜態函數。

  當咱們再定義屬於某一個類的一個對象時,咱們爲對象有分配了一些空間,裏邊僅包括非靜態的數據成員。

  也就是說,靜態數據和各類函數在整個類中是共用一個的。對於非靜態函數,其隱藏的第一參數爲this指針,所以編譯器纔可以找到相應對象的數據成員的位置並進行正確的操做。例如,對於Complex類,如有如下語句:

complex C1;
cout<<C1.real()

它的本質其實是    cout<<complex::real(&C1);

不過,編程時並不能這麼寫,這個指針的參數是自動加上的。

而對於靜態函數,並不包含隱藏的this指針,所以靜態函數就是用來操做靜態成員的(靜態函數沒有this指針沒法找到對象的位置,也就沒法對對象的非晶態成員進行任何操做)

  對於靜態的數據成員,必須在類的外面進行顯示的建立。由於在類中僅僅是聲明,並無真的爲它分配內存。

即便m_rate是private類型的變量,也仍然是採用這種方式去進行初始化;注意到其前面存在double的類型名,也就是說這裏實際上纔是真正爲m_rate分配內存的地方,在分配內存以後,它才表現出private的特色。

靜態變量能夠經過兩種方法去調用,既能夠經過類的方式,直接調用Account::m_rate;也能夠經過對象的方式,Account a;a.m_rate

 

3、new、delete與內存分配

  編譯器眼中的new和delete:

  new

Complex *pc = new Complex(1,2);
//自動轉化爲(注:本身這樣寫是沒法經過變異的)
Complex *pc;
void * mem = operator new(sizeof(Complex));//用萬能指針分配內存
pc = static_cast<Complex*>(mem);//萬能指針特化
pc->Complex::Complex(1,2);//進行定位構造(本身寫的話,就是定位new方法,之後會提到)
    

   delete

1 String * ps = new String ("hello");
2 delete ps;
3 //轉化爲
4 String::~String(ps);//先對自身進行析構;
5 operator delete(ps);/再歸還申請的內存;

  new的內存分配(以VC 32位、complex類爲例)

  1)單個變量

  

  左側爲Debug模式下編譯時對new分配的內存空間,含有一些調試信息;右側爲Release模式下分配的內存空間。在VC中,要求分配的內存都是16的倍數,所以左側存在着12Byte的佔位符;圖中的紅色部分爲Cookie,他表示着分配的內存塊的大小。0x41表示分配的內存是0x40也就是64Bytes,0x11表示分配的內存是)x10也就是16Bytes。

  2)變量數組

  

  new產生的變量數組,含有一個參數存儲着有多少個變量;例子中就爲3。

  在此能夠解釋一下delete p和delete [] p 的區別。如前面所說,delete分爲兩個操做,析構和釋放內存。析構的次數是由變量數這一參數決定的。delete [] p在這裏就會連續三次調用析構函數,而delete則跳過這一檢查只調用一次。在析構以後,根據cookie中所寫的內存塊大小,歸還所申請的內存空間。

  對於complex類這種析構函數爲空的類來講,調用多少次析構函數都無所謂,只要內存所有被歸還了就能夠了;所以delete p和delete [] p沒有什麼實質上的區別;

  但對於string類這種析構函數中進行了歸還內存空間的操做的類來講,使用delete p會致使後面的幾個指針所申請的空間沒有歸還(由於只調用了一次析構函數),可是指針自己卻被釋放掉了,從而那部分空間就再也沒有被回收的可能了,致使內存泄漏。

 

4、綜合OOP的各類方法的幾種設計模式簡介

1)Observer(觀察者)

  結合委託和繼承

  Subject中的attach操做是用來「註冊」的,就是將全部的想要查看m_value的數值的對象登記;在notify()函數中,則是對全部對象進行更新數值的操做。全部的對象都要從Observer中派生出去。

2) Composite(普遍應用於文件系統)

委託+繼承

Primitive——純淨物,在文件系統中就表示文件;Composite——混合物,在文件系統中表示文件夾。

圖中類的表示法,注意,成員變量的類型都寫在冒號以後,-號表示這個成員變量是private的。

Component——成分,是能夠組成文件夾的內容,這些內容既能夠是文件,也能夠是文件夾。文件和文件夾都由這個類派生出去。若是文件夾想要包含一個問價你,只須要讓文件夾中的指針指向這一文件,這就是委託的方式。經過這種繼承的關係,文件夾的這一指針既能夠指向文件夾,也能夠指向單獨的文件。

3) Prototype

有這樣的一個需求:在基類中定義一個函數,可以建立它的子類對象。

然而,因爲將來的子類尚未被定義,因此按照通常的方式是不可能作到的,所以採用添加原型—克隆原型的方法去進行「建立對象」。

圖中的表示法,#表示private;下劃線表示static。

實現這一功能的方法是,每一個派生類要包含一個靜態的他自己,在靜態函數的構造函數中,將其「註冊」到基類的數組中。當基類想要建立一個新的派生類對象時,經過這個靜態的成員,調用一個clone函數(注:clone在基類中是做爲純虛函數而存在的),clone函數將返回一個新的對象變量,用這種方式實現了變量的複製。

相關文章
相關標籤/搜索