主頁 > 軟體設計 > C++多型

C++多型

2021-10-21 08:20:34 軟體設計

文章目錄

  • 多型的概念
  • 多型的定義及實作
    • 多型的構成條件
    • 虛函式
    • 虛函式的重寫
    • 虛函式重寫的兩個例外
    • C++11 override和final
    • 多載、覆寫(重寫)、隱藏(重定義)的對比
  • 抽象類
    • 概念
    • 介面繼承和實作繼承
  • 多型的原理
    • 虛函式表
    • 多型的原理
    • 動態系結和靜態系結
  • 單繼承和多繼承關系的虛函式表
    • 單繼承中的虛函式表
    • 多繼承中的虛函式表
    • 菱形繼承、菱形虛擬繼承
  • 繼承和多型常見的面試問題
    • 概念考察
    • 問答題

多型的概念

多型就是函式呼叫的多種形態,使用多型能夠使得不同的物件去完成同一件事時,產生不同的動作和結果,

例如,在現實生活當中,普通人買票是全價,學生買票是半價,而軍人允許優先買票,不同身份的人去買票,所產生的行為是不同的,這就是所謂的多型,

多型的定義及實作

多型的構成條件

多型是指不同繼承關系的類物件,去呼叫同一函式,產生了不同的行為,在繼承中要想構成多型需要滿足兩個條件:

  1. 必須通過基類的指標或者參考呼叫虛函式,
  2. 被呼叫的函式必須是虛函式,且派生類必須對基類的虛函式進行重寫,

虛函式

被virtual修飾的類成員函式被稱為虛函式,

class Person
{
public:
	//被virtual修飾的類成員函式
	virtual void BuyTicket()
	{
		cout << "買票-全價" << endl;
	}
};

需要注意的是:

  1. 只有類的非靜態成員函式前可以加virtual,普通函式前不能加virtual,
  2. 虛函式這里的virtual和虛繼承中的virtual是同一個關鍵字,但是它們之間沒有任何關系,虛函式這里的virtual是為了實作多型,而虛繼承的virtual是為了解決菱形繼承的資料冗余和二義性,

虛函式的重寫

虛函式的重寫也叫做虛函式的覆寫,若派生類中有一個和基類完全相同的虛函式(回傳值型別相同、函式名相同以及引數串列完全相同),此時我們稱該派生類的虛函式重寫了基類的虛函式,

例如,我們以下Student和Soldier兩個子類重寫了父類Person的虛函式,

//父類
class Person
{
public:
	//父類的虛函式
	virtual void BuyTicket()
	{
		cout << "買票-全價" << endl;
	}
};
//子類
class Student : public Person
{
public:
	//子類的虛函式重寫了父類的虛函式
	virtual void BuyTicket()
	{
		cout << "買票-半價" << endl;
	}
};
//子類
class Soldier : public Person
{
public:
	//子類的虛函式重寫了父類的虛函式
	virtual void BuyTicket()
	{
		cout << "優先-買票" << endl;
	}
};

現在我們就可以通過父類Person的指標或者參考呼叫虛函式BuyTicket,此時不同型別的物件,呼叫的就是不同的函式,產生的也是不同的結果,進而實作了函式呼叫的多種形態,

void Func(Person& p)
{
	//通過父類的參考呼叫虛函式
	p.BuyTicket();
}
void Func(Person* p)
{
	//通過父類的指標呼叫虛函式
	p->BuyTicket();
}
int main()
{
	Person p;   //普通人
	Student st; //學生
	Soldier sd; //軍人

	Func(p);  //買票-全價
	Func(st); //買票-半價
	Func(sd); //優先買票

	Func(&p);  //買票-全價
	Func(&st); //買票-半價
	Func(&sd); //優先買票
	return 0;
}

注意: 在重寫基類虛函式時,派生類的虛函式不加virtual關鍵字也可以構成重寫,主要原因是因為繼承后基類的虛函式被繼承下來了,在派生類中依舊保持虛函式屬性,但是這種寫法不是很規范,因此建議在派生類的虛函式前也加上virtual關鍵字,

虛函式重寫的兩個例外

協變(基類與派生類虛函式的回傳值型別不同)

派生類重寫基類虛函式時,與基類虛函式回傳值型別不同,即基類虛函式回傳值基類物件的指標或者參考,派生類虛函式回傳派生類物件的指標或者參考,稱為協變,

例如,下列代碼中基類Person當中的虛函式fun的回傳值型別是基類A物件的指標,派生類Student當中的虛函式fun的回傳值型別是派生類B物件的指標,此時也認為派生類Student的虛函式重寫了基類Person的虛函式,

//基類
class A
{};
//子類
class B : public A
{};
//基類
class Person
{
public:
	//回傳基類A的指標
	virtual A* fun()
	{
		cout << "A* Person::f()" << endl;
		return new A;
	}
};
//子類
class Student : public Person
{
public:
	//回傳子類B的指標
	virtual B* fun()
	{
		cout << "B* Student::f()" << endl;
		return new B;
	}
};

此時,我們通過父類Person的指標呼叫虛函式fun,父類指標若指向的是父類物件,則呼叫父類的虛函式,父類指標若指向的是子類物件,則呼叫子類的虛函式,

int main()
{
	Person p;
	Student st;
	//父類指標指向父類物件
	Person* ptr1 = &p;
	//父類指標指向子類物件
	Person* ptr2 = &st;
	//父類指標ptr1指向的p是父類物件,呼叫父類的虛函式
	ptr1->fun(); //A* Person::f()
	//父類指標ptr2指向的st是子類物件,呼叫子類的虛函式
	ptr2->fun(); //B* Student::f()
	return 0;
}

解構式的重寫(基類與派生類解構式的名字不同)

