C++物件導向續

2020-08-08 12:38:10

C++物件導向續

1.繼承與派生

1.1 相關概念

**繼承(Inheritance)**可以理解爲一個類從另一個類獲取成員變數和成員函數的過程。例如類 B 繼承於類 A,那麼 B 就擁有 A 的成員變數和成員函數。

在C++中,**派生(Derive)**和繼承是一個概念,只是站的角度不同。繼承是兒子接收父親的產業,派生是父親把產業傳承給兒子。

被繼承的類稱爲父類別或基礎類別,繼承的類稱爲子類或派生類。「子類」和「父類別」通常放在一起稱呼,「基礎類別」和「派生類」通常放在一起稱呼。

以下是兩種典型的使用繼承的場景:

  1. 當你建立的新類與現有的類相似,只是多出若幹成員變數或成員函數時,可以使用繼承,這樣不但會減少程式碼量,而且新類會擁有基礎類別的所有功能。

  2. 當你需要建立多個類,它們擁有很多相似的成員變數或成員函數時,也可以使用繼承。可以將這些類的共同成員提取出來,定義爲基礎類別,然後從基礎類別繼承,既可以節省程式碼,也方便後續修改成員。

我們來看一個案例:

#include<iostream>
using namespace std;
//基礎類別 Pelple
class People{
public:
    void setname(char *name);
    void setage(int age);
    char *getname();
    int getage();
private:
    char *m_name;
    int m_age;
};
void People::setname(char *name){ m_name = name; }
void People::setage(int age){ m_age = age; }
char* People::getname(){ return m_name; }
int People::getage(){ return m_age;}
//派生類 Student
class Student: public People{
public:
    void setscore(float score);
    float getscore();
private:
    float m_score;
};
void Student::setscore(float score){ m_score = score; }
float Student::getscore(){ return m_score; }
int main(){
    Student stu;
    stu.setname("小明");
    stu.setage(16);
    stu.setscore(95.5f);
    cout<<stu.getname()<<"的年齡是 "<<stu.getage()<<",成績是 "<<stu.getscore()<<endl;
    return 0;
}

執行結果:
小明的年齡是 16,成績是 95.5

本例中,People 是基礎類別,Student 是派生類。Student 類繼承了 People 類的成員,同時還新增了自己的成員變數 score 和成員函數 setscore()、getscore()。這些繼承過來的成員,可以通過子類物件存取,就像自己的一樣。

請認真觀察程式碼第21行:

class Student: public People

這就是宣告派生類的語法。class 後面的「Student」是新宣告的派生類,冒號後面的「People」是已經存在的基礎類別。在「People」之前有一關鍵宇 public,用來表示是公有繼承。

繼承方式包括 public(公有的)、private(私有的)和 protected(受保護的),此項是可選的,如果不寫,那麼預設爲 private。我們將在下節詳細講解這些不同的繼承方式。

1.2 三種繼承方式

C++繼承的一般語法爲:

class 派生類名:[繼承方式] 基礎類別名{
派生類新增加的成員
};

繼承方式限定了基礎類別成員在派生類中的存取許可權,包括 public(公有的)、private(私有的)和 protected(受保護的)。此項是可選項,如果不寫,預設爲 private(成員變數和成員函數預設也是 private)。

現在我們知道,public、protected、private 三個關鍵字除了可以修飾類的成員,還可以指定繼承方式。

public、protected、private 修飾類的成員

類成員的存取許可權由高到低依次爲 public --> protected --> private,我們在《C++類別成員的存取許可權以及類的封裝》一節中講解了 public 和 private:public 成員可以通過物件來存取,private 成員不能通過物件存取。

現在再來補充一下 protected。protected 成員和 private 成員類似,也不能通過物件存取。但是當存在繼承關係時,protected 和 private 就不一樣了:基礎類別中的 protected 成員可以在派生類中使用,而基礎類別中的 private 成員不能在派生類中使用,下面 下麪是詳細講解。

public、protected、private 指定繼承方式

不同的繼承方式會影響基礎類別成員在派生類中的存取許可權。

1) public繼承方式

  • 基礎類別中所有 public 成員在派生類中爲 public 屬性;
  • 基礎類別中所有 protected 成員在派生類中爲 protected 屬性;
  • 基礎類別中所有 private 成員在派生類中不能使用。

2) protected繼承方式

  • 基礎類別中的所有 public 成員在派生類中爲 protected 屬性;
  • 基礎類別中的所有 protected 成員在派生類中爲 protected 屬性;
  • 基礎類別中的所有 private 成員在派生類中不能使用。

3) private繼承方式

  • 基礎類別中的所有 public 成員在派生類中均爲 private 屬性;
  • 基礎類別中的所有 protected 成員在派生類中均爲 private 屬性;
  • 基礎類別中的所有 private 成員在派生類中不能使用。

總結:共有繼承派生類裏面不改變繼承以後基礎類別的屬性,保護繼承的話除了私有繼承以外均變成保護屬性,私有繼承在派生類裏面全部私有。私有屬性不管在什麼繼承裏面都是基礎類別私有,派生類不能存取。

也就是說,繼承方式中的 public、protected、private 是用來指明基礎類別成員在派生類中的最高存取許可權的。

不管繼承方式如何,基礎類別中的 private 成員在派生類中始終不能使用(不能在派生類的成員函數中存取或呼叫)。

下表彙總了不同繼承方式對不同屬性的成員的影響結果

繼承方式/基礎類別成員 public成員 protected成員 private成員
public繼承 public protected 不可見
protected繼承 protected protected 不可見
private繼承 private private 不可見

我們來看一個案例:

#include<iostream>
using namespace std;
//基礎類別People
class People{
public:
    void setname(char *name);
    void setage(int age);
    void sethobby(char *hobby);
    char *gethobby();
protected:
    char *m_name;
    int m_age;
private:
    char *m_hobby;
};
void People::setname(char *name){ m_name = name; }
void People::setage(int age){ m_age = age; }
void People::sethobby(char *hobby){ m_hobby = hobby; }
char *People::gethobby(){ return m_hobby; }
//派生類Student
class Student: public People{
public:
    void setscore(float score);
protected:
    float m_score;
};
void Student::setscore(float score){ m_score = score; }
//派生類Pupil
class Pupil: public Student{
public:
    void setranking(int ranking);
    void display();
private:
    int m_ranking;
};
void Pupil::setranking(int ranking){ m_ranking = ranking; }
void Pupil::display(){
    cout<<m_name<<"的年齡是"<<m_age<<",考試成績爲"<<m_score<<"分,班級排名第"<<m_ranking<<",TA喜歡"<<gethobby()<<"。"<<endl;
}
int main(){
    Pupil pup;
    pup.setname("小明");
    pup.setage(15);
    pup.setscore(92.5f);
    pup.setranking(4);
    pup.sethobby("乒乓球");
    pup.display();
    return 0;
}

執行結果:
小明的年齡是15,考試成績爲92.5分,班級排名第4,TA喜歡乒乓球。

我們還可以改變存取許可權:

使用 using 關鍵字可以改變基礎類別成員在派生類中的存取許可權,例如將 public 改爲 private、將 protected 改爲 public。

注意:using 只能改變基礎類別中 public 和 protected 成員的存取許可權,不能改變 private 成員的存取許可權,因爲基礎類別中 private 成員在派生類中是不可見的,根本不能使用,所以基礎類別中的 private 成員在派生類中無論如何都不能存取。

