首頁 > 軟體

C++的多型和虛擬函式你真的瞭解嗎

2022-02-14 19:01:15

一、C++的面試常考點

阿里雖然是國內Java的第一大廠但是並非所有的業務都是由Java支撐,很多服務和中下層的儲存,計算,網路服務,大規模的分散式任務都是由C++編寫。在阿里所有部門當中對C++考察最深的可能就是阿里雲。

阿里對C++的常考點:

1.STL 容器相關實現

2.C++新特性的瞭解

3.多型和虛擬函式的實現

4.指標的使用

二、阿里真題

2.1 真題一

現在假設有一個編譯好的C++程式,編譯沒有錯誤,但是執行時報錯,報錯如下:你正在呼叫一個純虛擬函式(Pure virtual function call error),請問導致這個錯誤的原因可能是什麼?

純虛擬函式呼叫錯誤一般由以下幾種原因導致:

  • 從基礎類別建構函式直接呼叫虛擬函式。(直接呼叫是指函數內部直接呼叫虛擬函式)
  • 從基礎類別解構函式直接呼叫虛擬函式。
  • 從基礎類別建構函式間接呼叫虛擬函式。(間接呼叫是指函數內部呼叫其他的非虛擬函式,其內部直接或間接地呼叫了虛擬函式)
  • 從基礎類別解構函式間接呼叫虛擬函式。
  • 通過懸空指標呼叫虛擬函式。

注意:其中1,2編譯器會檢測到此類錯誤。3,4,5編譯器無法檢測出此類情況,會在執行時報錯。

(1)虛擬函式表vtbl

編譯器在編譯時期為每個帶虛擬函式的類建立一份虛擬函式表

範例化物件時, 編譯器自動將類物件的虛表指標指向這個虛擬函式表

(2)構造一個派生類物件的過程

1.構造基礎類別部分:

  • 構造虛表指標,將範例的虛表指標指向基礎類別的vtbl
  • 構造基礎類別的成員變數
  • 執行基礎類別的建構函式函數體

2.遞迴構造派生類部分:

  • 將範例的虛表指標指向派生類vtbl
  • 構造派生類的成員變數
  • 執行派生類別建構函式體

(3)解構一個派生類物件的過程

1.遞迴解構派生類部分:

  • 將範例的虛表指標指向派生類vtbl
  • 執行派生類的解構函式體
  • 解構派生類的成員變數(這裡的執行函數體,解構派生類成員變數,兩者的順序和構造的步驟是相反的)

2.解構基礎類別部分:

  • 將範例的虛表指標指向基礎類別的vtbl
  • 執行基礎類別的解構函式函數體
  • 解構基礎類別的成員變數

建構函式和解構函式執行函數體時,範例的虛擬函式表指標,指向建構函式和解構函式本身所屬的類的虛擬函式表,此時執行的虛擬函式,即呼叫的本身的該類本身的虛擬函式,下面是一個【間接呼叫】的栗子:基礎類別中的解構函式中,呼叫純虛擬函式(該虛擬函式就在基礎類別中定義)。

#include <iostream>
using namespace std;

class Parent {
public:
	//純虛擬函式
    virtual void virtualFunc() = 0;
    void helper() {
        virtualFunc();
    }
    virtual ~Parent(){
        helper();
    }
};

class Child : public Parent{
    public:
    void virtualFunc() {
        cout << "Child" << endl;
    }
    virtual ~Child(){}
};


int main() {
    Child child;
	//system("pause");
    return 0;
}

執行時報錯libc++abi.dylib: Pure virtual function called

2.2 真題二

在構造範例過程當中一部分是初始化列表一部分是在函數體內,你能說一下這些的順序是什麼?差別是什麼和this指標構造的順序

順序:

(1)初始化列表中的先初始化。

(2)執行函數體程式碼。

  • 執行類中函數體,如執行建構函式時,所有成員已經初始化完畢了;
  • this指標屬於物件,而物件還沒構造完成前,若使用this指標,編譯器會無法識別。在初始化列表中顯然不能使用this指標,注意:在建構函式體內部可以使用this指標。

建構函式的執行可以分成兩個階段:

  • 初始化階段:所有類型別的成員都會在初始化階段初始化,即使該成員沒有出現在建構函式的初始化列表中。
  • 計算賦值階段:一般用於執行建構函式體內的賦值操作。
#include <iostream>
using namespace std;

class Test1 {
public:
    Test1(){
    	cout << "Construct Test1" << endl;
    }
	//拷貝建構函式
    Test1& operator = (const Test1& t1) {
    	cout << "Assignment for Test1" << endl;
        this->a = t1.a;
        return *this;
    }
    int a ;
};

class Test2 {
public:
    Test1 test1;
	//Test2的建構函式
    Test2(Test1 &t1) {
    	cout << "建構函式體開始" << endl;
        test1 = t1 ;
        cout << "建構函式體結束" << endl;
    }
};