如果基類的解構式為虛函式,此時派生類解構式只要定義,無論是否加virtual關鍵字,都與基類的解構式構成重寫,雖然基類與派生類解構式名字不同,

例如,下面代碼中父類Person和子類Student的解構式構成重寫,

//父類
class Person
{
public:
	virtual ~Person()
	{
		cout << "~Person()" << endl;
	}
};
//子類
class Student : public Person
{
public:
	virtual ~Student()
	{
		cout << "~Student()" << endl;
	}
};

那父類和子類的解構式構成重寫的意義何在呢?試想以下場景:分別new一個父類物件和子類物件,并均用父類指標指向它們,然后分別用delete呼叫解構式并釋放物件空間,

int main()
{
	//分別new一個父類物件和子類物件,并均用父類指標指向它們
	Person* p1 = new Person;
	Person* p2 = new Student;

	//使用delete呼叫解構式并釋放物件空間
	delete p1;
	delete p2;
	return 0;
}

在這種場景下,若是父類和子類的解構式沒有構成重寫就可能會導致記憶體泄漏,因為此時delete p1和delete p2都是呼叫的父類的解構式,而我們所期望的是p1呼叫父類的解構式,p2呼叫子類的解構式,即我們期望的是一種多型行為,
此時只有父類和子類的解構式構成了重寫,才能使得delete按照我們的預期進行解構式的呼叫,才能實作多型,因此,為了避免出現這種情況,比較建議將父類的解構式定義為虛函式,

知識擴展:
在繼承當中,子類和的解構式和父類的解構式構成隱藏的原因就在這里,這里表面上看子類的解構式和父類的解構式的函式名不同,但是為了構成重寫,編譯后解構式的名字會被統一處理成destructor();

C++11 override和final

從上面可以看出,C++對函式重寫的要求比較嚴格,有些情況下由于疏忽可能會導致函式名的字母次序寫反而無法構成重寫,而這種錯誤在編譯期間是不會報錯的,直到在程式運行時沒有得到預期結果再來進行除錯會得不償失,因此,C++11提供了final和override兩個關鍵字,可以幫助用戶檢測是否重寫,

final:修飾虛函式,表示該虛函式不能再被重寫,

例如,父類Person的虛函式BuyTicket被final修飾后就不能再被重寫了,子類若是重寫了父類的BuyTicket函式則編譯報錯,

//父類
class Person
{
public:
	//被final修飾,該虛函式不能再被重寫
	virtual void BuyTicket() final
	{
		cout << "買票-全價" << endl;
	}
};
//子類
class Student : public Person
{
public:
	//重寫,編譯報錯
	virtual void BuyTicket()
	{
		cout << "買票-半價" << endl;
	}
};
//子類
class Soldier : public Person
{
public:
	//重寫,編譯報錯
	virtual void BuyTicket()
	{
		cout << "優先-買票" << endl;
	}
};

override:檢查派生類虛函式是否重寫了基類的某個虛函式,如果沒有重寫則編譯報錯,

例如,子類Student和Soldier的虛函式BuyTicket被override修飾,編譯時就會檢查子類的這兩個BuyTicket函式是否重寫了父類的虛函式,如果沒有則會編譯報錯,

//父類
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "買票-全價" << endl;
	}
};
//子類
class Student : public Person
{
public:
	//子類完成了父類虛函式的重寫,編譯通過
	virtual void BuyTicket() override
	{
		cout << "買票-半價" << endl;
	}
};
//子類
class Soldier : public Person
{
public:
	//子類沒有完成了父類虛函式的重寫,編譯報錯
	virtual void BuyTicket(int i) override
	{
		cout << "優先-買票" << endl;
	}
};

多載、覆寫(重寫)、隱藏(重定義)的對比

在這里插入圖片描述

抽象類

概念

在虛函式的后面寫上=0,則這個函式為純虛函式,包含純虛函式的類叫做抽象類(也叫介面類),抽象類不能實體化出物件,

#include <iostream>
using namespace std;
//抽象類(介面類)
class Car
{
public:
	//純虛函式
	virtual void Drive() = 0;
};
int main()
{
	Car c; //抽象類不能實體化出物件,error
	return 0;
}

派生類繼承抽象類后也不能實體化出對象,只有重寫純虛函式,派生類才能實體化出物件,

#include <iostream>
using namespace std;
//抽象類(介面類)
class Car
{
public:
	//純虛函式
	virtual void Drive() = 0;
};
//派生類
class Benz : public Car
{
public:
	//重寫純虛函式
	virtual void Drive()
	{
		cout << "Benz-舒適" << endl;
	}
};
//派生類
class BMV : public Car
{
public:
	//重寫純虛函式
	virtual void Drive()
	{
		cout << "BMV-操控" << endl;
	}
};
int main()
{
	//派生類重寫了純虛函式,可以實體化出物件
	Benz b1;
	BMV b2;
	//不同物件用基類指標呼叫Drive函式,完成不同的行為
	Car* p1 = &b1;
	Car* p2 = &b2;
	p1->Drive();  //Benz-舒適
	p2->Drive();  //BMV-操控
	return 0;
}

抽象類既然不能實體化出物件,那抽象類存在的意義是什么?

  1. 抽象類可以更好的去表示現實世界中,沒有實體物件對應的抽象型別,比如:植物、人、動物等,
  2. 抽象類很好的體現了虛函式的繼承是一種介面繼承,強制子類去重寫純虛函式,因為子類若是不重寫從父類繼承下來的純虛函式,那么子類也是抽象類也不能實體化出物件,

介面繼承和實作繼承

實作繼承: 普通函式的繼承是一種實作繼承,派生類繼承了基類函式的實作,可以使用該函式,

介面繼承: 虛函式的繼承是一種介面繼承,派生類繼承的是基類虛函式的介面,目的是為了重寫,達成多型,

建議: 所以如果不實作多型,就不要把函式定義成虛函式,

多型的原理

虛函式表

下面是一道常考的筆試題:Base類實體化出物件的大小是多少?

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

通過觀察測驗,我們發現Base類實體化的物件b的大小是8個位元組,

int main()
{
	Base b;
	cout << sizeof(b) << endl; //8
	return 0;
}

b物件當中除了_b成員外,實際上還有一個_vfptr放在物件的前面(有些平臺可能會放到物件的最后面,這個跟平臺有關),
在這里插入圖片描述
物件中的這個指標叫做虛函式表指標,簡稱虛表指標,虛表指標指向一個虛函式表,簡稱虛表,每一個含有虛函式的類中都至少有一個虛表指標,

虛函式表中到底放的是什么?

下面Base類當中有三個成員函式,其中Func1和Func2是虛函式,Func3是普通成員函式,子類Derive當中僅對父類的Func1函式進行了重寫,

#include <iostream>
using namespace std;
//父類
class Base
{
public:
	//虛函式
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	//虛函式
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	//普通成員函式
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
//子類
class Derive : public Base
{
public:
	//重寫虛函式Func1
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

通過除錯可以發現,父類物件b和基類物件d當中除了自己的成員變數之外,父類和子類物件都有一個虛表指標,分別指向屬于自己的虛表,
在這里插入圖片描述
實際上虛表當中存盤的就是虛函式的地址,因為父類當中的Func1和Func2都是虛函式,所以父類物件b的虛表當中存盤的就是虛函式Func1和Func2的地址,

而子類雖然繼承了父類的虛函式Func1和Func2,但是子類對父類的虛函式Func1進行了重寫,因此,子類物件d的虛表當中存盤的是父類的虛函式Func2的地址和重寫的Func1的地址,這就是為什么虛函式的重寫也叫做覆寫,覆寫就是指虛表中虛函式地址的覆寫,重寫是語法的叫法,覆寫是原理層的叫法,

其次需要注意的是:Func2是虛函式,所以繼承下來后放進了子類的虛表,而Func3是普通成員函式,繼承下來后不會放進子類的虛表,此外,虛函式表本質是一個存虛函式指標的指標陣列,一般情況下會在這個陣列最后放一個nullptr,

總結一下,派生類的虛表生成步驟如下:

  1. 先將基類中的虛表內容拷貝一份到派生類的虛表,
  2. 如果派生類重寫了基類中的某個虛函式,則用派生類自己的虛函式地址覆寫虛表中基類的虛函式地址,
  3. 派生類自己新增加的虛函式按其在派生類中的宣告次序增加到派生類虛表的最后,

虛表是什么階段初始化的?虛函式存在哪里?虛表存在哪里?

虛表實際上是在建構式初始化串列階段進行初始化的,注意虛表當中存的是虛函式的地址不是虛函式,虛函式和普通函式一樣,都是存在代碼段的,只是他的地址又存到了虛表當中,另外,物件中存的不是虛表而是指向虛表的指標,
至于虛表是存在哪里的,我們可以通過以下這段代碼進行判斷,

int j = 0;
int main()
{
	Base b;
	Base* p = &b;
	printf("vfptr:%p\n", *((int*)p)); //000FDCAC
	int i = 0;
	printf("堆疊上地址:%p\n", &i);       //005CFE24
	printf("資料段地址:%p\n", &j);     //0010038C

	int* k = new int;
	printf("堆上地址:%p\n", k);       //00A6CA00
	char* cp = "hello world";
	printf("代碼段地址:%p\n", cp);    //000FDCB4
	return 0;
}

代碼當中列印了物件b當中的虛表指標,也就是虛表的地址,可以發現虛表地址與代碼段的地址非常接近,由此我們可以得出虛表實際上是存在代碼段的,

多型的原理

那到底多型的原理是什么?

例如,下面代碼中,為什么當父類Person指標指向的是父類物件Mike時,呼叫的就是父類的BuyTicket,當父類Person指標指向的是子類物件Johnson時,呼叫的就是子類的BuyTicket?

#include <iostream>
using namespace std;
//父類
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "買票-全價" << endl;
	}
	int _p = 1;
};
//子類
class Student : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "買票-半價" << endl;
	}
	int _s = 2;
};
int main()
{
	Person Mike;
	Student Johnson;
	Johnson._p = 3; //以便觀察是否完成切片
	Person* p1 = &Mike;
	Person* p2 = &Johnson;
	p1->BuyTicket(); //買票-全價
	p2->BuyTicket(); //買票-半價
	return 0;
}

通過除錯可以發現,物件Mike中包含一個成員變數_p和一個虛表指標,物件Johnson中包含兩個成員變數_p和_s以及一個虛表指標,這兩個物件當中的虛表指標分別指向自己的虛表,
在這里插入圖片描述
圍繞此圖分析便可得到多型的原理:

  1. 父類指標p1指向Mike物件,p1->BuyTicket在Mike的虛表中找到的虛函式就是Person::BuyTicket,
  2. 父類指標p2指向Johnson物件,p2>BuyTicket在Johnson的虛表中找到的虛函式就是Student::BuyTicket,

這樣就實作出了不同物件去完成同一行為時,展現出不同的形態,

現在想想多型構成的兩個條件,一是完成虛函式的重寫,二是必須使用父類的指標或者參考去呼叫虛函式,必須完成虛函式的重寫是因為我們需要完成子類虛表當中虛函式地址的覆寫,那為什么必須使用父類的指標或者參考去呼叫虛函式呢?為什么使用父類物件去呼叫虛函式達不到多型的效果呢?

