C++數據結構繼承的概念與菱形繼承及虛擬繼承和組合

⭐️博客代碼已上傳至gitee:https://gitee.com/byte-binxin/cpp-class-code

🌏繼承的概念

繼承:繼承機制是面向對象程序設計使代碼可以復用的最重要的手段,它允許程序員在保持原有類特性的基礎上進行擴展,增加功能,這樣產生新的類,稱派生類。繼承呈現瞭面向對象程序設計的層次結構,體現瞭由簡單到復雜的認知過程。以前我們接觸的復用都是函數復用,繼承是類設計層次的復用。

🌏繼承的定義

語法:

說明: 派生類會將基類的成員變量和成員函數都繼承下來,但是訪問限定符會根據繼承方式而發生變化。

繼承方式有三種:

  • public繼承
  • protected繼承
  • private繼承

訪問限定符:

  • public訪問
  • protected訪問
  • private訪問

繼承基類成員的訪問方式的變化:

類成員/繼承方式 public繼承 protected繼承 private繼承
基類的public成員 派生類的public成員 派生類的protected成員 派生類的private成員
基類的protected成員 派生類的protected成員 派生類的protected成員 派生類的private成員
基類的private成員 派生類中不可見 派生類中不可見 派生類中不可見

總結:

  • 基類的private成員在派生類中都是不可見的,這裡的不可見是指基類的私有成員還是被繼承到瞭派生類對象中,但是語法上限制派生類對象不管在類裡面還是類外面都不能去訪問它。
  • 基類成員在父類中的訪問方式=min(成員在基類的訪問限定符,繼承方式),public>protected>private。
  • 一般會把基類中不想讓類外訪問的成員設置為protecd成員,不讓類外訪問,但是讓派生類可以訪問。

🌏基類和派生類對象之間的賦值轉換

派生類對象會通過 “切片” 或 “切割” 的方式賦值給基類的對象、指針或引用。但是基類對象不能賦值給派生類對象。

實例演示:

class Person
{
public:
	Person(const char* name = "")
		:_name(name)
	{}
	void Print()
	{
		cout << "name:" << _name << " age:" << _age << endl;
	}
protected:
	string _name = "";
	int _age = 1;
};
class Student : public Person
{
public:
	Student()
		:Person("xiaoming")
	{}
	void Print()
	{
		cout << "name:" << _name << " age:" << _age << " _stuid:" << _stuid << " _major:" << _major << endl;
	}
private:
	int _stuid = 0;// 學號
	int _major = 0;// 專業
};
int main()
{
	Student s;
	// 子類對象可以賦值給父類的對象、指針和引用  反過來不行
	// Student對象通過 “切片” 或 “切割” 的方式進行賦值
	Person p1 = s;
	Person* p2 = &s;
	Person& p3 = s;

	p1.Print();
	p2->Print();
	p3.Print();

	// 基類的指針可以通過強制類型轉換賦值給派生類的指針
	Student* ps = (Student*)p2;

	ps->Print();

	return 0;
}

總結:

  • 派生類對象可以“切片”或“切割”的方式賦值給基類的對象,基類的指針或基類的引用,就是把基類的那部分切割下來。
  • 基類對象不能給派生類對象賦值。
  • 基類的指針可以通過強制類型轉換賦值給派生類的指針。但必須是基類的指針指向派生類的對象才是安全的,因為如果基類是多態類型,會引發多態。

🌏繼承中的作用域

在繼承體系中,基類和派生類對象都有獨立的作用域,子類中的成員(成員變量和成員函數)會對父類的同名成員進行隱藏,也叫重定義。

實例演示:

class Person
{
public:
	Person(const char* name = "")
		:_name(name)
	{}
	void Print()
	{
		cout << "name:" << _name << " age:" << _age << endl;
	}
protected:
	string _name = "";
	int _age = 1;
};
class Teacher : public Person
{
public:
	void Print()
	{
		cout << "name:" << _name << " age:" << _age << " jobid:" << _jobid << endl;
	}
private:
	int _jobid = 0;// 工號
};
int main()
{
	Teacher t;

	t.Print();
	t.Person::Print();// 子類會隱藏(重定義)父類的同名成員(同名函數或同名成員變量) 可以通過指定域作用限定符訪問

	return 0;
}

代碼運行結果如下:

得出結論: 子類中的成員(成員變量和成員函數)會對父類的同名成員進行隱藏,如果相要訪問父類的同名成員,必須指定類域訪問。

