① 這頁從類別語法開始,逐步建立物件、建構式、封裝、組合與檔案拆分的觀念。已經熟的可以直接跳過。
② 每節都附一個「這個語法解決什麼問題」的小方框,讀完要能用自己的話解釋。
③ 讀完做 自我檢測,再翻 關鍵詞彙卡。
可依熟悉程度選讀,先確認能讀懂每個例子再往下。
例如一筆測量有數值,也有讀取與更新這個數值的操作。class 讓我們定義這樣的型別;建立物件後,每個物件保存自己的狀態。這頁先從一個 int 成員開始,再逐步加入存取控制、建構式與輸出介面。
「抽象資料型別」從使用者能儲存什麼、能做什麼操作來描述一種資料,不先限定內部實作。C++ 類別是表達這類介面的一種工具;不是每種資料結構都必須以一個類別實作。下表保留兩種觀點的對照。
| 抽象資料型別(ADT)的說法 | C++ 的說法 | 例子 |
|---|---|---|
| 這個結構「存什麼」 | 成員變數(member variable) | int value; |
| 這個結構「能做什麼」 | 成員函式(member function) | getData() / setData() |
| 結構的規格(介面) | class 定義 | class Reading { … }; |
| 實際被建立出來的一個結構 | 物件/實例 | Reading room(18); |
讀完應能自行寫出 Reading 與 ReadingLog,說明物件何時初始化、private 限制哪些存取,以及如何用公開介面讀值、更新與輸出。
建議先熟悉 P3 的參數傳遞與 P4 的指標,再讀本頁類別語法;P9 會接著介紹繼承與模板。
類別是藍圖,物件是照著藍圖蓋出來的東西。藍圖只有一份,蓋出來的房子可以有很多間,每一間的門牌、住戶各自不同。
n.getData() = 93
Reading n(93); 建立一個物件並自動執行建構式。每個 Reading 物件各自保存 value;修改其中一個物件不會改到另一個。
Reading 把測量值與操作包在一起。類別定義只有一份,但每個 instance 都有自己的 value。
this你寫 Reading n(93); 的時候,C++ 先配置一塊記憶體,然後自動呼叫 Reading(93) 把它填好。所以建構式裡做的事就是「這個物件一出生要帶著哪些資料」。
它有兩個外觀特徵:名字跟類別一模一樣,而且沒有回傳型別(連 void 都不寫)。
this:指向目前物件的指標在非靜態成員函式中,this 指向目前執行該函式的物件。例如以 obj.getData() 呼叫時,this 對應 &obj;它不是指向呼叫端函式。成員沒有被同名區域變數遮蔽時,可以直接寫 value,或明寫 this->value。
Reading(int initial) : value(initial) {} 把 initial 用作成員 value 的初值。冒號後、函式本體前的部分叫成員初始化列表(member initializer list)。它先初始化成員,再執行大括號中的敘述;先前的 this->value = value; 則是在本體內賦值。這個 int 範例兩種方式可得到相同數值,之後有 const 或參考成員時,初始化列表尤其重要。
self 差在哪Python 的 self 是明寫在參數列裡的第一個參數;C++ 的 this 是編譯器隱含傳進來的指標,不寫在參數列裡,而且因為是指標所以用 -> 存取。兩者角色一樣,但 C++ 這邊你平常根本不會看到它。
public 與 private:C++ 靠編譯器擋,不靠約定[編譯失敗] cell.cpp: In function ‘int main()’: cell.cpp:15:15: error: ‘int Reading::value’ is private within this context 15 | cout << n.value << endl; // 不行:value 是 private
private 由 C++ 編譯器檢查,類別外不能直接讀取這裡的 value。若熟悉 Python,可把底線命名的慣例與這種強制存取控制作比較;不需先學 Python,也能直接從成功/失敗程式看出差別。
| 區段 | 誰能存取 | 通常放什麼 |
|---|---|---|
| public: | 任何人 | 建構式、對外的操作(介面) |
| private: | 這個類別的成員與 friend(後面介紹) | 資料成員、內部輔助函式 |
| protected: | 自己 + 子類別 | 要給子類別用的東西(見 P9) |
class 預設是 private沒寫存取區段的話,class 裡的成員預設是 private(struct 則預設 public,這是兩者主要的差別)。所以建構式忘了放在 public: 底下,就會出現「明明有建構式卻說不能建立物件」的錯誤。另一個差別是預設繼承層級:class 預設 private 繼承、struct 預設 public,所以 P9 一律寫 : public。
這一節值得慢慢看,因為它是真實世界的取捨,不是參考資料的標準答案。
兩個類別可以依各自的不變條件採取不同的存取設計:
Reading 透過 getter/setter 集中處理讀寫,之後若要加入驗證或記錄修改,也有一致入口。DisplayPoint 只是一起傳遞座標,若沒有額外不變條件,公開成員也可能合理。封裝是控制使用者依賴的介面,不是單純替每個欄位機械地加上 getter/setter。
ReadingLog 以 vector 保存讀數,並希望使用者只透過 add、size、average 操作。內部 vector 應放在哪個存取區段?
Reading 透過 getter/setter 控制存取;有些單純資料型別則會公開成員。判準是外部直接修改狀態是否可能破壞類別承諾的不變條件。
int getData() const:成員函式的唯讀承諾const 成員函式透過 this 讀取一般成員,讓唯讀物件也能使用查詢介面。Reading 的 getter 加上 const,用來讀取 value;setter 會修改 value,因此不加 const。
因為 const 物件只能呼叫 const 成員函式。傳大物件時用 const T& 可避免複製並限制修改(見 P3);相應的查詢函式必須標成 const,否則唯讀物件無法呼叫。
判斷方式直接看函式承諾:查詢狀態且不修改成員的函式應標成 const;會更新狀態的函式則不能加。這讓同一組查詢介面也能安全用在唯讀物件與 const T& 參數上。
#include <iostream> class Reading { int value; public: Reading(int initial) : value(initial) {} int getData() const { return value; } }; void show(const Reading& sample) { std::cout << sample.getData() << "\n"; } int main() { Reading sample(18); show(sample); }
18
class Reading { int value; public: Reading(int initial) : value(initial) {} int getData() { return value; } // 只拿掉 const }; void show(const Reading& sample) { std::cout << sample.getData(); // 預期編譯錯誤 }
discards qualifiers 表示程式嘗試用唯讀物件呼叫可能修改狀態的函式。
Timer 的三種建立方式C++ 允許同一個名字有多個版本,只要參數列可區分即可。編譯器會依呼叫時提供的引數個數與型別挑選最合適的版本。
a: 3, 4 b: 5, 1 c: 0, 1
Timer a(3, 4) 選兩參數版本,b(5) 選單參數版本,c 選無參數版本。這裡透過已學過的公開 getter 印出初值與步幅,稍後再教如何替整個物件定義 operator<<。
#include <iostream>
using namespace std;
class Big {
public:
Big(int id) : tag(id) { cout << " [建構] Big " << tag << '\n'; }
Big(const Big &other) : tag(other.tag) {
cout << " [複製] 又抄了一份 Big " << tag << " ← 這行就是成本\n";
}
int getTag() const { return tag; }
private:
int tag;
};
void byValue(Big b) { cout << " 函式裡看到 " << b.getTag() << '\n'; }
void byConstRef(const Big &b) { cout << " 函式裡看到 " << b.getTag() << '\n'; }
int main() {
Big item(7);
cout << "呼叫 byValue(item):\n"; byValue(item);
cout << "呼叫 byConstRef(item):\n"; byConstRef(item);
}
[建構] Big 7 呼叫 byValue(item): [複製] 又抄了一份 Big 7 ← 這行就是成本 函式裡看到 7 呼叫 byConstRef(item): 函式裡看到 7
byValue(item) 觸發一次複製建構;byConstRef(item) 直接讀原物件而不複製。
「編譯器會依你傳的引數挑一個」這句話講得太快了。它實際上做三件事:先把同名的建構式全抓出來當候選,再逐一比對參數列,個數或型別對不上就排除,最後從符合條件的裡面挑一個。下面的逐步器把這個過程放慢給你看。
本例的 Timer 可使用編譯器隱式宣告的複製建構式,將兩個 int 成員複製到新物件。這不表示所有類別都永遠能複製:某些成員或其他特殊成員函式的宣告,會讓隱式複製建構式被定義為 deleted。
能區分多載的只有參數列:型別、個數、順序。名字全部相同,回傳型別也不算在內 —— 兩個函式只差回傳型別的話,那不是多載,是重複定義,直接編不過。
Timer g = f; 與 Timer g(f); 用已存在的同型別物件初始化新物件,這裡會複製成員。以左值物件傳入值參數也可能複製;回傳值則還可能移動或省略複製,不能說每次回傳都必定呼叫複製建構式。
前三個候選也可以用一個帶預設值的建構式取代:Timer(int initial = 0, int step = 1);。少寫兩份本體、少兩份要一起維護的程式碼,這是它的好處。
代價是兩者不能並存:留著預設參數版又留著 Timer(),寫 Timer c; 時兩個都可行、也分不出高下,編譯器會報 call of overloaded … is ambiguous。判準很簡單:各版本本體一樣、只差沒填的值就用預設參數;本體真的要做不同的事(例如收 int 跟收 Timer)就寫成多載。
Timer c; 不要寫成 Timer c();加了括號,C++ 會把它當成「宣告一個回傳 Timer 的函式 c」,而不是建立物件。這種解析問題稱為 most vexing parse,錯誤訊息通常離現場很遠。要建立物件就不要加空括號。
operator<< 與 friend:替物件定義輸出格式[編譯失敗]
cell.cpp: In function ‘int main()’:
cell.cpp:13:10: error: no match for ‘operator<<’ (operand types are ‘std::ostream’ {aka ‘std::basic_ostream<char>’} and ‘Point’)
13 | cout << p << endl; // 沒有定義 operator<<,編譯器不知道怎麼印cout << p 編不過,因為自訂類別沒有自動產生的串流輸出格式。訊息 no match for 'operator<<' 的意思是:編譯器找不到能接收這個型別的 << 版本。
要支援 cout << p,左運算元是 ostream,所以不能只寫成 Point 的一般成員運算子;可定義接收 ostream 與 Point 的非成員函式。下面函式需要直接讀取 private 的 x、y,因此在類別內以 friend 授權。如果改用公開 getter 讀值,就不一定需要 friend。
(3, 4)
回傳 ostream& 讓 cout << a << b 能繼續串接;參數 const Point& 避免複製並允許唯讀物件。friend 只授權指定函式存取,並不把該函式變成 Point 的成員。
定義 operator<< 後,計時設定、測量值或座標都能用一致方式輸出,方便檢查程式執行期間的物件狀態。
ReadingLog 保存多筆測量,內部持有 vector<int>,對外提供加入、計數與計算平均。這種「A 擁有 B」的關係就是組合:自己的類別決定介面,標準容器管理底層儲存。
count = 3 average = 20
重點是 vector<int> values;:ReadingLog 擁有一個 vector,並不是 vector 本身。values 維持 private,外部以 add、size、average 操作。沒有自訂建構式時,本例的預設建構會建立空 vector;對空記錄呼叫 average 會依契約拋出例外。未來更換內部儲存方式時,可以維持公開操作不變。
ReadingLog::add 目前用 push_back 加到 vector 尾端。若改用 insert(begin(), value) 從前端加入,新版本的一次插入成本有何差別?
ReadingLog 持有 vector,但不需要向外公開所有 vector 操作。尾端加入具有攤還常數成本;前端插入通常要搬移原有元素,成本線性。average 則走訪所有讀數,呼叫一次的成本與記錄數成正比。
.hpp 與 include guard:避免類別重複定義類別通常放在 .hpp 裡,讓別的檔案 #include 進來用。但 #include 就是把整個檔案的內容原地貼過來,同一個檔案被貼兩次,類別就重複定義了。
標頭中的 explicit 表示這個單參數建構式不供一般隱式轉換使用:Reading r(18); 可以明確建立物件,Reading r = 18; 則不接受。這與 include guard 是不同功能;guard 處理重複引入,explicit 限制建立物件時的轉換。
要建立 Reading 物件的來源檔,需 include 含完整類別定義的 reading.hpp。簡短成員函式可像上例直接定義在類別內;較長實作也可分到 .cpp 再一同連結。以型別為參數的 template 語法留到 P9,這裡先掌握普通類別與標頭防護。
已有 Reading room(18); Reading *p = &room;。若要透過 p 讀取 room 的測量值,哪個寫法正確?
| 概念 | C++ | Python |
|---|---|---|
| 建構式 | 類別名稱(…) | __init__ |
| 指向自己 | this(指標,隱含) | self(參數,要明寫) |
| 藏起來 | private(編譯器強制) | 底線開頭(只是約定) |
| 印出來 | operator<< | __str__ |
| 唯讀承諾 | 函式後面加 const | 沒有對應機制 |
| 同名多版本 | 參數列不同即可 | 只能靠預設參數 |
繼承、多型、virtual、運算子多載與 template,見 P9 · 物件導向(進階)。
| 資源 | 看什麼 |
|---|---|
| cppreference · Classes | 類別、成員與存取控制的語言參考。 |
| cppreference · Classes | 存取控制、建構式、成員函式的完整規則。 |
| C++ Tutor | 把本頁的 Reading 貼進去,看物件的成員在記憶體裡怎麼排。 |
每個選項都有解說:選錯也點開看看為什麼錯。全對之後再往下翻詞彙卡。
#include <iostream>
using namespace std;
class Reading {
private:
int value;
public:
Reading(int v) { value = v; }
int getData() const { return value; }
};
int main() {
Reading sample(93);
cout << sample.getData();
}
#include <iostream>
using namespace std;
class Reading {
private:
int value;
public:
Reading(int v) { value = v; }
};
int main() {
Reading sample(93);
cout << sample.value;
}
Timer a(3, 4), b(5), c;
cout << a.initial() << "," << a.step() << " "
<< b.initial() << "," << b.step() << " "
<< c.initial() << "," << c.step();
先看正面術語,心中默想定義再翻面對答案;洗牌後再過一輪,直到每張都能不假思索說出來。