Essential C++ 學習筆記 第五章

第五章 面向對象的編程風格。

前面其實已經用到了類的概念,但是並沒有引入面向對象的編程,因爲還沒有引入繼承和多態兩種重要的特性。本章主要介紹這一內容

面向對象編程概念

首先解釋繼承(inheritance)和多態(polymorphism)

繼承定義了類之間的父子關係,父類(parent)定義了所有子類共有的接口(public interface)和私有實現(private implementation)。子類可以增加或者覆蓋父類的成員變量或者成員函數。

父類也稱爲基類(base class),子類稱爲派生類(derived class)。父類和子類之間的關係稱爲繼承關係(inheritance hierarchy)。

此外,還有一個很重要的技巧,即抽象基類(abstract base class)。在文中的例子裏,圖書館中有book,ChildToy,Manazines,Films幾個類,而抽象基類LibMat是所有類的基類。它提供了圖書館的對象的基礎功能。並且我們可以使用如下的用法:

void load_check_in(LibMat &mat)
{
   mat.check_in();
  
   if(mat.is_late())
      mat.assess_file();

   if(mat.waiting_list())
      mat.notify_available();
}

其中輸入參數的類型爲LibMat,但是實際上在具體調用時,所有該抽象基類的子類都可以作爲輸入參數。從而可以避免對所有的子類都實現相同的功能(疑問:這是類中的成員函數特有的功能還是所有的函數均支持?)

而爲了解釋上面這個程序,就需要引入多態的概念。當編譯器編譯時就已經知道所執行的函數具體是指哪個子類時,就稱爲靜態綁定(static binding)。反之,如果只有具體調用,運行到這裏才知道時,就稱爲動態綁定(dynamic binding)。而多態實際上是讓基類的pointer*或者reference&得以指向任何一個派生類的對象,是一種動態綁定。另外這裏強調了,多態和動態綁定的特性只有在使用pointer*或者reference&時纔會發揮。

而這種編程思路,會講基礎的實現和具體的物品分離,方便維護。

漫遊:面向對象的編程思維

主要介紹了virtual這個關鍵字的使用方法,另外介紹了一種從泛化的類,然後進行分類的樹狀編程思路。這裏有三種類,分別是LibMatBookAudioBook。三者的成員函數和成員變量有重合的部分,但是其中的print成員函數,三個類的具體實現不同,這是就要使用virtual的關鍵字,當調用的是更新的類的print時,就以最新的類爲標準。這裏給出程序代碼:

class LibMat{
public:
   LibMat(){cout<<"LibMat::LibMat() default constructor!\n";}

   virtual ~LibMat(){cout<<"LibMat::~LibMat() destructor!\n"}
   virtual void print() const
   { cout<<"LibMat::print()--I am a LibMat object!\n";}
};
class Book:public LibMat{
public:
   Book(const string &title, const string &author)
      :_title(title),_author(author){
      cout<<"Book::Book("<<_title
          <<", "<<_author<<") constructor\n";
   }
   virtual ~Book(){
   ...}
   virtual void print() const{
   ...
   }
   ...
};
class AudioBook:public Book{
public:
   AudioBook(...){...}
   ~AudioBook(){...}
   virtual void print() const{...}
   const string& narrator() const{...}
   ...
};

//一個non-member function
void print(const LibMat &mat)
{
   cout<<"...";
 
   mat.print();
}

//執行
cout<<"\n"<<"Creating a object to print()\n";
AudioBook ab(...);
print(ab);

上述三個類,使用:號和public進行了繼承,並且類中同時具有成員函數print,而此外,還給定了一個非成員函數的print函數,輸入參數爲類對象,具體實現爲調用類對象中的成員函數print

這裏有幾條規則,新的類可以使用原有的類中所有聲明爲protected的成員函數和成員變量。比如Book中有成員變量_tilte_author,那麼類AudioBook就可以調用這個成員變量。但是同時,所有的print函數均聲明爲了virtual,在執行mat.print時,如果輸入參數的爲基類LibMat的對象,就調用基類的成員函數。如果輸入參數爲第一層子類Book的對象,就調用Book中的成員函數。那麼如果是第二層子類AudioBook的對象,也以此類推。

而在子類的對象初始化和銷燬時,其所有父類的構造函數和析構函數都會依次執行,以當前爲例,具體執行順序如下:

LibMat 的構造函數
Book 的構造函數
AudioBook 的構造函數

執行部分

AudioBook 的析構函數
Book 的析構函數
LibMat 的析構函數

可以看出執行順序正好是反過來的。

文中也強調了,不必刻意區分繼承而來的成員和自身定義的成員。例如

int main()
{
  AudioBook ab(...);
 
   cout<< ab.title()<<'\n'
       << ab.author()<<'\n'
       << ab.narrator()<<'\n';

}

其中titile()author()是父類Book的成員函數,而narrator()AudioBook的成員函數。其使用並無不同。

不帶繼承的多態

感覺是上面一章的內容,在不使用class以及,繼承的技巧的情況下。實現了上一章對多個數組的調用問題。非常華麗的技巧秀。但是沒有新知識,這裏不細說。見Page142

定義一個抽閒基類

繼續前一節的問題,雖然實現多個數組的方便調用是在C框架下可以實現的,但是需要極高的技巧,而且並不方便維護。這一節嘗試在C++框架下實現相同的功能。基本思想是設計一個抽象基類,然後用所有數列對應的類繼承這個抽象基類,從而實現基礎功能的統一。文中將這種設計分成了三個步驟。

其一,找出所有子類共通的操作行爲。即這個基類的公有接口(public interface),如下:

class num_sequence{
public:
   int elem(int pos); //返回pos位置上的元素
   void gen_elems(int pos); //產生直到pos位置的所有元素
   const char* what_am_i() const; //返回確切的數列類型
   ostream& print(ostream &os = cout) const;
   bool check_integrity(int pos);
   static int max_elems();
   ///...
};

其二,是找出哪些操作行爲與類型相關(type-dependent),即哪些行爲必須根據不同的派生類而有不同的實現方式。那麼這部分函數要使用virtual的技巧。這裏強調一下,static member function無法聲明爲虛函數

其三,是找出每個操作行爲的訪問層級(access level),即區別哪些程序使用public或者protected或者private,從而得到更加具體的實現:

class num_sequence{
public:
   virtual ~num_sequence(){};

   virtual int elem(int pos)=0; //返回pos位置上的元素
   virtual const char* what_am_i() const=0; //返回確切的數列類型
   virtual ostream& print(ostream &os = cout) const=0;
   static int max_elems() {return _max_elems};
protected:
   virtual void gen_elems(int pos)=0; //產生直到pos位置的所有元素
   bool check_integrity(int pos);
 
   const static int _max_elems = 1024;
};

我們注意到有將虛函數直接賦值爲0的操作,這裏稱之爲純虛函數pure virtual function,例如virtual int elem(int pos)=0;。本人理解爲將函數指針指向一個空地址,表示當前這個成員函數沒有任何意義。同時在語法上,任何一個類如果聲明有一個或多個純虛函數,由於接口的不完整性,程序無法爲它產生對象,同時包含純虛函數的類成爲抽象類。這種類只能又派生類的子對象(subobject)使用,且前提是當前派生類給了這個成員函數具體的定義。

注意到這裏的基類num_sequence並未定義任何成員變量,這就給其派生類的變量類型提供了很大的自由空間。這和前幾章逐漸泛化變量類型的目的是一樣的。

而關於析構函數,這裏強調了一個編程習慣,即儘量使用虛函數。例如:

class num_sequence{
public:
   virtual ~num_sequence();
   //...
};

具體理由是對於如下代碼:

num_sequence *ps = new Fibonacci(12);
delete ps;

這裏的ps是一個class的指針,語法上可以指向其派生類型的對象。但是在進行delete時,我們希望程序能夠調用Fibonacci這個類的析構函數。如果不對析構函數添加virtual的關鍵字,那麼這個析構函數的操作在編譯時就已經完成解析。而編譯時它的調用類型爲基類的類型num_sequence,所以調用的是基類的析構函數。爲了能讓析構函數在執行過程中動態調用,要引入virtual的關鍵字。

另外一個編程習慣是儘量不要把析構函數聲明爲純虛函數,(我自己覺得,可能是因爲析構函數本來就不代表具體的行爲)。比較推薦使用的空定義,即:

inline num_sequence::~num_sequence(){}

定義一個派生類

這部分建議有空反覆閱讀。重點並不是語法,而是編程思維。如何能夠藉助已有的語法,設計能夠自由的在基類和派生類之間的成員函數自由切換的程序。