看下面一個小問題: 請問A中的fun函數和B中的fun函數是構成重載還是隱藏?

class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i) 
	{
		A::fun();
		cout << "func(int i)->" << i << endl;
	}
};
void Test()
{
	B b;
	b.fun(10);
};

答案: 兩個函數在不同的作用域,不可能構成重載。因為構成重載的條件是兩個函數必須在同一作用域,而隱藏是要求在基類和派生類不同作用域的,所以這裡同名成員是構造隱藏。

🌏派生類的默認成員函數

C++中的每個對象中會有6個默認成員函數。默認的意思就是我們不寫,編譯器會生成一個。那麼在繼承中,子類的默認成員函數是怎麼生成的呢?

先看下面一個例子:

class Person
{
public:
	Person(const char* name = "", int age = 1)
		:_name(name)
		,_age(age)
	{
		cout << "Person()" << endl;
	}
	Person(const Person& p)
		:_name(p._name)
		, _age(p._age)
	{
		cout << "Person(const Person& p)" << endl;
	}
	Person& operator=(const Person& p)
	{
		_name = p._name;
		_age = p._age;
		cout << "Person& operator=(const Person& p)" << endl;
		return *this;
	}
	void Print()
	{
		cout << "name:" << _name << " age:" << _age << endl;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name;
	int _age;
};


class Student : public Person
{
public:
	Student(const char* name, int age, int stuid = 0)
		:Person(name, age)// 此處調用父類的構造函數堆繼承下來的成員進行初始化,不謝的話,編譯器調用父類的默認構造函數
		, _stuid(stuid)
	{
		cout << "Student()" << endl;
	}
	Student(const Student& s)
		:Person(s)// 子類對象可以傳給父類的對象、指針或引用
		,_stuid(s._stuid)
	{
		cout << "Student(const Student& s)" << endl;
	}
	Student& operator=(const Student& s)
	{
		cout << "Student& operator=(const Student& s)" << endl;
		if (this != &s)
		{
			Person::operator=(s);// 先完成基類的復制
			_stuid = s._stuid;
		}

		return *this;
	}
	void Print()
	{
		cout << "name:" << _name << " age:" << _age << " _stuid:" << _stuid << endl;
	}
	~Student()
	{
		// 基類和派生類的析構函數的函數名都被編譯器處理成瞭destruction,構成隱藏,是一樣指定域訪問
		//Person::~Person();// 不需要顯示調用 編譯器會自動先調用派生類的析構函數,然後調用基類的析構函數
		cout << "~Student()" << endl;
	}
private:
	int _stuid;// 學號
};

測試1:構造函數和析構函數

void test1()
{
	Student s("小明",18,10);
}

代碼運行結果如下:

總結1: 子類的構造函數必須調用基類的構造函數初始化基類的那一部分成員,如果基類沒有默認構造函數,則必須在派生類構造函數的初始化列表階段顯示調用。子類的析構函數會在被調用完成後自動調用基類的析構函數清理基類的成員。不需要顯示調用。這裡子類和父類的析構函數的函數名會被編譯器處理成destructor,這樣兩個函數構成隱藏。

測試2:拷貝構造函數

void test2()
{
	Student s1("小明", 18, 10);
	Student s2(s1);
}

代碼運行結果如下:

總結2: 子類的拷貝構造必須代用父類的拷貝構造完成父類成員的拷貝。

測試3:operator=

結論3: 子類的operator=必須調用基類的operator完成基類的賦值。

思考

如何設計一個不能被繼承的類? 把該類的構造函數設為私有。如果基類的構造函數是私有,那麼派生類不能調用基類的構造函數完成基類成員的初始化,則無法進行構造。所以這樣設計的類不可以被繼承。(後面還會將加上final關鍵字的類也不可以被繼承)

總結:

  • 子類的構造函數必須調用基類的構造函數初始化基類的那一部分成員,如果基類沒有默認構造函數,則必須在派生類構造函數的初始化列表階段顯示調用。
  • 子類的拷貝構造必須代用父類的拷貝構造完成父類成員的拷貝。
  • 子類的operator=必須調用基類的operator完成基類的賦值。
  • 子類的析構函數會在被調用完成後自動調用基類的析構函數清理基類的成員。不需要顯示調用。
  • 子類對象會先調用父類的構造在調用子類的構造。
  • 子類對象會先析構子類的析構再調用父類的析構。

🌏繼承中的兩個小細節

🌲繼承和友元

友元關系不能被繼承。也就是說基類的友元不能夠訪問子類的私有和保護成員。

🌲繼承和靜態成員

基類定義的static靜態成員,存在於整個類中,不屬於某個類,無論右多少個派生類,都這有一個static成員。

實例演示:

class Person
{
public:
	Person()
	{
		++_count;
	}
	// static成員存在於整個類  無論實例化出多少對象,都隻有一個static成員實例
	static int _count;
};

int Person::_count = 0;

class Student :public Person
{
public:
	int _stuid;
};

int main()
{
	Student s1;
	Student s2;
	Student s3;

	// Student()._count = 10;
	cout << "人數:" << Student()._count - 1 << endl;

	return 0;
}

代碼運行結果如下:

🌏單繼承和多繼承(菱形繼承)

單繼承: 一個子類隻有一個直接父類時稱這個繼承關系為單繼承。

多繼承: 一個子類有兩個或以上的直接父類時稱這個繼承關系為多繼承。

菱形繼承: 多繼承的一種特殊情況。

多繼承帶來的問題: 子類會得到兩份BenZ的數據,會造成數據冗餘和二義性。

🌏虛擬繼承

🐚概念

為瞭解決菱形繼承帶來的數據冗餘和二義性的問題,C++提出來虛擬繼承這個概念。虛擬繼承可以解決前面的問題,在繼承方式前加椰果virtual的關鍵字即可。

class Person
{
public:
	string _name;
};
// 不要在其他地方去使用。
class Student : virtual public Person
{
public:
	int _num; //學號
};
class Teacher : virtual public Person
{
public:
	int _id; // 職工編號
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修課程
};

🐚虛擬繼承的原理

先看下面一串代碼:

class A
{
public:
	int _a;
};

class B :virtual public A
{
public:
	int _b;
};

class C :virtual public A
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 4;
	d._c = 5;
	d._d = 6;

	return 0;
}

我們通過內存窗口查看它的對象模型:

原理: 從上圖可以看出,A對象同時屬於B和C,B和C中分別存放瞭一個指針,這個指針叫虛基表指針,分別指向的兩張表,叫虛基表,虛基表中存的是偏移量,B和C通過偏移量就可以找到公共空間(存放A對象的位置)。

🌏組合與繼承

總結一下幾點:

  • 組合和繼承都屬於類層次的復用。
  • public繼承是一種is-a的關系。也就是說每個派生類對象都是一個基類對象-。
  • 組合是一種has-a的關系。假設B組合瞭A,每個B對象中都有一個A對象。
  • 優先使用對象組合,而不是類繼承 。
  • 繼承允許你根據基類的實現來定義派生類的實現。這種通過生成派生類的復用通常被稱為白箱復用。術語“白箱”是相對可視性而言:在繼承方式中,基類的內部細節對子類可見 。繼承一定程度破壞瞭基類的封裝,基類的改變,對派生類有很大的影響。派生類和基類間的依賴關系很強,耦合度高。
  • 對象組合是類繼承之外的另一種復用選擇。新的更復雜的功能可以通過組裝或組合對象來獲得。對象組合要求被組合的對象具有良好定義的接口。這種復用風格被稱為黑箱復用,因為對象的內部細是不可見的。對象隻以“黑箱”的形式出現。 組合類之間沒有很強的依賴關系,耦合度低。優先使用對象組合有助於你保持每個類被封裝。
  • 實際盡量多去用組合。組合的耦合度低,代碼維護性好。不過繼承也有用武之地的,有些關系就適合繼承那就用繼承,另外要實現多態,也必須要繼承。類之間的關系可以用繼承,可以用組合,就用組合。

C++的缺陷之一:

多繼承就是一個。多繼承會帶來菱形繼承,菱形繼承又會帶來數據冗餘和二義性,為瞭解決這個問題,又引入瞭虛擬繼承。進而導致C++的底層結構對象模型非常復雜,這樣會帶來一定的損失。所以說盡量不要設計出菱形繼承。

🌐總結

C++的繼承使我們變得更加的富有,其中多繼承也是C++的缺陷。我們要盡量避開不好的而選擇好的一面。這篇博客就介紹到這裡瞭,喜歡的話,歡迎點贊。支持和關註~

到此這篇關於C++數據結構繼承的概念與菱形繼承及虛擬繼承和組合的文章就介紹到這瞭,更多相關C++ 繼承內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: