C++ | 類繼承

就良同學發表於2023-05-08

1. 概述

C++有3種繼承方式:公有繼承(public)、保護繼承(protected)、私有繼承(private)。

一個B類繼承於A類,或稱從類A派生類B。這樣的話,類A稱為基類(父類),類B稱為派生類(子類)。派生類中的成員,包含兩部分:一部分是從基類繼承過來的,另一類是派生類自己增加的成員。

派生類繼承基類,派生類擁有基類中全部成員變數和成員方法(除了建構函式和解構函式),但是在派生類中,繼承的成員並不一定能直接訪問,不同的繼承方式會導致不同的訪問許可權。派生類的訪問許可權規則如下:

image

#include<iostream>
using namespace std;

class A{
public:
	int mA;
protected:
	int mB;
private:
	int mC;
};

1.1 公有繼承

class B:public A{
public:
	void printB(){
		cout << "printB:\n";
		cout << mA << endl; // 可訪問基類A的public屬性
		cout << mB << endl; // 可訪問基類A的protected屬性
		// cout << mC << endl; // 不可訪問基類A的private屬性
	}
};
class SubB:public B{
public:
	void printSubB(){
		cout << "printSubB:\n";
		cout << mA << endl;
		cout << mB << endl;
		// cout << mC << endl; // 不可訪問
	}
};

1.2 私有繼承

使用私有繼承,基類的公有成員和保護成員都將稱為派生類的私有成員。這意味著基類方法將不會成為派生物件公有介面的一部分,但可以在派生類的成員函式中使用它們,而在繼承層次結構之外是不可用的。

class D:private A{
public:
	void printD(){
		cout << "printD:\n";
		cout << mA << endl; // 可訪問基類A的public屬性
		cout << mB << endl; // 可訪問基類A的protected屬性
		// cout << mC << endl; // 不可訪問基類A的private屬性
	}
};
class SubD:public D{ // 在繼承層次結構之外不可用
public:
	void printSubD(){
		cout << "printSubD:\n";
		// cout << mA << endl; // 不可訪問
		// cout << mB << endl; // 不可訪問
		// cout << mC << endl; // 不可訪問
	}
};

1.3 保護繼承

使用保護繼承時,基類的公有成員和保護成員都將成為派生類的保護成員。當從派生類派生出第三代類時,私有繼承和保護繼承的區別便呈現出來了。

使用私有繼承時,第三代類將不能使用基類(第一代類)的介面,這是因為基類的公有方法在其派生類(第二代類)中都將變成私有方法;

使用保護繼承時,基類的公有方法在第二代類中將變成受保護的,因此第三代類可以使用它們。

class C:protected A{
public:
	void printC(){
		cout << "printC:\n";
		cout << mA << endl;  // 可訪問基類A的public屬性
		cout << mB << endl;  // 可訪問基類A的protected屬性
		// cout << mC << endl; // 不可訪問基類A的private屬性
	}
};
class SubC:public C{
public:
	void printSubC(){
		cout << "printSubC:\n";
		cout << mA << endl;
		cout << mB << endl;
		// cout << mC << endl; // 不可訪問
	}
};

2. 繼承中的構造和析構

不是所有的函式都能自動從基類繼承到派生類中。建構函式和解構函式用來處理物件的建立和析構操作,構造和解構函式不能被繼承,必須為每一個特定的派生類分別建立。

  • 子類物件在建立時會首先呼叫父類的建構函式,父類建構函式執行完畢之後,才會呼叫子類的建構函式;
  • 當父類建構函式帶參時,需要在子類初始化列表中顯示呼叫父類的建構函式;
  • 解構函式呼叫順序和建構函式相反。
#include <iostream>
#include <string>
using namespace std;

class Animal{
private:
	string mName;
public:
	Animal(string name) { 
		cout << "Animal帶參建構函式...\n";
		mName = name;
	}
	~Animal(){
		cout << "Animal解構函式...\n";
	}
};