Person* p1 = &Mike;
Person* p2 = &Johnson;

使用父類指標或者參考時,實際上是一種切片行為,切片時只會讓父類指標或者參考得到父類物件或子類物件中切出來的那一部分,
在這里插入圖片描述
因此,我們后序用p1和p2呼叫虛函式時,p1和p2通過虛表指標找到的虛表是不一樣的,最終呼叫的函式也是不一樣的,

Person p1 = Mike;
Person p2 = Johnson;

使用父類物件時,切片得到部分成員變數后,會呼叫父類的拷貝建構式對那部分成員變數進行拷貝構造,而拷貝構造出來的父類物件p1和p2當中的虛表指標指向的都是父類物件的虛表,因為同型別的物件共享一張虛表,他們的虛表指標指向的虛表是一樣的,
在這里插入圖片描述
因此,我們后序用p1和p2呼叫虛函式時,p1和p2通過虛表指標找到的虛表是一樣的,最終呼叫的函式也是一樣的,也就無法構成多型,

總結一下:

  1. 構成多型,指向誰就呼叫誰的虛函式,跟物件有關,
  2. 不構成多型,物件型別是什么就呼叫誰的虛函式,跟型別有關,

動態系結和靜態系結

靜態系結: 靜態系結又稱為前期系結(早系結),在程式編譯期間確定了程式的行為,也成為靜態多型,比如:函式多載,

動態系結: 動態系結又稱為后期系結(晚系結),在程式運行期間,根據具體拿到的型別確定程式的具體行為,呼叫具體的函式,也稱為動態多型,

我們可以通過查看匯編的方式進一步理解靜態系結和動態系結,

對于下列代碼:

//父類
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "買票-全價" << endl;
	}
};
//子類
class Student : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "買票-半價" << endl;
	}
};

我們若是按照如下方式呼叫BuyTicket函式,則不構成多型,函式的呼叫是在編譯時確定的,

int main()
{
	Student Johnson;
	Person p = Johnson; //不構成多型
	p.BuyTicket();
	return 0;
}

將呼叫函式的那句代碼翻譯成匯編就只有以下兩潭訓編指令,也就是直接呼叫的函式,
在這里插入圖片描述
而我們若是按照如下方式呼叫BuyTicket函式,則構成多型,函式的呼叫是在運行時確定的,

int main()
{
	Student Johnson;
	Person& p = Johnson; //構成多型
	p.BuyTicket();
	return 0;
}

相比不構成多型時的代碼,構成多型時呼叫函式的那句代碼翻譯成匯編后就變成了八潭訓編指令,主要原因就是我們需要在運行時,先到指定物件的虛表中找到要呼叫的虛函式,然后才能進行函式的呼叫,
在這里插入圖片描述
這樣就很好的體現了靜態系結是在編譯時確定的,而動態系結是在運行時確定的,

單繼承和多繼承關系的虛函式表

單繼承中的虛函式表

以下列單繼承關系為例,我們來看看基類和派生類的虛表模型,

//基類
class Base
{
public:
	virtual void func1() { cout << "Base::func1()" << endl; }
	virtual void func2() { cout << "Base::func2()" << endl; }
private:
	int _a;
};
//派生類
class Derive : public Base
{
public:
	virtual void func1() { cout << "Derive::func1()" << endl; }
	virtual void func3() { cout << "Derive::func3()" << endl; }
	virtual void func4() { cout << "Derive::func4()" << endl; }
private:
	int _b;
};

其中,基類和派生類物件的虛表模型如下:
在這里插入圖片描述
在單繼承關系當中,派生類的虛表生成程序如下:

  1. 繼承基類的虛表內容到派生類的虛表,
  2. 對派生類重寫了的虛函式地址進行覆寫,比如func1,
  3. 虛表當中新增派生類當中新的虛函式地址,比如func3和func4,

在除錯程序中,某些編譯器的監視視窗當中看不到虛表當中的func3和func4,可能是編譯器的監視視窗故意隱藏了這兩個函式,也可以認為這是一個小bug,此時如果我們想要看到派生類物件完整的虛表有兩個方法,

一、使用記憶體監視視窗
使用記憶體監視視窗看到的內容是最真實的,我們調出記憶體監視視窗,然后輸入派生類物件當中的虛表指標,即可看到虛表當中存盤的四個虛函式地址,
在這里插入圖片描述
二、使用代碼列印虛表內容
我們可以使用以下代碼,列印上述基類和派生類物件的虛表內容,在列印程序中可以順便用虛函式地址呼叫對應的虛函式,從而列印出虛函式的函式名,這樣可以進一步確定虛表當中存盤的是哪一個函式的地址,

typedef void(*VFPTR)(); //虛函式指標型別重命名
//列印虛表地址及其內容
void PrintVFT(VFPTR* ptr)
{
	printf("虛表地址:%p\n", ptr);
	for (int i = 0; ptr[i] != nullptr; i++)
	{
		printf("ptr[%d]:%p-->", i, ptr[i]); //列印虛表當中的虛函式地址
		ptr[i](); //使用虛函式地址呼叫虛函式
	}
	printf("\n");
}
int main()
{
	Base b;
	PrintVFT((VFPTR*)(*(int*)&b)); //列印基類物件b的虛表地址及其內容
	Derive d;
	PrintVFT((VFPTR*)(*(int*)&d)); //列印派生類物件d的虛表地址及其內容
	return 0;
}

運行結果如下:
在這里插入圖片描述

多繼承中的虛函式表

以下列多繼承關系為例,我們來看看基類和派生類的虛表模型,