基礎的派生類的實現如下,這裏強調派生類聲明之前必須已經存在基類。而聲明派生除去使用:之外,還使用了關鍵字public,這代表了繼承方式,具體細節書中未提而是給出了參考文獻。代碼如下:

#include "num_sequence.h"

class Fibonacci : public num_sequence{
public:
   Fibonacci(int len=1, int beg_pos = 1)
     :_length(len), _beg_pos(beg_pos){}

   virtual int    elem(int pos) const;
   virtual const char* what_am_i() const {return "Fobonacci";}
   virtual ostream&   print(ostream &os = cout ) const;
   int  length() const {return _length;}
   int beg_pos() const {return _beg_pos;}

protected:
   virtual void gen_elems(int pos) const;
   int _length;
   int _beg_pos;
   static vector<int> _elems;
}

這裏出現了問題,其中的length()beg_pos()爲定義的成員函數。如果類似之前使用基類的指針指向派生類的對象,那麼函數指針就無法使用:

num_sequence *ps = new Fibonacci;
ps -> length();  //錯誤

這裏給出的解決方法有兩個。其一,是在基類中將length()beg_pos()添加爲純虛函數,然後在派生類中添加具體實現。這一方法的問題在於,所有的派生類都必須提供這兩個成員函數的具體實現。其二,是直接將這兩個成員函數寫成基類的inline nonvirtual function。(個人感覺第二種比較合理)

在對虛函數進行實現時,不需要給定virtual的關鍵字,例如:

int Fibonacci:
elem(int pos) const
{ 
   if(!check_integrity(pos))
      return 0;

   if(pos>_elems.size())
      Fibonacci::gen_elems(pos);

   return _elems[pos-1];
}

除去沒有virtual這個關鍵字之外,還有一個細節,Fibonacci::gen_elems(pos)。這裏指定了我們調用的成員函數是派生類的。正常情況下,虛函數的解析是動態的。在具體執行到這裏時,解釋器纔會解析這裏要調用的成員函數屬於誰,但是如果用戶希望直接在編譯時就指定,就可以使用這種class scope運算符。(猜測是因爲動態會損失程序性能)。

文中爲了完整性給出了gen_elems()print()的具體實現,這裏並不給出。但是這elem()print()都需要進行一個操作,即檢查數組_elem中的元素是否足夠,如果不夠就用函數gen_elems()增添函數。文中希望將這個操作寫成新的函數check_integrity()。而之前在基類中,已經定義了同名的成員函數。此時編譯器會優先將成員函數解釋爲派生類的成員函數,如果希望調用基類中的同名成員函數,必須類似的指明num_sequence::check_integrity(pos)

但同時這種覆蓋的語法又會引入新的問題。

num_sequence *ps = new Fibonacci(12,8);
ps->check_integrity(pos);

這裏的利用基類的指針調用成員函數時,編譯器會解析成基類的成員函數check_integrity(),這是我們不希望的。解決辦法文中又提供了不止一種。其一,將基類中所有的函數聲明爲虛函數。其二,也是本文采用的,是給這個成員函數寫成了更加巧妙的形式,如下:

bool num_sequence::
check_integrity(int pos, int size)
{
   if(pos<=0 || pos>_max_elems){
      //和先前相同...
   }

   if (pos>size)
      //gen_elems()系通過虛擬機制調用
      gen_elems(pos);

   return true;
}