class Bird:public Animal{
private:
	bool can_flight; // 能否飛行
public:
	Bird(bool cf, string name):Animal(name){
		cout << "Bird帶參建構函式...\n";
		can_flight = cf;
	}
	~Bird(){
		cout << "Bird解構函式...\n";
	}

};

int main(){
	Bird(true, "海鷗");
	return 0;
}

輸出:

Animal帶參建構函式...
Bird帶參建構函式...
Bird解構函式...
Animal解構函式...

注意:operator=也不能被繼承,因為它完成類似建構函式的行為。

3 派生類和基類之間的特殊關係

派生類與基類之間有一些特殊關係。

3.1 派生類物件可以使用基類的方法

派生類物件可以使用基類的方法,條件是該方法不是私有的:

#include <iostream>
#include <string>
using namespace std;

class Animal{
private:
	string mName;
public:
	Animal() { mName = "no name";}
	Animal(string name) { mName = name;}
	void showAnimal(){
		cout << "Name: " << mName << endl;
	}
};
class Bird:public Animal{
private:
	bool can_flight; // 能否飛行
public:
	Bird(bool cf, string name):Animal(name){ can_flight = cf; }
	void showBird(){
		cout << "Can_flight(1-can;0-can't): " << can_flight << endl;
	}
};

int main(){
	Bird b(true, "海鷗");
	b.showAnimal(); // 派生類物件可以使用基類的方法
	b.showBird();
	return 0;
}

輸出:

Name: 海鷗
Can_flight(1-can;0-can't): 1

3.2 基類指標可以在不進行顯式型別轉換的情況下指向派生類物件

int main(){
	Bird b(true, "海鷗");
	Animal *pa = &b;
	pa->showAnimal();
	return 0;
}

輸出:

Name: 海鷗

3.3 基類引用可以在不進行顯式型別轉換的情況下引用派生類物件

int main(){
	Bird b(true, "海鷗");
	Animal &ra = b;
	ra.showAnimal();
	return 0;
}

輸出:

Name: 海鷗

然而,基類指標或者引用只能呼叫基類方法,因此,不能使用pa或ra來呼叫派生類的showBird方法,只是單向的,不可以將基類物件和地址賦給派生類引用或指標:

int main(){
	Animal a("海鷗");
	Bird &rb = a; // 非法
    Bird *pb = &a; // 非法
	return 0;
}

3. 繼承中同名成員的處理方法

3.1 同名變數

  • 當子類成員和父類成員同名時,子類依然從父類繼承同名成員 。子類訪問其成員預設訪問子類的成員(本作用域,就近原則);
  • 在子類透過作用域::進行同名成員區分。
#include <iostream>
#include <string>
using namespace std;

class Father{
public:
	int mParam;
public:
	Father():mParam(0){}
	void display(){cout << mParam << endl;}
};

class Son:public Father{
public:
	int mParam;
public:
	Son():mParam(1){}
	void display(){
		cout << Father::mParam << endl; // 在派生類中使用於基類同名成員,顯示使用類名限定符
		cout << mParam << endl;
	}
};

int main(){
	Son son;
	cout << son.mParam << endl; // 就近原則,預設訪問子類成員
	son.display();
	return 0;
}

3.2 同名方法

Father.cpp

class Father{
public:
	// 過載方法
	void func1(){
		cout << "Father::void func1()...\n";
	}
	void func1(int param){
		cout << "Father::void func1(int param)...\n";
	}
	// 非過載方法
	void func2(){
		cout << "Father::void func2()...\n";
	}
};

Son.cpp

class Son:public Father{
public:
	void func2(){
		Father::func2(); // 基類func2()將隱藏,可透過類作用域運算子指定呼叫基類func2()方法
		cout << "Son::void func2()...\n";
	}
};

int main(){
	Son son;
	son.func2();
	return 0;
}

輸出:

Father::void func2()...
Son::void func2()...

Daughter.cpp

class Daughter:public Father{
public:
	void func1(int param1, int param2){ // 改變引數列表->重新定義繼承自基類的方法,同名基類方法將被隱藏
		Father::func1(10); // 可透過類作用域運算子指定呼叫基類方法
		cout << "Daughter::void func2(int param1, int param2)...\n";
	}
	int func1(int param){ // 改變返回值型別->重新定義繼承自基類的方法,同名基類方法將被隱藏
		Father::func1(10); // 可透過類作用域運算子指定呼叫基類方法
		cout << "Daughter::int func1(int param)...\n";
		return param;
	}
};

int main(){
	Daughter daughter;
	cout << daughter.func1(1) << endl;;
	return 0;
}

輸出:

Father::void func1(int param)...
Daughter::int func1(int param)...
1

總結:

重新定義繼承的方法並不是過載。如果重新定義派生類中的繼承函式,基類中所有的同名方法都將被隱藏,派生類物件將無法使用它們。(但是在派生類中可以透過類作用域運算子指定呼叫基類方法)

如果基類方法在基類的類定義中被過載了,則應該在派生類中重新定義所有的基類版本。如果只定義一個版本,則其他版本將被隱藏,派生類物件將無法使用它們。

若不需要修改繼承自基類的方法,則只需透過類作用域運算子指定呼叫基類方法即可。

4. 多繼承

4.1 多繼承概念

同時繼承多個類,即為多繼承。

image

#include <iostream>
#include <string>
using namespace std;

class Singer{
public:
	void show(){
		cout << "Singer::show()..." << endl;
	}
};
class Waiter{
public:
	void show(){
		cout << "Waiter::show()..." << endl;
	}
};

class SiningWaiter:public Singer, public Waiter{};

int main(){
	SiningWaiter sw;
	// sw.show(); // show是從Singer繼承而來的,還是從Waiter繼承來的呢?
	return 0;
}

多繼承會帶來一些二義性的問題,如果兩個基類中有同名的函式或變數,那麼透過派生類物件去訪問時就不能明確到底是呼叫從基類1繼承的版本還是從基類2繼承的版本。

解決方法:顯式指定呼叫哪個基類的版本。

int main(){
	SiningWaiter sw;
	sw.Singer::show();	
	sw.Waiter::show();
	return 0;
}

輸出:

Singer::show()...
Waiter::show()...

4.2 菱形繼承和虛繼承:菱形繼承

兩個派生類繼承同一基類,而又有某個第三代類同時繼承了這兩個派生類,這種繼承稱為菱形繼承。

image

class Animal{
protected:
	int age;
public:
	Animal(){ age = 0; }
	void show(){
		cout << "Animal::show()..." << endl;
	}
};

class Sheep:public Animal{ // 羊類
public:
	void show(){
		cout << "Sheep::show()..." << endl;
	}
};
class Camel:public Animal{ // 駝類
public:
	void show(){
		cout << "Camel::show()..." << endl;
	}
};

class Alpaca:public Sheep, public Camel{}; // 羊駝類

這種繼承主要帶來兩類問題:

  • 成員訪問產生二義性;
  • 第三代類重複繼承了第一代類的資料(羊駝類繼承自動物類的函式與資料繼承了雙份)。
int main(){
	Alpaca alpaca;
	// 問題1:成員訪問二義性
	// alpaca.show(); // 二義性
	alpaca.Sheep::show();	
	alpaca.Camel::show();
	// 問題2:重複繼承
	cout << "Animal size: " << sizeof(Animal) << endl;
	cout << "Sheep size: " << sizeof(Sheep) << endl;
	cout << "Camel size: " << sizeof(Camel) << endl;
	cout << "Alpaca size: " << sizeof(Alpaca) << endl;
	return 0;
}

輸出:

Sheep::show()...
Camel::show()...
Animal size: 4
Sheep size: 4
Camel size: 4
Alpaca size: 8

建立派生類物件時,程式首先呼叫基類建構函式,然後再呼叫派生類建構函式。因此,Sheep物件和Camel物件中各含有一個基類物件(即Animal物件),所以才會有Sheep size: 4Camel size: 4。而由於Alpaca同時繼承了Sheep和Camel,因此Alpaca物件中將包含兩個Animal物件,因此Alpaca size: 8

image

這種重複繼承將帶來一些問題。例如,通常可以將派生類都西昂的地址賦予基類指標,但現在將出現二義性:

Alpaca al;
Animal *pa = &al; // 出現二義性

通常,這種賦值將把基類指標設定為派生類物件中的基類物件的地址。但現在al中包含2個Animal物件,有2個地址可供選擇,所以應使用型別轉換來指定物件:

Animal *pa1 = (Sheep)&al; // the Animal in Sheep
Animal *pa2 = (Camel)&al; // the Animal in Camel

對於這種菱形繼承所帶來的問題,C++提供了一種方式——採用虛基類。

4.3 菱形繼承和虛繼承:虛基類

1)虛基類