//基類1
class Base1
{
public:
	virtual void func1() { cout << "Base1::func1()" << endl; }
	virtual void func2() { cout << "Base1::func2()" << endl; }
private:
	int _b1;
};
//基類2
class Base2
{
public:
	virtual void func1() { cout << "Base2::func1()" << endl; }
	virtual void func2() { cout << "Base2::func2()" << endl; }
private:
	int _b2;
};
//多繼承派生類
class Derive : public Base1, public Base2
{
public:
	virtual void func1() { cout << "Derive::func1()" << endl; }
	virtual void func3() { cout << "Derive::func3()" << endl; }
private:
	int _d1;
};

其中,兩個基類的虛表模型如下:
在這里插入圖片描述
而派生類的虛表模型就不那么簡單了,派生類的虛表模型如下:
在這里插入圖片描述
在多繼承關系當中,派生類的虛表生成程序如下:

  1. 分別繼承各個基類的虛表內容到派生類的各個虛表當中,
  2. 對派生類重寫了的虛函式地址進行覆寫(派生類中的各個虛表中存有該被重寫虛函式地址的都需要進行覆寫),比如func1,
  3. 在派生類第一個繼承基類部分的虛表當中新增派生類當中新的虛函式地址,比如func3,

這里在除錯時,在某些編譯器下也會出現顯示不全的問題,此時如果我們想要看到派生類物件完整的虛表也是用那兩種方法,

一、使用記憶體監視視窗
在這里插入圖片描述
二、使用代碼列印虛表內容
需要注意的是,我們在派生類第一個虛表地址的基礎上,向后移sizeof(Base1)個位元組即可得到第二個虛表的地址,

typedef void(*VFPTR)(); //虛函式指標型別重命名
//列印虛表地址及其內容
void PrintVFT(VFPTR* ptr)
{
	printf("虛表地址:%p\n", ptr);
	for (int i = 0; ptr[i] != nullptr; i++)
	{
		printf("ptr[%d]:%p-->", i, ptr[i]); //列印虛表當中的虛函式地址
		ptr[i](); //使用虛函式地址呼叫虛函式
	}
	printf("\n");
}
int main()
{
	Base1 b1;
	Base2 b2;
	PrintVFT((VFPTR*)(*(int*)&b1)); //列印基類物件b1的虛表地址及其內容
	PrintVFT((VFPTR*)(*(int*)&b2)); //列印基類物件b2的虛表地址及其內容
	Derive d;
	PrintVFT((VFPTR*)(*(int*)&d)); //列印派生類物件d的第一個虛表地址及其內容
	PrintVFT((VFPTR*)(*(int*)((char*)&d + sizeof(Base1)))); //列印派生類物件d的第二個虛表地址及其內容
	return 0;
}

運行結果如下:
在這里插入圖片描述

菱形繼承、菱形虛擬繼承

以下列菱形虛擬繼承關系為例,我們來看看基類和派生類的虛表模型,

class A
{
public:
	virtual void funcA()
	{
		cout << "A::funcA()" << endl;
	}
private:
	int _a;
};
class B : virtual public A
{
public:
	virtual void funcA()
	{
		cout << "B::funcA()" << endl;
	}
	virtual void funcB()
	{
		cout << "B::funcB()" << endl;
	}
private:
	int _b;
};
class C : virtual public A
{
public:
	virtual void funcA()
	{
		cout << "C::funcA()" << endl;
	}
	virtual void funcC()
	{
		cout << "C::funcC()" << endl;
	}
private:
	int _c;
};
class D : public B, public C
{
public:
	virtual void funcA()
	{
		cout << "D::funcA()" << endl;
	}
	virtual void funcD()
	{
		cout << "D::funcD()" << endl;
	}
private:
	int _d;
};

代碼當中的繼承關系圖如下:
在這里插入圖片描述
其中,A類當中有一個虛函式funcA,B類當中有一個虛函式funcB,C類當中有一個虛函式funcC,D類當中有一個虛函式funcD,此外B類、C類和D類當中均對A類當中的funcA進行了重寫,

A類物件當中的成員及其分布情況,

A類物件的成員包括一個虛表指標和成員變數_a,虛表指標指向的虛表當中存盤的是A類虛函式funcA的地址,
在這里插入圖片描述

B類物件當中的成員及其分布情況,

B類由于是虛擬繼承的A類,所以B類物件當中將A類繼承下來的成員放到了最后,除此之外,B類物件的成員還包括一個虛表指標、一個虛基表指標和成員變數_b,虛表指標指向的虛表當中存盤的是B類虛函式funcB的地址,
虛基表當中存盤的是兩個偏移量,第一個是虛基表指標距離B虛表指標的偏移量,第二個是虛基表指標距離虛基類A的偏移量,
在這里插入圖片描述

C類物件當中的成員及其分布情況,

C類物件當中的成員分布情況與B類物件當中的成員分布情況相同,C類也是虛擬繼承的A類,所以C類物件當中將A類繼承下來的成員放到了最后,除此之外,C類物件的成員還包括一個虛表指標、一個虛基表指標和成員變數_c,虛表指標指向的虛表當中存盤的是C類虛函式funcC的地址,
虛基表當中存盤的是兩個偏移量,第一個是虛基表指標距離C虛表指標的偏移量,第二個是虛基表指標距離虛基類A的偏移量,
在這里插入圖片描述

D類物件當中的成員及其分布情況,

D類物件當中成員的分布情況較為復雜,D類的繼承方式是菱形虛擬繼承,在D類物件當中,將A類繼承下來的成員放到了最后,除此之外,D類物件的成員還包括從B類繼承下來的成員、從C類繼承下來的成員和成員變數_d,
需要注意的是,D類物件當中的虛函式funcD的地址是存盤到了B類的虛表當中,
在這里插入圖片描述
友情提示:
實際中我們不建議設計出菱形繼承及菱形虛擬繼承,一方面太復雜容易出問題,另一方面使用這樣的模型訪問基類成員有一定的性能損耗,

