C++類開發第七篇(詳細說說多型和編譯原理)

ivanlee717發表於2024-03-04

polymorphism

靜態聯編和動態聯編

多型性(polymorphism)提供介面與具體實現之間的另一層隔離,從而將”what”和”how”分離開來。多型性改善了程式碼的可讀性和組織性,同時也使建立的程式具有可擴充套件性,專案不僅在最初建立時期可以擴充套件,而且當專案在需要有新的功能時也能擴充套件。

c++支援編譯時多型(靜態多型)和執行時多型(動態多型),運算子過載和函式過載就是編譯時多型,而派生類和虛擬函式實現執行時多型。

靜態多型和動態多型的區別就是函式地址是早繫結(靜態聯編)還是晚繫結(動態聯編)。如果函式的呼叫,在編譯階段就可以確定函式的呼叫地址,併產生程式碼,就是靜態多型(編譯時多型),就是說地址是早繫結的。而如果函式的呼叫地址不能編譯不能在編譯期間確定,而需要在執行時才能決定,這這就屬於晚繫結(動態多型,執行時多型)。

將原始碼中的函式呼叫解釋為執行特定的函式程式碼塊被稱為函式名聯編。編譯器必須檢視函式引數以及函式名才能確定使用哪個函式。

指標和引用型別的相容性以及向上型別轉換

在C++裡面動態聯編與透過指標和引用的呼叫方法有關。通常c++不允許將一種一類的地址賦給另一種型別的指標,也不允許一種型別的引用指向另一種型別。

double x = 2.5;
int * pi = &x; //型別不對不能這樣定義
long & r1 = x; //問題同上

物件可以作為自己的類或者作為它的基類的物件來使用。還能透過基類的地址來操作它。取一個物件的地址(指標或引用),並將其作為基類的地址來處理,這種稱為向上型別轉換。

父類引用或指標可以指向子類物件,透過父類指標或引用來操作子類物件。

就是指將子類物件的引用賦給父類型別的引用變數的過程。在物件導向程式設計中,這種型別轉換是安全的,因為子類物件可以被當做父類物件來對待。透過向上型別轉換,可以實現多型性,即一個父類引用變數可以引用不同子類物件,並根據實際物件型別呼叫相應的方法。

class Base {
public :
	virtual void func() {
		cout << "class Base" << endl;
	}
};
class Derive_1 : public Base {
public:
	void func() {
		cout << "class Derive_1" << endl;
	}
};
class Derive_2 : public Base {
public:
	void func() {
		cout << "class Derive_2" << endl;
	}
};
void test() {
	Derive_1 d1;
	Derive_2 d2;
	//向上型別轉換
	Base* b1 = &d1;
	Base* b2 = &d2;
//透過父類引用變數呼叫子類方法
	b1->func();
	b2->func();

}

image-20240302105606439

雖然父類呼叫func函式,但是父類的指標全部指向了子類的引用,並且可以完成隱式型別轉換。再如下面這個程式碼

class Base {
public :
	void func() {
		cout << "class Base" << endl;
	}
};
class Derive_1 : public Base {
public:
	void func() {
		cout << "class Derive_1" << endl;
	}
};

void GetQuestion(Base& b) {
	b.func();
}

void test() {
	Derive_1 d1;
	
	GetQuestion(d1);


}

image-20240302110720280

引數定的是基類的引用,但是傳參傳的是子類,最後呼叫的依然是基類的方法。這個地方就引出了一個叫做捆綁的概念。把函式體與函式呼叫相聯絡稱為繫結(捆綁,binding)

當繫結在程式執行之前(由編譯器和聯結器)完成時,稱為早繫結(early binding).C語言中只有一種函式呼叫方式,就是早繫結。上面的問題就是由於早繫結引起的,因為編譯器在只有Base地址時並不知道要呼叫的正確函式。編譯是根據指向物件的指標或引用的型別來選擇函式呼叫。

在程式碼裡,GetQuestion函式的引數型別是Base&,編譯器確定了應該呼叫的func函式是Base::func(),並不是傳入的d1.解決方法就是遲繫結(遲捆綁,動態繫結,執行時繫結,late binding),意味著繫結要根據物件的實際型別,發生在執行。C++語言要實現這種動態繫結,必須有某種機制來確定執行時物件的型別並呼叫合適的成員函式。對於一種編譯語言,編譯器並不知道實際的物件型別(編譯器並不知道Animal型別的指標或引用指向的實際的物件型別)。

