第 9 章 建構函式與解構函式
本章提要 9-1 建構函式 (Constructor) 9-2 複製建構函式 9-3 解構函式 9-4 物件的陣列 9-5 成員初始化串列 9-6 綜合演練
9-1 建構函式 (Constructor) 當我們建立好一個類別之後, 便希望能將它當成一般的基本型別來使用, 而要達到這個目的, 必須靠一些特殊的函式成員來實作某些功能。其中建構函式的功用, 就是讓我們能像定義基本資料型別的變數一樣, 可以在建立物件同時就初始化資料成員的內容。
物件的初始化 當我們以基本型別來宣告變數時, C++ 的編譯器會自動根據其型別配置好記憶空間, 同時若有必要, 也會為它設定初值。而且我們也可在宣告變數時即指定初值: 當我們用類別來定義物件時, 編譯器也會依照類別的大小來配置記憶空間給這個物件。但用前一章所學的方法, 我們無法對物件做類似的初始值設定:
物件的初始化
物件的初始化 如果物件的資料成員是公開的成員, 則雖可利用大括號的方式設定物件初始值, 但這樣就失去資料封裝的意義了:
物件的初始化 如果希望使用私有資料成員的類別在建立物件時, 也能像定義資本資料型別的變數時一樣方便的設定初始值, 甚至要物件做其它額外的準備工作, 只需在類別中定義必要的建構函式即可, 首先要介紹的是預設建構函式。
預設建構函式 建構函式也是成員函式, 但它和一般成員函式有兩點最大的不同:建構函式必須與類別同名, 而且不能有任何的傳回值。即使我們未替類別設計任何的建構函式, 編譯器仍會為類別定義一個預設建構函式 (Default Constructor), 也就是不需傳遞任何參數即可呼叫的建構函式, 例如:
預設建構函式
預設建構函式 當我們宣告新的物件時, 這個預設建構函式就會被呼叫, 只不過由於編譯器自動產生的預設建構函式中並沒有執行什麼特別的動作, 所以感覺好像根本沒有預設建構函式一樣。我們可用以下的範例來檢視建構函式被呼叫的情形。為了瞭解建構函式被呼叫的時點, 我們故意將預設建構函式的內容重新定義成只輸出一段訊息:
預設建構函式 並未宣告傳回值型別, 連 void 也未指定, 這是因為建構函式本來就規定不能有傳回值, 所以連 void 都可省了。如果在建構函式前面加上 void 關鍵字, 反而會造成編譯錯誤。
宣告指向物件的指標變數, 宣告以後並不會呼叫建構函式。 預設建構函式 宣告指向物件的指標變數, 宣告以後並不會呼叫建構函式。
預設建構函式 在第 14 行先宣告 2 個 Time 物件, 接著在第 16 行宣告指向物件的指標、第 18 行宣告指標並用 new 運算子配置 1 個物件。由執行結果可以發現, 只有第 16 行宣告指標的動作未引發建構函式, 其它敘述由於都會建立實際的物件, 所以都會自動呼叫建構函式來做初始化的動作。因此我們可以瞭解, 系統會在建立物件時自動呼叫建構函式, 我們只需將需要初始化的敘述寫在建構函式中讓系統呼叫即可, 不需自行呼叫建構函式。
預設建構函式 請注意第 6 行的 Time() 函式原型並未宣告傳回值型別, 連 void 也未指定, 這是因為建構函式本來就規定不能有傳回值, 所以連 void 都可省了。如果在建構函式前面加上 void 關鍵字, 反而會造成編譯錯誤。 若類別中有其它類型的資料成員, 例如其它類別的物件, 則在呼叫預設建構函式之前, 會先呼叫成員物件的建構函式, 請參考以下的例子:
預設建構函式
預設建構函式
預設建構函式 在 main( ) 函式並未建立 Time 物件, 但由執行結果可以發現, Time 的建構函式被呼叫了 2 次!這是因為 Clock 類別有 2 個資料成員都是 Time 類別的物件, 所以編譯器會先呼叫 Time 的建構函式來建構這 2 個成員物件, 接著才呼叫 Clock 自己的預設建構函式。因此當我們用 Clock 類別建立物件時, 就會引發 Time 的建構函式被執行 2 次, 接著才會執行 Clock( ) 預設建構函式。
預設建構函式 由於預設建構函式一定會在建立物件時被呼叫 (除非我們呼叫其它版本的建構函式), 所以最適合用來做最基本的初始化動作, 例如將資料成員都設定一個有意義的初始值等等。例如我們就可將前述 Time 類別的預設建構函式改寫成如下的樣子:
預設建構函式
預設建構函式 Time -hour: int -min: int -sec: int <<constructor>>+Time() +show() t[0] t[1] t[2] -hour: -min: -sec: Time() +show() -hour: -min: -sec: Time() +show() -hour: -min: -sec: Time() +show() 12 12 12
預設建構函式 除了用來初始化資料成員外, 我們也可在建構函式中做其它的處理, 舉例來說, 如果類別需要隨時記錄共有幾個物件存在, 此時可用一個靜態資料成員來記錄物件的總數, 並在建構函式中每次都將這個數值加 1, 以記錄目前存在的物件數量。
預設建構函式
預設建構函式
﹒ ﹒ ﹒ Car -gas: double -eff: double -counter: int <<constructor>>+Car() +howmany(): int goodcar[0] goodcar[1] eff 30.0 gas: Car() +howmary() 12 gas: Car() +howmary() 12 counter goodcar[2] goodcar[9] badcar gas: Car() +howmary() 12 gas: Car() +howmary() gas: Car() +howmary() 12 12 ﹒ ﹒ ﹒
預設建構函式 上列程式在第 7 行宣告靜態資料成員 counter 以記錄物件總數, 並在第 15 行定義初始值為 0, 第 6 行的預設建構函式中, 則是將 counter 的值遞增, 所以每建立一個物件, counter 的值就會加 1 因此程式中建立含 10 個 Car 物件元素的陣列後, counter 的值就變成 10 再用 new 運算子建立一個物件, counter 的值就變成 11 了。
建構函式的多載 除了不含任何參數的預設建構函式外, 我們當然也能設計含參數的建構函式, 透過參數來指定資料成員的初始值, 這樣一來, 就能在建立物件時即設好各物件的屬性, 不必像前一章的範例, 還要在建立物件後, 用額外的函式呼叫來設定物件的屬性。這類建構函式的設計方式和一般成員函式沒有太大的不同, 只要記得建構函式與類別同名, 且無傳回值即可。而在建立物件時, 可用如下語法讓編譯器呼叫具有參數的建構函式:
建構函式的多載 請參見以下的例子:
建構函式的多載
建構函式的多載 亦可寫成===>
建構函式的多載 1. 第 6 行定義預設建構函式, 將時間設為 12 點整。第 7、8 行則宣告 2 種有參數列的建構函式。 2. 第 17~21 行定義只有 1 個參數的建構函式內容, 並以參數為小時的初值, 所以要檢查其值是否超出小時的合理範圍 (本例採 24 小時制), 若超出範圍, 則仍是設為 12 點。 3. 第 23~28 行定義有 3 個參數的建構函式內容, 同理, 分及秒的值都不能為負值或超過 59, 若參數值超出此合理範圍, 則仍會將分或秒設為 0。
建構函式的多載 程式在第 32~34 行即以不同的方式建立新物件, 編譯器會依我們建立物件時所指定的參數多寡, 尋找參數數量相符的建構函式。第 33-34 行的程式也可寫成:
建構函式的多載 注意, 程式中未定義 2 個參數的建構函式, 因此若建立物件時寫 "Time t4 (3,12);", 則編譯時將會出現錯誤, 因為編譯器找不到簽名相符的建構函式可以呼叫。要解決這個問題, 除了為每一種可能狀況設計對應的建構函式版本外, 還有另一種較彈性的作法, 就是替建構函式的參數設定預設值。
為建構函式的參數設定預設值 由於建構函式的基本用法和行為也和一般函式相同, 所以我們也可為建構函式的參數加上預設值, 這樣一來, 我們在建立新物件時就可以比較有彈性, 有時用比較少的參數也能建立物件, 不必再另外定義參數較少的建構函式版本。 在為建構函式設定預設值時要注意一點, 如果所有的參數都有預設值, 則類別中就不應再定義不含參數的預設建構函式, 因為此舉將會造成語意不明 (ambiguous) 的錯誤, 也就是編譯器不知應呼叫哪一個建構函式, 因而產生錯誤:
為建構函式的參數設定預設值
為建構函式的參數設定預設值 以下我們就利用參數預設值的技巧, 讓 Time 類別的建構函式簡化成只有 1 個, 但使用者仍是可依其需要, 在建立新物件時, 指定或多或少的初始值:
為建構函式的參數設定預設值
為建構函式的參數設定預設值 第 15 行的建構函式 3 個參數都有預設值, 所以建構物件時可自由指定 0~3 個參數, 都會呼叫到這個建構函式替物件的資料成員設定初值。
9-2 複製建構函式 編譯器除了會自動產生預設建構函式外, 還會產生一個特殊的複製建構函式 (Copy Constructor)。當程式中定義新物件, 並以同類別的其他物件來做初始值時, 編譯器會呼叫複製建構函式 (Copy constructor) 來進行物件的複製。例如:
自動產生的複製建構函式 如果我們在類別中並沒有定義複製物件的複製建構函式, 那麼編譯器會自動產生一個, 其內容只是將來源物件的非靜態資料成員, 逐一地複製給新物件的非靜態資料成員:
自動產生的複製建構函式 這種將來源物件的各資料成員逐一複製給新物件的方法, 又稱為逐成員初始化 (Memberwise Initialization)。在以下 3 種情況, 編譯器都會呼叫複製建構函式: 用已建立好的物件來定義新物件。(Time t2=t1;) 以物件為函式的參數來做傳值呼叫時。( Time t3(t2); ) 將物件做為函式的傳回值傳遞時。 ( return t1; ) 在上例的 2, 3 項中若所傳的是參考型別, 則不會呼叫複製建構函式, 這是因為所用的只是別名而已, 所以並不會有任何新的物件產生。
自動產生的複製建構函式 對於資料成員都是基本資料的類別而言, 除非是有特殊的需求, 預設的複製建構函式就足以應付大多數的情況, 我們也不需重新定義自訂的複製建構函式。但若資料成員的型別包含指標等, 則可能需自訂複製建構函式才能使物件有正常的行為。舉例 來說, 字串類別 Str 的定義中有一個指標:
自動產生的複製建構函式 假設已建好一物件 a, 所存的字串是 "Happy", 此時用它來初始化物件 b, 則 b 物件的字元指標也會指向相同的記憶體空間:
自動產生的複製建構函式 若稍後物件 a 先被刪除了, 將連帶使配置字串的空間也被釋放。此時物件 b 也將失去所指的字串資料:
自動產生的複製建構函式 被釋放出的空間可能隨後又被程式用來存放其它的資料, 此時將導致物件 b 所存的字串資料變成其它奇怪的內容。為避免這種情況, 對於有資料成員是指標型別的類別, 就必須定義複製建構函式, 以合理的方式複製指標的資料。以字串類別為例, 我們可在複製建構函式中, 配置新的空間給新物件, 然後複製字串內容到新配置的空間, 請參考以下的範例:
自動產生的複製建構函式
自動產生的複製建構函式
自動產生的複製建構函式
自動產生的複製建構函式 第 34~39 行即為複製建構函式, 其中第 37 行用 new 配置新的空間, 再於第 38 行呼叫標準函式庫的字串複製函式 strcpy() 將參數物件 s 的字串內容複製過來。 定義好 Str 類別的內容後, 我們可在程式中含括這個 .h 檔, 然後在程式中以既有物件初始化新物件, 以測試複製建構函式:
自動產生的複製建構函式
9-3 解構函式 和建構函式相對的成員函式稱為解構函式 (Destructor), 建構函式是在物件建立時被呼叫, 而解構函式則是在物件的生命期結束時 (或是以 delete 來將 new 配置的物件釋放時), 會由編譯器自動呼叫以進行善後工作的成員函式。舉例來說, 如果在建構函式中曾配置新的記憶體空間, 那麼就必須利用解構函式來將之釋回給系統。
解構函式 解構函式的名稱是在類別名稱前加上一 個 ~ 符號。此外解構函式不但不可有傳回值, 而且也不可以接收任何參數。換句話說, 它是不能夠多載的, 每一個類別只能有一個解構函式。例如:
解構函式 不可有參數
解構函式 請注意, 物件本身的空間是由系統負責建立和釋回的, 以上面的 main() 來說, 在執行 "A i(5);" 時: 2. 呼叫建構函式, 配置 5 個 int 空間, 並將其位址存入資料成員 p 中:
解構函式 當 main() 執行到結尾的 }, 也就是 i 的生命期結束, 這時候會做下面 2 個動作: 1. 呼叫解構函式將 p 所指的空間釋回。 2. 系統將 i 本身的空間釋放掉。此項處理與解構函式無關。
解構函式 如果是用 new/delete 配置 / 釋放物件的空間, 也會有類似的建構及解構過程, 例如: 執行 "A *a = new A(10);" 敘述時, 會進行如下的初始化過程:
解構函式 1. 系統配置指標 變數 a 的空間: 2. 用 new 配置物件本身的空間, 並將其位址指定給 a 指標:
解構函式 最後執 行 “delete a;” 敘述時, 所做的善後工作包括: 3. 呼叫建構函式, 配置 10 個 int 空間, 並將其位址存入資料成員 a->p 中: * A(int size=1) { p = new int[size]; } 最後執 行 “delete a;” 敘述時, 所做的善後工作包括:
解構函式 1. 在將物件本身的空間釋回以前, 先呼叫解構函式將其資料成員 p 所指的空間釋回。 2. 系統將物件本身的空間釋回 (指位器 a 的空間要等到其生命期結束時, 才由系統將之釋放掉) 。 然而, 對一個具有永久生命期的物件 (全域物件或靜態物件) 來說, 它的解構函式則是在程式結束時才被呼叫。以下範例簡單示範解構函式被呼叫的情形:
解構函式
解構函式 與 // 嘗試加上 Destruct d(‘d’); Destruct a(‘a’); 相同 Destruct -c: char <<constructor>>+Destruct(char) <<destructor>>+ ~Destruct() a c: Destruct(char) ~Destruct() ‘a’ 與 Destruct a(‘a’); 相同 // 嘗試加上 Destruct d(‘d’); c b c: Destruct(char) ~Destruct() c: Destruct(char) ~Destruct() ‘c’ ‘b’
解構函式 參考下頁說明
解構函式 程式中有 3 個物件分別是全域、局部靜態、局部物件, 由執行結果可發現全域變數會在程式開始執行前就先建構, 它們的建構順序為: 解構的順序則是倒過來:
解構函式 局部物件會在函式結束時就結束其生命期, 並引發解構函式執行;而全域及局部靜態物件, 都是等程式結束後才結束其生命期, 所以在上述執行結果中, 是在程式執行第 31 行的敘述後, 才會執行 a、b 物件的解構函式。
用解構函式釋放記憶體空間 前一章提到類別中有指標型別的成員時, 有幾項工件必須自行處理, 首先就是在建構函式及複製建構函式中處理指標成員的初始化 (例如配置新的記憶體空間), 另一項則是在物件生命期結束時, 需用解構函式釋放原先所配置的記憶體空間。 以前面的自訂字串類別為例, 應在解構函式中釋放用來存放字串的動態配置記憶體空間, 修改後的內容如下:
用解構函式釋放記憶體空間
用解構函式釋放記憶體空間 第 11 行的解構函式 ~String() 中的 delete 的敘述寫成 "delete [ ] data;", 是第 7 章介紹過的釋放陣列空間的語法。因為我們在 Str 類別的建構函式中, 是用 "new char[len+1]" 的方式配置記憶體, 所以釋放時就要用上述的語法。 定義好 Str 類別的內容後, 我們可在程式中含括這個 .h 檔, 然後在程式中測試解構函式:
用解構函式釋放記憶體空間
用解構函式釋放記憶體空間
9-4 物件的陣列 定義好的類別就像是基本型別一樣, 所以我們也可以用來定義物件的陣列。例如: 編譯器在建立每個元素時都會自動呼叫預設建構函式。在使用物件時, 我們可以用中括弧 [] 來指明是哪一個元素, 例如:
物件的陣列 在定義物件陣列時, 也可為每一個元素設定初值, 其方法和一般的陣列相似: 編譯器會依大括號中的所列值, 為對應的元素呼叫格式相符的建構函式, 若大括號內的初始值數目比指定的陣列元素少, 則其它元素就用預設建構函式來初始化。請參見以下範例:
物件的陣列
物件的陣列 我們在第 7 行輸出用 sizeof() 取得的類別及陣列大小, 分別是 8 和 32, 表示陣列恰好是 4 個 Str 物件的大小。因為物件中的資料成員只有記錄字串長度的 int 及指向字串的指標, 所以陣列大小為 (4+4)x4=32。請注意, 這個大小並不包括建構函式所動態配置的記憶體空間, 所以即使我們存放含 100 個字元的字串, 物件本身的大小仍是固定的。
物件的陣列 另外, 我們也可以用 new 來建立物件陣列, 不過這時就不能設定各物件的初值了, 例如: 在物件陣列的生命期結束時, 編譯器會為每一個元素分別呼叫解構函式, 如此才能保證所有由建構函式配置的記憶體都被釋放掉。再次提醒讀者, 如果使用:
物件的陣列 則將只有陣列的第一個元素會呼叫解構函式, 這是因為編譯器並不知道 p 所指的是一個陣列我們必須在 delete 和指位器之間加上一 個 [] 才行 (不必指明陣列的元素數目): 事實上, 加上 [] 的目的只是要編譯器為每一個元素都呼叫解構函式, 所以如果類別內並沒有定義解構函式的話, 在使用 delete 時加不加 [] 都無所謂。
9-5 成員初始化串列 我們可在定義物件時即指定資料成員初始值, 讓建構函式可初始化資料成員。但如果資料成員是其它類別的物件, 這時該如何初始化這種物件型的資料成員? 其實當我們用類別來定義物件時, 系統會先為類別內的資料成員配置好記憶空間, 這個動作稱為初始化, 接著系統才會呼叫適當的建構函式來設定各資料成員的初值。
成員初始化串列 換言之, 建構函式中各敘述的功用只是將初始值指定給各資料成員。舉例來說, 假設有個存款帳戶類別是以 Str 字串類別的物件記錄帳戶名稱:
成員初始化串列 這時要如何在 Account() 建構函式中初始化 name 的值呢?由本章開頭的介紹已知:在執 行 Account() 建構函式前, 編譯器會先呼叫 Str() 建構函式來建 構 name 成員, 所以我們最多只能 用 Str 類別提供的設定字串成員函式 (假設有) 來設定 name 的字串值, 這樣一來又無法享受到建構函式所提供的便利性。其次, 如果 Str 類別未提供無參數的預設建構函式, 則 Account 類別將無法使用, 因為編譯器將找不到預設建構函式來建構 name 成員。
成員初始化串列 為解決這個問題, C++ 提供另一種物件初始化的方法, 稱為成員初始化串列 (Member initialization list)。成員初始化串列顧名思義, 可指定各資料成員在初始化時所用的初始值, 讓系統在初始化資料成員時, 即可先設定好初始值, 不必再於建構函式中用指定的方式設定其值。對物件成員而言, 成員初始化串列中所設的初始值, 就會成為呼叫其建構函式的參數。成員初始化串列需放在建構函式定義的參數列後面, 其格式如下:
成員初始化串列 初始值運算式可以是常數、變數, 或是複雜的運算式。例如剛才的帳戶類別即可透過如下的成員初始化串列, 呼叫資料成員的建構函式:
成員初始化串列 基本資料型別的成員也可用成員初始化串列來初始化, 不過這樣做不會有任何效能上的改進, 例如:
成員初始化串列 以下就是實作上述銀行存款帳類別的簡例:
成員初始化串列
成員初始化串列
成員初始化串列 除了物件成員外, 參考型別及 const 型別的資料成員也必須使用成員初始化串列來初始化其值。因為大家應還記得:參考型別及 const 變數都必須在定義時即設定其值, 不能在宣告後才指定新的值, 換言之這類資料成員都不能在建構函式中指定其值, 而必須用成員初始化串列在配置空間時, 即做好初始化其值的工作。
成員初始化串列
成員初始化串列 這樣一來, 用上列建構函式建立 Test 的物件時, 系統就會在建立 ri 資料成員的同時, 即將它參考到 b;在建立 ci 資料成員時, 就以 c 為其初始值。 最後要提醒讀者, 每個資料成員在串列中只能出現一次, 而且各資料成員在成員初始化串列中的次序並不重要, 因為系統在為資料成員配置空間時, 乃是依照它們在類別定義中的出現順序來執行, 所以和串列中的排列順序完全無關。
成員初始化串列 例如:
9-6 綜合演練 複數類別的強化 (建構函式) 圓形類別的建構函式
複數類別的強化 (建構函式) 在前一章我們建立了一個複數類別, 但其使用非常不便, 最主要的原因之一, 就是沒有設計建構函式, 所以現在我們就為它加上適當的建構函式, 簡化物件的初始化:
複數類別的強化 (建構函式)
複數類別的強化 (建構函式)
複數類別的強化 (建構函式) 第 6 行的建構函式同時為兩個參數都設定預設值, 所以建立物件時, 可以不加任何參數 (複數值為 0)、只指定實部 (虛部為 0)、或是自行指定實部與虛部的值。 在加減法的運算部分, 使用起來仍相當不便, 不能像使用基本資料型別一樣, 直接以內建的運算子進行基本的運算, 這些要留待下一章學會運算子的多載後, 再來提升 Complex 類別的功能。
圓形類別的建構函式 大家在國中學習幾何時, 決定圓的方式通常是用圓心座標 (x,y) 加上其半徑, 但在程式設計的繪圖世界中, 要在畫面上畫出圓形時 (或橢圓), 通常是以指 定圓的外切 矩形的座標 來決定:
圓形類別的建構函式 如圖所示, 這種指定方式需指定外切矩形的左上角及右下角座標, 然後程式會根據這個座標, 畫出在矩形內部的圓形 (若矩形不是正方形, 就會畫出橢圓形)若我們要設計一代表圓的類別, 並讓使用者能以指定圓心座標及半徑或是圓的外切正方形的方式來建立其物件, 則需設計可應用於此兩種狀況的建構函式, 範例程式如下:
圓形類別的建構函式
因為怕使用者誤輸入的是長方形或非正方形的座標點, 所以建構函式會先取較小的一邊為正方形的邊長。 圓形類別的建構函式 (3,5) (x, y) r 1 因為怕使用者誤輸入的是長方形或非正方形的座標點, 所以建構函式會先取較小的一邊為正方形的邊長。 (x1, y1) (30,30) (20,20) (x, y) r 10 (x0, y0) (10,10)
Circle -x: double -y: double -r: double <<constructor>>+Circle(double,double,double) <<constructor>>+Circle(double,double,double,double) +area():double +circum():double c1 x: y: r: Circle(double,double,double) Circle(double,double,double,double) area():double circum():double c2 x: y: r: Circle(double,double,double) Circle(double,double,double,double) area():double circum():double
圓形類別的建構函式
圓形類別的建構函式 1. 第 11、12 行宣告 3 個資料成員:圓心座標 (x,y) 及半徑 r。 2. 第 8、9 行為計算圓面積及圓周長的成員函式。 3. 第 15 行的建構函式是以傳入圓心座標及半徑的方式建構物件, 其中半徑可省略, 預設值為 1。
圓形類別的建構函式 4. 第 20 行的建構函式是以傳入外切矩形的兩個點座標來定義圓, 因為怕使用者誤輸入的是長方形或非正方形的座標點, 所以建構函式會先取較小的一邊為正方形的邊長 (min( )C++ 內建函式, 會傳回 2 參數中的較小值), 再用此值計算圓心座標及半徑。為方便使用, 函式未限制一定要將較小的座標點當成第 1 對參數, 所以程式在計算時, 需先比較座標點的大小, 以免計算出來的半徑為負值。