虛基類使得從多個第二代類(它們的基類(第一代類)相同)派生出的物件只繼承一個基類(第一代類)物件。

透過在類宣告中使用關鍵字virtual,可以使Animal被硬座Sheep和Camel的虛基類(virtual和public的次序無關緊要):

class Sheep:virtual public Animal{...};
class Camel:virtual public Animal{...};
class Alpaca:public Sheep, public Camel{...};

現在,Alpaca物件將只包含一個Animal物件。從本質上說,繼承的Sheep和Camel物件共享一個Animal物件,而不是各自引入自己的Animal物件副本。

image

class Sheep:virtual public Animal{};
class Camel:virtual public Animal{};

class Alpaca:public Sheep, public Camel{}; // 羊駝類
int main(){
	Alpaca alpaca;
	alpaca.show();
	cout << "Alpaca size: " << sizeof(Alpaca) << endl;
	return 0;
}

輸出:

Animal::show()...
Alpaca size: 12

透過虛繼承的方式解決了菱形繼承帶來的二義性問題。但是為什麼Alpaca size: 12呢?

2)虛基類實現原理(難點)

Sheep和Camel透過虛繼承的方式派生自Animal,編譯器將為Sheep類和Camel類各自增加一個指標vbptr(virtual base pointer),vbptr指向了一張表,這張表儲存了當前虛指標(即Sheep和Camel中的vbptr)相對於虛基類首地址的偏移量。

Alpaca派生於Sheep和Camel,將繼承二者的vbptr指標,並調整了vbptr與虛基類首地址的偏移量(從‘ 第二代類相對基類的偏移量 ’調整為“ ‘第三代類中的第二代類副本’ 相對基類的偏移量”)。

因此,最後的Alpaca建立物件後,包含2個vbptr指標(Sheep子物件和Camel子物件各一個)和Animal子物件中的一個整形變數age,總共佔12個位元組。

就這樣,使得菱形繼承時,Alpaca物件將只包含一個Animal子物件。從本質上說,繼承的Sheep子物件和Camel子物件共享一個Animal子物件,而不是各自引入自己的Animal子物件副本。

即使共享虛基類,但是必須要有一個類來完成基類的初始化(因為所有的物件都必須被初始化,哪怕是預設的),同時還不能夠重複進行初始化,那到底誰應該負責完成初始化呢?C++標準中選擇在每一次繼承子類中都必須書寫初始化語句(因為每一次繼承子類可能都會用來定義物件),但是虛基類的初始化是由最後的子類完成,其他的初始化語句都不會呼叫。

class Animal{
protected:
	int age;
public:
	Animal(int age){ this->age = age; }
};

class Sheep:virtual public Animal{
public: // 每一次繼承子類中都必須書寫初始化語句
    Sheep(int age):Animal(age){} // 不呼叫Animal構造
};
class Camel:public Animal{
public: // 每一次繼承子類中都必須書寫初始化語句
    Camel(int age):Animal(age); // 不呼叫Animal構造
};