虛擬函式

其實在上面的程式碼裡也能發現區別就是基類的函式是不是虛擬函式決定了這個繫結發生在什麼時候。如果沒有定義虛的,b.func();將根據引用型別呼叫函式,編譯時已知型別之後,對於非虛方法就是用的是靜態聯編。

  1. 為建立一個需要動態繫結的虛成員函式,可以簡單在這個函式宣告前面加上virtual關鍵字,定義時候不需要.

  2. 如果一個函式在基類中被宣告為virtual,那麼在所有派生類中它都是virtual的.

  3. 在派生類中virtual函式的重定義稱為重寫(override).

  4. Virtual關鍵字只能修飾成員函式.

  5. 建構函式不能為虛擬函式

僅需要在基類中宣告一個函式為virtual.呼叫所有匹配基類宣告行為的派生類函式都將使用虛機制。雖然可以在派生類宣告前使用關鍵字virtual(這也是無害的),但這個樣會使得程式顯得冗餘和雜亂。

image-20240302111643528

用《C++Primers》裡面的一個圖解釋一下虛擬函式的工作原理。通常編譯器處理虛擬函式的方法是:給每個物件新增一個隱藏成員,這個隱藏成員中儲存了一個指向函式地址陣列的指標(圖裡的vptr),而這種地址陣列就叫做虛擬函式表(vtbl)。虛擬函式表裡儲存了為類物件進行宣告的虛擬函式的地址。比如基類物件Base包含一個指標,該指標指向基類中所有虛擬函式的地址表,派生類物件將包含一個指向獨立地址表的指標。如果派生類提供了虛擬函式的新定義,該虛擬函式表將儲存新的地址;相反,該虛擬函式表將儲存原始版本的地址。