class Student : public People {
public:
    void learning();
public:
    using People::m_name;  //將protected改爲public
    using People::m_age;  //將protected改爲public
    float m_score;
private:
    using People::show;  //將public改爲private
};
void Student::learning() {
    cout << "我是" << m_name << ",今年" << m_age << "歲,這次考了" << m_score << "分!" << endl;
}

1.3 繼承當中的覆蓋問題

如果派生類中的成員(包括成員變數和成員函數)和基礎類別中的成員重名,那麼就會遮蔽從基礎類別繼承過來的成員。所謂遮蔽,就是在派生類中使用該成員。就相當於只調用了派生類裏面的成員。

這種方法叫做覆蓋重寫。

#include<iostream>
using namespace std;
//基礎類別People
class People{
public:
    void show();
protected:
    char *m_name;
    int m_age;
};
void People::show(){
    cout<<"嗨,大家好,我叫"<<m_name<<",今年"<<m_age<<"歲"<<endl;
}
//派生類Student
class Student: public People{
public:
    Student(char *name, int age, float score);
public:
    void show();  //遮蔽基礎類別的show()
private:
    float m_score;
};
Student::Student(char *name, int age, float score){
    m_name = name;
    m_age = age;
    m_score = score;
}
void Student::show(){
    cout<<m_name<<"的年齡是"<<m_age<<",成績是"<<m_score<<endl;
}
int main(){
    Student stu("小明", 16, 90.5);
    //使用的是派生類新增的成員函數,而不是從基礎類別繼承的
    stu.show();
    //使用的是從基礎類別繼承來的成員函數
    stu.People::show();
    return 0;
}

執行結果:
小明的年齡是16,成績是90.5
嗨,大家好,我叫小明,今年16歲

本例中,基礎類別 People 和派生類 Student 都定義了成員函數 show(),它們的名字一樣,會造成遮蔽。第 37 行程式碼中,stu 是 Student 類的物件,預設使用 Student 類的 show() 函數。

但是,基礎類別 People 中的 show() 函數仍然可以存取,不過要加上類名和域解析符。

基礎類別成員和派生類成員的名字一樣時會造成遮蔽,這句話對於成員變數很好理解,對於成員函數要引起注意,不管函數的參數如何,只要名字一樣就會造成遮蔽。換句話說,基礎類別成員函數和派生類成員函數不會構成過載,如果派生類有同名函數,那麼就會遮蔽基礎類別中的所有同名函數,不管它們的參數是否一樣。

其實,繼承的本質就是:作用域能夠彼此包含,被包含(或者說被巢狀)的作用域稱爲內層作用域(inner scope),包含着別的作用域的作用域稱爲外層作用域(outer scope)。一旦在外層作用域中宣告(或者定義)了某個名字,那麼它所巢狀着的所有內層作用域中都能存取這個名字。同時,允許在內層作用域中重新定義外層作用域中已有的名字。

1.4 繼承與派生的建構函式

前面我們說基礎類別的成員函數可以被繼承,可以通過派生類的物件存取,但這僅僅指的是普通的成員函數,類別建構函式不能被繼承。

這種矛盾在C++繼承中是普遍存在的,解決這個問題的思路是:在派生類別建構函式中呼叫基礎類別的建構函式。

看一個例子,怎麼在派生類裏面呼叫基礎類別的建構函式:

#include<iostream>
using namespace std;
//基礎類別People
class People{
protected:
    char *m_name;
    int m_age;
public:
    People(char*, int);
};
People::People(char *name, int age): m_name(name), m_age(age){}
//派生類Student
class Student: public People{
private:
    float m_score;
public:
    Student(char *name, int age, float score);
    void display();
};
//People(name, age)就是呼叫基礎類別的建構函式
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
void Student::display(){
    cout<<m_name<<"的年齡是"<<m_age<<",成績是"<<m_score<<"。"<<endl;
}
int main(){
    Student stu("小明", 16, 90.5);
    stu.display();
    return 0;
}

執行結果爲:
小明的年齡是16,成績是90.5。

請注意第 23 行程式碼:

Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }

People(name, age)就是呼叫基礎類別的建構函式,並將 name 和 age 作爲實參傳遞給它,m_score(score)是派生類的參數初始化表,它們之間以逗號,隔開。

建構函式的呼叫順序

從上面的分析中可以看出,基礎類別建構函式總是被優先呼叫,這說明建立派生類物件時,會先呼叫基礎類別建構函式,再呼叫派生類建構函式,如果繼承關係有好幾層的話,例如:

A --> B --> C

那麼建立 C 類物件時建構函式的執行順序爲:

A類建構函式 --> B類建構函式 --> C類建構函式

建構函式的呼叫順序是按照繼承的層次自頂向下、從基礎類別再到派生類的。

就相當於建構函式的呼叫順序是從上到下的

但是,還有一點要注意,派生類建構函式中只能呼叫直接基礎類別的建構函式,不能呼叫間接基礎類別的。以上面的 A、B、C 類爲例,C 是最終的派生類,B 就是 C 的直接基礎類別,A 就是 C 的間接基礎類別。

事實上,通過派生類建立物件時必須要呼叫基礎類別的建構函式,這是語法規定。換句話說,定義派生類建構函式時最好指明基礎類別建構函式;如果不指明,就呼叫基礎類別的預設建構函式(不帶參數的建構函式);如果沒有預設建構函式,那麼編譯失敗。

1.5 繼承與派生的解構函式

和建構函式類似,解構函式也不能被繼承。與建構函式不同的是,在派生類的解構函式中不用顯式地呼叫基礎類別的解構函式,因爲每個類只有一個解構函式,編譯器知道如何選擇,無需程式設計師幹涉。

另外解構函式的執行順序和建構函式的執行順序也剛好相反:

  • 建立派生類物件時,建構函式的執行順序和繼承順序相同,即先執行基礎類別建構函式,再執行派生類建構函式。
  • 而銷燬派生類物件時,解構函式的執行順序和繼承順序相反,即先執行派生類解構函式,再執行基礎類別解構函式。

就相當於建構函式反過來。

#include <iostream>
using namespace std;
class A{
public:
    A(){cout<<"A constructor"<<endl;}
    ~A(){cout<<"A destructor"<<endl;}
};
class B: public A{
public:
    B(){cout<<"B constructor"<<endl;}
    ~B(){cout<<"B destructor"<<endl;}
};
class C: public B{
public:
    C(){cout<<"C constructor"<<endl;}
    ~C(){cout<<"C destructor"<<endl;}
};
int main(){
    C test;
    return 0;
}

執行結果:
A constructor
B constructor
C constructor
C destructor
B destructor
A destructor

1.6 多繼承

在前面的例子中,派生類都只有一個基礎類別,稱爲單繼承(Single Inheritance)。除此之外,C++也支援多繼承(Multiple Inheritance),即一個派生類可以有兩個或多個基礎類別。

多繼承的語法也很簡單,將多個基礎類別用逗號隔開即可。例如已宣告瞭類A、類B和類C,那麼可以這樣來宣告派生類D:

class D: public A, private B, protected C{
  //類D新增加的成員
}

D 是多繼承形式的派生類,它以公有的方式繼承 A 類,以私有的方式繼承 B 類,以保護的方式繼承 C 類。D 根據不同的繼承方式獲取 A、B、C 中的成員,確定它們在派生類中的存取許可權。

多繼承形式下的建構函式和單繼承形式基本相同,只是要在派生類別建構函式中呼叫多個基礎類別的建構函式。以上面的 A、B、C、D 類爲例,D 類建構函式的寫法爲:

D(形參列表): A(實參列表), B(實參列表), C(實參列表){
  //其他操作
}

