使用 C 和 C++ 的程式語言概念/面向物件和 C++ 中的繼承
繼承的邏輯在不同的程式語言中變化不大。例如,如果基類和派生類共享相同的公共介面,則派生類被稱為其基類的子型別,並且它的例項可以被視為基類的例項。或者,由於動態排程,繼承可以用來提供多型性。
但是,從 Java 或任何其他面向物件的程式語言轉到 C++ 的新手可能會遇到一些意外。本章將介紹 C++ 中的繼承,並重點介紹與其他程式語言的區別。
C++ 的第一個特殊之處在於它為程式設計師提供的各種繼承種類:public、protected 和 private。這使得新手在嘗試第一個繼承示例時就遇到了第一個問題。請看以下程式碼片段。
class D : B { ... }
這段看似無害的程式碼聲稱從 B 派生 D。但是,它不允許您將 D 的物件視為 B 的物件。沒錯:D 未被視為 B 的子型別。看起來 C++ 或程式設計師出錯了。不完全是!與預設節是 private 相似,繼承,除非另有說明,被認為是所謂的私有種類。我們將延遲迴答私有繼承的含義,並將對上述程式碼進行修改,以滿足我們的預期,如下所示。
class D : public B { ... }
解決了這個特殊情況,讓我們看看一些程式碼示例。在這些示例中,我們將使用以下類定義。
class B { public: B(void) { } void f1s(void) { cout << "In B::f1s(void)" << endl; } virtual void f1d(void) { cout << "In B::f1d(void)" << endl; } virtual void f2(int i) { cout << "In B::f2(int)" << endl; } ... }; // 結束 class B class D : public B { public: D(void) { } void f1s(void) { cout << "In D::f1s(void)" << endl; } virtual void f1d(void) { cout << "In D::f1d(void)" << endl; } virtual void f2(short s) { cout << "In D::f2(short)" << endl; } ... int _m_i; short _m_s; }; // 結束 class D
... D* d = new D(); cout << d->_m_i << " " << d->_m_s << endl; ...
作為 Java 程式設計師,你可能會認為上面的程式碼片段應該連續輸出兩個 0。但是,它會輸出兩個隨機值。與 Java 和 C# 不同,在 C++ 中,除非被覆蓋,否則資料成員不會被賦予預設初始值。如果需要將它們初始化為 0 或任何其他值,則必須由程式設計師顯式完成。還要注意,不能用初始值宣告資料成員。也就是說,將 int _m_i; 更改為 int _m_i = 0; 在 D 中將導致語法錯誤。
D() : _m_i(0), _m_s(0) { }或D() { _m_i = 0; _m_s = 0; }
C++ 編譯器對程式設計師充滿信心。畢竟,C++ 程式設計師不會犯錯。在 Java 中被視為可怕錯誤的起源的容易出錯的語句,被認為是無所不知的程式設計師的明智決定。這不是 Bug,而是特性!
預設排程型別是靜態排程
[edit | edit source]... B* b; // the static type of b is B* if (bool_expr) b = new D(); // if this branch is taken b’s dynamic type will be D* else b = new B(); // if control falls through to this limp, dynamic type of b will be B* b->f1s(); ...
作為一個熟練的 Java 程式設計師,你希望這會根據 bool_expr 的值輸出——"In B::f1s(void)" 或 "In D::f1s(void)"。但是,在 C++ 中,它總是輸出 "In B::f1s(void)"!與 Java 不同,C++ 除非另有說明,否則使用靜態排程繫結函式呼叫。這意味著呼叫 f1s 時呼叫的函式地址將靜態解析。也就是說,編譯器將使用識別符號的靜態型別。換句話說,可以檢查程式文字來找出作為呼叫結果呼叫的函式。
... B* b; if (bool_expr) b = new D(); else b = new B(); b->f1d(); ...
虛擬函式是動態排程的。因此,與前面的示例不同,本示例將編譯並生成根據 bool_expr 的值而定的輸出。如果它評估為 true,它將輸出 "In D::f1d(void)",否則它將輸出 "In B::f1d(void)"。
按名稱隱藏過載
[edit | edit source]... D* d = new D(); int i = 3; d->f2(i); // will be dispatched to D::f2(short) ...
使用 Java 語義,上面的程式碼輸出 "In B::f2(int)"。畢竟,d 也是型別為 B 的物件,並且可以像真正的 B 物件一樣使用 B 的公共介面。因此,D::f2(short) 和 B::f2(int) 都對 d 的客戶端公開。在 C++ 中不是這樣!與 Java 不同,在 Java 中基類和派生類成員函式構成一組過載函式,而 C++ 將此集合限制在單個範圍內。由於派生類和基類是不同的範圍,因此任何名稱與基類函式重合的派生類函式將隱藏基類中的所有函式。從技術上講,我們說 C++ 按名稱隱藏,而 Java 則被稱為 *按簽名隱藏*。
但這不違背繼承的邏輯嗎?你聲稱 d 是 B 的物件(透過公共繼承關係),並且不允許其客戶端使用出現在 B 的公共介面中的某些函式?沒錯,C++ 提供了滿足您期望的方法。
| 示例:委託 |
|---|
|
|
無論呼叫的是否是虛擬函式,在函式呼叫中顯式使用類名都會導致函式靜態分派。例如,在以下函式中,[儘管 this 的型別為 D* 且 f2d(int) 是 virtual],第二個語句中的函式呼叫將被分派到 B::f2d(int)。
請注意,這種靜態分派可用於呼叫接收物件類中的任何函式或任何一個祖先類中的任何函式。
void f2d(int i) { cout << "Delegating..."; B::f2d(i); } ... }; // end of class D ... D* d = new D(); int i = 3; d->f2d(i); // will be delegated to B::f2d(int) through D::f2d(int) short s = 5; d->f2d(s); // will be dispatched to D::f2d(short) ...
| 示例:使用宣告 |
|---|
|
|
多重繼承,無根類
[edit | edit source]與 Java 不同的是,Java 中一個類只能從一個類派生,而 C++ 支援從多個類派生。考慮到介面的概念不支援,此功能被大量用於實現介面。
class D : public B1, public B2 { ... }
在 C++ 中需要注意的另一點是它缺少根類。也就是說,沒有像 Java 中的 Object 類這樣的類,它作為不同類之間的共同點。因此,人們談論的是類有向無環圖而不是類樹。
測試程式
[edit | edit source]#include <iostream>
#include <string>
using namespace std;
namespace CSE224 {
namespace DS {
class B {
public:
B(void) { }
void f1s(void) { cout << "In B::f1s(void)" << endl; }
virtual void f1d(void) { cout << "In B::f1d(void)" << endl; }
virtual void f2(int i) { cout << "In B::f2(int)" << endl; }
virtual void f2d(int i) { cout << "In B::f2d(int)" << endl; }
virtual void f2u(string s) { cout << "In B::f2u(string)" << endl; }
virtual void f2u(void) { cout << "In B::f2u(void)" << endl; }
}; // end of class B
class D : public B {
public:
D(void) { }
void f1s(void) { cout << “In D::f1s(void)” << endl; }
virtual void f1d(void) { cout << “In D::f1d(void)” << endl; }
virtual void f2(short s) { cout << “In D::f2(short)” << endl; }
virtual void f2d(short s) { cout << “In D::f2d(short)” << endl; }
virtual void f2d(int i) { cout << “Delegating...”; this->B::f2d(i); }
virtual void f2u(float f) { cout << “In D::f2u(float)” << endl; }
using B::f2u;
int _m_i;
short _m_s;
}; // end of class D
} // end of namespace DS
} // end of namespace CSE224
using namespace CSE224::DS;
void default_is_static_dispatch(void) {
cout << "TESTING DEFAULT DISPATCH TYPE" << endl;
cout << "b: Static type: B*, Dynamic type: D*" << endl;
B* b = new D();
cout << "Sending (non-virtual) f1s(void) to b..."; b->f1s();
cout << "Sending (virtual) f1d(void) to b..."; b->f1d();
} // end of void default_is_static_dispatch(void)
void call_delegation(void) {
cout << "Testing delegation..." << endl;
D* d = new D();
int i = 3;
cout << "Sending (virtual) f2d(int) to d...";
d->f2d(i);
short s = 5;
cout << "Sending (virtual) f2d(short) to d...";
d->f2d(s);
} // end of void call_delegation(void)
void using_declaration(void) {
cout << "Testing the using declaration..." << endl;
D* d = new D();
float f = 3.0;
cout << "Sending (virtual) f2u(float) to d...";
d->f2u(f);
string s = string(“abc”);
cout << "Sending (virtual) f2u(string) to d...";
d->f2u(s);
cout << "Sending (virtual) f2u(void) to d...";
d->f2u();
} // end of void using_declaration(void)
void CPP_hides_by_name(void) {
cout << "TESTING HIDE-BY NAME" << endl;
D* d = new D();
int i = 3;
cout << "Sending (virtual) f2(int) to d...";
d->f2(i);
call_delegation();
using_declaration();
} // end of void CPP_hides_by_name(void)
void no_member_initialization(void) {
cout << "TESTING MEMBER INITIALIZATION" << endl;
D* d = new D();
cout << "_m_i: " << d->_m_i << " _m_s: " << d->_m_s << endl;
} // end of void no_member_initialization(void)
int main(void) {
no_member_initialization();
cout << endl;
default_is_static_dispatch();
cout << endl;
CPP_hides_by_name();
return 0;
} // end of int main(void)
gxx –o Test.exe Peculiarities.cxx↵ Test↵ TESTING MEMBER INITIALIZATION _m_i: -1 _m_s: 9544 TESTING DEFAULT DISPATCH TYPE b: Static type: B*, Dynamic type: D* Sending (non-virtual) f1s(void) to b...In B::f1s(void) Sending (virtual) f1d(void) to b...In D::f1d(void) TESTING HIDE-BY NAME Sending (virtual) f2(int) to d...In D::f2(short) Testing delegation... Sending (virtual) f2d(int) to d...Delegating...In B::f2d(int) Sending (virtual) f2d(short) to d...In D::f2d(short) Testing the using declaration... Sending (virtual) f2u(float) to d...In D::f2u(float) Sending (virtual) f2u(string) to d...In B::f2u(string) Sending (virtual) f2u(void) to d...In B::f2u(void)
Java 風格的繼承
[edit | edit source]在本部分講義中,我們提供了一個關於 C++ 和 Java 在繼承方面的相關性的見解。這是透過使用 C++ 中發現的概念來模擬 Java 中發現的概念來實現的。這種方法不應被視為對 Java 的宣傳活動;不用說,Java 並非沒有競爭。它應該被視為對上述概念的內部運作提供線索的不完整嘗試。
根類和介面概念
[edit | edit source]在 Java 中,透過根類和介面概念可以表達不相關物件之間的共同屬性。前者定義了所有類之間的共同點,而後者用於對一組類進行分類。[1] 例如,由於它在 Object 中列出,因此所有物件都可以測試其與相容型別物件的相等性;或者宣告為 Comparable 的類物件可以與相容物件進行比較。
這兩個概念在 C++ 中不被支援作為語言抽象。相反,程式設計師期望訴諸於使用約定或透過其他結構來模擬它。例如,測試相等性是透過覆蓋 == 運算子的預設實現來實現的;介面概念,它不被直接支援,可以透過具有純虛擬函式的抽象類來模擬。
#ifndef OBJECT_HXX
#define OBJECT_HXX
namespace System {
class Object {
我們引入標頭檔案 Object 的目的是定義一個根類,可以作為通用函式中的多型型別使用,例如 compareTo 函式在 IComparable 中定義;我們並不打算提供任何像 Java 中 Object 類那樣提供的共享功能。然而,僅僅定義一個空類並不能實現這一目標。為了使一個型別在 C++ 中成為多型的,它必須至少包含一個虛擬函式。因此,我們在類定義中包含一個虛擬的啞函式。
但為什麼我們要將它的訪問修飾符設定為 protected 呢?首先,它不能是 public,因為我們不想透過這個類暴露任何功能。那將 no_op 宣告為 private 呢?畢竟,將它宣告為 protected 意味著派生類現在可以傳送 no_op 訊息。答案在於多型的本質:為了使多型成為可能,應該能夠覆蓋在基類中找到的動態分派函式的定義。這意味著這些函式應該至少對派生類開放。事實上,C++ 編譯器甚至不允許你在 <syntaxhighlightlang="cpp" enclose="none">private</syntaxhighlight> 部分中宣告 virtual 函式。
protected:
virtual void no_op(void) { return; }
}; // end of class Object
} // end of namespace System
#endif
定義:純虛擬函式是一個在宣告類中沒有給出函式體的虛擬函式。因此,聲稱是具體的派生類必須為這樣的函式提供實現。
在 C++ 支援的概念方面,介面是一個“無欄位”的抽象類,其所有函式都是純虛擬函式。
#ifndef ICOMPARABLE_HXX
#define ICOMPARABLE_HXX
#include "Object"
namespace System {
class IComparable {
public:
virtual int compareTo(const Object&) const = 0;
}; // end of class IComparable
} // end of namespace System
#endif
#ifndef RATIONAL_HXX
#define RATIONAL_HXX
#include <iostream>
using namespace std;
#include "IComparable"
#include "Object"
using namespace System;
#include "math/exceptions/NoInverse"
#include "math/exceptions/ZeroDenominator"
#include "math/exceptions/ZeroDivisor"
using namespace CSE224::Math::Exceptions;
namespace CSE224 {
namespace Math {
在將介面概念定義為類概念的一種變體之後,我們自然應該謹慎地談論實現關係。這確實是 C++ 中的情況:人們只能談論擴充套件關係。因此,對多重繼承的支援是必須的。
class Rational : public Object, public IComparable {
public:
Rational(long num = 0, long den = 1) throw(ZeroDenominator) {
if (den == 0) {
cerr << "Error: ";
cerr << "About to throw ZeroDenominator exception" << endl;
throw ZeroDenominator();
} // end of if (den == 0)
_n = num;
_d = den;
this->simplify();
} // end of constructor(long=, long=)
Rational(Rational& existingRat) {
_n = existingRat._n;
_d = existingRat._d;
} // end of copy constructor
請注意,以下函式與其他成員函式不同,它們將被靜態分派。在 Java 中,可以透過將方法宣告為 final 來實現這種效果。
long getNumerator(void) const { return _n; }
long getDenominator(void) const { return _d; }
除了將函式標記為 virtual 之外,我們還宣告它們返回引用。這是因為引用是作為 Java 中控制代碼的最佳候選者:它是一個繼承感知的、編譯器管理的指標。[2] 也就是說,我們可以將屬於以 Rational 類為根的類層次結構中的物件作為引數傳遞給下一個函式(或者任何期望 Rational 的引用的函式)。引用的解引用由編譯器生成的程式碼自動完成。
作為替代方案(儘管生成的程式碼的可寫性和可讀性會降低),我們可以使用普通的指標。但是,使用普通的物件型別是不可能的。這是因為多型性與繼承相結合,需要向可能大小不同的物件傳送相同的訊息(這就是多型性的本質),而這反過來又意味著傳遞和返回大小可變的物件。這是編譯器無法處理的!我們應該提供一些幫助,我們透過在兩者之間注入一個固定大小的程式設計實體來實現:指標或引用。
virtual Rational& add(const Rational&) const;
virtual Rational& divide(const Rational&) const throw(ZeroDivisor)
virtual Rational& inverse(void) const throw(NoInverse);
virtual Rational& multiply(const Rational&) const;
virtual Rational& subtract(const Rational&) const;
virtual int compareTo(const Object&) const;
請注意以下函式的作用類似於 Java 中的 toString 函式。如果使用 sstream 替換 ostream 並相應地更改實現,就可以使類比更加完美。
friend ostream& operator<<(ostream&, const Rational&);
private:
long _n, _d;
long min(long n1, long n2);
Rational& simplify(void);
}; // end of class Rational
} // end of namespace Math
} // end of namespace CSE224
#endif
#include <iostream>
#include <memory>
using namespace std;
#include "Object"
using namespace System;
#include "math/exceptions/NoInverse"
#include "math/exceptions/ZeroDenominator"
#include "math/exceptions/ZeroDivisor"
using namespace CSE224::Math::Exceptions;
#include "math/Rational"
namespace CSE224 {
namespace Math {
Rational& Rational::
add(const Rational& rhs) const {
請注意缺少的 try-catch 塊!與 Java 不同,C++ 不強制程式設計師將所有可能出現問題的程式碼放入受保護的區域。可以說,所有 C++ 異常都像從 Java 中 RuntimeException 類派生的異常一樣處理。這給了程式設計師一定的自由度,使她能夠編寫更簡潔的程式碼。例如,到達下一行意味著我們正在新增兩個格式良好的 Rational 物件。這種操作的結果永遠不會造成問題!
Rational* sum = new Rational(_n * rhs._d + _d * rhs._n, _d * rhs._d);
return sum->simplify();
} // end of Rational& Rational::add(const Rational&) const
Rational& Rational::
divide(const Rational& rhs) const throw(ZeroDivisor) {
try {
Rational& tmp_inv = rhs.inverse();
Rational& ret_rat = this->multiply(tmp_inv);
既然我們已經完成了儲存 rhs 的逆的臨時物件,我們必須將其返回給記憶體分配器,否則將會在每次使用該函式時產生垃圾。這很煩人!但話又說回來,C/C++ 程式設計師不會犯這種低階錯誤。
請注意 tmp_inv 之前的取地址運算子。將此運算子應用於引用將返回該引用別名所指向的區域的起始地址。[請記住,引用在其使用點會自動解引用] 在我們的例子中,這將是透過向 rhs 傳送 inverse 訊息而建立的物件的地址。
delete &tmp_inv;
return ret_rat;
} catch (NoInverse e) {
cerr << "Error: About to throw ZeroDivisor exception" << endl;
throw ZeroDivisor();
}
} // end of Rational& Rational::divide(const Rational&) const
Rational& Rational::
inverse(void) const throw(NoInverse) {
try {
Rational *res = new Rational(_d, _n);
return *res;
} catch(ZeroDenominator e) {
cerr << "Error: About to throw NoInverse exception" << endl;
throw NoInverse(_n, _d);
}
} // end of Rational& Rational::inverse(void) const
Rational& Rational::
multiply(const Rational& rhs) const {
Rational *res = new Rational(_n * rhs._n, _d * rhs._d);
return res->simplify();
} // end of Rational& Rational::multiply(const Rational&) const
Rational& Rational::
subtract(const Rational& rhs) const {
我們將減法公式化為其他操作:我們不減去一個值,而是加上負值。為此,我們建立了兩個臨時物件,它們只在當前呼叫期間有意義。在返回呼叫者之前,我們應該將它們返回給記憶體分配器。
一個所謂的智慧指標物件正是我們想要的。這樣的物件被初始化為指向由 new 表示式建立的動態分配物件,並在其(智慧指標的)生命週期結束時釋放它(動態分配的物件)。下面的圖顯示了執行第 47 行後的記憶體佈局,這應該使這一點更加清晰。

與智慧指標物件一起建立了對函式本地化的堆物件,而智慧指標物件本身是執行時堆疊上建立的本地物件。9 這意味著該智慧指標物件的分配建構函式呼叫和解構函式呼叫-釋放將由編譯器生成的程式碼處理。換句話說,程式設計師無需擔心智慧指標物件的生存期管理。因此,如果我們能保證堆物件與該智慧指標一起被銷燬-釋放,那麼它的生存期管理將不再是一個問題。這是透過在相關智慧指標物件的解構函式中刪除堆物件來實現的,這意味著在智慧指標物件銷燬-釋放完成之前,堆物件將已經被銷燬-釋放。以下描述了智慧指標和相關堆物件的生存期。
- 在執行時堆疊中建立智慧指標。
- 將相關堆物件傳遞給智慧指標的建構函式。
- 使用堆物件。
- 透過編譯器生成的程式碼呼叫智慧指標的解構函式。
- 從智慧指標的解構函式中刪除堆物件。
根據此,在以下定義中建立的匿名 Rational 物件(new Rational(-1) 和 &(rhs.multiply(*neg_1)))將在離開函式之前被自動(即無需程式設計師干預)返回。
auto_ptr< Rational > neg_1(new Rational(-1));
auto_ptr< Rational > tmp_mul(&(rhs.multiply(*neg_1)));
觀察以下對解引用運算子的應用,其運算元是一個非指標變數,這乍看起來可能是一個錯誤。畢竟,* 透過返回其唯一運算元指示的記憶體內容來工作。然而,這個相當有限的描述忽略了過載解引用運算子的可能性。實際上,正是該運算子的過載版本使非指標型別能夠使用。以下對 * 的應用使用了在 auto_ptr 類中定義的過載版本,該版本返回智慧指標管理的堆物件的內容。
為了使事情更加清晰,我們可以為 auto_ptr 類建議以下實現。
模板 <類 ManagedObjectType> 類 auto_ptr { 公共: auto_ptr(ManagedObjectType* managedObj) { _managed_heap_object = managedObj; ... } // end of constructor(ManagedObjectType*) ... ManagedObjectType 運算子*(空) { ... 返回 *_managed_heap_object; } // end of ManagedObjectType operator*(void) ... 私有: ManagedObjectType* _managed_heap_object; } // end of class auto_ptr<ManagedObjectType>
Rational &ret_rat = add(*tmp_mul);
return(ret_rat);
} // end of Rational& Rational::subtract(const Rational&) const
int Rational::
compareTo(const Object& rhs) const {
double this_equi = ((double) _n) / _d;
除了傳統的 C 風格強制型別轉換,C++ 還提供了多種強制型別轉換運算子:const_cast,dynamic_cast,static_cast 和 reinterpret_cast。這些運算子中的每一個都執行傳統強制型別轉換運算子提供的功能的一個子集,因此可以說新的運算子沒有新增任何新功能。但是,由於編譯器的額外支援,它們使得編寫更型別安全的程式成為可能。使用新的運算子,我們明確地說明了我們的意圖,從而獲得更易於維護的程式碼。
示例:移除物件的 const 屬性。 |
|---|
|
|
需要注意的是,const_cast 也可以用於更改物件的 volatile 屬性。 |
由於我們移除 const 屬性的意圖已由相關運算子明確表示,程式碼維護者將更快地發現強制型別轉換的發生,並意識到正在執行的操作。使用 C 風格強制型別轉換的替代方案缺乏這些特性:很難找到強制型別轉換的位置,也很難確定是否正在移除 const 屬性。
使用 dynamic_cast 也會為我們提供更安全程式碼的優勢。此特定運算子用於在多型類之間進行雙向強制型別轉換,即至少具有一個虛擬函式的類,這些類透過公共派生相互關聯。
| 問題 | ||
|---|---|---|
dynamic_cast 只能用於在指標/引用型別之間進行轉換。為什麼? | ||
|
定義:從一個類轉換到同一類層次結構中的另一個類,如果目標型別更專業,則稱為 *向下轉換*。如果目標型別不太專業,則強制型別轉換的行為稱為 *向上轉換*。
向上轉換到公共基類總是成功的,因為目標型別介面中列出的訊息是源型別介面的一個子集。另一方面,從派生類強制型別轉換為其非公共基類之一會導致編譯時錯誤。同樣,向下轉換可能會導致執行時錯誤,因為我們可能會發送源型別介面中找不到的訊息。
示例:使用 dynamic_cast 獲得更安全的程式碼。 |
|---|
|
|
上面的程式碼將一個 PB* 變數向下轉換為 PD*,透過它可以傳送名為 g 的額外訊息。對於此示例,這似乎不是問題。但是,如果 pb 用於指向一個 PB 物件而不是 PD 物件怎麼辦?如果它被用於指向不同型別的物件,如以下程式碼片段所示,會怎樣呢?
如果(some_condition) { ... ; pb = 新 D; ... } 否則 { ... pb = 新 B; ... } ... PD* pd = dynamic_cast<PD*>(pb);
我們不能保證可以向底層物件傳送 g,它可以是 PB 或 PD 型別。只有在執行時檢查物件型別的情況下,才能提供我們正在尋求的這種保證。這正是 dynamic_cast 所做的:透過檢查指標/引用的相容性(靜態型別)與物件(動態型別),dynamic_cast 決定正在進行的強制型別轉換是否有效。如果是,則返回一個合法值。否則,如果強制型別轉換指標失敗,則返回一個 NULL 值,這基本上消除了傳送非法訊息的可能性;如果強制型別轉換引用失敗,則丟擲 std::bad_cast 異常。當源型別和目標型別沒有繼承關係時,也會採取相同的操作。
請注意,由編譯器生成的程式碼執行執行時檢查而導致的此成本,在使用傳統強制轉換運算子時不會出現。這是因為 C 樣式強制轉換運算子不使用任何執行時資訊。
觀察向上轉換類層次結構——由於派生類物件接收的訊息是其基類的訊息子集——不需要任何執行時檢查。這意味著由於 dynamic_cast 造成的成本是不合理的:為什麼我們要為進行一個結果已知的控制而付費?C++ 提供的解決方案是另一個強制轉換運算子,它使用編譯時資訊來完成其工作:static_cast。此運算子可用於執行編譯器隱式執行的轉換,並在反方向執行這些隱式轉換。如果可以保證跳過執行時檢查是安全的,它也可以用作 dynamic_cast 的替代。
| 示例 |
|---|
|
|
這並不能完全涵蓋傳統強制轉換運算子提供的功能。例如,不相關/不相容指標型別之間的轉換缺失。此缺失的功能由 reinterpret_cast operator 涵蓋,它還可以執行指標和整型之間的轉換。
| 示例 |
|---|
|
|
應該記住,此運算子不檢查源型別和目標型別;它只是將目標內容按位複製到源中。
double rhs_equi = ((double) dynamic_cast<const Rational&>(rhs)._n) / dynamic_cast<const Rational&>(rhs)._d;
if (this_equi > rhs_equi) return 1;
else if (this_equi == rhs_equi) return 0;
else return -1;
} // end of int Rational::compareTo(const Object&) const
long Rational::
min(long n1, long n2) { return (n1 > n2 ? n1 : n2); }
Rational& Rational::
simplify(void) {
long upperLimit = min(_n, _d);
for (long i = 2; i <= upperLimit;)
if ((_n % i == 0) && (_d % i == 0)) { _n /= i; _d /= i; }
else i++;
if (_d < 0) { _n *= -1; _d *= -1; }
return(*this);
} // end of Rational& Rational::simplify(void)
ostream& operator<<(ostream& os, const Rational& rat) {
os << rat._n << " ";
if (rat._d != 1) os << "/ " << rat._d;
return os;
} // end of ostream& operator<<(ostream&, const Rational&)
} // end of namespace Math
} // end of CSE224
介面 (整體)
[edit | edit source]#ifndef WHOLE_HXX
#define WHOLE_HXX
#include <iostream>
using namespace std;
#include "math/Rational"
namespace CSE224 {
namespace Math {
class Whole : public Rational {
public:
請記住,Whole 派生自 Rational。換句話說,由於繼承可以被視為編譯器管理的組合,因此所有 Whole 物件在其記憶體佈局中都包含一個 Rational 子物件。在成員初始化列表中使用不引用正在初始化的成員的表示法,將初始化在正在構造的 Whole 物件中找到的 Rational 子物件。
Whole(long num) : Rational(num) { }
Whole(void) : Rational(0) { }
Whole(Whole& existingWhole) :
Rational(existingWhole.getNumerator()) { }
Whole& add(const Whole& rhs) const;
virtual Rational& add(const Rational&) const;
}; // end of class Whole
} // end of namespace Math
} // end of namespace CSE224
#endif
實現 (整體)
[edit | edit source]#include <iostream>
using namespace std;
#include "math/Rational"
#include "math/Whole"
namespace CSE224 {
namespace Math {
Rational& Whole::
add(const Rational& rhs) const {
cout << "[In Whole::add(Rational)] ";
return (Rational::add(rhs));
} // end of Rational& Whole::add(const Rational&) const
Whole& Whole::
add(const Whole& rhs) const {
cout << "[In Whole::add(Whole)] ";
Whole *res = new Whole(getNumerator() + rhs.getNumerator());
return *res;
} // end of Whole& Whole::add(const Whole&) const
} // end of namespace Math
} // end of namespace CSE224
異常類
[edit | edit source]#ifndef NOINVERSE_HXX
#define NOINVERSE_HXX
#include <iostream>
using namespace std;
namespace CSE224 {
namespace Math{
namespace Exceptions {
class NoInverse {
public:
NoInverse(long num, long den) {
cerr << "Error: Throwing a NoInverse exception" << endl;
_n = num; _d = den;
} // end of constructor(long, long)
void writeNumber(void) {
cerr << "The problematic number is " << _n << "/" << _d << endl;
} // end of void writeNumber()
private:
long _n, _d;
}; // end of class NoInverse
} // end of namespace Exceptions
} // end of namespace Math
} // end of namespace CSE224
#endif
#ifndef ZERODENOMINATOR_HXX
#define ZERODENOMINATOR_HXX
namespace CSE224 {
namespace Math {
namespace Exceptions {
class ZeroDenominator {
public:
ZeroDenominator(void) {
cerr << "Error: Throwing a ZeroDenominator exception" << endl;
} // end of default constructor
}; // end of class ZeroDenominator
} // end of namespace Exceptions
} // end of namespace Math
} // end of namespace CSE224
#endif
#ifndef ZERODIVISOR_HXX
#define ZERODIVISOR_HXX
#include <iostream>
using namespace std;
namespace CSE224 {
namespace Math {
namespace Exceptions {
class ZeroDivisor {
public:
ZeroDivisor(void) {
cerr << "Error: Throwing a ZeroDivisor exception" << endl;
} // end of default constructor
}; // end of class ZeroDivisor
} // end of namespace Exceptions
} // end of namespace Math
} // end of namespace CSE224
#endif
測試程式
[edit | edit source]#include <iostream>
#include <memory>
using namespace std;
#include "math/Whole"
using namespace CSE224::Math;
#include "math/exceptions/ZeroDenominator"
using namespace CSE224::Math::Exceptions;
int main(void) {
cout << "Creating a Whole object(five) and initializing it with 5..." << endl;
auto_ptr < Whole > fivep(new Whole(5));
Whole& five = *fivep;
cout << "Creating a Rational object(three) and initializing it with 3..." << endl;
auto_ptr < Rational > threep(new Rational(3));
Rational& three = *threep;
cout << "Result of five.multiply(three) = ";
cout << five.multiply(three) << endl;
cout << "***************************************************" << endl;
cout << "Result of three.add(three) = ";
cout << three.add(three) << endl;
cout << "Result of three.add(five) = ";
cout << three.add(five) << endl;
cout << "Result of five.add(three) = ";
cout << five.add(three) << endl;
cout << "Result of five.add(five) = ";
cout << five.add(five) << endl;
cout << "***************************************************" << endl;
cout << "Setting a Rational object(ratFive) as an alias for a Whole object(five)..." << endl;
Rational& ratFive = five;
cout << "Result of ratFive.add(three) = ";
cout << ratFive.add(three) << endl;
cout << "Result of ratFive.add(five) = ";
cout << ratFive.add(five) << endl;
cout << "Result of ratFive.add(ratFive) = ";
cout << ratFive.add(ratFive) << endl;
cout << "Result of five.add(ratFive) = ";
cout << five.add(ratFive) << endl;
cout << "Result of three.add(ratFive) = ";
cout << three.add(ratFive) << endl;
cout << "***************************************************" << endl;
cout << "Creating a Rational object(r1) and initializing it with 3/2..." << endl;
auto_ptr < Rational > r1p(new Rational(3, 2));
Rational& r1 = *r1p;
cout << "Result of five.multiply(r1) = ";
cout << five.multiply(r1) << endl;
cout << "Result of five.divide(r1) = ";
cout << five.divide(r1) << endl;
return 0;
} // end of int main(void)
私有繼承
[edit | edit source]程式設計是一項務實的活動,必須盡一切努力以最高效率地進行。可以說,實現這一點最有效的方式是重複使用在先前專案不同階段中已經使用過的工件。[3] 透過這樣做,我們節省了開發和測試時間,這意味著我們的下一個產品可以更快地進入市場。
實現重用的一種方式是繼承。對許多人來說,這似乎是唯一負擔得起的方式。但是,還有一個競爭者:組合。此技術是透過將一個物件作為另一個物件的成員來實現的。
class C {... B b; ...};
對於上面的例子,我們說一個 C 類的物件,除了其他東西之外,還包含一個 B 類的物件。換句話說,我們說一個 C 類的物件擁有(包含)一個 B 類的物件。這與繼承關係肯定不同,繼承關係被定義為“is-a”關係。
在 C++ 中,可以透過所謂的私有繼承來實現類似的效果。
class C : /* private */ B { ... }
#ifndef LIST_HXX
#define LIST_HXX
#include "ds/exceptions/List_Exceptions"
using namespace CSE224::DS::Exceptions;
namespace CSE224 {
namespace DS {
class List {
friend ostream& operator<<(ostream&, const List&);
public:
List(void) : _size(0), _head(NULL), _tail(NULL) { }
List(const List&);
~List(void);
List& operator=(const List&);
bool operator==(const List&);
double get_first(void) throw(List_Empty);
double get_last(void) throw(List_Empty);
void insert_at_end(double new_item);
void insert_in_front(double new_item);
double remove_from_end(void) throw(List_Empty);
double remove_from_front(void) throw(List_Empty);
bool is_empty(void);
unsigned int size(void);
private:
下面是巢狀類的定義,巢狀類是在另一個類中定義的類。當巢狀類在 private 部分定義時,它在其封閉類外部不可見。當兩個類緊密耦合時,這種方案很有用,例如我們的例子:一個 List_Node 物件僅在 List 物件的上下文中使用。構成我們的 List 物件的實現細節,不應被其使用者關注。
雖然可能很想在巢狀類和 Java 的內部類之間建立平行關係,但這將是一個錯誤。與內部類與其封閉類之間的特殊關係相反,在 C++ 中,封閉類對巢狀在其內部的類沒有特殊訪問許可權。因此,將 public 更改為 private 或 protected 不是一個好主意。
另一個需要說明的是,C++ 的巢狀類不會在內部類的物件中保留任何關於封閉物件的記錄,這使得它們更像 Java 中的靜態內部類。
class List_Node {
public:
List_Node(double val) : _info(val), _next(NULL), _prev(NULL) { }
double _info;
List_Node *_next, *_prev;
}; // end of class List_Node
private:
List_Node *_head, *_tail;
unsigned int _size;
在 private 部分宣告的函式?是的!在 private 部分聲明瞭由其他函式使用且不是介面一部分的函式。請注意,您不能不宣告此類函式,即使它們不在類介面中。
void insert_first_node(double);
}; // end of List class
} // end of namespace DS
} // end of namespace CSE224
#endif
#include <iostream>
using namespace std;
#include "ds/List"
#include "ds/exceptions/List_Exceptions"
using namespace CSE224::DS::Exceptions;
namespace CSE224 {
namespace DS {
List::
List(const List& rhs) : _head(NULL), _tail(NULL), _size(0) {
List_Node *ptr = rhs._head;
for(unsigned int i = 0; i < rhs._size; i++) {
this->insert_at_end(ptr->_info);
ptr = ptr->_next;
} // end of for(unsigned int i = 0; i < rhs._size; i++)
} // end of copy constructor
現在 List 物件的節點是在堆上分配的,我們必須確保在 List 物件本身隱式或顯式地被刪除時,它們被返回給記憶體分配器。因此,我們需要編寫一個解構函式來釋放這些節點。請注意,一個 List 物件由兩個指標組成,它們指向列表的頭和尾,以及一個儲存其大小的欄位。這些指標直接或間接指向的節點不是 List 物件的一部分。因此,它們不會與列表物件一起自動釋放。因此,我們需要一個解構函式。
請注意,我們的決定不受 List 物件本身是在堆上建立還是在棧上建立的影響。List 物件的建立位置會影響誰應該呼叫解構函式:無論誰是負責方(編譯器或程式設計師),在所有可能的情況下,解構函式都會在物件釋放之前被隱式呼叫。如果它是在堆上建立的,則程式設計師負責進行呼叫。否則,編譯器會在物件作用域結束時處理這些繁瑣的工作。
List::
~List(void) {
for (int i = 0; i < _size; i++)
remove_from_front();
} // end of destructor
List& List::
operator=(const List& rhs) {
if (this == &rhs) return (*this);
for(unsigned int i = _size; i > 0; i--)
this->remove_from_front();
List_Node *ptr = rhs._head;
for(unsigned int i = 0; i < rhs._size; i++) {
this->insert_at_end(ptr->_info);
ptr = ptr->_next;
} // end of for(unsigned int i = 0; i < rhs._size; i++)
if (rhs._size == 0) {
_head = _tail = NULL;
_size = 0;
} // end of if(rhs._size == 0)
return (*this);
} // end of assignment operator
bool List::
operator==(const List& rhs) {
if (_size != rhs._size) return false;
if (_size == 0 || this == &rhs) return true;
List_Node *ptr = _head;
List_Node *ptr_rhs = rhs._head;
for (unsigned int i = 0; i < _size; i++) {
if (!(ptr->_info == ptr_rhs->_info))
return false;
ptr = ptr->_next;
ptr_rhs = ptr_rhs->_next;
} // end of for(unsigned int i = 0; i < _size; i++)
return true;
} // end of equality-test operator
double List::
get_first(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
return (_head->_info);
} // end of double List::get_first(void)
double List::
get_last(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
return (_tail->_info);
} // end of double List::get_last(void)
void List::
insert_at_end(double new_item) {
if (is_empty()) insert_first_node(new_item);
else {
List_Node *new_node = new List_Node(new_item);
_tail->_next = new_node;
new_node->_prev = _tail;
_tail = new_node;
}
_size++;
} // end of void List::insert_at_end(double)
void List::
insert_in_front(double new_item) {
if (is_empty()) insert_first_node(new_item);
else {
List_Node *new_node = new List_Node(new_item);
new_node->_next = _head;
_head->_prev = new_node;
_head = new_node;
}
_size++;
} // end of void List::insert_in_front(double)
double List::
remove_from_end(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
double ret_val = _tail->_info;
List_Node *temp_node = _tail;
if (_size == 1) { head = _tail = NULL; }
else {
_tail = _tail->_prev;
_tail->_next = NULL;
}
delete temp_node;
_size--;
return ret_val;
} // end of double List::remove_from_front(void)
double List::
remove_from_front(void) throw(List_Empty) {
if (is_empty()) throw List_Empty();
double ret_val = _head->_info;
List_Node *temp_node = _head;
if (_size == 1) { _head = _tail = NULL; }
else {
_head = _head->_next;
_head->_prev = NULL;
}
delete temp_node;
_size--;
return ret_val;
} // end of double List::remove_from_front(void)
bool List::
is_empty(void) { return(_size == 0); }
unsigned int List::
size(void) { return _size; }
void List::
insert_first_node(double new_item) {
List_Node *new_node = new List_Node(new_item);
_head = _tail = new_node;
} // end of void List::insert_first_node(double)
ostream& operator<<(ostream& os, const List& rhs) {
List tmp_list = rhs;
os << "<" << rhs._size << "> <head: ";
for (int i = 0; i < rhs._size - 1; i++) {
os << tmp_list._head->_info << ", ";
tmp_list._head = tmp_list._head->_next;
} // end of for(int i = 0; i < rhs._size; i++)
if (rhs._size > 0) os << tmp_list._head->_info;
os << ": tail>";
return(os);
} // end of ostream& operator<<(ostream&, const List&)
} // end of namespace DS
} // end of namespace CSE224
#ifndef STACK_HXX
#define STACK_HXX
#include <iostream>
using namespace std;
#include "ds/exceptions/Stack_Exceptions"
using namespace CSE224::DS::Exceptions;
#include "ds/List"
namespace CSE224 {
namespace DS {
List 類提供了堆疊功能的超集。這最初可能讓我們認為,我們可以定義一個新的類 Stack,並讓它(公開地)從 List 類派生。這種方法的問題是,基類的公共介面將作為派生類的公共介面的一部分暴露出來。這不是我們在這個案例中想要的:List 類提供了比我們對 Stack 類期望的更多功能。因此,我們應該採用其他方法,例如組合。
C++ 提供了一種替代方法:私有繼承。使用私有繼承,派生類仍然可以利用基類提供的功能,但基類介面不會透過派生類暴露。因此,Stack 類私有地繼承自 List 類。
class Stack : private List {
public:
現在派生類可以重用基類功能,但不會將其暴露給使用者,這種型別的繼承也稱為實現繼承。出於類似原因,公有繼承也被稱為介面繼承。
我們不需要編寫正統規範形式的函式,因為編譯器生成的版本提供了我們所需功能的等效實現。這主要是因為 Stack 的唯一資料欄位是它從 List 繼承的 List 子物件。
// Stack(void);
// Stack(const Stack&);
// ~Stack(void);
// Stack& operator=(const Stack&);
// bool operator==(const Stack&);
double peek(void) throw(Stack_Empty);
double pop(void) throw(Stack_Empty);
void push(double new_item);
由於以下語句,我們有選擇地從私有繼承的基類中暴露了一個函式。它就像 List 類中的 is_empty 函式是公有繼承的一樣。
using List::is_empty;
}; // end of Stack class
} // end of namespace DS
} // end of namespace CSE224
#endif
#include <iostream>
using namespace std;
#include "ds/Stack"
#include "ds/exceptions/Stack_Exceptions"
using namespace CSE224::DS::Exceptions;
namespace CSE224 {
namespace DS {
double Stack::
peek(void) throw(Stack_Empty) {
double ret_val;
我們的類的使用者不應該知道我們如何實現 Stack 類。這就是為什麼我們需要重新丟擲 List 類丟擲的異常,這樣對使用者來說更有意義。
try { ret_val = get_first();}
catch(List_Empty) { throw Stack_Empty(); }
return ret_val;
} // end of double Stack::peek(void)
void Stack::
push(double new_item) { List::insert_in_front(new_item); }
double Stack::
pop(void) throw(Stack_Empty) {
double ret_val;
try { ret_val = remove_from_front(); }
catch(List_Empty) { throw Stack_Empty(); }
return ret_val;
} // end of double Stack::pop(void)
} // end of namespace DS
} // end of namespace CSE224
隨著多重繼承的可能性出現,出現了所謂的虛繼承問題。考慮圖 2 中所示的類層次結構。等待您回答的問題是:一個 Politician 物件中會有多少個 Animal 子物件?從圖中看,正確答案似乎是兩個。但是,我們的邏輯告訴我們一個不同的故事:一個 Politician 物件中只能有一個 Animal 子物件。

無論哪一個答案是正確的,可能會有情況使其中任何一個都成為更好的選擇。我們必須找到一種方法來區分這些選項。這就是虛繼承的概念發揮作用的地方。我們將 Humanoid 和 Ape 類定義為從 Animal 虛派生。
| 示例:虛繼承 |
|---|
|
|
由於這些定義,現在在Politician中只有一個Animal子物件。這是透過確保將指標(而不是物件本身)插入派生類來實現的。也就是說,Politician物件現在有兩個指標,它們都指向同一個Animal子物件,而不是包含兩個Animal子物件。
請注意,使用虛繼承會導致建構函式呼叫順序發生變化:虛基類始終在非虛基類之前構造,無論它們在繼承層次結構中處於什麼位置。
虛繼承的典型應用包括實現mixin類。mixin類用於調整基類的行為,並且可以組合起來獲得更專門的類。例如,使用以下程式碼可以建立具有不同樣式的視窗:普通視窗、帶選單的視窗、帶邊框的視窗以及帶選單和邊框的視窗。實際上,我們可以建立我們自己的mixin,例如捲軸mixin,並獲得支援捲軸的這些視窗樣式版本。
| 示例:透過虛繼承實現mixin。 |
|---|
|
|
請注意,視窗樣式的數量隨著mixin數量的增加呈指數增長。但是,由於虛繼承,我們不必考慮每種組合。我們從基類和一些mixin開始。當我們需要更精細的視窗樣式時,我們會建立一個從相關mixin類繼承的新類。如果我們發現mixin類中缺少某些屬性,我們可以編寫我們自己的mixin並像其他mixin一樣使用它。
實現多型性
[edit | edit source]在本節中,我們將探討兩種廣泛使用的實現多型性的方法。值得注意的是,這兩種方法都依賴於動態排程函式呼叫到相關的[函式]入口點。換句話說,多型性是透過動態排程來實現的。另一個要說明的點是我們用來表達多型性的工具:繼承。
在不同的上下文中提及多型性、繼承和動態排程可能會讓一些人認為它們是無關的概念。然而,這完全是錯誤的。事實上,在面向物件的上下文中,這些是互補的概念,協同工作以自然的方式實現“is-a”關係。為了表達兩個類之間的“is-a”關係,我們需要繼承和多型性的幫助:繼承用於在基類和派生類之間定義一個通用的訊息協議,而多型性則需要提供行為可變性。[4] 這是透過動態排程訊息來進一步實現的。
如上一節所述,沒有多型性的繼承會導致僵化、面向物件的解決方案。同樣,僅憑多型性通常不是你想要的。因此,建議你考慮將這兩個概念結合使用。
前面的說明不應該讓你認為我們只能使用繼承和多型性的組合。我們在軟體行業的成功取決於生產可靠的、易於擴充套件的、高效的軟體。實現這一目標的關鍵詞是重用,上面提到的概念並非沒有替代方案。除了古老的組合技術之外,我們還可以使用泛型。對類或子程式進行引數化也會讓我們享受到重用的好處。前者的一個示例在引數化型別章節中給出,而後者的使用示例如下。由於這種方法,使用者可以對任何陣列進行排序,只要提供了元件型別的Comparator<V>物件。
| 示例:Java 中的泛型方法。 |
|---|
|
|
這種方法被稱為提供引數多型性。它是多型的,因為相同的方法對不同型別的引數執行相同的操作;對於方法的使用者來說,它看起來就像為每種不同型別都有單獨的方法一樣。[5]
在過載子程式的情況下,也可以體驗到類似的效果,其中具有不同引數列表的呼叫將分派到具有不同簽名的子程式,並且使用者會因為呼叫看似相同的子程式而得到不同的行為。因此,過載有時被稱為臨時多型性。[6]
現在我們已經消除了對繼承和多型性的困惑,讓我們繼續討論在實現多型性中常用的技術。但在我們這樣做之前,我們將列出我們演示中使用的示例類。
class B1 { ... public: virtual void f(int); void fs(int); ... ... } ; // end of base class B1 class B2 { ... public: virtual void f(int); virtual void g(void); ... ... } ; // end of base class B2 class D : public B1, public B2 { ... public: void f(int); ... ... }; // end of derived class D
vtables
[edit | edit source]vtable 技術通常用於 PC 世界的編譯器,它使用一個表,該表包含函式入口地址和偏移量對的行。所有至少有一個動態分派函式的類的物件都有一個指標,稱為vptr,指向該表的開頭。偏移列用於調整this指標的值。當派生類的物件透過基類的指標使用時,需要進行這種調整。以D的定義為例,其物件記憶體佈局如下所示。給定一個D的物件,我們可以透過D的指標以及D的任何祖先型別(在本例中為D*、B1*和B2*)的指標來使用它。換句話說,透過型別為B1*的變數,我們可以操作任何從B1派生的類的例項,在本例中為D和B1。因此,以下操作是可能的。
... D* d_obj = new D(...); B1* b1_obj = new B1(...); B2* b2_obj = new B2(...); ... fd(D* d_par) { ... d_par->f(...); d_par->g(...); ... } // end of ... fd(D*) ... fb1(B1* b1_par) { ... b1_par->f(...); ... ... } // end of ... fb1(B1*) ... fb2(B2* b2_par) { ... b2_par->f(...); b2_par->g(...); ... } // end of ... fb2(B2*) ... fd(d_obj) ...; ... fb1(b1_obj) ...; ... fb1(d_obj) ...; ... fb2(b2_obj) ...; ... fb2(d_obj) ...;
在派生類(D)中重寫了 f 函式,意味著呼叫這個版本的 f 函式可能會使用 B1、B2 和 D 中的所有屬性,這意味著該函式的接收物件必須至少是 D 物件。如前所述,這樣的物件也可以透過 B2* 指標來操作。這可以透過執行上面程式碼片段第 7 行,緊隨第 15 行的呼叫來體現。另外,在執行第 14 行的函式呼叫時,第 7 行的函式呼叫使用了一個 B2 物件。由於 b2_par 的型別是 B2*,兩種情況都透過 B2 物件的 vptr 欄位處理。然而,在一種情況下,這個物件是 D 物件的一部分,而在另一種情況下,它是一個普通的 B2 物件。如上圖所示。
B2 作為 D 物件的一部分需要我們注意。物件的起始地址(D)和用於操作該物件的指標(B2*)在記憶體中指示不同的位置。這要求為了能夠使用 D 物件的所有屬性,我們需要調整指標值,調整的位元組數與 B2 子物件之前的位元組數一樣,這在我們圖中被稱為 delta(B2)。
調整thunk
[edit | edit source]我們將要介紹的第二種技術可以被視為第一種技術的一種可移植性較差[7] 的最佳化。與 vptr 技術一樣,vtable 中的一列包含指向函式的指標。但我們現在使用 thunk 而不是調整列。如果需要對指標進行調整,vtable 中唯一一列中的指向函式的指標指向由編譯器生成的 thunk 程式碼,該程式碼會修改指標並跳轉到函式入口。如果沒有需要調整,指向函式的指標包含要呼叫的函式的入口地址。
多重繼承導致的複雜性
[edit | edit source]觀察多重[實現]繼承的要求使得多型性的實現變得複雜。如果我們只考慮單繼承,一個物件將始終只有一個 vptr,並且我們永遠不需要對其進行任何調整。
問題:在實現多重介面繼承時,我們是否會面臨相同的複雜性?作為起點,考慮實現多個介面或實現從其他介面多重繼承的介面的類的物件的記憶體佈局。
建構函式和解構函式的呼叫順序
[edit | edit source]物件構造涉及為物件分配記憶體並透過呼叫相關的建構函式來初始化其內容[以及根據需要獲取外部資源]。這套原子操作序列在三種情況下觸發
- 定義具有靜態範圍的變數:對於全域性變數,物件的記憶體是在編譯時在靜態資料區域分配的,並且建構函式的呼叫[即初始化和資源獲取]作為程式的第一條語句執行。如果有多個這樣的變數,則建構函式的執行順序與文字中相應的定義順序相同。靜態區域性變數在兩個方面有所不同:建構函式的呼叫是在第一次進入函式時執行一次,並且此呼叫不會修改函式中語句執行的順序。
- 定義塊變數:分配和初始化都在執行時進行,每次控制流到達變數定義的位置時。
- 使用 new 運算子建立物件:當控制流到達應用 new 運算子的語句時,物件被分配和初始化。
| 示例:具有靜態範圍的變數的構造順序。 |
|---|
|
|
| → |
In the constructor of C... // Global variable c_g First statement in f... In the constructor of C... // Static local variable c_sl Last statement in f... In main... First statement in f... Last statement in f... |
如果可能沒有程式設計師定義的建構函式,C++ 編譯器會合成一個預設建構函式,它會呼叫子物件的預設建構函式。對於具有基本型別欄位的物件,由於基本型別不支援建構函式的概念,這意味著永遠不會呼叫建構函式,這會導致新建立的物件處於隨機狀態。
作為構造的補充,物件的銷燬是透過呼叫解構函式來完成的,然後釋放物件的記憶體。解構函式(如果有)旨在釋放即將回收的物件正在使用的資源,包括堆記憶體和外部資源。
靜態變數的解構函式呼叫發生在程式的最後一條語句之後,而塊變數在定義它們的塊退出時被銷燬。[8] 在同一作用域中存在多個變數宣告的情況下,兩種變數的解構函式呼叫的順序與建構函式呼叫的順序相反。
具有基本型別欄位的物件
[edit | edit source]與 Java 和 C# 不同,在 Java 和 C# 中保證基本型別欄位具有特定的初始值,C++ 不會對這些欄位進行任何隱式初始化。換句話說,除非由程式設計師提供,否則這些欄位將保持未初始化狀態。
| 示例 |
|---|
|
|
| → |
... In the C1 constructor taking an int... i = 3 // for o3 In the default constructor of C1 // for o In the C1 constructor taking an int... i = 1 // for o1 In the C1 constructor taking an int... i = 2 // for o2 In the destructor of C1... i = 3 // for o3 In the destructor of C1... i = 2 // for o2 In the destructor of C1... i = 1 // for o1 In the destructor of C1... i = 138645 // for o ... |
在建立複合物件的過程中,對複合物件建構函式的呼叫先於對子物件的建構函式呼叫。除非在成員初始化列表中顯式地進行這些呼叫,否則子物件將使用預設建構函式進行初始化。
| 示例 |
|---|
|
|
| → |
... In the default constructor of C1 // for o._o In the default constructor of C2 // for o In the destructor of C2 // for o In the destructor of C1 // for o._o ... |
在有多個子物件的情況下,建構函式呼叫按它們在類定義中出現的順序進行。
| 示例:包含多個子物件的類 |
|---|
|
|
| → |
... In the default constructor of C1 // for o._o1 In the default constructor of C1 // for o._o2 In the default constructor of C3 // for o In the destructor of C3 // for o In the destructor of C1 // for o._o2 In the destructor of C1 // for o._o1 ... |
為了定義一組屬於相同型別的變數(陣列變數),需要初始化陣列中的每個元素。類似地,銷燬該陣列需要銷燬陣列中的每個元素。
| 示例 |
|---|
|
|
| → |
... In the default constructor of C1 // for o[0]._o In the default constructor of C2 // for o[0] In the default constructor of C1 // for o[1]._o In the default constructor of C2 // for o[1] In the destructor of C2 // for o[1] In the destructor of C1 // for o[1]._o In the destructor of C2 // for o[0] In the destructor of C1 // for o[0]._o ... |
| 示例:包含子物件陣列的類 |
|---|
|
|
| → |
... In the default constructor of C1 // for o._o[0] In the default constructor of C1 // for o._o[1] In the default constructor of C4 // for o In the destructor of C4 // for o In the destructor of C1 // for o._o[1] In the destructor of C1 // for o._o[0] ... |
| 示例 |
|---|
|
|
| → |
... In the default constructor of C1 // for o._o[0]._o In the default constructor of C2 // for o._o[0] In the default constructor of C1 // for o._o[1]._o In the default constructor of C2 // for o._o[1] In the default constructor of C5 // for o In the destructor of C5 // for o In the destructor of C2 // for o._o[1] In the destructor of C1 // for o._o[1]._o In the destructor of C2 // for o._o[0] In the destructor of C1 // for o._o[0]._o ... |
繼承
[edit | edit source]將繼承視為“編譯器管理的組合”是弄清楚建構函式和解構函式呼叫順序的關鍵。其他方面都相同。
| 示例 |
|---|
|
|
上面片段的第一行可以被認為是由編譯器轉換成下面這樣。注意識別符號名稱是任意的,不能以任何方式在類實現中引用。
類 IC1 { public: C1 _c1_part; private:
| → |
... In the default constructor of C1 // for C1 part of o1 In the default constructor of IC1 // for o1 In the destructor of IC1 // for o1 In the destructor of C1 // for C1 part of o1 ... |
| 示例:繼承和組合。 |
|---|
|
|
| → |
... In the default constructor of C1 // for C1 part of o1 In the default constructor of C1 // for o1._o In the default constructor of IC2 // for o1 In the destructor of IC2 // for o1 In the destructor of C1 // for o1._o In the destructor of C1 // for C1 part of o1 ... |
成員初始化列表
[edit | edit source]除非另有說明,所有隱式建構函式呼叫都將呼叫預設建構函式。此行為可以透過將成員初始化列表附加到建構函式函式頭來更改。
| 示例 |
|---|
|
|
為了教學目的,上面片段的第 4 行可以看成下面這樣。但是,因為它用兩個初始化替換了兩個“初始化後跟賦值”的序列,所以使用成員初始化列表是一個更有效的選擇。這是因為——即使您沒有成員初始化列表——子物件的建構函式將在複合物件的建構函式之前呼叫,這意味著以下片段中的兩行實際上是賦值,而不是初始化。[9] 在執行之前,每個子物件都將已經使用 C1 的預設建構函式進行初始化。
IC3(int i) { _c1_part = C1(1); _o = C1(1);
| → |
... In the C1 constructor taking an int // for C1 part of o1 In the C1 constructor taking an int // for o1._o In the IC3 constructor taking an int // for o1 In the destructor of IC3 // for o1 In the destructor of C1 // for o1._o In the destructor of C1 // for C1 part of o1 ... |
多重繼承
[edit | edit source]基於我們對繼承的非正式定義,即編譯器管理的組合,我們可以將多重繼承類的物件視為由多個子物件組成。因此,為了教學目的,我們可以相應地將下面片段的第 1 行視為以下內容。
類 IC4 { public: C1 _c1_part; C2 _c2_part;
| 示例 |
|---|
class IC4 : public C1, public C2 {
public:
IC4(void) { cout << "In the default constructor of IC4" << endl; }
~IC4(void) { cout << "In the destructor of IC4" << endl; }
}; // end of class IC4
...
{
IC4 o1;
...;
}
...
|
| → |
... In the default constructor of C1 // for C1 part of o1 In the default constructor of C1 // for _o of the C2 part of o1 In the default constructor of C2 // for C2 part of o1 In the default constructor of IC4 // for o1 In the destructor of IC4 // for o1 In the destructor of C2 // for C2 part of o1 In the destructor of C1 // for _o of the C2 part of o1 In the destructor of C1 // for C1 part of o1 ... |
重新說明公式:繼承是編譯器管理的組合
[edit | edit source]以下列出了三對等效的類定義,旨在讓您瞭解編譯器在幕後完成的工作。在瀏覽程式碼時請記住,右欄中給出的程式碼只反映了編譯器的行為,而不是其工作方式。
類 SC1 { ...; public: ...; void SC1_f1(...); void SC1_f2(...); ...; }; // 類 SC1 結束
公有繼承允許透過派生類的介面使用基類的介面。程式設計師無需任何額外操作,編譯器會自動處理。如果您出於某種原因想在不使用繼承的情況下實現,則必須顯式地公開基類的函式,並將對這些函式的呼叫委託給基類中的對應函式。透過私有繼承和選擇性公開基類介面,C++ 程式設計師可以減輕這種額外負擔。
|
|
|
私有派生意味著基類中的功能無法透過派生類物件訪問。但是,仍然可以在實現派生類介面中找到的函式時利用這種功能。
|
|
|
有時可能需要將兩種情況混合使用。也就是說,基類中的一部分功能是可見的,而其餘部分必須隱藏起來。這種選擇性公開可以透過私有繼承和 using 宣告組合來實現。
|
|
|
虛繼承
[edit | edit source]虛擬基類的物件總是在非虛擬類的物件之前構造。需要注意的是,“虛擬性”實際上是派生的屬性,而不是基類本身的屬性。
| 示例 |
|---|
|
|
| → |
... In the default constructor of C1 In the default constructor of VC2 In the destructor of VC2 In the destructor of C1 ... |
| 示例 |
|---|
|
|
| → |
... In the default constructor of C1 In the default constructor of VC2 In the default constructor of VC3 In the default constructor of VC4 In the destructor of VC4 In the destructor of VC3 In the destructor of VC2 In the destructor of C1 ... |
- ↑ 另一個選擇是利用 Java 註解的程式碼生成方面。
- ↑ 唯一缺少的是垃圾回收。智慧指標將在實現部分介紹,並提供部分解決方案。
- ↑ 重用通常指程式碼重用。然而,分析和設計文件、程式程式碼、測試用例和測試程式碼都是可以重用的。事實上,軟體生產過程早期階段的工件重用對生產率的影響更大。
- ↑ 我們可以透過在介面中定義公共訊息協議,並讓類實現這個介面來實現相同的結果。
- ↑ 注意,再一次是動態排程使魔法起作用。直到程式執行並執行第 6 行程式碼時,才知道
compare訊息將被分派到哪個方法。在樣本 C 程式章節的回撥部分中,等效 C 程式碼中使用指向函式的指標證明了這一點。 - ↑ 與其他使用“多型性”一詞的語境不同,函式呼叫——透過將引數與函式的簽名匹配——在編譯時或連結時被解析,因此是靜態分派。
- ↑ 例如,某些體系結構不允許 goto 指令進入另一個函式的主體。
- ↑ 動態分配物件的解構函式呼叫發生在使用
delete的相關應用程式的點上。 - ↑ 這與我們對建構函式的定義相矛盾,我們的定義指出建構函式用於初始化物件。就 C++ 而言,在建構函式中發生的是賦值;初始化是使用成員初始化列表進行的。