呼叫虛擬函式時,程式將檢視儲存在物件裡的vtbl地址,然後轉向相應的函式地址表,如果使用類宣告中的第一個虛擬函式,則程式將使用陣列中的第一個函式地址,並執行具有該地址的函式。總之使用虛擬函式時,在記憶體和執行速度方面有一定成本:

  1. 每個物件都會被增大其儲存空間(和虛基類一樣
  2. 每個類編譯器都會有一個虛擬函式地址表
  3. 每個函式的呼叫都需要執行一項額外的操作就是到表裡查詢。

實現動態繫結細節過程

當子類無重寫基類虛擬函式時:

image-20240302114920328

image-20240302114947419

子類完全繼承基類的函式,他們擁有各自的虛擬函式表image-20240302115023473

當程式執行到這裡,會去animal指向的空間中尋找vptr指標,透過vptr指標找到func1函式,此時由於子類並沒有重寫也就是覆蓋基類的func1函式,所以呼叫func1時,仍然呼叫的是基類的func1.

當子類重寫基類虛擬函式時

image-20240302115324609

子類重寫了基類的func1,但是沒有寫func2,所以對應的地址表應該是image-20240302115400573

當程式執行到這裡,會去animal指向的空間中尋找vptr指標,透過vptr指標找到func1函式,由於子類重寫基類的func1函式,所以呼叫func1時,呼叫的是子類的func1.

抽象基類和純虛擬函式

在設計時,常常希望基類僅僅作為其派生類的一個介面。這就是說,僅想對基類進行向上型別轉換,使用它的介面,而不希望使用者實際的建立一個基類的物件。同時建立一個純虛擬函式允許介面中放置成員原函式,而不一定要提供一段可能對這個函式毫無意義的程式碼。

做到這點,可以在基類中加入至少一個純虛擬函式(pure virtual function),使得基類稱為抽象類(abstract class).

  1. 純虛擬函式使用關鍵字virtual,並在其後面加上=0。如果試圖去例項化一個抽象類,編譯器則會阻止這種操作。

  2. 當繼承一個抽象類的時候,必須實現所有的純虛擬函式,否則由抽象類派生的類也是一個抽象類。

  3. Virtual void fun() = 0;告訴編譯器在vtable中為函式保留一個位置,但在這個特定位置不放地址。

建立公共介面目的是為了將子類公共的操作抽象出來,可以透過一個公共介面來操縱一組類,且這個公共介面不需要事先(或者不需要完全實現)。可以建立一個公共類.

class AbstractClass {
public:
	virtual void sleep() = 0;
	virtual void dolove() = 0;
	virtual void cook() = 0;
	void func() {
		cook();
		dolove();
		sleep();
	}

};
class Regina : public AbstractClass {
public:
	virtual void sleep() {
		cout << "Regina::sleep()" << endl;
	}
	virtual void dolove() {
		cout << "Regina::dolove()" << endl;
	}
	virtual void cook() {
		cout << "Regina::cook()" << endl;
	}
	
};
class Ivanlee : public AbstractClass {
public:
	virtual void sleep() {
		cout << "Ivanlee::sleep()" << endl;
	}
	virtual void dolove() {
		cout << "Ivanlee::dolove()" << endl;
	}
	virtual void cook() {
		cout << "Ivanlee::cook()" << endl;
	}
};
void home(AbstractClass* a) {
	a->func();
	delete a;
}
void home(AbstractClass& a) {
	a.func();
}
void test() {
	home(new Regina);
	Ivanlee ivan;
	home(ivan);
}

image-20240304155721828

純虛擬函式和虛擬函式是 C++ 中的重要概念,它們都與多型性(polymorphism)和繼承相關。它們之間的主要區別在於以下幾點:

  1. 虛擬函式:
    • 虛擬函式是在基類中宣告為虛擬函式的成員函式,它可以在派生類中被重寫(覆蓋)。
    • 虛擬函式可以有預設的實現,如果派生類沒有重寫虛擬函式,則會呼叫基類的實現。
    • 虛擬函式透過基類指標或引用呼叫時,可以根據指標或引用所指向的物件的實際型別來動態地決定呼叫哪個版本的函式(動態聯編)。
  2. 純虛擬函式:
    • 純虛擬函式是在基類中宣告並且沒有給出實現的虛擬函式,它只是一個介面,要求任何派生類都必須提供實現。
    • 在 C++ 中,透過在虛擬函式宣告後面加上 = 0 來將其宣告為純虛擬函式,例如:virtual void myFunction() = 0;
    • 含有純虛擬函式的類稱為抽象類,無法例項化物件,只能作為基類來派生出其他類。派生類必須提供純虛擬函式的實現,否則它們也會變成抽象類。

虛解構函式

虛解構函式是為了解決基類指標指向派生類物件,並用基類的指標刪除派生類物件。當透過基類指標刪除指向派生類物件的例項時,如果解構函式不是虛擬函式,那麼只會呼叫基類的解構函式,而不會呼叫派生類的解構函式,這可能導致派生類資源得不到正確釋放,從而產生記憶體洩漏或未定義的行為。

class Base {
public:
    virtual ~Base() {
        // 虛解構函式
    }
};

class Derived : public Base {
public:
    ~Derived() {
        // 派生類的解構函式
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;  // 透過基類指標刪除派生類物件
    return 0;
}

重寫 過載 重定義

class Shape {
public:
    virtual double calculateArea() {
        return 0.0;
    }
};
  1. 重寫(Override):

    • 重寫是指派生類重新定義(覆蓋)基類中已經存在的虛擬函式的行為。

    • 當派生類定義一個與基類中的虛擬函式具有相同名稱和簽名的函式時,它就會覆蓋(重寫)基類中的虛擬函式。

    • 透過使用重寫,可以在派生類中改變虛擬函式的行為,實現多型性,即在執行時根據物件的實際型別來確定呼叫哪個版本的函式。

      class Rectangle : public Shape {
      public:
          double calculateArea() override {
              // 重寫基類的虛擬函式
              return width * height;
          }
      private:
          double width, height;
      };
      
  2. 過載(Overload):

    • 過載是指在同一個作用域內允許存在多個同名函式,但它們的引數列表不同(引數型別、引數個數或引數順序不同)。

    • 過載函式可以具有相同的名稱,但是由於引數列表不同,編譯器可以根據呼叫時提供的引數型別來確定應該呼叫哪個版本的函式。

      class Shape {
      public:
          virtual double calculateArea() {
              return 0.0;
          }
      
          double calculateArea(int a, int b) {
              // 過載的函式
              return a * b;
          }
      };
      
  3. 重新定義(Redefine):

    • 重新定義通常用於描述對於非虛擬函式的重新定義。在基類和派生類中,如果存在同名但引數列表不同的函式,這種情況稱為函式的重新定義。

    • 在重新定義中,基類和派生類中的函式並不構成多型性,呼叫哪個版本的函式取決於編譯器能夠靜態確定的最匹配的函式。

      class Circle : public Shape {
      public:
          void draw(int radius) {
              // 派生類中重新定義的函式
              cout << "Drawing a circle with radius " << radius << endl;
          }
      };
      

相關文章