class Alpaca:public Sheep, public Camel{
public: // 每一次繼承子類中都必須書寫初始化語句
    Alpaca(int age):Animal(age); // 呼叫Animal構造 
};

5. 靜態聯編和動態聯編

5.1 虛擬函式

1)虛擬函式概念

在函式宣告時加上關鍵字virtual。這些函式被稱為虛擬函式(virtual method)或虛方法。

在基類和派生類存在同名函式的情況下。如果函式是透過引用或指標而不是物件本身呼叫的,虛擬函式將確定使用基類的方法還是派生類的方法。如果使用了virtual關鍵字,程式將根據引用或指標指向的物件的型別來選擇方法,否則將根據引用或指標的型別來選擇。

如果show()不是虛的,則程式的行為如下:

#include <iostream>
#include <string>
using namespace std;

class Animal{
protected:
	int age;
public:
	Animal(){ age = 0; }
	void show(){ // 沒有新增關鍵字virtual
		cout << "Animal::show()..." << endl;
	}
};

class Sheep:public Animal{
public:
	void show(){ // 沒有新增關鍵字virtual
		cout << "Sheep::show()..." << endl;
	}
};
int main(){
	Animal al;
	Sheep sp;
	Animal &ra = al;
	Animal *pa = &sp;
	ra.show();
	pa->show();
	return 0;
}

輸出:

Animal::show()...
Animal::show()...

如果show()是虛的,則行為如下:

#include <iostream>
#include <string>
using namespace std;

class Animal{
protected:
	int age;
public:
	Animal(){ age = 0; }
	void virtual show(){ // 新增關鍵字virtual
		cout << "Animal::show()..." << endl;
	}
    virtual ~Animal(){} // 虛解構函式 
};

class Sheep:public Animal{
public:
	void virtual show(){ // 新增關鍵字virtual
		cout << "Sheep::show()..." << endl;
	}
};
int main(){
	Animal al;
	Sheep sp;
	Animal &ra = al;
	Animal *pa = &sp;
	ra.show();
	pa->show();
	return 0;
}

輸出:

Animal::show()...
Sheep::show()...

如果要在派生類中重新定義基類的方法,通常應將基類方法宣告為虛的。這樣,程式將根據物件型別而不是引用或指標的型別來選擇方法版本。

2)虛解構函式

virtual ~Animal(){} // 虛解構函式 

虛解構函式是為了解決基類指標指向派生類物件,並用基類指標釋放派生類物件。確保釋放派生類物件時,按正確的順序呼叫解構函式

如果基類的解構函式不是虛的,則將只呼叫對應於指標或引用型別的解構函式:

class Animal{
public:
	Animal(){ cout << "Animal建構函式\n"; }
	~Animal(){ cout << "Animal解構函式\n";} // 基類的解構函式不是虛的
};

class Sheep:public Animal{
private:
	char *mName;
public:
	Sheep(){
		cout << "Sheep建構函式\n";
		mName = new char[64];
		memset(mName, 0, 64);
		strcpy(mName, "no name");
	};
	~Sheep(){
		cout << "Sheep解構函式\n";
		if(mName!=NULL){
			delete[] mName;
			mName = NULL;
		}
	}
};
int main(){
	Animal *al = new Sheep;
	delete al;
	return 0;
}

輸出:

Animal建構函式
Sheep建構函式
Animal解構函式

這意味著只有Animal的解構函式被呼叫,即使指標Animal *al指向的是一個Sheep物件。

如果基類的解構函式是虛的,則將呼叫指標指向的相應物件的解構函式:

class Animal{
public:
	Animal(){ cout << "Animal建構函式\n"; }
	virtual ~Animal(){ cout << "Animal解構函式\n";} // 基類的解構函式是虛的
};

class Sheep:public Animal{
private:
	char *mName;
public:
	Sheep(){
		cout << "Sheep建構函式\n";
		mName = new char[64];
		memset(mName, 0, 64);
		strcpy(mName, "no name");
	};
	~Sheep(){
		cout << "Sheep解構函式\n";
		if(mName!=NULL){
			delete[] mName;
			mName = NULL;
		}
	}
};
int main(){
	Animal *al = new Sheep;
	delete al;
	return 0;
}