//調用
int Fibonacci::
elem(int pos)
{
   if (!check_integrity(pos,_elem.size())
      return 0;
   //...
}

這種寫法一個明顯的好處,直接在基類中定義了一個非虛的成員函數。而在函數中具體調用gen_elems()函數時,使用了虛函數的機制,從而能夠在不同的派生類中調用不同的成員函數。將所有派生類中一致的操作提取出來,然後將不同的操作設計成接口交給派生類具體實現。

運用繼承體系

類似第一個派生類,將所有的派生類Pell,Lucas,Square,Triangular,Pentagonal,Fibonacci實現之後。我們就得到了一個和前一章程序功能完全相同的程序。只是這並不需要像之前一樣極高的技巧。這裏給出了這一程序的調用方式。

其一,是定義了一個display()函數:

inline void display(ostream &os,
            const num_sequence &ns,int pos)
{
   os << "The element at position"
      << pos  << " for the "
      << ns.what_am_i << " sequence is "
      << ns.elem(pos) << endl;
}

//調用
int main()
{
   const int pos = 8;
  
   Fibonacci fib;
   display(cout,fib,pos);
   Pell pell;
   display(cout,pell,pos);
   Lucas lucas;
   display(cout,lucas,pos);
   ...
}

//輸出
The element at position 8 for the Fibonacci sequence is 21
The element at position 8 for the Pell sequence is 408
...

沒啥好說的,因爲使用了虛函數,一個非成員函數可以調用同一個基類下的多個派生類的同名成員函數。

其二,定義了運算符的重載操作,這個還是有點秀的

ostream& operator<<(ostream &os, const num_sequence &ns)
{return ns.print(os);}

int main()
{
   Fibonacci fib(8);
   Pell pell(6,4);
   ...
   cout << "fib: " << fib << '\n'
        << "pell:" << pell << '\n'
        ...
};

//輸出
fib: (1,8) 1 2 3 5 8 13 21
pell: ...

其中return np.print(os)的理由前一章提到過,可以讓<<連續使用。從而輸出變得很方便。

基類應該多麼抽象

這裏強調前面的程序結構設計是否合理,是根據應用場景的,並給出了另外一種設計模式。

首先介紹這部分裏面的一個技巧,在類的成員變量裏面聲明瞭vecotr<int> &_elems。這裏使用了reference,而不是pointer。理由是reference無法表示空對象(null object),而pointer可以。這就導致pointer在使用時要檢查是否爲null,而reference不需要。

而成員變量如果是reference,就一定要在構造函數中初始化,並且一旦初始化,就不能指向另一個對象。如果是pointer,則沒有這個限制,可以在構造函數內初始化,也可以提供null讓用戶初始化。兩者各有各的優勢。

然後再談這裏面提到的程序設計思想,前面的程序設計儘可能的將基類進行泛化,從而方便日後開發者對程序擴容。基類的設計越廣泛,今後就可能納入更多的體系。
然而如果程序準備開源給用戶使用,讓用戶在程序中進行修改。那麼這種過於泛化的設計,會使得用戶難以理解,需要比較高的編程素養。此外,即使能夠理解程序,在派生類中,需要指定的部分過多,也是會增加工作量。

(感受其中應用不同帶來的設計需求的不同。前者只提供功能,開發者維護即可,可以儘可能的泛化。後者用戶本身也要進行程序修改,就要將程序適當的特殊化,使得用戶自定義特殊類時需要設置的部分減少。
程序這裏就不敲了,就是將很多成員函數和成員變量放在了基類中。所以派生類中只需要給定少量的成員變量和函數。)

初始化、析構、複製

派生類和基類都有構造函數和析構函數,其執行順序前面已經討論過了。這裏討論的問題是該如何使用這個特性。

首先,書中強調了一個編程習慣。基類中的成員變量要在基類的構造函數中初始化,派生類的成員變量要在派生類的構造函數中初始化,以此類推。

齊次,派生類的構造函數,不僅必須給派生類的成員函數進行初始化操作,還需要爲基類的成員函數提供適當的值。否則會出現編譯錯誤

inline Fibonacci::
Fibonacci(int len, int beg_pos)
     : num_sequence(len,beg_pos,_elems)
{}

或者我們可以給基類的成員變量設定默認值

num_sequence::
num_sequence(int len=1, int bp=1, vector<int> *pe=0)
   : _length(len), _beg_pos(bp), _pelems(pe) {}

另外文中討論了類對象的賦值操作,其實這個前面也討論過

Fibonacci fib1(12);
Fibonacci fib2 = fib1;
//或者
Fibonacci fib2(fib1);

賦值操作前面討論過,類的賦值操作默認爲所有的成員變量分別複製過來,但是數組爲指針的賦值,所以需要額外的定義。而另外一種複製方法就是利用構造函數。沒啥好說的

在派生類中定義一個虛函數

其實是強調了派生類中的虛函數覆蓋基類中的虛函數的條件。要求函數原型必須完全相同,包括:參數列表,返回類型,常量性。少寫一個const,或者返回值類型有不同,均會導致虛函數無法覆蓋,從而變成兩個獨立的成員函數。不過,如果出現這種情況,編譯器會進行警告warning

warning #653 "const char *Fibonacci::what_am_i()"
          does not match "num_sequence::what_am_i"
          -- virtual function override intended?

不過這個事情也有例外,當返回值爲某個類的pointer或者reference時,基類或者其派生類可以混用:

virtual num_sequence *clone()=0;
virtual Fibonacci *clone()=0;
虛函數的靜態解析(Static Resolution)

開門見山,虛函數的機制在兩種情況下是失效的:其一,基類的構造函數和析構函數內。其二,使用的是基類的對象,而不是基類的pointer或者reference。除去邏輯上的原因外,文中還解釋了更多細節。當基類的構造函數運行時,派生類的成員變量還沒有初始化,所以一定不能調用。

而對於其二的規則,則更加複雜,例如如下程序:

void print(LibMat object,
       const LibMat *pointer,
       const LibMat &reference)
{ 
   //基類的
   object.print();
   //派生類
   pointer->print();
   reference.print();
}

int main()
{
   AudioBook iWish(...);
   print(iWish,&iWish,*iWish);
}

這裏的非成員函數print()的三個輸入變量,均爲基類的對象。但是具體執行其中的成員函數print()時,後兩個爲派生類的成員函數,而第一個爲基類的成員函數。在“單一對象中展現多類型”,這就是多態,而使用pointerreference才能實現多態。同時主程序的調用過程中,輸入值全部爲派生類的對象,後面兩個輸入值可以正常的調用。而第一個輸入參數,執行時只會保留基類中的成員變量,其餘的部分均被切掉sliced off。因爲程序編譯時只保留了基類的內存空間,無法容納更多的成員變量。

運行時的類型鑑定機制

前面已經多次使用的一個成員函數:

class Fibonacci : public num_sequence {
public:
   virtual const char* what_am_i() const {return "Fibonacci";}
   //...
};

但是問題在於不同的派生類,均要給出自己的該成員函數的定義,這不夠泛化。所以文中考慮其他方法,其中一種是通過構造函數初始化字符串:

inline Fibonacci::
Fibonacci(int len, int beg_pos)
        :num_sequence(len, beg_pos, _elems, "Fibonacci")
{}

另外一種爲新知識,是調用typeid運算符,這需要typeinfo這個頭文件。首先給出使用方法:

#include <typeinfo>

inline const char* num_sequence::
what_am_i() const
{return typeid(*this).name();}

這裏的typeid(*this)會返回一個type_info對象,對象中的name()函數會返回當前類的名稱的字符串。

除此之外,這個type_info對象還有更多的功能,比如說類型比較:

num_sequence *ps = &fib;
if (typeid(*ps) == typeid(Fibonacci))

我們注意到typeid()函數的輸入參數,可以是類名,也可以是類對象。而且返回的type_info還可以直接用來比較相等,從而進行類型檢查。

此外,我們還可以對基類指針進行類型轉換,使用這一操作是因爲如下命令編譯器無法編譯通過:

ps-> Fibonacci::gen_elems(64);

ps是基類的指針,如果使用ps->gen_elems(64)即可調用派生類的成員函數。但是當我們聲明成員函數所屬的類時,反而會報錯。處理方法就是前面提到的對類指針進行類型轉換。具體的函數有兩個選擇:

if (typeid(*ps) == typeid(Fibonacci))
{
   Fibonacci *pf = static_cast<Fibonacci*>(ps);
   pf->gen_elems(64);
}

if (Fibonacci *pf = dynamic_cast<Fibonacci*>(ps))
   pf->gen_elems(64);

static_castdynamic_cast兩個運算符。兩者的主要區別在於,前者爲無條件轉換,後者爲有條件轉換。條件爲,當前指針指向的類類型,是否爲想要轉換的類型。比如現在的ps實際上指向它的派生類Fibonacci,所以ps的類型才能轉換成Fibonacci*

第一個運算符static_cast自己並不能判斷是否滿足條件,但是不滿足時會出錯。所以我們必須用if進行判斷。而後者可以自己進行判斷,如果滿足條件,會返回一個Fibonacci*類型的指針,如果不滿足條件,則會返回0。所以第二個代碼段在條件不滿足時,if語句也不成立,和第一個代碼段達到了相同的效果。

OK!這是倒數第三章,最後兩章內容開始減少了,勝利在望!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章