基礎類別建構函式的呼叫順序和和它們在派生類建構函式中出現的順序無關,而是和宣告派生類時基礎類別出現的順序相同。仍然以上面的 A、B、C、D 類爲例,即使將 D 類建構函式寫作下面 下麪的形式:

D(形參列表): B(實參列表), C(實參列表), A(實參列表){
//其他操作
}

那麼也是先呼叫 A 類別建構函式,再呼叫 B 類建構函式,最後呼叫 C 類建構函式。

看一個例子:

#include <iostream>
using namespace std;
//基礎類別
class BaseA{
public:
    BaseA(int a, int b);
    ~BaseA();
protected:
    int m_a;
    int m_b;
};
BaseA::BaseA(int a, int b): m_a(a), m_b(b){
    cout<<"BaseA constructor"<<endl;
}
BaseA::~BaseA(){
    cout<<"BaseA destructor"<<endl;
}
//基礎類別
class BaseB{
public:
    BaseB(int c, int d);
    ~BaseB();
protected:
    int m_c;
    int m_d;
};
BaseB::BaseB(int c, int d): m_c(c), m_d(d){
    cout<<"BaseB constructor"<<endl;
}
BaseB::~BaseB(){
    cout<<"BaseB destructor"<<endl;
}
//派生類
class Derived: public BaseA, public BaseB{
public:
    Derived(int a, int b, int c, int d, int e);
    ~Derived();
public:
    void show();
private:
    int m_e;
};
Derived::Derived(int a, int b, int c, int d, int e): BaseA(a, b), BaseB(c, d), m_e(e){
    cout<<"Derived constructor"<<endl;
}
Derived::~Derived(){
    cout<<"Derived destructor"<<endl;
}
void Derived::show(){
    cout<<m_a<<", "<<m_b<<", "<<m_c<<", "<<m_d<<", "<<m_e<<endl;
}
int main(){
    Derived obj(1, 2, 3, 4, 5);
    obj.show();
    return 0;
}

執行結果:
BaseA constructor
BaseB constructor
Derived constructor
1, 2, 3, 4, 5
Derived destructor
BaseB destructor
BaseA destructor

從執行結果中還可以發現,多繼承形式下解構函式的執行順序和建構函式的執行順序相反。

總結:多繼承下執行順序是先呼叫基礎類別的建構函式,再呼叫派生類別建構函式,再呼叫相關方法,最後再回到解構函式。

命名衝突

當兩個或多個基礎類別中有同名的成員時,如果直接存取該成員,就會產生命名衝突,編譯器不知道使用哪個基礎類別的成員。這個時候需要在成員名字前面加上類名和域解析符::,以顯式地指明到底使用哪個類的成員,消除二義性。

1.7 虛繼承與虛基礎類別

多繼承(Multiple Inheritance)是指從多個直接基礎類別中產生派生類的能力,多繼承的派生類繼承了所有父類別的成員。

多繼承時很容易產生命名衝突,即使我們很小心地將所有類中的成員變數和成員函數都命名爲不同的名字,命名衝突依然有可能發生,比如典型的是菱形繼承。

在一個派生類中保留間接基礎類別的多份同名成員,雖然可以在不同的成員變數中分別存放不同的數據,但大多數情況下這是多餘的:因爲保留多份成員變數不僅佔用較多的儲存空間,還容易產生命名衝突。假如類 A 有一個成員變數 a,那麼在類 D 中直接存取 a 就會產生歧義,編譯器不知道它究竟來自 A -->B–>D 這條路徑,還是來自 A–>C–>D 這條路徑。下面 下麪是菱形繼承的具體實現:

//間接基礎類別A
class A{
protected:
    int m_a;
};
//直接基礎類別B
class B: public A{
protected:
    int m_b;
};
//直接基礎類別C
class C: public A{
protected:
    int m_c;
};
//派生類D
class D: public B, public C{
public:
    void seta(int a){ m_a = a; }  //命名衝突
    void setb(int b){ m_b = b; }  //正確
    void setc(int c){ m_c = c; }  //正確
    void setd(int d){ m_d = d; }  //正確
private:
    int m_d;
};
int main(){
    D d;
    return 0;
}

這段程式碼實現了上圖所示的菱形繼承,第 25 行程式碼試圖直接存取成員變數 m_a,結果發生了錯誤,因爲類 B 和類 C 中都有成員變數 m_a(從 A 類繼承而來),編譯器不知道選用哪一個,所以產生了歧義。

爲了消除歧義,我們可以在 m_a 的前面指明它具體來自哪個類:

void seta(int a){ B::m_a = a; }

這樣表示使用 B 類的 m_a。當然也可以使用 C 類的:

void seta(int a){ C::m_a = a; }

爲了解決命名衝突的問題,我們引入了新的東西:虛基礎類別與虛繼承。

虛繼承(Virtual Inheritance)

爲了解決多繼承時的命名衝突和冗餘數據問題,C++ 提出了虛繼承,使得在派生類中只保留一份間接基礎類別的成員。

//間接基礎類別A
class A{
protected:
    int m_a;
};
//直接基礎類別B
class B: virtual public A{  //虛繼承
protected:
    int m_b;
};
//直接基礎類別C
class C: virtual public A{  //虛繼承
protected:
    int m_c;
};
//派生類D
class D: public B, public C{
public:
    void seta(int a){ m_a = a; }  //正確
    void setb(int b){ m_b = b; }  //正確
    void setc(int c){ m_c = c; }  //正確
    void setd(int d){ m_d = d; }  //正確
private:
    int m_d;
};
int main(){
    D d;
    return 0;
}

這段程式碼使用虛繼承重新實現了上圖所示的菱形繼承,這樣在派生類 D 中就只保留了一份成員變數 m_a,直接存取就不會再有歧義了。

虛繼承的目的是讓某個類做出聲明,承諾願意共用它的基礎類別。其中,這個被共用的基礎類別就稱爲虛基礎類別(Virtual Base Class),本例中的 A 就是一個虛基礎類別。在這種機制 機製下,不論虛基礎類別在繼承體系中出現了多少次,在派生類中都只包含一份虛基礎類別的成員。

我們會發現虛繼承的一個不太直觀的特徵:必須在虛派生的真實需求出現前就已經完成虛派生的操作。在上圖中,當定義 D 類時纔出現了對虛派生的需求,但是如果 B 類和 C 類不是從 A 類虛派生得到的,那麼 D 類還是會保留 A 類的兩份成員。

換個角度講,虛派生隻影響從指定了虛基礎類別的派生類中進一步派生出來的類,它不會影響派生類本身。

1.8 虛繼承的建構函式

虛基礎類別是由最終的派生類初始化的,換句話說,最終派生類別建構函式必須要呼叫虛基礎類別的建構函式。對最終的派生類來說,虛基礎類別是間接基礎類別,而不是直接基礎類別。這跟普通繼承不同,在普通繼承中,派生類建構函式中只能呼叫直接基礎類別的建構函式,不能呼叫間接基礎類別的。

我們看看程式碼:

#include <iostream>
using namespace std;
//虛基礎類別A
class A{
public:
    A(int a);
protected:
    int m_a;
};
A::A(int a): m_a(a){ }
//直接派生類B
class B: virtual public A{
public:
    B(int a, int b);
public:
    void display();
protected:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"m_a="<<m_a<<", m_b="<<m_b<<endl;
}
//直接派生類C
class C: virtual public A{
public:
    C(int a, int c);
public:
    void display();
protected:
    int m_c;
};
C::C(int a, int c): A(a), m_c(c){ }
void C::display(){
    cout<<"m_a="<<m_a<<", m_c="<<m_c<<endl;
}
//間接派生類D
class D: public B, public C{
public:
    D(int a, int b, int c, int d);
public:
    void display();
private:
    int m_d;
};
D::D(int a, int b, int c, int d): A(a), B(90, b), C(100, c), m_d(d){ }
void D::display(){
    cout<<"m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<", m_d="<<m_d<<endl;
}
int main(){
    B b(10, 20);
    b.display();
   
    C c(30, 40);
    c.display();
    D d(50, 60, 70, 80);
    d.display();
    return 0;
}

在最終派生類 D 的建構函式中,除了呼叫 B 和 C 的建構函式,還呼叫了 A 的建構函式,這說明 D 不但要負責初始化直接基礎類別 B 和 C,還要負責初始化間接基礎類別 A。而在以往的普通繼承中,派生類別建構函式只負責初始化它的直接基礎類別,再由直接基礎類別的建構函式初始化間接基礎類別,使用者嘗試呼叫間接基礎類別的建構函式將導致錯誤。

1.9 向上轉型

類其實也是一種數據型別,也可以發生數據型別轉換,不過這種轉換隻有在基礎類別和派生類之間纔有意義,並且只能將派生類賦值給基礎類別,包括將派生類物件賦值給基礎類別物件、將派生類指針賦值給基礎類別指針、將派生類參照賦值給基礎類別參照,這在 C++ 中稱爲向上轉型(Upcasting)。相應地,將基礎類別賦值給派生類稱爲向下轉型(Downcasting)。

將派生類物件賦值給基礎類別物件

下面 下麪的例子演示瞭如何將派生類物件賦值給基礎類別物件:

#include <iostream>
using namespace std;
//基礎類別
class A{
public:
    A(int a);
public:
    void display();
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}
//派生類
class B: public A{
public:
    B(int a, int b);
public:
    void display();
public:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int main(){
    A a(10);
    B b(66, 99);
    //賦值前
    a.display();
    b.display();
    cout<<"--------------"<<endl;
    //賦值後
    a = b;
    a.display();
    b.display();
    return 0;
}

執行結果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99

本例中 A 是基礎類別, B 是派生類,a、b 分別是它們的物件,由於派生類 B 包含了從基礎類別 A 繼承來的成員,因此可以將派生類物件 b 賦值給基礎類別物件 a。通過執行結果也可以發現,賦值後 a 所包含的成員變數的值已經發生了變化。

賦值的本質是將現有的數據寫入已分配好的記憶體中,物件的記憶體只包含了成員變數,所以物件之間的賦值是成員變數的賦值,成員函數不存在賦值問題。執行結果也有力地證明了這一點,雖然有a=b;這樣的賦值過程,但是 a.display() 始終呼叫的都是 A 類的 display() 函數。換句話說,物件之間的賦值不會影響成員函數,也不會影響 this 指針。

這種轉換關係是不可逆的,只能用派生類物件給基礎類別物件賦值,而不能用基礎類別物件給派生類物件賦值。理由很簡單,基礎類別不包含派生類的成員變數,無法對派生類的成員變數賦值。同理,同一基礎類別的不同派生類物件之間也不能賦值。

2.多型與虛擬函式

「多型(polymorphism)」指的是同一名字的事物可以完成不同的功能。多型可以分爲編譯時的多型和執行時的多型。前者主要是指函數的過載(包括運算子的過載)、對過載函數的呼叫,在編譯時就能根據實參確定應該呼叫哪個函數,因此叫編譯時的多型;而後者則和繼承、虛擬函式等概念有關,是本章要講述的內容。本教學後面提及的多型都是指執行時的多型。

2.1 多型

基礎類別的指針也可以指向派生類物件,請看下面 下麪的例子:

#include <iostream>
using namespace std;
//基礎類別People
class People{
public:
    People(char *name, int age);
    void display();
protected:
    char *m_name;
    int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
    cout<<m_name<<"今年"<<m_age<<"歲了,是個無業遊民。"<<endl;
}
//派生類Teacher
class Teacher: public People{
public:
    Teacher(char *name, int age, int salary);
    void display();
private:
    int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
    cout<<m_name<<"今年"<<m_age<<"歲了,是一名教師,每月有"<<m_salary<<"元的收入。"<<endl;
}
int main(){
    People *p = new People("王志剛", 23);
    p -> display();
    p = new Teacher("趙宏佳", 45, 8200);
    p -> display();
    return 0;
}

執行結果:
王志剛今年23歲了,是個無業遊民。
趙宏佳今年45歲了,是個無業遊民。

我們直觀上認爲,如果指針指向了派生類物件,那麼就應該使用派生類的成員變數和成員函數,這符合人們的思維習慣。但是本例的執行結果卻告訴我們,當基礎類別指針 p 指向派生類 Teacher 的物件時,雖然使用了 Teacher 的成員變數,但是卻沒有使用它的成員函數,導致輸出結果不倫不類(趙宏佳本來是一名老師,輸出結果卻顯示人家是個無業遊民),不符合我們的預期。

換句話說,通過基礎類別指針只能存取派生類的成員變數,但是不能存取派生類的成員函數。

爲了消除這種尷尬,讓基礎類別指針能夠存取派生類的成員函數,C++ 增加了虛擬函式(Virtual Function)。使用虛擬函式非常簡單,只需要在函數宣告前面增加 virtual 關鍵字。

#include <iostream>
using namespace std;
//基礎類別People
class People{
public:
    People(char *name, int age);
    virtual void display();  //宣告爲虛擬函式
protected:
    char *m_name;
    int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
    cout<<m_name<<"今年"<<m_age<<"歲了,是個無業遊民。"<<endl;
}
//派生類Teacher
class Teacher: public People{
public:
    Teacher(char *name, int age, int salary);
    virtual void display();  //宣告爲虛擬函式
private:
    int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
    cout<<m_name<<"今年"<<m_age<<"歲了,是一名教師,每月有"<<m_salary<<"元的收入。"<<endl;
}
int main(){
    People *p = new People("王志剛", 23);
    p -> display();
    p = new Teacher("趙宏佳", 45, 8200);
    p -> display();
    return 0;
}

執行結果:
王志剛今年23歲了,是個無業遊民。
趙宏佳今年45歲了,是一名教師,每月有8200元的收入。

和前面的例子相比,本例僅僅是在 display() 函數宣告前加了一個virtual關鍵字,將成員函數宣告爲了虛擬函式(Virtual Function),這樣就可以通過 p 指針呼叫 Teacher 類的成員函數了,執行結果也證明了這一點(趙宏佳已經是一名老師了,不再是無業遊民了)。

有了虛擬函式,基礎類別指針指向基礎類別物件時就使用基礎類別的成員(包括成員函數和成員變數),指向派生類物件時就使用派生類的成員。換句話說,基礎類別指針可以按照基礎類別的方式來做事,也可以按照派生類的方式來做事,它有多種形態,或者說有多種表現方式,我們將這種現象稱爲多型(Polymorphism)

總結:使用虛擬函式就可以解決了多型裏面的採用哪一個派生類的方法進行執行。

多型是物件導向程式設計的主要特徵之一,C++中虛擬函式的唯一用處就是構成多型。

C++提供多型的目的是:可以通過基礎類別指針對所有派生類(包括直接派生和間接派生)的成員變數和成員函數進行「全方位」的存取,尤其是成員函數。如果沒有多型,我們只能存取成員變數。

我們藉助參照也可以。

int main(){
    People p("王志剛", 23);
    Teacher t("趙宏佳", 45, 8200);
   
    People &rp = p;
    People &rt = t;
   
    rp.display();
    rt.display();
    return 0;
}

執行結果:
王志剛今年23歲了,是個無業遊民。
趙宏佳今年45歲了,是一名教師,每月有8200元的收入。

2.2 虛擬函式構成條件

這節我們來重點說一下虛擬函式的注意事項。