輸出:

Animal建構函式
Sheep建構函式
Sheep解構函式
Animal解構函式

如果指標指向的是Sheep物件,將呼叫Sheep的解構函式,然後再自動呼叫基類的解構函式。

因此,使用虛解構函式能夠確保正確的解構函式序列被呼叫。對於1)虛擬函式概念小節中將Animal解構函式宣告為virtual並不是很重要,因為Sheep解構函式沒有執行任何操作。然而,如果Sheep包含一個執行某些操作的解構函式,則基類Animal必須有一個虛解構函式,即使該虛解構函式不執行任何操作,見本小節Sheep的解構函式

因此,為基類宣告一個虛解構函式成為一種慣例。

3)虛擬函式實現原理

》from 傳智播客

image

》 from C++ Primer Plus

通常,編譯器處理虛擬函式的方法是:給每個物件新增一個隱藏成員。隱藏成員中儲存了一個指向函式地址陣列的指標。這種陣列稱為虛擬函式表(virtual function table,vtbl)。(每個類編譯器都為其建立了一個虛擬函式地址表(陣列))虛擬函式表中儲存了為類物件進行宣告的虛擬函式的地址。

例如,基類物件包含一個指標vptr,該指標指向基類中所有虛擬函式的地址表。派生類物件也包含一個指標vptr,該指標指向派生類中所有虛擬函式的地址表。

如果派生類提供了虛擬函式的新定義,該虛擬函式表將儲存新函式的地址;如果派生類沒有重新定義虛擬函式,該虛擬函式表將儲存繼承自基類的函式原始版本的地址。如果派生類定義了新的虛擬函式,該函式的地址也將被新增到虛擬函式表中。

注意,無論類中包含的虛擬函式是1個還是10個,都只需要在物件中新增1個地址成員,只是表的大小不同而已。

image

呼叫虛擬函式時,程式將根據呼叫物件隱藏的指標vptr轉向相應的虛擬函式地址表(函式地址陣列)。如果使用類宣告中定義的第1個虛擬函式,則程式將使用陣列中的第1個函式地址,並執行具有該地址的函式程式碼塊。如果使用類宣告中的第3個虛擬函式,程式將使用陣列中的第3個函式。

5.2 靜態聯編

程式呼叫函式時,將使用哪個可執行程式碼塊呢?編譯器將負責回答這個問題。將原始碼中的函式呼叫解釋為執行特定的函式程式碼塊被稱為函式名聯編。

在C語言中,函式名聯編非常簡單,因為每個函式名都對應一個不同的函式。在C++中,由於函式過載的原因,這項任務更加複雜。編譯器必須檢視函式引數以及函式名才能確定使用哪個函式。然而,C/C++編譯器可以在編譯階段就完成這種聯編。在編譯階段中進行的聯編被稱為靜態聯編,又稱為早期聯編。

編譯器根據函式呼叫者的物件型別,在編譯階段就確定函式的呼叫地址,這就是靜態聯編。

5.3 動態聯編

然而,虛擬函式導致具體使用哪一個函式時不能在編譯時確定的,因為編譯器不知道使用者將選擇哪種型別的物件。所以,編譯器必須生成能夠在程式執行時選擇正確的虛擬函式的程式碼,這被稱為動態聯編,又被稱為晚期聯編。

在執行階段才能確定呼叫哪個函式。

編譯器對非虛方法使用靜態聯編,對虛方法使用動態聯編。例如:

Scientist *st = new Physicist;
st->show_all();

根據st指向的物件型別將show_all()呼叫Physicist:show_all()而不是Scientist:show_all()。但只有在執行程式時才能確定st指向的物件型別。所以編譯器生成的程式碼將在程式執行時,根據物件型別將show_all()關聯到Physicist:show_all()。

相關文章