繼承和多型常見的面試問題

概念考察

1、下面哪種面向物件的方法可以讓你變得富有()

A.繼承?B.封裝?C.多型?D.抽象

2、()是面向物件程式設計語言中的一種機制,這種機制實作了方法的定義與具體的物件無關,而方法的呼叫則可以關聯于具體的物件,

A.繼承?B.模板?C.物件的自身參考?D.動態系結

3、關于面向物件設計中的繼承和組合,下面說法錯誤的是()

A.繼承允許我們覆寫重寫父類的實作細節,父類的實作對于子類是可見的,是一種靜態復用,也稱為白盒復用,
B.組合的物件不需要關系各自的實作細節,之間的關系是在運行時候才確定的,是一種動態復用,也稱為黑盒復用,
C.優先使用繼承,而不是組合,是面向物件設計的第二原則,
D.繼承可以使子類能自動繼承父類的介面,但在設計模式中認為這是一種破壞了父類的封裝性的表現,

4、以下關于純虛函式的說法,正確的是()

A.宣告純虛函式的類不能實體化物件
B.宣告純虛函式的類是虛基類
C.子類必須實作基類的純虛函式
D.純虛函式必須是空函式

5、關于虛函式的描述正確的是()

A.派生類的虛函式與基類的虛函式具有不同的引數個數和型別
B.行內函式不能是虛函式
C.派生類必須重新定義基類的虛函式
D.虛函式可以是一個static型的函式

6、關于虛表的說法正確的是()

A.一個類只能有一張虛表
B.基類中有虛函式,如果子類中沒有重寫基類的虛函式,此時子類與基類共用同一張虛表
C.虛表是在運行期間動態生成的
D.一個類的不同物件共享該類的虛表

7、假設A類中有虛函式,B繼承自A,B重寫A中的虛函式,也沒有定義任何虛函式,則()

A.A類物件的前4個位元組存盤虛表地址,B類物件的前4個位元組不是虛表地址
B.A類物件和B類物件前4個位元組存盤的都是虛基表的地址
C.A類物件和B類物件前4個位元組存盤的虛表地址相同
D.A類和B類虛表中虛函式個數相同,但A類和B類使用的不是同一張虛表

8、下面程式輸出結果是什么?

#include <iostream>
using namespace std;
class A
{
public:
	A(char* s) { cout << s << endl; }
	~A() {};
};
class B : virtual public A
{
public:
	B(char* s1, char* s2)
		:A(s1)
	{
		cout << s2 << endl;
	}
};
class C : virtual public A
{
public:
	C(char* s1, char* s2)
		:A(s1)
	{
		cout << s2 << endl;
	}
};
class D : public B, public C
{
public:
	D(char* s1, char* s2, char* s3, char* s4)
		:B(s1, s2)
		, C(s1, s3)
		, A(s1)
	{
		cout << s4 << endl;
	}
};
int main()
{
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

A.class A?class B?class C?class D
B.class D?class B?class C?class A
C.class D?class C?class B?class A
D.class A?class C?class CBemsp;class D

9、下面說法正確的是?(多繼承中指標的偏移問題)

class Base1
{
public:
	int _b1;
};
class Base2
{
public:
	int _b2;
};
class Derive : public Base1, public Base2
{
public:
	int _d;
};
int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}

A.p1 == p2 == p3
B.p1 < p2 < p3
C.p1 == p3 != p2
D.p1 != p2 != p3

10、以下程式輸出結果是什么?

#include <iostream>
using namespace std;
class A
{
public:
	virtual void func(int val = 1)
	{
		cout << "A->" << val << endl;
	}
	virtual void test()
	{
		func();
	}
};
class B : public A
{
public:
	void func(int val = 0)
	{
		cout << "B->" << val << endl;
	}
};
int main()
{
	B* p = new B;
	p->test();
	return 0;
}

A.A->0?B.B->1?C.A->1?D.B->0
E.編譯錯誤?F.以上都不正確

參考答案:

題號答案題號答案
1A6D
2D7D
3C8A
4A9C
5B10B

問答題

1、什么是多型?

多型是指不同繼承關系的類物件,去呼叫同一函式,產生了不同的行為,多型又分為靜態的多型和動態的多型,靜態的多型

2、什么是多載、重寫(覆寫)、重定義(隱藏)?

多載是指兩個函式在同一作用域,這兩個函式的函式名相同,引數不同,
重寫(覆寫)是指兩個函式分別在基類和派生類的作用域,這兩個函式的函式名、引數、回傳值都必須相同(協變例外),且這兩個函式都是虛函式,重定義(隱藏)是指兩個函式分別在基類和派生類的作用域,這兩個函式的函式名相同,若兩個基類和派生類的同名函式不構成重寫就是重定義,

3、多型的實作原理?

構成多型的父類物件和子類物件的成員當中都包含一個虛表指標,這個虛表指標指向一個虛表,虛表當中存盤的是該類對應的虛函式地址,因此,當父類指標指向父類物件時,通過父類指標找到虛表指標,然后在虛表當中找到的就是父類當中對應的虛函式;當父類指標指向子類物件時,通過父類指標找到虛表指標,然后在虛表當中找到的就是子類當中對應的虛函式,

4、inline函式可以是虛函式嗎?

我們知道行內函式是會在呼叫的地方展開的,也就是說行內函式是沒有地址的,但是行內函式是可以定義成虛函式的,當我們把行內函式定義虛函式后,編譯器就忽略了該函式的行內屬性,這個函式就不再是行內函式了,因為需要將虛函式的地址放到虛表中去,