  1. 只需要在虛擬函式的宣告處加上 virtual 關鍵字,函數定義處可以加也可以不加。

  2. 爲了方便,你可以只將基礎類別中的函數宣告爲虛擬函式,這樣所有派生類中具有遮蔽關係的同名函數都將自動成爲虛擬函式。關於名字遮蔽已在《C++繼承時的名字遮蔽》一節中進行了講解。

  3. 當在基礎類別中定義了虛擬函式時,如果派生類沒有定義新的函數來遮蔽此函數,那麼將使用基礎類別的虛擬函式。

  4. 只有派生類的虛擬函式覆蓋基礎類別的虛擬函式(函數原型相同)才能 纔能構成多型(通過基礎類別指針存取派生類函數)。例如基礎類別虛擬函式的原型爲virtual void func();,派生類虛擬函式的原型爲virtual void func(int);,那麼當基礎類別指針 p 指向派生類物件時,語句p -> func(100);將會出錯,而語句p -> func();將呼叫基礎類別的函數。

  5. 建構函式不能是虛擬函式。對於基礎類別的建構函式,它僅僅是在派生類建構函式中被呼叫,這種機制 機製不同於繼承。也就是說,派生類不繼承基礎類別的建構函式,將建構函式宣告爲虛擬函式沒有什麼意義。

  6. 解構函式可以宣告爲虛擬函式,而且有時候必須要宣告爲虛擬函式,這點我們將在下節中講解。

下面 下麪是構成多型的條件:

  • 必須存在繼承關係;
  • 繼承關係中必須有同名的虛擬函式,並且它們是覆蓋關係(函數原型相同)。
  • 存在基礎類別的指針,通過該指針呼叫虛擬函式。

上節我們講到,建構函式不能是虛擬函式,因爲派生類不能繼承基礎類別的建構函式,將建構函式宣告爲虛擬函式沒有意義。

這是原因之一,另外還有一個原因:C++ 中的建構函式用於在建立物件時進行初始化工作,在執行建構函式之前物件尚未建立完成,虛擬函式表尚不存在,也沒有指向虛擬函式表的指針,所以此時無法查詢虛擬函式表,也就不知道要呼叫哪一個建構函式。

在實際開發中,一旦我們自己定義了解構函式,就是希望在物件銷燬時用它來進行清理工作,比如釋放記憶體、關閉檔案等,如果這個類又是一個基礎類別,那麼我們就必須將該解構函式宣告爲虛擬函式,否則就有記憶體泄露的風險。也就是說,大部分情況下都應該將基礎類別的解構函式宣告爲虛擬函式。

2.3 純虛擬函式與抽象

C++中,可以將虛擬函式宣告爲純虛擬函式,語法格式爲:

virtual 返回值型別 函數名 (函數參數) = 0;

純虛擬函式沒有函數體,只有函數宣告,在虛擬函式宣告的結尾加上=0,表明此函數爲純虛擬函式。

最後的=0並不表示函數返回值爲0,它只起形式上的作用,告訴編譯系統「這是純虛擬函式」。

包含純虛擬函式的類稱爲抽象類(Abstract Class)。

純虛擬函式沒有函數體,不是完整的函數,無法呼叫,也無法爲其分配記憶體空間。

抽象類通常是作爲基礎類別,讓派生類去實現純虛擬函式。派生類必須實現純虛擬函式才能 纔能被範例化。

也就是說抽象類必須要用一個函數進行範例化,並且覆蓋重寫虛擬函式。

#include <iostream>
using namespace std;
//線
class Line{
public:
    Line(float len);
    virtual float area() = 0;
    virtual float volume() = 0;
protected:
    float m_len;
};
Line::Line(float len): m_len(len){ }
//矩形
class Rec: public Line{
public:
    Rec(float len, float width);
    float area();
protected:
    float m_width;
};
Rec::Rec(float len, float width): Line(len), m_width(width){ }
float Rec::area(){ return m_len * m_width; }
//長方體
class Cuboid: public Rec{
public:
    Cuboid(float len, float width, float height);
    float area();
    float volume();
protected:
    float m_height;
};
Cuboid::Cuboid(float len, float width, float height): Rec(len, width), m_height(height){ }
float Cuboid::area(){ return 2 * ( m_len*m_width + m_len*m_height + m_width*m_height); }
float Cuboid::volume(){ return m_len * m_width * m_height; }
//正方體
class Cube: public Cuboid{
public:
    Cube(float len);
    float area();
    float volume();
};
Cube::Cube(float len): Cuboid(len, len, len){ }
float Cube::area(){ return 6 * m_len * m_len; }
float Cube::volume(){ return m_len * m_len * m_len; }
int main(){
    Line *p = new Cuboid(10, 20, 30);
    cout<<"The area of Cuboid is "<<p->area()<<endl;
    cout<<"The volume of Cuboid is "<<p->volume()<<endl;
  
    p = new Cube(15);
    cout<<"The area of Cube is "<<p->area()<<endl;
    cout<<"The volume of Cube is "<<p->volume()<<endl;
    return 0;
}

執行結果:
The area of Cuboid is 2200
The volume of Cuboid is 6000
The area of Cube is 1350
The volume of Cube is 3375

本例中定義了四個類,它們的繼承關係爲:Line --> Rec --> Cuboid --> Cube。

Line 是一個抽象類,也是最頂層的基礎類別,在 Line 類中定義了兩個純虛擬函式 area() 和 volume()。

在 Rec 類中,實現了 area() 函數;所謂實現,就是定義了純虛擬函式的函數體。但這時 Rec 仍不能被範例化,因爲它沒有實現繼承來的 volume() 函數,volume() 仍然是純虛擬函式,所以 Rec 也仍然是抽象類。

直到 Cuboid 類,才實現了 volume() 函數,纔是一個完整的類,纔可以被範例化。

純虛擬函式的幾點說明

  1. 一個純虛擬函式就可以使類成爲抽象基礎類別,但是抽象基礎類別中除了包含純虛擬函式外,還可以包含其它的成員函數(虛擬函式或普通函數)和成員變數。

