← 10/15|結構與類別(Ch 5–6) | 回總覽 | 10/29|vector 與運算子重載入門(Ch 7) →
這週要會什麼
1 | 建構子 → 重載建構子 → 初始化列表 → 預設建構子與委派 → 組合(成員是物件)→ 解構子 → static 成員 |
建構子在解決什麼問題
上一週的 Circle 有個隱患:
1 | Circle c; |
建構子(constructor) 就是為了保證「物件一出生就處於合理狀態」而存在的。
它有三個特徵:
- 名字必須與類別完全同名。
- 沒有回傳型別(連
void都不能寫)。 - 建立物件時自動被呼叫。
Circle b(5);的(5)就是交給建構子的引數;建構子只在物件誕生的那一刻自動跑一次,之後不能像一般成員函式那樣寫b.Circle();再叫它一次。
1 |
|
輸出:
1 | 3.14159 |
同一個類別可以有好幾個建構子,靠參數的個數與型別決定用哪一個——這就是 10/01 學過的函式重載,只是套用在建構子上。
雷區 ①:建立無參數物件時不要加括號
1
2 Circle a(); // 這不是建立物件!編譯器認為你在「宣告一個回傳 Circle 的函式 a」
Circle a; // 正確較新的 g++(11 以上)會先給警告
warning: empty parentheses were disambiguated as a function declaration [-Wvexing-parse](虛擬機的 g++ 9 沒有這個警告),接著在用到a的地方報error: request for member 'area' in 'a', which is of non-class type 'Circle()'。看到non-class type 'Circle()'這種帶括號的型別名,就是踩到這個坑了。課本上的名稱是 most vexing parse。
初始化列表(member initializer list)
建構子還有一種寫法:在大括號之前就把成員的值交代掉。在大括號裡寫 r = x; 是「先生出一個 r,再把 x 蓋上去」;初始化列表 : r(x) 是「直接拿 x 把 r 生出來」,少一個步驟——而且有兩種成員只能這樣寫(等一下說)。
1 | class Circle { |
多個成員用逗號隔開。成員是 int、double 這類內建型別時兩種寫法效果一樣,但建議一律用初始化列表——習慣養成了,之後遇到下面幾種成員才不會卡住;成員是物件(例如 string owner)時差別才明顯:{ owner = name; } 是先做一個空字串、再整個蓋掉,: owner(name) 是直接用 name 生出字串。
有兩種成員不能在大括號裡用 = 指派,只能靠初始化列表決定初值:
- 成員是
const(常數一旦生成就不能指派) - 成員是參考(
int&,參考必須在誕生時就決定綁誰)
1 | class Config { |
雷區 ②:初始化順序看的是「宣告順序」,不是你寫的順序
1
2
3
4
5
6 class Box {
int w; // 先宣告
int h;
public:
Box(int a, int b) : h(b), w(a) { } // 寫的順序相反
};實際初始化順序永遠是
w然後h(依宣告順序)。-Wall(實際觸發的旗標叫-Wreorder)會給你warning: 'Box::h' will be initialized after ...——上機考這就是 2 分。解法:讓初始化列表的順序跟成員宣告順序一致。
自己寫了建構子,預設建構子就沒了
規則:只要你自己寫了任何一個建構子,編譯器就不再幫你生成無參數的預設建構子。
1 | class Circle { |
什麼時候一定要有預設建構子?兩種情況:(1) 要宣告陣列,像 Circle arr[10];;(2) 這個類別被當成別的類別的成員,而對方的建構子沒在初始化列表裡寫它。除此之外不補也活得下去(後面 Car 裡的 Engine 就是靠初始化列表活下來的)。要補就加一行 Circle() : r(1.0) { }。
也可以改用預設引數,一個建構子兼兩用:
1 | Circle(double x = 1.0) : r(x >= 0 ? x : 0) { } |
但用了這招就不要再留一個無參數建構子,否則 Circle a; 兩個都配得上,編譯器會報 error: call of overloaded 'Circle()' is ambiguous。二選一。
更漂亮的做法:建構子委派(C++11)——讓一個建構子去呼叫另一個,共用的邏輯只寫一次:
1 | class Circle { |
執行順序是:先整個跑完 Circle(double),回來才跑 Circle() 自己的大括號。委派時初始化列表裡不能再寫別的成員,Circle() : Circle(1.0), r(2.0) { } 會報 error: mem-initializer for 'Circle::r' follows constructor delegation。
三個做法怎麼選:兩個建構子有共同邏輯就用委派;沒有共同邏輯,補一個獨立的預設建構子最單純;預設引數只適合完全同一套邏輯的情況。
補充:類別的大括號裡是一個整體。成員函式(含建構子)彼此可以互相呼叫,不受先後順序限制,也不必像 09/24 那樣另外寫原型——上面
Circle()委派給寫在它下面的Circle(double)完全合法。而且呼叫同類別的其他成員函式不用寫「物件.」,直接寫函式名就好,它會作用在「呼叫我的那個物件」身上,跟直接寫成員變數名是同一回事(Q2 的建構子就這樣呼叫addSeconds)。
一個完整的例子:BankAccount
1 |
|
輸出:
1 | 餘額不足 |
1000 存 500 再領 300,所以是 1200。注意 withdraw 的設計:用回傳值告訴呼叫者成功與否,而不是直接印錯誤訊息。這樣同一個類別在不同程式裡都能用——要印中文、印英文還是寫 log,交給呼叫者決定。回傳值也可以不接:acc.withdraw(300); 這一行就是知道一定成功,直接把回傳的 bool 丟掉,語法上完全合法,-Wall 也不會唸(它只管沒用到的變數,不管沒用到的回傳值)。
另外注意:print() 裡的 fixed << setprecision(2) 會一直留在 cout 上(09/17 講過),所以最後一行的 getBalance() 也印成 1200.00。不想影響後面就補一句 cout << defaultfloat << setprecision(6);——defaultfloat 是 fixed 的反向開關,6 是 cout 原本的預設有效位數;只寫 defaultfloat 不夠,留著的 setprecision(2) 會把 1200 印成 1.2e+03。
補充:
inline。寫在class定義裡面的成員函式(像上面的deposit、withdraw)自動算inline——編譯器可以把函式內容直接展開在呼叫處。現在只要認得這個名詞,11/26 講 header 檔時會用到一次。
成員也可以是另一個類別的物件
資料成員不一定是 int、double,也可以是別的類別的物件:
1 |
|
輸出:
1 | Civic:180 匹馬力,引擎發動 |
兩件事要記住:
- 成員物件會先被建構,才輪到外層類別建構子的大括號。所以
Car建構子的大括號裡可以放心用engine(例如直接呼叫engine.start()),它已經初始化完畢。物件消失時順序相反:先跑外層Car的解構子,才輪到成員engine(解構子是下一節的主題)。 - 上面的
Engine只有帶參數的建構子、沒有預設建構子,所以Car一定要在初始化列表裡寫engine(hp),不能省略——因為編譯器不知道該怎麼生出那個成員。
這種「A 裡面有一個 B」的寫法叫組合(composition),講繼承那一節會拿它跟繼承比較。
解構子:物件消失時自動呼叫
解構子(destructor) 的名字是 ~ 加上類別名(~Widget()),沒有參數、沒有回傳型別,一個類別只能有一個;物件生命結束時(離開所在區塊、或程式結束)自動被呼叫,就像區域變數離開區塊就消失一樣。下一節先拿它做「物件消失時計數減一」,真正重要的用途(歸還記憶體)留到指標那一節。
static 成員:屬於「類別」而不是「物件」
1 |
|
輸出:
1 | 0 |
- 一般成員每個物件各有一份;
static成員整個類別只有一份,所有物件共用。典型用途是「統計目前有幾個物件」「產生不重複的流水號」。 - 為什麼要在類別外面多寫
int Widget::count = 0;?因為class只是藍圖,不會真的配置記憶體;static資料成員必須在檔案最外層(不能寫在main裡)安排真正的位置並給初值,整個程式只寫這一次,而且這一行不可以再寫一次static。它雖然寫在類別外面,卻是這個成員的定義,所以是private的唯一例外,不會被權限擋下來——在main裡寫Widget::count才會報is private within this context。 static成員函式:函式前面加static(例如getCount())。它不是對著某個物件呼叫的,裡面沒有「我是哪個物件」這個資訊,所以只能碰static成員;好處是不必先有物件,寫成Widget::getCount()。(成員函式寫在類別裡就結束了,不用像資料成員那樣在類別外再定義一次。)- 這個計數器有個盲點:
Widget d = a;這種複製不會經過Widget(),count不會加,但d消失時解構子照樣減,就會數錯。複製走的是「拷貝建構子」,Q4 會先補一個最簡單的版本,11/19 再完整講。
本週重點回顧
- 建構子與類別同名、沒有回傳型別、建立物件時自動呼叫;同名不同參數就是重載。
Circle a();是宣告函式,不是建立物件;無參數就寫Circle a;。- 初始化列表優先,
const與參考成員不能在大括號裡指派、只能用它;初始化順序依宣告順序(寫反會有-Wreorder警告)。 - 自己寫了任何建構子,預設建構子就沒了;要宣告陣列或被別人當成員時記得補一個(或用建構子委派共用邏輯)。
- 成員可以是別的類別的物件(組合):成員先建構,才輪到外層建構子的大括號;解構順序相反。
- 解構子
~類別名()在物件生命結束時自動呼叫。 static資料成員全類別共用、這門課的寫法是在類別外定義一次;static成員函式不必有物件就能呼叫,但只能碰static成員。
本週練習題
Q1. 委派建構子
把 Circle 改寫成:預設建構子用建構子委派呼叫帶參數版本(預設半徑 1,負數視為 0),提供 area() 與 perimeter();在 main 裡分別用預設建構子與半徑 5 各建一個物件,每個物件印出面積與周長各一行,輸出到小數第四位。
1 | 輸出: |
參考解答
1 |
|
「負數視為 0」這段共同邏輯只寫在帶參數的建構子裡,預設建構子委派過去就自動享有,不必抄第二遍。
Q2. Time 類別
寫 class Time,存時、分、秒(private)。提供預設建構子(00:00:00)與 Time(int h, int m, int s) 兩個建構子、addSeconds(int n)(加上 n 秒,超過 24 小時自動繞回)、print()(輸出 hh:mm:ss)。輸入四個整數:時、分、秒、要加的秒數。
1 | 輸入: 23 59 50 20 |
參考解答
1 |
|
技巧:把「時分秒」一律換算成「總秒數」再運算,最後換回來,就不用處理一堆進位的 if。(total % 3600) / 60 的括號其實可以省略(% 和 / 優先順序相同,同一層由左到右算),但加上去比較不會看錯。另外注意建構子直接呼叫了 addSeconds——同一個類別的成員函式互相呼叫不用寫「物件.」,重複的邏輯只寫一個地方。
Q3. Date 類別與合法性檢查
寫 class Date,建構子接受年、月、日,若日期不合法(含閏年判斷)則設為 2000/1/1。提供 print() 輸出 YYYY/MM/DD,以及 isLeapYear()。
閏年規則:是 4 的倍數但不是 100 的倍數,或者是 400 的倍數。
1 | 輸入: 2024 2 30 |
參考解答
1 |
|
leap 與 valid 寫成 private static 成員函式:它們是類別內部的工具,外面不該直接呼叫;寫成 static 是因為它們只靠參數算答案、不必碰任何物件的資料(這兩個函式寫在類別裡就結束了,不用在類別外再定義一次)。
Q4. 物件計數器
寫一個 class Counter,用 static 成員統計「目前存在幾個物件」與「總共建立過幾個物件」,並用一段程式驗證兩者的差別。用 Counter f = c; 複製出來的物件也要算進去。
1 | 輸出: |
參考解答
1 |
|
離開內層區塊時 c、d、e、f 的解構子把 alive 減回 2,但 created 只加不減,所以還是 6。拷貝建構子那行是關鍵:Counter f = c; 不會呼叫 Counter(),而是呼叫「參數是 const Counter&」的那個建構子;編譯器預設會幫你生一個只複製成員的版本,它不會幫你 alive++,於是 f 消失時 alive-- 就多扣了一次。自己寫一個、在裡面計數,數字才對。這是正文 Widget 那個例子的盲點,也是 11/19 三法則的第一次登場。
實驗課題型加練
以下三題的題型取自去年(2025)第 7 週實驗課的課堂練習(今年的投影片還沒出,題目可能會換)。去年這週的三個關鍵字是 const 參考參數、static 成員、把上一題的類別當成員再包一層——三題是一路接著寫的。題目不是我原創的:練的東西跟去年那幾題一樣,但題目本身、規則、範例資料和解答都是我自己重寫的,不是原題。
Q5. 兩個日期是否相同(const 參考參數)
寫 class Date,建構子收年月日,isValid() 檢查日期存在(含閏年二月),sameAs(const Date& other) const 比較是否同一天。讀入兩個日期,任一不合法印 invalid date,否則印出比較結果。
1 | 輸入: |
1 | 輸入: |
參考解答
1 |
|
sameAs 的參數是 const Date&:不複製、也保證不改對方;函式本身也是 const,因為不改自己。這兩個 const 缺一個助教都會問。同一個類別的成員函式可以直接讀另一個物件的 private 成員(other.year),因為 private 是「類別外不能碰」,不是「物件外不能碰」。
Q6. 售票紀錄(static 成員)
寫 class Ticket,private 存活動名稱、座位號、票價,另外用兩個 static 資料成員統計「總共賣出幾張」與「總收入」。反覆讀入「活動 座位 票價」,讀到 end 停止;最後印出所有票、賣出張數與總收入。張數與總收入要透過 static 成員函式 Ticket::count()、Ticket::total() 取得。
1 | 輸入: |
參考解答
1 |
|
static int sold 屬於整個類別,不屬於任何一張票,所以「每建構一次就 sold++、revenue += p」自然就是總張數與總收入。兩個細節:int Ticket::sold = 0; 這種定義一定要寫在類別外(少了會 linker error);Ticket list[MAX]; 開陣列會對每一格呼叫預設建構子,所以要提供一個不計數的預設建構子,否則 100 格空位也會被算成賣出去的票。
Q7. 氣溫紀錄
寫 class Reading,private 存一個 Date(沿用 Q5 的類別,加一個 before() 比先後、一個 inMonth() 判斷是不是某年某月)與當天量到的氣溫,用 static 成員累計筆數與溫度總和。讀入 n 筆紀錄後:依日期由早到晚印出(同一天依氣溫由低到高)、印出筆數與平均氣溫;再讀入「年 月」,印出那個月的所有紀錄。氣溫一律印到小數一位。
1 | 輸入: |
參考解答
1 |
|
這題是前兩題的組合:Reading 裡面放一個 Date(10/15 說的 has-a),排序規則交給 Reading::before,而 Reading::before 又把日期的部分交給 Date::before——每個類別只管自己那層的比較。Date 的建構子給了預設值 (1, 1, 1),是為了 Reading() 這個預設建構子能編過(陣列需要它)。平均用 static 函式算,因為它是「全部紀錄」的性質,不屬於任何一筆;排序骨架還是 10/08 的選擇排序,換掉的只有比較條件和交換的型別。
說些什麼吧!