int main() {
    Test1 t1;
    Test2 test(t1);
	system("pause");
    return 0;
}

分析上面的結果:

(1)第一行結果即Test t1範例化物件時,執行Test1的建構函式;

(2)第二行程式碼,範例化Test2物件時,在執行Test2建構函式時,正如上面所說的,建構函式的第一步是初始化階段:所有類型別的成員都會在初始化階段初始化,即使該成員沒有出現在建構函式的初始化列表中。所以Test2在建構函式體執行之前已經使用了Test1的預設建構函式初始化好了t1。列印出Construct Test1

這裡的拷貝建構函式中可以使用this指標,指向當前物件。

(3)第三四五行結果:執行Test2的建構函式。

2.3 真題三

初始化列表的寫法和順序有沒有什麼關係?

建構函式的初始化列表中的前後位置,不影響實際標量的初始化順序。成員初始化的順序和它們在類中的定義順序一致。

必須使用初始化列表的情況:資料成員是const、參照,或者屬於某種未提供預設建構函式的類型別。

2.4 真題四

在普通的函數當中呼叫虛擬函式和在建構函式當中呼叫虛擬函式有什麼區別?

普調函數當中呼叫虛擬函式是希望執行時多型。而在建構函式當中不應該去呼叫虛擬函式因為建構函式當中呼叫的就是本型別當中的虛擬函式,無法達到執行時多型的作用。

2.5 真題五

成員變數,虛擬函式表指標的位置是怎麼排布?

如果一個類帶有虛擬函式,那麼該類範例物件的記憶體佈局如下:

  • 首先是一個虛擬函式指標,
  • 接下來是該類的成員變數,按照成員在類當中宣告的順序排布,整體物件的大小由於記憶體對齊會有空白補齊。
  • 其次如果基礎類別沒有虛擬函式但是子類含有虛擬函式:
    • 此時記憶體子類物件的記憶體排布也是先虛擬函式表指標再各個成員。

如果將子類指標轉換成基礎類別指標此時編譯器會根據偏移做轉換。在visual studio,x64環境下測試,下面的Parent p = Child();是父類別物件,由子類來範例化物件。

#include <iostream>
using namespace std;

class Parent{
public:
    int a;
    int b;
};

class Child:public Parent{
public:
    virtual void test(){}
    int c;
};

int main() {
    Child c = Child();
    Parent p = Child();
    cout << sizeof(c) << endl;//24
    cout << sizeof(p) << endl;//8

    Child* cc = new Child();
    Parent* pp = cc;
    cout << cc << endl;//0x7fbe98402a50
    cout << pp << endl;//0x7fbe98402a58
	cout << endl << "子類物件abc成員地址:" << endl;
    cout << &(cc->a) << endl;//0x7fbe98402a58
    cout << &(cc->b) << endl;//0x7fbe98402a5c
    cout << &(cc->c) << endl;//0x7fbe98402a60
	system("pause");
    return 0;
}

結果如下:

24
8
0000013AC9BA4A40
0000013AC9BA4A48

子類物件abc成員地址:
0000013AC9BA4A48
0000013AC9BA4A4C
0000013AC9BA4A50
請按任意鍵繼續. . .

分析上面的結果:

(1)第一行24為子類物件的大小,首先是虛擬函式表指標8B,然後是2個繼承父類別的int型數值,還有1個是該子類本身的int型數值,最後的4是填充的。

(2)第二行的8為父類別物件的大小,該父類別物件由子類初始化,含有2個int型成員變數。

(3)子類指標cc指向又new出來的子類物件(第三個),然後父類別指標pp指向這個子類物件,這兩個指標的值:

  • 父類別指標pp值:0000013AC9BA4A48
  • 子類指標cc值:0000013AC9BA4A40

即發現如之前所說的:如果將子類指標轉換成基礎類別指標此時編譯器會根據偏移做轉換。我測試環境是64位元,所以指標為8個位元組。轉換之後pp和cc相差一個虛表指標的偏移。

(4)&(cc->a)的值即 0000013AC9BA4A48,和pp值是一樣的,注意前面的 0000013AC9BA4A40到0000013AC9BA4A47其實就是子類物件的虛擬函式表指標了。

三、小結

阿里常考的C++的問題集中在以下幾點:

  • 虛擬函式的實現
  • 虛擬函式使用出現的問題原因
  • 帶有虛擬函式的類物件的構造和解構過程
  • 物件的記憶體佈局
  • 虛擬函式的缺點:相比普通函數,虛擬函式呼叫需要2次跳轉(即需要先找到物件的虛擬函式表,再查詢該表項,即虛擬函式指標,即真正的虛擬函式地址),會降低CPU快取的命中率。執行時繫結,編譯器不好優化。

總結

本篇文章就到這裡了,希望能夠給你帶來幫助,也希望您能夠多多關注it145.com的更多內容!    


IT145.com E-mail:sddin#qq.com