  2. 只有類中的虛擬函式才能 纔能被宣告爲純虛擬函式,普通成員函數和頂層函數均不能宣告爲純虛擬函式。

3.運算子過載

3.1 基本內容

所謂過載,就是賦予新的含義。函數過載(Function Overloading)可以讓一個函數名有多種功能,在不同情況下進行不同的操作。**運算子過載(Operator Overloading)**也是一個道理,同一個運算子可以有不同的功能。

實際上,我們已經在不知不覺中使用了運算子過載。例如,+號可以對不同類型(int、float 等)的數據進行加法操作;<<既是位移運算子,又可以配合 cout 向控制檯輸出數據。C++ 本身已經對這些運算子進行了過載。

我們先看一個例子:

#include <iostream>
using namespace std;
class complex{
public:
    complex();
    complex(double real, double imag);
public:
    //宣告運算子過載
    complex operator+(const complex &A) const;
    void display() const;
private:
    double m_real;  //實部
    double m_imag;  //虛部
};
complex::complex(): m_real(0.0), m_imag(0.0){ }
complex::complex(double real, double imag): m_real(real), m_imag(imag){ }
//實現運算子過載
complex complex::operator+(const complex &A) const{
    complex B;
    B.m_real = this->m_real + A.m_real;
    B.m_imag = this->m_imag + A.m_imag;
    return B;
}
void complex::display() const{
    cout<<m_real<<" + "<<m_imag<<"i"<<endl;
}
int main(){
    complex c1(4.3, 5.8);
    complex c2(2.4, 3.7);
    complex c3;
    c3 = c1 + c2;
    c3.display();
    return 0;
}

執行結果:
6.7 + 9.5i

本例中義了一個複數類 complex,m_real 表示實部,m_imag 表示虛部,第 10 行宣告瞭運算子過載,第 21 行進行了實現(定義)。認真觀察這兩行程式碼,可以發現運算子過載的形式與函數非常類似。

運算子過載其實就是定義一個函數,在函數體內實現想要的功能,當用到該運算子時,編譯器會自動呼叫這個函數。也就是說,運算子過載是通過函數實現的,它本質上是函數過載。

運算子過載的格式爲:

返回值型別 operator 運算子名稱 (形參表列){
  //TODO:
}

operator是關鍵字,專門用於定義過載運算子的函數。我們可以將operator 運算子名稱這一部分看做函數名,對於上面的程式碼,函數名就是operator+

operator是關鍵字,專門用於定義過載運算子的函數。我們可以將operator 運算子名稱這一部分看做函數名,對於上面的程式碼,函數名就是operator+

運算子過載函數除了函數名有特定的格式,其它地方和普通函數並沒有區別。

上面的例子中,我們在 complex 類中過載了運算子+,該過載只對 complex 物件有效。當執行c3 = c1 + c2;語句時,編譯器檢測到+號左邊(+號具有左結合性,所以先檢測左邊)是一個 complex 物件,就會呼叫成員函數operator+(),也就是轉換爲下面 下麪的形式:

c3 = c1.operator+(c2);

c1 是要呼叫函數的物件,c2 是函數的實參。

上面的運算子過載還可以有更加簡練的定義形式:

complex complex::operator+(const complex &A)const{    return complex(this->m_real + A.m_real, this->m_imag + A.m_imag);}

return 語句中的complex(this->m_real + A.m_real, this->m_imag + A.m_imag)會建立一個臨時物件,這個物件沒有名稱,是一個匿名物件。在建立臨時物件過程中呼叫建構函式,return 語句將該臨時物件作爲函數返回值。

運算子過載函數不僅可以作爲類的成員函數,還可以作爲全域性函數。更改上面的程式碼,在全域性範圍內過載+,實現複數的加法運算:

#include <iostream>
using namespace std;
class complex{
public:
    complex();
    complex(double real, double imag);
public:
    void display() const;
    //宣告爲友元函數
    friend complex operator+(const complex &A, const complex &B);
private:
    double m_real;
    double m_imag;
};
complex operator+(const complex &A, const complex &B);
complex::complex(): m_real(0.0), m_imag(0.0){ }
complex::complex(double real, double imag): m_real(real), m_imag(imag){ }
void complex::display() const{
    cout<<m_real<<" + "<<m_imag<<"i"<<endl;
}
//在全域性範圍內過載+
complex operator+(const complex &A, const complex &B){
    complex C;
    C.m_real = A.m_real + B.m_real;
    C.m_imag = A.m_imag + B.m_imag;
    return C;
}
int main(){
    complex c1(4.3, 5.8);
    complex c2(2.4, 3.7);
    complex c3;
    c3 = c1 + c2;
    c3.display();
    return 0;
}

運算子過載函數不是 complex 類的成員函數,但是卻用到了 complex 類的 private 成員變數,所以必須在 complex 類中將該函數宣告爲友元函數。

當執行c3 = c1 + c2;語句時,編譯器檢測到+號兩邊都是 complex 物件,就會轉換爲類似下面 下麪的函數呼叫:

c3 = operator+(c1, c2);

3.2 遵循規則

  1. 並不是所有的運算子都可以過載。能夠過載的運算子包括:
    + - * / % ^ & | ~ ! = < > += -= = /= %= ^= &= |= << >> <<= >>= == != <= >= && || ++ – , -> -> () [] new new[] delete delete[]

  2. 過載不能改變運算子的優先順序和結合性。假設上一節的 complex 類中過載了+號和*號,並且 c1、c2、c3、c4 都是 complex 類的物件,那麼下面 下麪的語句:

c4 = c1 + c2 * c3;

等價於:

c4 = c1 + ( c2 * c3 );

3.3 流物件過載

C++中,標準庫本身已經對左移運算子<<和右移運算子>>分別進行了過載,使其能夠用於不同數據的輸入輸出,但是輸入輸出的物件只能是 C++ 內建的數據型別(例如 bool、int、double 等)和標準庫所包含的類型別(例如 string、complex、ofstream、ifstream 等)。

cout 是 ostream 類的物件,cin 是 istream 類的物件,要想達到這個目標,就必須以全域性函數(友元函數)的形式過載<<>>,否則就要修改標準庫中的類,這顯然不是我們所期望的。

過載輸入運算子>> 與 <<

下面 下麪我們以全域性函數的形式過載>>,使它能夠讀入兩個 double 型別的數據,並分別賦值給複數的實部和虛部:

  1. 1. istream & **operator**>>(istream &in, complex &A){
    2. ​    in >> A.m_real >> A.m_imag;
    3. ​    **return** in;
    4. }
    

同樣地,我們也可以模仿上面的形式對輸出運算子>>進行過載,讓它能夠輸出複數,請看下面 下麪的程式碼:

ostream & operator<<(ostream &out, complex &A){    out << A.m_real <<" + "<< A.m_imag <<" i ";    return out;}

ostream 表示輸出流,cout 是 ostream 類的物件。由於採用了參照的方式進行參數傳遞,並且也返回了物件的參照,所以過載後的運算子可以實現連續輸出。

爲了能夠直接存取 complex 類的 private 成員變數,同樣需要將該函數宣告爲 complex 類的友元函數:

friend ostream & operator<<(ostream &out, complex &A);

3.4 []過載

下標運算子[ ]必須以成員函數的形式進行過載。該過載函數在類中的宣告格式如下:

返回值型別 & operator[ ] (參數);

或者:

const 返回值型別 & operator[ ] (參數) const;

3.5 自增自減運算子過載

自增++和自減--都是一元運算子,它的前置形式和後置形式都可以被過載。請看下面 下麪的例子:

#include <iostream>
#include <iomanip>
using namespace std;
//秒錶類
class stopwatch{
public:
    stopwatch(): m_min(0), m_sec(0){ }
public:
    void setzero(){ m_min = 0; m_sec = 0; }
    stopwatch run();  // 執行
    stopwatch operator++();  //++i,前置形式
    stopwatch operator++(int);  //i++,後置形式
    friend ostream & operator<<( ostream &, const stopwatch &);
private:
    int m_min;  //分鐘
    int m_sec;  //秒鐘
};
stopwatch stopwatch::run(){
    ++m_sec;
    if(m_sec == 60){
        m_min++;
        m_sec = 0;
    }
    return *this;
}
stopwatch stopwatch::operator++(){
    return run();
}
stopwatch stopwatch::operator++(int n){
    stopwatch s = *this;
    run();
    return s;
}
ostream &operator<<( ostream & out, const stopwatch & s){
    out<<setfill('0')<<setw(2)<<s.m_min<<":"<<setw(2)<<s.m_sec;
    return out;
}
int main(){
    stopwatch s1, s2;
    s1 = s2++;
    cout << "s1: "<< s1 <<endl;
    cout << "s2: "<< s2 <<endl;
    s1.setzero();
    s2.setzero();
    s1 = ++s2;
    cout << "s1: "<< s1 <<endl;
    cout << "s2: "<< s2 <<endl;
    return 0;
}

執行結果:
s1: 00:00
s2: 00:01
s1: 00:01
s2: 00:01

operator++() 函數實現自增的前置形式,直接返回 run() 函數執行結果即可。

operator++ (int n) 函數實現自增的後置形式。

自減運算子一模一樣。

4.C++模板

我們進入之前,我們先看一個函數:

//交換 int 變數的值
void Swap(int *a, int *b){
    int temp = *a;
    *a = *b;
    *b = temp;
}
//交換 float 變數的值
void Swap(float *a, float *b){
    float temp = *a;
    *a = *b;
    *b = temp;
}
//交換 char 變數的值
void Swap(char *a, char *b){
    char temp = *a;
    *a = *b;
    *b = temp;
}
//交換 bool 變數的值
void Swap(bool *a, bool *b){
    char temp = *a;
    *a = *b;
    *b = temp;
}

是不是發現一個函數名有很多個,看着就煩。

我們就引入了這樣的話題:模板。

4.1 函數模板

C++中,數據的型別也可以通過參數來傳遞,在函數定義時可以不指明具體的數據型別,當發生函數呼叫時,編譯器可以根據傳入的實參自動推斷數據型別。這就是型別的參數化。

值(Value)和型別(Type)是數據的兩個主要特徵,它們在C++中都可以被參數化。

所謂函數模板,實際上是建立一個通用函數,它所用到的數據的型別(包括返回值型別、形參型別、區域性變數型別)可以不具體指定,而是用一個虛擬的型別來代替(實際上是用一個識別符號來佔位),等發生函數呼叫時再根據傳入的實參來逆推出真正的型別。這個通用函數就稱爲函數模板(Function Template)

在函數模板中,數據的值和型別都被參數化了,發生函數呼叫時編譯器會根據傳入的實參來推演形參的值和型別。換個角度說,函數模板除了支援值的參數化,還支援型別的參數化。

一但定義了函數模板,就可以將型別參數用於函數定義和函數宣告瞭。說得直白一點,原來使用 int、float、char 等內建型別的地方,都可以用型別參數來代替。

#include <iostream>
using namespace std;
template<typename T> void Swap(T *a, T *b){
    T temp = *a;
    *a = *b;
    *b = temp;
}
int main(){
    //交換 int 變數的值
    int n1 = 100, n2 = 200;
    Swap(&n1, &n2);
    cout<<n1<<", "<<n2<<endl;
   
    //交換 float 變數的值
    float f1 = 12.5, f2 = 56.93;
    Swap(&f1, &f2);
    cout<<f1<<", "<<f2<<endl;
   
    //交換 char 變數的值
    char c1 = 'A', c2 = 'B';
    Swap(&c1, &c2);
    cout<<c1<<", "<<c2<<endl;
   
    //交換 bool 變數的值
    bool b1 = false, b2 = true;
    Swap(&b1, &b2);
    cout<<b1<<", "<<b2<<endl;
    return 0;
}

執行結果:
200, 100
56.93, 12.5
B, A
1, 0

請讀者重點關注第 4 行程式碼。template是定義函數模板的關鍵字,它後面緊跟尖括號<>,尖括號包圍的是型別參數(也可以說是虛擬的型別,或者說是型別佔位符)。typename是另外一個關鍵字,用來宣告具體的型別參數,這裏的型別參數就是T。從整體上看,template<typename T>被稱爲模板頭。

在講解C++函數過載時我們還沒有學到參照(Reference),爲了達到交換兩個變數的值的目的只能使用指針,而現在我們已經對參照進行了深入講解,不妨趁此機會來實踐一把,使用參照重新實現 Swap() 這個函數模板:

#include <iostream>
using namespace std;
template<typename T> void Swap(T &a, T &b){
    T temp = a;
    a = b;
    b = temp;
}
int main(){
    //交換 int 變數的值
    int n1 = 100, n2 = 200;
    Swap(n1, n2);
    cout<<n1<<", "<<n2<<endl;
   
    //交換 float 變數的值
    float f1 = 12.5, f2 = 56.93;
    Swap(f1, f2);
    cout<<f1<<", "<<f2<<endl;
   
    //交換 char 變數的值
    char c1 = 'A', c2 = 'B';
    Swap(c1, c2);
    cout<<c1<<", "<<c2<<endl;
   
    //交換 bool 變數的值
    bool b1 = false, b2 = true;
    Swap(b1, b2);
    cout<<b1<<", "<<b2<<endl;
    return 0;
}

下面 下麪我們來總結一下定義模板函數的語法:

template <typename 型別參數1 , typename 型別參數2 , ...> 返回值型別  函數名(形參列表){
  //在函數體中可以使用型別參數
}

型別參數可以有多個,它們之間以逗號,分隔。型別參數列表以< >包圍,形式參數列表以( )包圍。

typename關鍵字也可以使用class關鍵字替代,它們沒有任何區別。

4.2 類別範本

C++ 除了支援函數模板,還支援類別範本(Class Template)。函數模板中定義的型別參數可以用在函數宣告和函數定義中,類別範本中定義的型別參數可以用在類宣告和類實現中。類別範本的目的同樣是將數據的型別參數化。

宣告類別範本的語法爲:

template<typename 型別參數1 , typename 型別參數2 , …> class 類名{
  //TODO:
};

類別範本和函數模板都是以 template 開頭(當然也可以使用 class,目前來講它們沒有任何區別),後跟型別參數;型別參數不能爲空,多個型別參數用逗號隔開。

一但宣告瞭類別範本,就可以將型別參數用於類的成員函數和成員變數了。換句話說,原來使用 int、float、char 等內建型別的地方,都可以用型別參數來代替。

上面的程式碼僅僅是類的宣告,我們還需要在類外定義成員函數。在類外定義成員函數時仍然需要帶上模板頭,格式爲:

template<typename 型別參數1 , typename 型別參數2 , …>
返回值型別 類名<型別參數1 , 型別參數2, ...>::函數名(形參列表){
  //TODO:
}

第一行是模板頭,第二行是函數頭,它們可以合併到一行,不過爲了讓程式碼格式更加清晰,一般是將它們分成兩行。

