使用 C 和 C++ 的程式語言概念/C++ 中的面向物件程式設計
最初稱為 C-with Classes,C++ 將類概念融入到語言中。這是一個好訊息:我們在 C 中必須採用的某些約定和程式設計實踐現在由編譯器強制執行。我們不再需要包含抽象資料型別 [預期] 介面的標題檔案,現在我們在標題檔案中有一個類定義,它仍然旨在包含介面;實現細節 - 例如實用函式和物件的結構 - 現在被列為類定義的私有部分,而不是將它們的定義推遲到實現檔案;類似建構函式的函式被真正的建構函式取代,這些建構函式是由編譯器生成的程式碼隱式呼叫的。
上面的列表可以擴充套件其他示例。但要點仍然相同:面向物件程式設計在 C++ 中要容易得多。唯一的陷阱是屈服於過程正規化的簡單性,並將 C++ 程式碼編寫為普通的舊 C。除此之外,以下介紹對於入門者來說應該是不言自明的。[1]
#ifndef COMPLEX_HXX
#define COMPLEX_HXX
#include <cmath>
#include <iostream>
using namespace std;
namespace CSE224 {
namespace Math {
class Complex {
對類成員的訪問限制由類主體內的標記為 public、private 和 protected 的部分指定。關鍵字 public、private 和 protected 稱為訪問說明符。
- 一個
public成員是可訪問的 - 無論參考點如何 - 來自程式中的任何位置。對資訊隱藏的正確強制執行將類public成員限制為可以由通用程式用於操縱類型別物件的函式。 - 一個
private成員只能由其類的成員函式和友元訪問。一個強制資訊隱藏的類將其資料成員宣告為private。 - 一個
protected成員對派生類及其類的友元表現得像public成員,對程式的其餘部分表現得像private成員。
一個類可以包含多個 public、protected 和 private 部分。每個部分都保持有效,直到出現另一個部分標籤或類主體的結束右大括號為止。如果未指定訪問說明符,預設情況下,緊隨類主體開始左大括號之後的段是 private。
public:
定義:預設建構函式是在沒有使用者指定引數的情況下可以呼叫的建構函式。
在 C++ 中,這並不意味著它不能接受任何引數。這意味著每個建構函式引數都關聯一個預設值。例如,以下每個都代表一個預設建構函式
Complex::Complex(double realPart = 0.0, double imPart = 0.0) { ... } Stack::Stack(int initialCapacity = 1) { ... } List::List(void) { ... }
在 C++ 中,可以呼叫一個引數的建構函式,它充當轉換運算子。它有助於我們提供從建構函式引數型別的 value 到類物件的隱式轉換,這將在 C++ 編譯器在稱為函式過載解析的過程中使用的過程中使用。
在當前類中,將一個 double 傳遞給建構函式將將其轉換為一個虛部為零的 Complex 物件。一個非常方便的工具!畢竟,實數不是沒有虛部的複數嗎?但是,這種方便有時會變成難以發現的錯誤。例如,當傳遞一個單獨的 double 引數時,以下建構函式將充當從 double 到 Complex 的轉換運算子。它很可能產生意外的結果。
Complex::Complex(double imPart = 0.0, double realPart = 0.0) { ... }
當使用一個引數呼叫時,此建構函式將建立一個實部設定為零的 Complex 物件。也就是說,Complex(3.0) 將對應於 3i,而不是 3!為了避免這種不必要的轉換,同時保持引數列表不變,您必須使用 explicit 關鍵字修改簽名,如下所示。
explicit Complex::Complex(double impart = 0.0, double realPart = 0.0) { ... }
這種使用會停用透過該建構函式的隱式轉換。請注意,explicit 關鍵字只能應用於建構函式。
定義:由編譯器完成的隱式型別轉換稱為強制轉換。
由於透過以下建構函式進行了這種隱式轉換,因此,每當一個函式或一個運算子接受一個 Complex 物件時,我們將能夠傳遞一個型別為 double 的引數。例如,請檢視第 12 行的函式簽名。除了將一個 Complex 物件分配給另一個物件外,此運算子現在還允許將一個 double 分配給一個 Complex 物件。因為這個 double 值首先 [隱式] 轉換為一個 Complex 物件,然後執行此結果物件的分配。[2]
Complex(double = 0.0, double = 0.0);
定義:複製建構函式使用第二個的副本初始化一個物件。通常,它接受一個指向該類 const 物件的引用的形式引數。
請注意,這個建構函式與之前的建構函式一樣,沒有返回型別。這不是打字錯誤!類的建構函式(以及解構函式,如果有的話)不能指定返回型別,即使是 void。
Complex(const Complex&);
除了函式名過載之外,C++ 還為程式設計師提供了運算子過載功能。過載的運算子允許將類型別的物件與 C++ 中定義的內建運算子[3] 一起使用,從而使它們的操縱與內建型別的操縱一樣直觀。
為了過載運算子,必須定義一個具有特殊名稱的函式,該名稱由在運算子符號之前新增 operator 一詞構成。應該牢記,運算子的元數、優先順序和結合性不能改變。對於一元運算子,接收訊息的物件對應於運算子的唯一運算元;對於其餘運算子,運算元對應關係以從左到右的方式建立。例如,根據以下宣告,接收物件對應於賦值的左側,而[顯式]形式引數對應於右側。
Complex& operator=(const Complex&);
沒錯!與 C 不同,C++ 對布林值有型別支援。不幸的是,它[C++] 繼續支援 C 樣式的布林表示式語義。換句話說,您仍然可以使用整數值代替布林值。但話說回來,您可以成為一個好孩子,開始使用 bool 型別的變數。
bool operator==(const Complex&);
Complex operator+(const Complex&);
如果過載賦值和加法運算子,當前類的使用者會自然地尋找相應的過載複合賦值運算子,+=。
Complex& operator+=(const Complex&);
Complex operator-(const Complex&);
Complex& operator-=(const Complex&);
將顯式引數修改為常量不是什麼大問題。只需在該引數位置之前插入 const 關鍵字,就完成了。但是,如果我們想使接收物件——即正在向其傳送訊息的物件——成為常量呢?這個作為隱式引數傳遞給函式的引數,不能以類似的方式修改。以下簽名是對此的示例:將 const 關鍵字放在引數列表的右括號之後,它將被視為代表接收物件。
程式設計師可以定義——即提供函式體——類的成員函式,無論是在類內部(在標頭檔案中)還是在類外部(在實現檔案中)。在標頭檔案中提供函式體可能不是一個好主意,尤其是在它暴露實現細節的情況下[因為這意味著違反資訊隱藏原則]。然而,對於像接下來的兩個函式一樣簡單的函式,這不是一個壞主意。
double real(void) const { return(_re); }
double imag(void) const { return(_im); }
private:
double _re, _im;
}; // end of Complex class definition
接下來的三個函式不是 Complex 類的成員。[4] 它們是為補充類定義而提供的。證明相等性測試運算子的情況也應該說服我們關於其他兩個運算子的情況。對於兩種可能型別的兩個變數,我們有四種相等性測試組合。兩個 Complex 物件的相等性測試以及[由於透過充當使用者定義的轉換函式的建構函式提供的轉換]一個 Complex 和一個 double 被提供為類成員函式。兩個 double 的相等性測試由編譯器提供。剩下的我們需要進行的測試是在一個 double 和一個 Complex 數字之間進行的測試。這當然不是由編譯器提供的。它也不能作為類成員函式提供。因為左側運算元是 double,我們知道在 Complex 類定義中,this——隱式引數——是指向 Complex 物件的常量指標。因此,我們需要遵循不同的路徑來提供此功能:一個普通的全域性函式,它接受一個 double 和一個 Complex 數字作為引數。
extern bool operator==(double, const Complex&);
extern Complex operator+(double, const Complex&);
extern Complex operator-(double, const Complex&);
一個行內函數在其每次呼叫點都被擴充套件到程式中,從而消除了與函式呼叫相關的開銷。因此,只要函式被呼叫足夠多次,它就可以提供顯著的效能提升。
在類定義中定義的成員函式——例如 real 和 imag——預設情況下是內聯的,這樣的函式不需要進一步指定為內聯。另一方面,在類體外部定義的成員函式或任何全域性函式[5],必須在定義點透過在函式原型之前新增 inline 關鍵字來指定它,並且應該包含在包含類定義的標頭檔案中。
請注意,內聯規範只是一個對編譯器的建議。編譯器可以選擇忽略此建議,因為宣告為內聯的函式不是在呼叫點進行擴充套件的良好候選者。遞迴函式不能在呼叫點完全擴充套件。同樣,大型函式很可能會被忽略。通常,內聯機制旨在最佳化小型、直線型、頻繁呼叫的函式。
請注意訪問器的使用,這實際上與我們透過內聯來產生更快程式碼的意圖相矛盾。直接訪問欄位,而不是透過訪問器函式,會更快,並且與下一行上的 inline 關鍵字更加一致。[6] 然而,這是不可能的。物件欄位被宣告為——正如預期的那樣——private,這意味著類外部的任何人,包括同一檔案中其他類的程式碼,都不能操作它。嗯,事實上,有一個例外。透過宣告某些函式和/或類透過friend 機制擁有特殊權利,可以獲得對類的內部結構的直接訪問。在異常處理章節中將對此進行更多介紹。
inline ostream& operator<<(ostream& os, const Complex& rhs) {
double im = rhs.imag(), re = rhs.real();
if (im == 0) {
os << re;
return(os);
} // end of if (im == 0)
if (re == 0) {
os << im << 'i';
return(os);
} // end of if (re == 0)
os << '(' << re;
os << (im < 0 ? '-' : '+' << abs(im) << "i)";
return(os);
} // end of ostream& operator<<(ostream&, const Complex&)
} // end of namespace Math
} // end of namespace CSE224
#endif
實現
[edit | edit source]#include <iostream>
using namespace std;
#include "math/Complex"
namespace CSE224 {
namespace Math {
在某些情況下,C++ 編譯器會隱式呼叫預設建構函式。這些是
- 基於堆的陣列的所有元件都將使用元件類的預設建構函式進行初始化。
- 如果在成員初始化列表中沒有提供顯式建構函式呼叫,則繼承的子物件將使用基類的預設建構函式進行初始化。
- 如果在成員初始化列表中沒有提供顯式建構函式呼叫,則構成物件的非原始欄位將使用它們的預設建構函式進行初始化。
為此,作為設計過程的一部分,應該始終認真考慮是否需要預設建構函式。
由於預設引數提供的靈活性,接下來的三行都使用相同的建構函式。
Complex c(3, 5); // c 被初始化為 3.0 + 5.0i Complex real3(3); // real3 被初始化為 3.0 + 0.0i Complex zero; // zero 被初始化為 0.0 + 0.0i
正如預期的那樣,C++ 會將 int 型別的實際引數強制轉換為 double,並將它們傳遞給相應的建構函式。
Complex::
Complex(double realPart = 0.0, double imPart = 0.0) {
_re = realPart;
_im = imPart;
} // end of Complex::Complex(double, double)
如果沒有提供複製建構函式,編譯器將透過呼叫每個例項欄位的複製建構函式來複制物件。對於原始型別和指標型別,這意味著按位複製欄位。
定義:所有欄位都按位複製的類被稱為支援淺複製。
對於我們的示例,淺複製已經足夠了。[7] 然而,在物件例項欄位[8] 指向其他物件或需要跳過或特殊處理的情況下,例如密碼欄位,我們可能需要比淺複製更多的操作。考慮列表的連結實現。只複製頭和尾指示器就足夠了嗎?還是我們必須複製專案?答案是:取決於。如果您希望共享相同的列表,那麼淺複製是可行的。否則,您需要覆蓋編譯器的預設行為。一旦覆蓋,編譯器將始終在需要進行復制時使用此函式。
只要您按值傳遞引數,這個建構函式就會被隱式呼叫。請記住,按值傳遞的引數會在執行時堆疊上被複制。對於從函式返回物件也是如此,這需要在相反的方向上進行復制。這種複製行為將透過複製建構函式完成。因此,您應該花些時間決定是否需要這樣的建構函式。
Complex::
Complex(const Complex& rhs) {
_re = rhs._re;
_im = rhs._im;
} // end of Complex::Complex(Complex&)
與其他非靜態成員函式一樣,過載運算子接受指向正在應用該函式的物件的指標。也就是說,給定宣告
Complex c1(3), c2, result; ... c1 + c2…
可以看作
... c1.operator+(c2) ...
可以進一步看作
operator+(&c1, c2)
相應的函式定義有一個隱式的第一個形式引數。這個名為 this 的形式引數可以用來引用正在傳送訊息的物件。因此,給定函式定義
ClassName::FunctionName(Formal-Par-List) { ... }
編譯器在內部呼叫函式
FunctionName(ClassName *const this, Formal-Par-List) { ... }
無論何時
c1.FunctionName(Actual-Par-List);
在程式碼中使用,將其轉換為
FunctionName(&c1, Actual-Par-List);
注意,宣告為常量的是指標,而不是指標指向的記憶體內容。程式設計師可以直接或間接地改變物件記憶體,但不能改變函式正在應用的物件。
以下函式定義過載了賦值運算子。這種運算子用於用另一個物件的內容修改已經存在的物件。[9] 除非提供過載版本,否則編譯器——類似於複製建構函式的情況——將呼叫物件的每個欄位的預設賦值運算子。否則,它將使用你提供的其中一個函式。至於是否需要過載,複製建構函式情況下的考慮因素適用,並且應該特別注意做出正確的決定。
我們使用 Complex& 作為函式的返回值 [和引數] 型別的原因是為了以最經濟和最簡單的方式促進賦值運算子的級聯。[10] 例如,可以有一個像這樣的語句
a = b = c;
該語句首先將 c 賦值給 b,然後將 b 賦值給 a。也就是說,b 將首先用作傳送賦值訊息的物件,然後用作傳送到 a 的另一個賦值訊息的引數。
Complex& Complex::
operator=(const Complex& rhs) {
接下來的兩行可以不使用關鍵字 this 編寫。因為所有對物件記憶體的非限定引用都被認為屬於 this 指向的物件。因此,它可以改寫為
_re = rhs._re; _im = rhs._im;
還要注意 -> 的使用。這是另一個表明這不是接收物件本身,而是一個指向它的常量指標的事實。
this->_re = rhs._re;
this->_im = rhs._im;
return(*this);
} // end of Complex::Complex& operator=(const Complex&)
bool Complex::
operator==(const Complex& rhs) {
return(this->_re == rhs._re && this->_im == rhs._im);
} // end of bool Complex::operator==(const Complex&)
Complex Complex::
operator+(const Complex& rhs) {
return(Complex(_re + rhs._re, _im + rhs._im));
} // end of Complex Complex::operator+(const Complex&)
Complex& Complex::
operator+=(const Complex& rhs) {
this->_re += rhs._re;
this->_im += rhs._im;
return(*this);
} // end of Complex& Complex::operator+=(const Complex&)
Complex Complex::
operator-(const Complex& rhs) {
return(Complex(_re - rhs._re, _im - rhs._im));
} // end of Complex Complex::operator-(const Complex&)
Complex& Complex::
operator-=(const Complex& rhs) {
this->_re -= rhs._re;
this->_im -= rhs._im;
return(*this);
} // end of Complex& Complex::operator-=(const Complex&)
bool operator==(double lhs, const Complex& rhs) {
return(rhs.imag() == 0 && rhs.real() == lhs)
} // end of bool operator==(double, const Complex&)
Complex operator+(double lhs, const Complex& rhs) {
return(Complex(lhs + rhs.real(), rhs.imag()));
} // end of Complex operator+(double, const Complex&)
Complex operator-(double lhs, const Complex& rhs) {
return(Complex(lhs - rhs.real(), -rhs.imag()));
} // end of Complex operator-(double, const Complex&)
} // end of namespace Math
} // end of namespace CSE224
函式過載解析
[edit | edit source]如標頭檔案中預設建構函式簽名之前的註釋所述,函式過載解析是指找出要呼叫的函式。一個以三種可能結果之一結束的過程——成功、歧義和無匹配函式——函式呼叫在三個步驟中解析。
候選函式的識別
[edit | edit source]函式過載解析的第一步涉及識別與被呼叫的函式同名且在呼叫點可見的函式。這組函式也稱為候選函式。空集的候選函式會導致編譯時錯誤。
使用名稱空間中定義的型別作為引數型別和/或從名稱空間匯入識別符號可能會導致此集的大小增加。
| 示例:引入名稱空間的影響。 |
|---|
|
|
最後一行上的函式呼叫將導致一個大小為三的集合: ns::f(ns::Type), f(int) 和 f(double)。如果沒有使用指令 ns::f(ns::Type) 將不可見,因此不會包含在候選函式集中。但是,用以下程式碼序列替換 using 指令將再次導致該函式包含在集中。
... ns::Type t; ... f(t); ...
如將在繼承章節中討論的那樣,根據語言的不同,引入新的範圍可能會以不同的方式影響候選函式集。例如,在 Java 中,新集合是透過取舊集合和新範圍引入的集合的並集形成的;如果簽名相同,則新範圍的方法會隱藏舊範圍中的方法。但是,在 C++ 中,具有與舊範圍內找到的方法衝突的名稱的函式將替換具有該名稱的所有函式簽名。
| 示例:Java 中繼承對候選函式集的影響。 |
|---|
|
|
|
類 D 物件的使用者可以使用三種方法,它們都名為 f: B.f(int), B.f(int, int) 以及 D.f(String)。 |
選擇可行的函式
[edit | edit source]第二步是選擇第一階段形成的非空集合中的可呼叫函式。 這需要消除不匹配引數數量和型別 的函式。 此階段結束時獲得的函式集稱為可行函式。 可行函式為空意味著沒有匹配函式,並導致編譯時錯誤。
請注意,我們在這裡不是在尋找完美的匹配。 就引數的數量而言,預設值會增加該集合的大小。 例如,具有單個引數的函式呼叫不僅可以定向到具有單個引數的函式,還可以定向到具有n個引數的函式,其中除了第一個引數外,所有引數都保證具有預設值。 同樣,對於引數型別,也考慮了可以應用於引數的轉換。 作為這個的示例,一個 char 引數可以傳遞給型別為 char, short, int 等等的對應引數。
| 示例:C++ 中選擇可行函式 |
|---|
void f(int i, int i2 = 0) { ... }
void f(char) { ... }
void f(double d, float f) { ... }
void f(Complex c) { ... }
...
short s;
...
f(s);
...
|
在第 8 行呼叫 f 將導致一個大小為 2 的集合: f(char) 和 f(int, int=)。 如果 Complex 類可能有一個建構函式,它可以用於將 short 轉換為 Complex 物件,該集合的大小將增加到 3。 |
對於來自更安全的語言(如 Java 或 C#)的程式設計師來說,將 f(char) 包含在此集合中可能會令人驚訝。 由於這是一個縮窄轉換,將 short 引數傳遞給 char 引數可能會導致資訊丟失,因此這些語言認為這是違反了約定。 為了使這種情況發生,必須將引數顯式轉換為 char。 然而,在 C++ 中卻完全不同。 C++ 編譯器將很樂意考慮這一點——不顯式轉換引數——作為節省按鍵的活動,並將其接受為可行函式。 如果恰好是唯一可行的函式,則將呼叫該函式呼叫來分派到 f(char)。
引數轉換
[edit | edit source]這將我們帶到可應用於引數的可能轉換的話題。 除了精確匹配[11] 之外,C++ 編譯器還會透過對引數應用一系列轉換來擴充套件可行函式的集合。 這些可以分為兩個不同的類別:精確匹配和型別轉換。
精確匹配轉換是較小的轉換,可以進一步分為四個子組
- 左值到右值轉換:將引數傳遞視為賦值的特殊情況,其中引數被分配給對應引數,此轉換基本上獲取引數中的值並將其複製到引數中。
- 陣列到指標轉換:陣列作為指向其第一個元素的指標傳遞。 請注意,此轉換和以下轉換基本上是 C/C++ 語言的一部分。
- 函式到指標轉換:類似於上一項,作為引數傳遞的函式識別符號將轉換為指向函式的指標。
- 限定符轉換:僅適用於指標型別,此轉換透過使用
const[12] 和volatile修飾符中的一個或兩個來修改普通指標型別。 應該注意的是,如果引數傳遞給具有相同型別的引數,但具有一個或兩個這些修飾符,則不會進行任何型別轉換。
| 示例:C++ 中的精確匹配轉換。 |
|---|
int ia[20];
int *ia2 = new int[30];
...
void f(int *arr) { ... } // could have been void f(int arr[]) { ... }
void g(const int i) { ... }
...
f(ia); // won't get any treatment different than f(ia2). Both will be ranked equal
...
g(ia[3]); // will be dispatched to g(const int) without any [qualification] conversion
|
在第 7 行,陣列引數被轉換為並作為指向其第一個元素的指標傳遞。 在第 9 行,非常量引數按值傳遞給相同型別的常量引數。 由於使用按值呼叫傳遞初始值的任何對引數的更改都不會反映回引數,並且常量引數永遠不會改變——更不用說改變並將其反映到對應引數——將 int 引數傳遞給 const int 引數根本不需要任何轉換。 |
引數轉換的第二類,型別轉換,可以分為兩組:提升和標準轉換。 前者組,也稱為拓寬轉換,包含可以在沒有資訊丟失的情況下執行的轉換。 這些包括以下內容
- 型別為
bool,char,unsigned char和short的引數被提升為int。 如果使用的編譯器支援int的更大尺寸,則unsigned short也被提升為int。 否則,它被提升為unsigned int。 - 型別為
float的引數被提升為double。 - 列舉型別被提升為
int,unsigned int,long或unsigned long之一。 透過選擇可以表示列舉中所有值的最小可能型別來做出決定。
型別轉換的第二組,標準轉換,分為五種
int到long的轉換以及縮窄整數轉換。double到float的轉換。- 在浮點型別和整數型別之間進行的轉換,例如
float到int,int到double以及char到double。 - 將 0 轉換為指標型別以及將任何指標型別轉換為
void*。 - 從整數型別、浮點型別、列舉型別或指標型別轉換為
bool的轉換。
請注意,這份冗長的規則列表包含了許多由於 C++ 的“底層”性質和系統程式設計方面而繼承自 C 的細節。例如,由於 C 語言沒有型別來儲存邏輯值,因此它採用了一種在底層程式設計中常用的約定:零表示假,而任何其他值都被解釋為真。因此,從其他型別到 bool 的轉換。類似地,體系結構往往為字長資料提供了更好的支援,在 C/C++ 中被稱為 int。[13] 一個典型的例子是在將資料壓入硬體堆疊時進行的調整量。除非編譯器將它們打包,否則所有壓入的資料(讀作“傳遞的所有引數”),小於或等於字長(讀作 int)的資料都將被調整到字邊界。這基本上意味著所有這樣的資料將被擴充套件到字長,這解釋了提升的第一項。再加上與環境相關的整型大小和由兩種版本(signed 和 unsigned)的存在引入的複雜性,您就可以理解為什麼它突然變成了一個噩夢。
| 示例:Java 中的注意事項。 |
|---|
| ConversionInJava.java |
|
|
編譯並執行此程式會導致 f(int) 中的訊息輸出到標準輸出。因為在 Java 中,引數始終會被提升到最接近的型別。 |
在轉換過程中,C++ 編譯器可以應用兩個序列中的任何一個。在第一個序列中,稱為“標準轉換序列”,允許應用零個或一個完全匹配轉換(除了限定符轉換外),然後應用零個或一個提升或標準轉換,之後可以再應用零個或一個限定符轉換。第二個序列涉及應用使用者定義的轉換函式,該函式可以在標準轉換序列之前和之後應用。如果需要,可以應用兩次這個序列。
查詢最佳匹配
[edit | edit source]在解析函式呼叫的最後一步,C++ 編譯器會選擇最佳匹配的可行函式。在確定最佳匹配時,使用兩個標準:應用於最佳匹配函式引數的轉換不比呼叫任何其他可行函式所需的轉換更糟糕;對某些引數的轉換比呼叫其他可行函式時對相同引數所需的轉換更好。如果最終沒有這樣的函式,則呼叫被稱為不明確,並會導致編譯時錯誤。
在查詢最佳匹配時,編譯器會對之前步驟中獲得的可行函式進行排名。根據這種排名,完全匹配轉換比提升更好,而提升比標準轉換更好。可行函式的排名由用於將引數轉換為對應引數的最低級別轉換的排名決定。
測試程式
[edit | edit source]接下來的程式,除了提供函式過載解析過程的示例外,還表明在 C++ 中,可以在所有三個資料區域中建立物件。在某些面向物件的程式語言中,例如 Java,物件始終在堆中建立。Java 的這種“限制”,或者換句話說,C++ 提供的這種“自由”,可以歸因於語言設計理念。作為 C 的直接後代,C++ 提供了替代方案,並期望程式設計師選擇正確的方案。同樣作為 C 的後代,儘管距離更遠,但 Java 往往提供了一個更簡單的框架,替代方案更少。
在這種情況下,C++ 為程式設計師提供了無需使用指標的替代方案。與 C 的 struct 中的變數或 C# 中的值型別的物件一樣,可以直接操作類物件。換句話說,透過控制代碼間接地操作物件並不是唯一的選擇。這意味著物件佔用的空間更少。然而,多型性(因此面向物件)不再是一個選項。畢竟,多型性要求根據物件的動態型別,將相同的訊息分派給可能不同的子程式定義,這意味著我們應該能夠使用相同的識別符號來引用不同型別的物件。這進一步意味著由識別符號指示的物件所需的記憶體可能會發生變化。由於靜態資料區域處理的是固定大小的資料,因此我們不可能將物件放在程式記憶體的這一部分。類似地,執行時堆疊上分配的記憶體大小應該事先為編譯器所知,執行時堆疊也不在考慮範圍內。我們必須想出一個讓雙方都滿意的解決方案:編譯器獲得一個固定大小的實體,同時滿足了繼承的變數大小物件的要求。這是透過在堆上建立物件來實現的,這是唯一剩下的地方,並透過中間體對其進行操作。這就是物件控制代碼的用武之地!
因此,只有在堆上建立物件並透過指標進行操作時,才有可能啟用多型性。這就解釋了為什麼 Java(像任何其他宣稱是面向物件的程式語言一樣)以這種方式建立物件。但它沒有提供任何關於為什麼沒有提供以 C++ 方式建立物件的任何解釋。回答另一個問題——當我們以 C++ 方式建立物件時會獲得什麼好處——將提供解釋:我們獲得了更快、基於物件的(而不是面向物件的)解決方案。由於多型性不再是一個選項,因此動態分派也不再需要,所有函式呼叫都可以進行靜態分派,順便說一下,這是 C++ 中的預設方式。但之後,隨著所有這些正規化的出現,事情開始變得有點混亂。除了程序式程式設計(感謝其 C 遺產)和麵向物件程式設計之外,我們現在還有基於物件程式設計。更糟糕的是,C++ 中的預設程式設計模式(由於預設分派型別是靜態的)是基於物件的。在 Java 中,預設分派型別是動態的,因此預設程式設計正規化是面向物件的。這意味著程式設計師期望進行一些面向物件程式設計,不會被替代方案的存在所迷惑;她不需要告訴編譯器她想進行面向物件程式設計。再加上在實現開閉原則[14] 時利用面向物件正規化,這似乎是生成可擴充套件軟體的更安全選擇。
#include <iostream>
#include <iomanip>
using namespace std;
#include "math/Complex"
using CSE224::Math::Complex;
以下程式碼行在靜態資料區域建立了一個 Complex 類的物件。因此,該物件將在程式執行的整個過程中存在,其分配/釋放將由編譯器負責。這行程式碼可以寫成 Complex c(5); 或 Complex c = Complex(5);。
注意,第二種形式只有在我們將一個引數傳遞給建構函式時才有可能。
Complex c = 5;
int main(void) {
接下來的四次例項化在執行時堆疊上建立了四個 Complex 物件。每次呼叫子程式或進入一個塊時,子程式/塊的區域性物件將被建立並分配到執行時堆疊上。從子程式/塊退出時,物件將透過更改堆疊指標的值(指向執行時堆疊上的最頂層幀)自動釋放。因此,區域性物件的生存期僅限於它們定義的塊。[15]
注意第四個物件是透過使用複製建構函式建立的。透過此語句,c3 和 c4 都具有相同的物件記憶體配置。但請注意,它們不是同一個物件。
Complex c1(3, -5), c2(3), c3(0, 5), c4(c3);
cout << "c: " << c << "c1: " << c1 << " c2: " << c2;
cout << " c3: " << c3 << " c4: " << c4 << endl;
double d = 3;
int i = 5;
cout << "d: " << d << " i: " << i << endl << endl;
cout << "d + c1 = " << d + c1 << endl;
下一行程式碼乍一看可能像一個編譯時錯誤。畢竟,沒有函式可以將一個 Complex 物件和一個 int 相加。如前一節所述,int 透過提升為 double 進行轉換,並且執行 Complex 物件與 double 的加法運算。但你可能會說:我找不到這樣的函式!由於帶預設引數的建構函式,這個被提升為 double 的 int 值後來被傳遞給該建構函式,並構造了一個 Complex 物件。現在,已經定義了兩個 Complex 物件的加法運算,因此請求得到了滿足。
cout << "c1 + i = " << c1 + i << endl;
下一行程式碼在自由儲存區(堆)上建立了一個 Complex 類的物件。這是透過使用 new 運算子讓編譯器知道的,該運算子還表示建立的物件將由程式設計師管理。
Classname *ptr = new ClassName(parList);
等同於以下偽 C++ 程式碼
ptr = Classname::operator new(sizeof(ClassName)); ClassName::ClassName(ptr, parList);
換句話說,new 運算子首先分配[16] 物件所需的區域,然後隱式呼叫相應的建構函式進行初始化。
完成下一個物件建立後,我們將得到如下所示的部分記憶體映像。請注意,物件已在所有三個資料區域中建立。

不要將指標與指標指向的儲存空間混淆。雖然指標隨著子程式的呼叫和返回而出現和消失,但指標指向的記憶體區域(如果它們已在堆區域中分配)可能比子程式的呼叫時間更長。這是因為此類區域由程式設計師管理,她可以在任何認為合適的時間返回它。因此,讓我們再次重申:動態管理的不是指標本身,而是指標指向的記憶體區域。
Complex *result = new Complex;
cout << "*result = i - c1 + c2 - d = ";
假設從左到右的求值順序(出於教學目的),下一行程式碼將按如下方式執行
i被提升為double。- 使用
operator-(double, const Complex&)從i中減去c1,此時i是一個double值。在此過程中,c1首先從Complex轉換為const Complex。 - 使用
Complex::operator+(const Complex&)將c2加到步驟 2 中得到的結果。與上一步類似,c2首先用const限定。 - 利用帶預設引數值的建構函式,編譯器將
d轉換為一個Complex物件。 - 使用
Complex::operator-(const Complex&)從步驟 3 中得到的結果中減去d(現在是一個Complex物件)。 - 最後,步驟 5 中獲得的值透過程式設計師提供的賦值運算子 [
Complex::operator=(const Complex&)] 賦值到result指向的記憶體區域。
*result = i - c1 + c2 - d;
cout << *result << endl;
cout << "*result += c3 = " << (*result += c3) << endl;
cout << "*result -= d = " << (*result -= d) << endl;
delete 運算子用於釋放透過 new 獲取的堆記憶體。應確保所有未使用的記憶體都返回到系統以供可能重用。
delete ptr;
等同於
ClassName::~ClassName(ptr); ClassName::operator delete(ptr, sizeof(*ptr));
換句話說,在 delete 釋放參數指向的記憶體區域之前,物件使用的其他資源將在一個名為解構函式的特殊函式中釋放。這可能包括編譯器不管理的任何東西。例如,作業系統資源(如檔案控制代碼、套接字、訊號量等);由 DBMS 管理的資料庫連線;或由程式設計師管理的從指標可訪問的其他堆記憶體。
在 C++ 中,此特殊函式的名稱是類名字首一個波浪號(~)。它既不能返回值也不能接受任何引數。這就是為什麼它不能過載的原因。雖然我們可以定義多個類建構函式,但我們可以提供一個用於所有類的物件的單一解構函式。
事實上,我們也可以選擇根本不提供解構函式。當已知特定類的物件不使用任何外部資源時,這是正確的選擇。換句話說,如果資料成員是按值包含的(即,成員中沒有指標欄位),並且永遠不會獲取超出編譯器管轄範圍的資源,那麼我們不需要提供解構函式。出於這個原因,我們沒有為當前類實現解構函式。所有資料成員都是按值包含的。也就是說,我們在物件本身中擁有相關資訊,而不是指向堆中某個位置的變數大小資訊的指標。編譯器可以透過簡單地釋放 delete 運算子的引數指向的區域來處理此類固定大小的資訊。但是,當涉及到處理可變大小的資訊或外部資源時,程式設計師必須提供一些額外的幫助。這就是我們使用解構函式的原因。缺少這種幫助意味著浪費寶貴的系統資源,這很可能導致崩潰。因此,應該認真考慮是否需要解構函式。
定義:正規規範形式是一組在實現類過程中應給予特殊處理的函式。這些函式包括:預設建構函式、複製建構函式、賦值運算子、相等性測試運算子和解構函式。
需要強調的是,自動垃圾回收並不能免除我們考慮解構函式式功能的必要性。垃圾回收器解決了一部分問題:它處理堆記憶體的釋放。現在我們不必再考慮資料成員是內聯還是非內聯。所有與堆資料有關的處理都將由垃圾回收器負責。但是,其他外部資源呢?它們仍然需要在類似解構函式的功能中由程式設計師處理,這種功能被稱為終結器。
定義:在具有自動垃圾回收的語言中,用於清理物件使用的非堆外部資源的隱式呼叫特殊函式被稱為終結器。
最後需要記住的是,解構函式和堆之間的緊密關係並不意味著解構函式只在釋放堆物件時才會被呼叫。即使所討論的物件沒有任何外部資源,解構函式也會被呼叫——這次是隱式地由編譯器合成的程式碼呼叫——在退出塊(對於區域性物件)或程式結束時(對於全域性或靜態區域性物件)。
delete result;
cout << "Equality test operator..." << endl;
cout << "c1 – c2 + c3 ?= 0...";
if (c1 - c2 + c3 == 0) cout << "OK";
else cout << "Failed";
cout << endl << "c ?= i...";
cout << (c == i ? "OK" : "Failed");
exit(0);
} // end of int main()
g++ -I ~/include –c Complex.cxx↵ # 使用 Linux-gccc g++ -I ~/include –o Complex_Test Complex_Test.cxx Complex.o↵ ./Complex_Test↵ c: 5 c1: (3-5i) c2: 3 c3: 5i c4: 5i d: 3 i: 5 d + c1 = (6-5i) c1 + i = (8-5i) *result = i - c1 + c2 - d = (2+5i) *result += c3 = (2+10i) *result -= d = (-1+10i) 相等性 測試 運算子... c1 - c2 + c3 ?= 0...OK c ?= i...OK
- ↑ 事實上,這是一個相當簡化和不完整的實現,它缺少你在 C++ 類中通常會尋找的許多功能。
- ↑ 這裡提供了關於轉換的更多資訊。
- ↑ 以下運算子不能被過載:
?:(if-then-else 運算子)、.(成員選擇運算子)、.*(指標成員選擇運算子)、::(作用域運算子)。 - ↑ 需要注意的是,以下運算子只能作為類成員函式過載:
=(賦值運算子)、[](下標運算子)、()(函式呼叫運算子)、->(成員訪問運算子)。 - ↑ 那是任何沒有在類內定義的函式。
- ↑ ref!!!
- ↑ 換句話說,我們實際上覆制了原本由編譯器合成的程式碼。
- ↑ 不需要複製靜態欄位。
- ↑ ref9
- ↑ ref10
- ↑ ref11
- ↑ ref12
- ↑ ref13
- ↑ ref14
- ↑ ref15
- ↑ ref16