← 11/19|指標、動態記憶體與 C 風格字串(Ch 9、Ch 10) | 回總覽 | 12/03|檔案輸入輸出(Ch 12) →
對應課本習題:Ch11: 1, 3
這幾題各要用到什麼(動手前先看)
主課的上機考幾乎就是這些題目,所以每一題都自己寫過。下表是每題需要的東西(我的歸納,不是題目本文)與本系列對應的練習:
| 課本題號 | 要用到的東西 | 先練 |
|---|---|---|
| Ch11-1 | 兩個函式各自拆成 .h/.cpp、放在同一個 namespace,main.cpp 只 include 需要的檔 |
Q4(幾乎同一題型) |
| Ch11-3 | 把一個類別整個放進 namespace,不用檔案層級的 using namespace |
Q1、正文〈三種使用方式〉 |
這週要會什麼
1 | header 檔與實作檔 → include guard → 多檔案編譯與連結 → Makefile 增量編譯 → namespace |
為什麼要拆檔
當程式長到幾百行,全部塞在 main.cpp 會有三個問題:
- 改一個小地方就要重編整份,很慢。
- 多人合作時同時改同一個檔,一定衝突。
- 寫好的類別想在別的專案重用,只能複製貼上。
解法是把每個類別拆成兩個檔案:
Circle.h(header,標頭檔)放宣告:類別有哪些成員、每個函式長什麼樣,等於這個類別的說明書。Circle.cpp(實作檔)放定義:每個函式實際怎麼做。
講白一點:header 放的就是 09/24 學過的函式原型(只有簽名、結尾分號),只是現在連類別一起放進去;.cpp 放帶大括號的本體。別人(或未來的你)只要 #include "Circle.h",看宣告就知道有哪些函式能呼叫,不必翻 Circle.cpp。
把 Circle 拆成三個檔案
Circle.h
1 |
|
最外面的 #ifndef / #define / #endif 三行叫 include guard,每個 header 都要寫,等一下的〈Include guard〉那一節會解釋它在擋什麼——先照抄。單參數建構子加 explicit 是 11/12 講過的習慣,只寫在 header 這一邊。
Circle.cpp
1 |
|
建構子寫在類別外面長這樣:建構子的名字就是類別名,所以會寫成 Circle::Circle()——看起來像重複兩次,其實左邊 Circle:: 是「屬於哪個類別」、右邊才是函式名。建構子沒有回傳型別,所以 Circle:: 前面什麼都不寫,跟 double Circle::area() 前面要寫 double 不一樣。另外初始化列表只能寫在定義這一邊,header 裡的 Circle(); 後面不可以掛 : r(1.0)。
main.cpp
1 |
|
角括號 vs 雙引號,以及 #include 放哪個檔
#include <iostream>:角括號用於系統/標準函式庫。#include "Circle.h":雙引號用於你自己寫的檔案(先找目前目錄)。
還有一條擺放規則:header 只 include 宣告本身用得到的——資料成員型別是 std::string,.h 就非 #include <string> 不可;只有實作才用到的 <iostream>、<iomanip> 放 .cpp 就好,否則每個引入它的檔案都被迫一起吃進去。
Include guard:#ifndef / #define / #endif
#include 做的事就是把檔案內容原地貼上。如果同一個 header 被貼進來兩次(例如你後來又寫了 Canvas.h 和 Report.h,兩個都 #include "Circle.h",而 main.cpp 把兩個都引進來),類別就會被定義兩次 → error: redefinition of 'class Circle'。
1 |
|
第二次引入時 CIRCLE_H 已經存在,整段就被跳過。每個 header 都要寫,#define 後面那個名字通常用檔名大寫加底線。(另外有個一行版的 #pragma once,效果一樣,但課本與考試請寫 #ifndef 三行式。)
guard 擋得住什麼、擋不住什麼:它只保證同一個 .cpp 裡展開一次,擋不住「兩個 .cpp 各含一次」。所以 header 裡只能放「宣告,或寫在 class 大括號裡面的東西」——類別定義(含直接寫在 class 大括號內的成員函式本體,它們自動算 inline,被兩個 .cpp 各引入一次也不會撞,所以 10/15 到 11/12 那種一體成型的類別整個搬進 header 是合法的)、函式原型、const 常數可以;把函式實作(int twice(int x) { return x * 2; })寫進 header,兩個 .cpp 各引入一次再一起連結,就會爆 multiple definition of 'twice(int)',而且 include guard 救不了,因為那是連結階段的錯,不是前置處理階段。(範例把 const double PI 放在 Circle.cpp 而不是 Circle.h,就是因為只有實作用得到。)
多檔案怎麼編譯
手動編譯(了解原理用):
1 | g++ -c Circle.cpp # 產生 Circle.o |
輸出:
1 | 78.5398 31.4159 |
-c 的意思是「只編譯,不連結」。每個 .cpp 各自變成一個 .o,最後一次連結起來。趕時間時也可以 g++ -o app main.cpp Circle.cpp 一行搞定,但那是整份重編;拆成 .o 的意義是「只有改過的 .cpp 需要重編」——幫你判斷哪些要重編的,就是下一節的 make。
雷區:
undefined reference to 'Circle::area() const'
(const成員函式的訊息尾巴會帶著const;忘了編Circle.cpp時第一行其實是undefined reference to 'Circle::Circle(double)',缺幾個就印幾行。)
這是連結階段的錯誤,代表「有宣告但找不到實作」。最常見原因:
- 忘了把
Circle.cpp一起編譯(只g++ -o app main.cpp)。- 定義時忘了寫
Circle::/mathUtil::,變成定義了一個全域函式。(注意分兩種:漏的是 namespace 前綴一定是連結錯;漏的是類別的Circle::,只有該函式完全不碰資料成員時才會拖到連結,像area()要用到r,編Circle.cpp當下就先報error: 'r' was not declared in this scope,若原本是const成員還會多一句non-member function 'double area()' cannot have cv-qualifier。)- 非成員函式的簽名跟 header 不一致(header 宣告
int twice(int);,cpp 卻定義成double twice(double))——連結器照著 header 的簽名去找,找不到。(成員函式漏寫const則是編譯.cpp時就報no declaration matches,不會拖到連結。)另外:永遠不要
#include "Circle.cpp"。#include只拿.h;把.cpp貼進來再跟它自己一起連結,會得到一整串multiple definition of 'Circle::Circle()'。
多檔案的 Makefile
1 | CC = g++ |
變數名照〈環境設置〉那篇(也就是助教投影片)的 CC/FLAG。rm -f *.o app 的 * 是「目前目錄下所有 .o 結尾的檔案」;rm 配 * 威力很大,下指令前先 pwd 確認站對資料夾。
為什麼 main.o 要把 Circle.h 列為相依? 因為 main.cpp 引入了它——改了 Circle.h 卻沒重編 main.o,就會編出前後不一致的程式。把 header 列進相依清單,make 才知道要重編。
跟〈環境設置〉「每題兩行」的寫法比,這裡多了一層:每個 .cpp 各自一條 .o 規則,相依清單要把它 #include 的 .h 列進去,改了 header 才會重編;最後一條規則把所有 .o 連結成執行檔。
命名空間(namespace)
當兩個函式庫都定義了 sort 或 Node,名字就會撞在一起。命名空間就是幫名字加上「姓氏」:
1 |
|
輸出:
1 | 9 |
兩邊都合理地把函式取名叫 area,有「姓氏」就不會打架。注意 namespace 的結尾大括號不加分號(跟 class/struct/enum 相反)——它是區塊不是型別定義,if、for、函式的大括號也一樣不加。
三種使用方式:
| 寫法 | 意思 | 建議用在哪 |
|---|---|---|
std::cout << "A"; |
每次寫全名,最明確 | header 檔一律用這個 |
using std::cout; |
using 宣告:只把 cout 一個名字拉進來 |
想少打字又想控制範圍時 |
using namespace std; |
using 指令:把整個 std 拉進來,最方便也最髒 |
只寫在 .cpp 最上面或函式內 |
兩種 using 都遵守作用域:寫在函式裡就只有那個函式受影響,寫在檔案最上面則是整個檔案。
重要規則:
using namespace std;絕對不要寫在 header 檔裡。
因為所有引入這個 header 的檔案都會被影響,等於強迫別人接受你的選擇,名稱衝突的風險會傳染出去。header 裡請乖乖寫std::string。
namespace 跨檔案怎麼寫
宣告與定義分家之後,兩邊都要說清楚自己屬於哪個 namespace:
1 | // mathUtil.h —— 宣告包在 namespace 裡 |
兩種寫法效果一樣,後者就像類別的 Circle::area。順便記一條:namespace 跟 class 不同,可以重複打開——.h 開一次放宣告、.cpp 再開一次放定義,編譯器會把同名的合併,不算重複定義。
忘了包(也沒寫 mathUtil::)就會定義出一個全域 square,mathUtil::square 只有宣告沒有實作,連結時報 undefined reference to 'mathUtil::square(double)'——正好是上一節的雷區。
未命名的命名空間
預設情況下,.cpp 裡的全域函式是跨檔可見的——別的 .cpp 寫個宣告就叫得到,也代表兩個 .cpp 各寫一個 helper 會在連結時撞成 multiple definition。包進未命名的命名空間,它就只剩本檔案看得到:
1 | // Circle.cpp |
順帶一提:匿名 namespace 裡的函式如果同一個 .cpp 裡沒人呼叫,-Wall -Wextra 會提醒 warning: 'double {anonymous}::helper(double)' defined but not used [-Wunused-function]。這是正常的——因為編譯器很確定外面也不會有人叫它。把它真的用起來,警告就消失。
巢狀命名空間
命名空間可以包命名空間,做更細的分類:
1 |
|
輸出:
1 | 9 |
名字一長就很囉唆,這時可以取別名:namespace sm = school::math;,之後寫 sm::square(3) 就好。
本週重點回顧
.h放宣告、.cpp放定義;class 大括號外的函式實作不要放進 header(寫在 class 裡面的成員函式是例外,自動算inline,不會撞),兩個.cpp一起連結會multiple definition,include guard 救不了。- 每個 header 都要加 include guard(
#ifndef/#define/#endif);#include <...>找系統函式庫,#include "..."找自己的檔案。 undefined reference是連結階段的錯誤,意思是「有宣告但找不到實作」——通常是少編譯某個.cpp,或定義時忘了寫類別名::/namespace 名::。- header 裡絕對不要寫
using namespace std;;Makefile 的相依清單一定要含 header,否則改了 header 不會重編。
本週練習題
Q1. 拆解 BankAccount
把 10/22〈類別與建構子〉那篇的 BankAccount(deposit、withdraw、getBalance、getOwner、print)拆成 BankAccount.h、BankAccount.cpp、main.cpp 三個檔案,加上 include guard,並寫一份 Makefile 讓 make 可以編出執行檔、make clean 清乾淨。
參考解答
BankAccount.h
1 |
|
BankAccount.cpp
1 |
|
header 寫 std::string、.cpp 因為有 using namespace std; 而寫 string,兩者是同一個型別,簽名算一致——編譯器比對的是型別,不是字面上的文字。
main.cpp
1 |
|
Makefile
1 | CC = g++ |
Q2. 自己的工具函式庫
建立 utils.h / utils.cpp,放進 namespace utils,包含 gcd、lcm、isPrime、reverseNumber 四個函式,在 main.cpp 用 utils:: 前綴呼叫。規格:參數都是 int,isPrime 回傳 bool、其餘回傳 int;gcd/lcm 要能吃負數(先取絕對值),lcm(0, x) 回傳 0;reverseNumber 保留正負號、反轉後的前導零直接丟掉(-1230 → -321)。main.cpp 請依序各印一行:utils::gcd(24, 36)、utils::lcm(4, 6)、utils::isPrime(97)、utils::reverseNumber(-1230),預期輸出是 12 / 12 / 1 / -321。
參考解答
utils.h
1 |
|
utils.cpp
1 |
|
main.cpp
1 |
|
lcm 寫成 a / gcd(a,b) * b 而不是 a * b / gcd(a,b),是為了避免 a * b 先溢位——但這只擋得住中間的溢位;lcm(100000, 99999) 這種答案本身就超過 int 的函式,回傳型別是 int 就不可能正確,所以規格應該說清楚「結果在 int 範圍內才保證正確」。另一個容易漏的是負數:gcd 裡的 a = -a 改的是它自己的參數複本,lcm 的 a、b 還是負的,不再取一次絕對值的話 lcm(-4, 6) 會回 -12——正是 10/01 傳值的觀念。
Q3. 觀察增量編譯
用 Q1 的專案做實驗:先 make,再只改 main.cpp 裡的一行,重新 make,觀察哪些檔案被重新編譯;接著只改 BankAccount.h,再 make 一次,比較差異並解釋原因。
參考答案
- 只改
main.cpp:只有main.o被重編,BankAccount.o沒動,最後重新連結。因為BankAccount.o的相依檔案都沒有變新。 - 只改
BankAccount.h:main.o與BankAccount.o都會重編,因為兩條規則都把BankAccount.h列為相依。 - 如果 Makefile 忘了把 header 寫進相依清單,改 header 時
make會以為沒事做(Q1 的 Makefile 預設目標是all,所以印的是make: Nothing to be done for 'all'.;直接下make app才會看到make: 'app' is up to date.),編出來的程式就可能不一致——這是很難查的 bug。
實驗課題型加練
下面這題的題型取自去年(2025)第 10 週實驗課的課堂練習(今年的投影片還沒出,題目可能會換):三個同名函式各放在自己的 namespace 與自己的 .cpp,必須用 Makefile 編譯、執行檔名指定。題目不是我原創的:練的東西跟去年那題一樣,但要做的轉換、檔名、輸出格式和解答都是我自己重寫的,不是原題。這是檢查分離編譯的標準題型,做完就等於期末上機考多檔案題的暖身。
Q4. 三種轉換(namespace + 分離編譯)
讀入一個非負整數,用三種方式輸出:左右反轉(123 → 321)、二進位(123 → 1111011)、八進位(123 → 173)。三個功能分別放在 namespace Reverse、Binary、Octal 裡,函式都叫 show。檔案拆成:formats.h(只放三個宣告)、reverse.cpp、binary.cpp、octal.cpp(各自實作)、main.cpp;Makefile 要能編出名為 convert 的執行檔,make clean 要能清乾淨。
1 | 輸入: 123 |
參考解答
formats.h
1 |
|
reverse.cpp
1 |
|
binary.cpp
1 |
|
octal.cpp
1 |
|
main.cpp
1 |
|
Makefile
1 | CC = g++ |
1 | $ make |
三個 show 同名卻不衝突,就是因為各在自己的命名空間,呼叫時寫 Reverse::show(n)。實作檔裡函式定義寫成 void Reverse::show(int number),跟成員函式的寫法一樣「用 :: 說明它屬於誰」。.h 只放宣告、不放實作,否則三個 .cpp 都 include 它就會重複定義。二進位那段用「餘數接在字串前面」(static_cast<char>('0' + number % 2) + bits),比先存進陣列再反著印少一個步驟;八進位是同一個做法,只是除以 8、餘數是 0–7——兩個幾乎一樣的函式各放在自己的命名空間、各自一個檔案,呼叫端只要看 Binary:: 還是 Octal:: 就知道在用哪一個。Makefile 裡每個 .o 都把 formats.h 列進相依,改了宣告四個檔案才會一起重編(本篇 Q3 的實驗)。
← 11/19|指標、動態記憶體與 C 風格字串(Ch 9、Ch 10) | 回總覽 | 12/03|檔案輸入輸出(Ch 12) →
說些什麼吧!