  1. 1. **template**<**typename** T1, **typename** T2>  //模板頭
    2. T1 Point<T1, T2>::getX() **const** /*函數頭*/ {
    3. ​    **return** m_x;
    4. }
    5. 
    6. **template**<**typename** T1, **typename** T2>
    7. void Point<T1, T2>::setX(T1 x){
    8. ​    m_x = x;
    9. }
    10. 
    11. **template**<**typename** T1, **typename** T2>
    12. T2 Point<T1, T2>::getY() **const**{
    13. ​    **return** m_y;
    14. }
    15. 
    16. **template**<**typename** T1, **typename** T2>
    17. void Point<T1, T2>::setY(T2 y){
    18. ​ m_y = y;
    19. }
    

使用類別範本建立物件

上面的兩段程式碼完成了類的定義,接下來就可以使用該類建立物件了。使用類別範本建立物件時,需要指明具體的數據型別。請看下面 下麪的程式碼:

純文字複製
Point<int, int> p1(10, 20);
Point<int, float> p2(10, 15.5);
Point<float, char*> p3(12.4, "東經180度");

與函數模板不同的是,類別範本在範例化時必須顯式地指明數據型別,編譯器不能根據給定的數據推演出數據型別。

光看看是不行的,不知道怎麼用,所以我們看一個綜合案例:

#include <iostream>
using namespace std;
template<class T1, class T2>  //這裏不能有分號
class Point{
public:
    Point(T1 x, T2 y): m_x(x), m_y(y){ }
public:
    T1 getX() const;  //獲取x座標
    void setX(T1 x);  //設定x座標
    T2 getY() const;  //獲取y座標
    void setY(T2 y);  //設定y座標
private:
    T1 m_x;  //x座標
    T2 m_y;  //y座標
};
template<class T1, class T2>  //模板頭
T1 Point<T1, T2>::getX() const /*函數頭*/ {
    return m_x;
}
template<class T1, class T2>
void Point<T1, T2>::setX(T1 x){
    m_x = x;
}
template<class T1, class T2>
T2 Point<T1, T2>::getY() const{
    return m_y;
}
template<class T1, class T2>
void Point<T1, T2>::setY(T2 y){
    m_y = y;
}
int main(){
    Point<int, int> p1(10, 20);
    cout<<"x="<<p1.getX()<<", y="<<p1.getY()<<endl;
    Point<int, char*> p2(10, "東經180度");
    cout<<"x="<<p2.getX()<<", y="<<p2.getY()<<endl;
    Point<char*, char*> *p3 = new Point<char*, char*>("東經180度", "北緯210度");
    cout<<"x="<<p3->getX()<<", y="<<p3->getY()<<endl;
    return 0;
}

執行結果:
x=10, y=20
x=10, y=東經180度
x=東經180度, y=北緯210度

5.異常

我們看似程式沒有什麼問題,但是結果通常是bug!bug!bug!

我們在C++裏面引入了異常機制 機製。

程式執行時常會碰到一些錯誤,例如除數爲 0、年齡爲負數、陣列下標越界等,這些錯誤如果不能發現並加以處理,很可能會導致程式崩潰。

C++ 例外處理機制 機製就可以讓我們捕獲並處理這些錯誤,然後我們可以讓程式沿着一條不會出錯的路徑繼續執行,或者不得不結束程式,但在結束前可以做一些必要的工作,例如將記憶體中的數據寫入檔案、關閉開啓的檔案、釋放分配的記憶體等。

C++ 例外處理機制 機製會涉及 try、catch、throw 三個關鍵字,本章將爲你一一講解。

5.1 異常捕獲

我們可以藉助 C++ 異常機制 機製來捕獲上面的異常,避免程式崩潰。捕獲異常的語法爲:

try{
  // 可能拋出異常的語句
}catch(exceptionType variable){
  // 處理異常的語句
}

trycatch都是 C++ 中的關鍵字,後跟語句塊,不能省略{ }。try 中包含可能會拋出異常的語句,一旦有異常拋出就會被後面的 catch 捕獲。從 try 的意思可以看出,它只是「檢測」語句塊有沒有異常,如果沒有發生異常,它就「檢測」不到。

從 try 的意思可以看出,它只是「檢測」語句塊有沒有異常,如果沒有發生異常,它就「檢測」不到。catch 是「抓住」的意思,用來捕獲並處理 try 檢測到的異常;如果 try 語句塊沒有檢測到異常(沒有異常拋出),那麼就不會執行 catch 中的語句。

這就好比,catch 告訴 try:你去檢測一下程式有沒有錯誤,有錯誤的話就告訴我,我來處理,沒有的話就不要理我!

我們來看一個案例:

#include <iostream>
#include <string>
#include <exception>
using namespace std;
int main(){
    string str = "http://c.biancheng.net";
  
    try{
        char ch1 = str[100];
        cout<<ch1<<endl;
    }catch(exception e){
        cout<<"[1]out of bound!"<<endl;
    }
    try{
        char ch2 = str.at(100);
        cout<<ch2<<endl;
    }catch(exception &e){  //exception類位於<exception>標頭檔案中
        cout<<"[2]out of bound!"<<endl;
    }
    return 0;
}

發生異常時必須將異常明確地拋出,try 才能 纔能檢測到;如果不拋出來,即使有異常 try 也檢測不到。所謂拋出異常,就是明確地告訴程式發生了什麼錯誤。

說得直接一點,檢測到異常後程式的執行流會發生跳轉,從異常點跳轉到 catch 所在的位置,位於異常點之後的、並且在當前 try 塊內的語句就都不會再執行了;即使 catch 語句成功地處理了錯誤,程式的執行流也不會再回退到異常點,所以這些語句永遠都沒有執行的機會了。

執行完 catch 塊所包含的程式碼後,程式會繼續執行 catch 塊後面的程式碼,就恢復了正常的執行流。

關於「如何拋出異常」,我們將在下節講解,這裏重點是讓大家明白異常的處理流程:

拋出(Throw)–> 檢測(Try) --> 捕獲(Catch)

發生異常後,程式的執行流會沿着函數的呼叫鏈往前回退,直到遇見 try 才停止。在這個回退過程中,呼叫鏈中剩下的程式碼(所有函數中未被執行的程式碼)都會被跳過,沒有執行的機會了。

當然,我們也可以使用多個catch塊來處理異常。

try{
    //可能拋出異常的語句
}catch (exception_type_1 e){
    //處理異常的語句
}catch (exception_type_2 e){
    //處理異常的語句
}
//其他的catch
catch (exception_type_n e){
    //處理異常的語句
}

範例:

#include <iostream>
#include <string>
using namespace std;
class Base{ };
class Derived: public Base{ };
int main(){
    try{
        throw Derived();  //拋出自己的異常型別,實際上是建立一個Derived型別的匿名物件
        cout<<"This statement will not be executed."<<endl;
    }catch(int){
        cout<<"Exception type: int"<<endl;
    }catch(char *){
        cout<<"Exception type: cahr *"<<endl;
    }catch(Base){  //匹配成功(向上轉型)
        cout<<"Exception type: Base"<<endl;
    }catch(Derived){
        cout<<"Exception type: Derived"<<endl;
    }
    return 0;
}

實際上, catch 在匹配異常型別時發生了向上轉型(Upcasting)

上述程式碼結果是:Exception type: Base

5.2 異常拋出

在 C++ 中,我們使用 throw 關鍵字來顯式地拋出異常,它的用法爲:

throw exceptionData;

exceptionData 是「異常數據」的意思,它可以包含任意的資訊,完全有程式設計師決定。exceptionData 可以是 int、float、bool 等基本型別,也可以是指針、陣列、字串、結構體、類等聚合型別

throw 關鍵字除了可以用在函數體中拋出異常,還可以用在函數頭和函數體之間,指明當前函數能夠拋出的異常型別,這稱爲異常規範

double func (char param) throw (int);

這條語句宣告瞭一個名爲 func 的函數,它的返回值型別爲 double,有一個 char 型別的參數,並且只能拋出 int 型別的異常。如果拋出其他型別的異常,try 將無法捕獲,只能終止程式。

如果函數會拋出多種型別的異常,那麼可以用逗號隔開:

double func (char param) throw (int, char, exception);

如果函數不會拋出任何異常,那麼( )中什麼也不寫:

double func (char param) throw ();

如此,func() 函數就不能拋出任何型別的異常了,即使拋出了,try 也檢測不到

但是,異常規範的初衷實現起來有點困難,所以大家達成的一致意見是,最好不要使用異常規範。

異常規範是 C++98 新增的一項功能,但是後來的 C++11 已經將它拋棄了,不再建議使用。