5、靜態成員函式可以是虛函式嗎?

靜態成員函式不能是虛函式,因為靜態成員函式沒有this指標,使用型別::成員函式的呼叫方式無法訪問虛表,所以靜態成員函式無法放進虛表,

6、建構式可以是虛函式嗎?

建構式不能是虛函式,因為物件中的虛表指標是在建構式初始化串列階段才初始化的,

7、解構式可以是虛函式嗎?什么場景下解構式是虛函式?

解構式可以是虛函式,并且最后把基類的解構式定義成虛函式,若是我們分別new一個父類物件和一個子類物件,并均用父類指標指向它們,當我們使用delete呼叫解構式并釋放物件空間時,只有當父類的解構式是虛函式的情況下,才能正確呼叫父類和子類的解構式分別對父類和子類物件進行析構,否則當我們使用父類指標delete物件時,只能呼叫到父類的解構式,

8、物件訪問普通函式快還是虛函式更快?

物件訪問普通函式比訪問虛函式更快,若我們訪問的是一個普通函式,那直接訪問就行了,但當我們訪問的是虛函式時,我們需要先找到虛表指標,然后在虛表當中找到對應的虛函式,最后才能呼叫到虛函式,

9、虛函式表是在什么階段生成的?存在哪的?

虛表是在建構式初始化串列階段進行初始化的,虛表一般情況下是存在代碼段(常量區)的,

10、C++菱形繼承的問題?虛繼承的原理?

菱形虛擬繼承因為子類物件當中會有兩份父類的成員,因此會導致資料冗余和二義性的問題,
虛繼承對于相同的虛基類在物件當中只會存盤一份,若要訪問虛基類的成員需要通過虛基表獲取到偏移量,進而找到對應的虛基類成員,從而解決了資料冗余和二義性的問題,

什么是抽象類?抽線類的作用?

抽象類很好的體現了虛函式的繼承是一種介面繼承,強制子類去抽象純虛函式,因為子類若是不抽象從父類繼承下來的純虛函式,那么子類也是抽象類也不能實體化出物件,其次,抽象類可以很好的去表示現實世界中沒有示例物件對應的抽象型別,比如:植物、人、動物等,

轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/328083.html

標籤:其他

上一篇:博客園next主題皮膚(cnblogs-theme-next-pro)

下一篇:安卓EditText.requestFocus失效

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 面試突擊第一季,第二季,第三季

    第一季必考 https://www.bilibili.com/video/BV1FE411y79Y?from=search&seid=15921726601957489746 第二季分布式 https://www.bilibili.com/video/BV13f4y127ee/?spm_id_fro ......

    uj5u.com 2020-09-10 05:35:24 more
  • 第三單元作業總結

    1.前言 這應該是本學期最后一次寫作業總結了吧。總體來說,對作業的節奏也差不多掌握了,作業做起來的效率也更高了。雖然和之前的作業一樣,作業中都要用到新的知識,但是相比之前,更加懂得了如何利用工具以及資料。雖然之間卡過殼,但總體而言,這幾次作業還算完成的比較好。 2.作業程序總結 相比前兩個單元,此單 ......

    uj5u.com 2020-09-10 05:35:41 more
  • 北航OO(2020)第四單元博客作業暨課程總結博客

    北航OO(2020)第四單元博客作業暨課程總結博客 本單元作業的架構設計 在本單元中,由于UML圖具有比較清晰的樹形結構,因此我對其中需要進行查詢操作的元素進行了包裝,在樹的父節點中存盤所有孩子的參考。考慮到性能問題,我采用了快取機制,一次查詢后盡可能快取已經遍歷過的資訊,以減少遍歷次數。 本單元我 ......

    uj5u.com 2020-09-10 05:35:48 more
  • BUAA_OO_第四單元

    一、UML決議器設計 ? 先看下題目:第四單元實作一個基于JDK 8帶有效性檢查的UML(Unified Modeling Language)類圖,順序圖,狀態圖分析器 MyUmlInteraction,實際上我們要建立一個有向圖模型,UML中的物件(元素)可能與同級元素連接,也可與低級元素相連形成 ......

    uj5u.com 2020-09-10 05:35:54 more
  • 6.1邏輯運算子

    邏輯運算子 1. && 短路與 運算式1 && 運算式2 01.運算式1為true并且運算式2也為true 整體回傳為true 02.運算式1為false,將不會執行運算式2 整體回傳為false 03.只要有一個運算式為false 整體回傳為false 2. || 短路或 運算式1 || 運算式2 ......

    uj5u.com 2020-09-10 05:35:56 more
  • BUAAOO 第四單元 & 課程總結

    1. 第四單元:StarUml檔案決議 本單元采用了圖模型決議UML。 UML檔案可以抽象為圖、子圖、邊的邏輯結構。 在實作中,圖的節點包括類、介面、屬性,子圖包括狀態圖、順序圖等。 采用了三次遍歷UML元素的方法建圖,第一遍遍歷建點,第二、三次遍歷設定屬性、連邊,實作圖物件的初始化。這里借鑒了一些 ......

    uj5u.com 2020-09-10 05:36:06 more
  • 談談我對C# 多型的理解

    面向物件三要素:封裝、繼承、多型。 封裝和繼承,這兩個比較好理解,但要理解多型的話,可就稍微有點難度了。今天,我們就來講講多型的理解。 我們應該經常會看到面試題目:請談談對多型的理解。 其實呢,多型非常簡單,就一句話:呼叫同一種方法產生了不同的結果。 具體實作方式有三種。 一、多載 多載很簡單。 p ......

    uj5u.com 2020-09-10 05:36:09 more
  • Python 資料驅動工具:DDT

    背景 python 的unittest 沒有自帶資料驅動功能。 所以如果使用unittest,同時又想使用資料驅動,那么就可以使用DDT來完成。 DDT是 “Data-Driven Tests”的縮寫。 資料:http://ddt.readthedocs.io/en/latest/ 使用方法 dd. ......

    uj5u.com 2020-09-10 05:36:13 more
  • Python里面的xlrd模塊詳解

    那我就一下面積個問題對xlrd模塊進行學習一下: 1.什么是xlrd模塊? 2.為什么使用xlrd模塊? 3.怎樣使用xlrd模塊? 1.什么是xlrd模塊? ?python操作excel主要用到xlrd和xlwt這兩個庫,即xlrd是讀excel,xlwt是寫excel的庫。 今天就先來說一下xl ......

    uj5u.com 2020-09-10 05:36:28 more
  • 當我們創建HashMap時,底層到底做了什么?

    jdk1.7中的底層實作程序(底層基于陣列+鏈表) 在我們new HashMap()時,底層創建了默認長度為16的一維陣列Entry[ ] table。當我們呼叫map.put(key1,value1)方法向HashMap里添加資料的時候: 首先,呼叫key1所在類的hashCode()計算key1 ......

    uj5u.com 2020-09-10 05:36:38 more
最新发布
  • 【中介者設計模式詳解】C/Java/JS/Go/Python/TS不同語言實作

    * 中介者模式是一種行為型設計模式,它可以用來減少類之間的直接依賴關系,
    * 將物件之間的通信封裝到一個中介者物件中,從而使得各個物件之間的關系更加松散。
    * 在中介者模式中,物件之間不再直接相互互動,而是通過中介者來中轉訊息。 ......

    uj5u.com 2023-04-20 08:20:47 more
  • 露天煤礦現場調研和交流案例分享

    他們集團的資訊化公司及研究院在一個礦區正在做智能礦山的統一平臺的 試點,專案投資大概1億,包括了礦山的各方面的內容,顯示得我們這次交流有點多余。他們2年前開始做智能礦山的規劃,有很多煤礦行業專家的加持,他們的描述是非常完美,但是去年底應該上線的平臺,現在還沒有看到影子。他們確實有很多場景需求,但是被... ......

    uj5u.com 2023-04-20 08:20:25 more
  • 《社區人員管理》實戰案例設計&個人案例分享

    設計是一個讓人夢想成真程序,開始編碼、測驗、除錯之前進行需求分析和架構設計,才能保證關鍵方面都做正確 ......

    uj5u.com 2023-04-20 08:20:17 more
  • 軟體架構生態化-多角色交付的探索實踐

    作為一個技術架構師,不僅僅要緊跟行業技術趨勢,還要結合研發團隊現狀及痛點,探索新的交付方案。在日常中,你是否遇到如下問題 “ 業務需求排期長研發是瓶頸;非研發角色感受不到研發技改提效的變化;引入ISV 團隊又擔心質量和安全,培訓周期長“等等,基于此我們探索了一種新的技術體系及交付方案來解決如上問題。 ......

    uj5u.com 2023-04-20 08:20:10 more
  • 【中介者設計模式詳解】C/Java/JS/Go/Python/TS不同語言實作

    * 中介者模式是一種行為型設計模式,它可以用來減少類之間的直接依賴關系,
    * 將物件之間的通信封裝到一個中介者物件中,從而使得各個物件之間的關系更加松散。
    * 在中介者模式中,物件之間不再直接相互互動,而是通過中介者來中轉訊息。 ......

    uj5u.com 2023-04-20 08:19:44 more
  • 露天煤礦現場調研和交流案例分享

    他們集團的資訊化公司及研究院在一個礦區正在做智能礦山的統一平臺的 試點,專案投資大概1億,包括了礦山的各方面的內容,顯示得我們這次交流有點多余。他們2年前開始做智能礦山的規劃,有很多煤礦行業專家的加持,他們的描述是非常完美,但是去年底應該上線的平臺,現在還沒有看到影子。他們確實有很多場景需求,但是被... ......

    uj5u.com 2023-04-20 08:19:07 more
  • 《社區人員管理》實戰案例設計&個人案例分享

    設計是一個讓人夢想成真程序,開始編碼、測驗、除錯之前進行需求分析和架構設計,才能保證關鍵方面都做正確 ......

    uj5u.com 2023-04-20 08:18:57 more
  • 軟體架構生態化-多角色交付的探索實踐

    作為一個技術架構師,不僅僅要緊跟行業技術趨勢,還要結合研發團隊現狀及痛點,探索新的交付方案。在日常中,你是否遇到如下問題 “ 業務需求排期長研發是瓶頸;非研發角色感受不到研發技改提效的變化;引入ISV 團隊又擔心質量和安全,培訓周期長“等等,基于此我們探索了一種新的技術體系及交付方案來解決如上問題。 ......

    uj5u.com 2023-04-20 08:18:49 more
  • 05單件模式

    #經典的單件模式 public class Singleton { private static Singleton uniqueInstance; //一個靜態變數持有Singleton類的唯一實體。 // 其他有用的實體變數寫在這里 //構造器宣告為私有,只有Singleton可以實體化這個類! ......

    uj5u.com 2023-04-19 08:42:51 more
  • 【架構與設計】常見微服務分層架構的區別和落地實踐

    軟體工程的方方面面都遵循一個最基本的道理:沒有銀彈,架構分層模型更是如此,每一種都有各自優缺點,所以請根據不同的業務場景,并遵循簡單、可演進這兩個重要的架構原則選擇合適的架構分層模型即可。 ......

    uj5u.com 2023-04-19 08:42:41 more