← 11/12|運算子重載、friend 與 string(Ch 8、Ch 9) | 回總覽 | 11/26|分離編譯與命名空間(Ch 11) →
對應課本習題:Ch9: 2, 4, 6, 10;Ch10: 1, 3, 4, 8
這幾題各要用到什麼(動手前先看)
主課的上機考幾乎就是這些題目,所以每一題都自己寫過。下表是每題需要的東西(我的歸納,不是題目本文)與本系列對應的練習:
| 課本題號 | 要用到的東西 | 先練 |
|---|---|---|
| Ch9-2 | 逐字元依種類做不同的轉換——cctype、% |
11/12 Q5、Q8 |
| Ch9-4 | 把一行裡符合條件的單字換掉,大小寫要保留——切單字、substr |
11/12 Q4 |
| Ch9-6 | 每行用固定的分隔符拆成兩欄再重排——find/substr+讀檔 |
11/12 Q10、12/03 Q7 |
| Ch9-10 | 問答類別(題目、答案、金額)放進 vector 逐題比對——類別+string == |
10/29 Q2 |
| Ch10-1 | 二維動態陣列類別:double* 加列數行數、建構子、存取函式、friend 的 + |
Q4、Q3 |
| Ch10-3 | 頭尾兩個指標往中間掃 C 字串數單字——指標算術 | 正文〈指標與陣列〉、Q5 |
| Ch10-4 | 類別含動態字串陣列:指派運算子、解構子——三法則 | Q3 |
| Ch10-8 | 類別含動態陣列與拷貝建構子,建立多個物件——三法則 | Q3 |
這週要會什麼
1 | 指標是什麼 → nullptr → new / delete 與動態陣列 → 指標與陣列 → 指標的指標 |
這是整學期最多人卡關的一次,但核心觀念只有一句話:指標是一個「存位址」的變數。
記憶體、位址、指標
白話說:把記憶體想成一整排有編號的信箱,每個變數都住在某個編號的信箱裡。
a是信箱裡的內容。&a是信箱的編號(位址)。- 指標就是「專門用來記某個信箱編號」的變數。
1 |
|
某一次的執行結果:
1 | 5 |
位址預設用十六進位印出:0x 是十六進位的開頭記號,後面每一位可以是 0-9 或 a-f(a 代表 10、f 代表 15)。數值每次執行都不一樣,也不重要——重要的是第 2、3 行一定相同。
執行完 *p = 10; 之後,記憶體長這樣:
1 | 變數 a 指標 p |
p 這個信箱裡裝的不是數字,是另一個信箱的編號——這就是指標的全部。
* 和 & 各有兩種意思,看它出現在哪裡:
| 寫法 | 出現位置 | 意思 |
|---|---|---|
int* p; |
宣告時 | 「p 是指向 int 的指標」 |
&a |
運算式中 | 取 a 的位址 |
*p |
運算式中 | 取出 p 指的東西(解參考 dereference) |
int& r = a; |
宣告時 | r 是 a 的別名(10/01 學過);同一個 &,宣告時是型別的一部分,運算式裡才是取位址 |
雷區 ①:
int* p, q;只有p是指標
int* p;、int *p;、int * p;對編譯器完全一樣,空白位置只是排版習慣;關鍵在於*是跟著變數名走的。所以int* p, q;的意思是「*p是 int、q是 int」——q就變成普通整數了。兩個解法:一行只宣告一個指標,或先幫指標型別取個名字——
1
2
3 typedef int* IntPtr; // 傳統寫法:幫「int*」這個型別取一個新名字叫 IntPtr
using IntPtr = int*; // C++11 寫法,意思完全一樣(跟 using namespace std; 只是剛好共用同一個字,用途不同)
IntPtr p, q; // 現在 p、q 都是指標
空指標 nullptr
1 | int* p = nullptr; // 明確表示「目前不指向任何東西」 |
nullptr是 C++11 的寫法,比舊的NULL或0安全,請一律用它。- 對空指標解參考(
*p當p是nullptr)會直接Segmentation fault。
動態記憶體:new 與 delete
10/29 的 vector 能邊跑邊長大,靠的就是這節要拆開來看的 new 和 delete——它讓你在執行時才決定要多少記憶體。(平常寫程式請直接用 vector,學 new / delete 是為了看懂課本與考題。)
記憶體大致分兩塊:stack(堆疊)放區域變數,函式一結束就整批回收;heap(堆積,又叫 free store)是
new拿記憶體的地方,除非你delete,否則它一直都在。這就是等一下「new出來的東西可以安全回傳、區域陣列不行」的原因。
1 | int* p = new int(7); // 跟系統要一格 int,初始化為 7 |
但 new 真正的用途是動態陣列:
1 |
|
輸入 3 再輸入 10 20 30,輸出:
1 | 60 |
四條鐵律:
- 每個
new都要有一個對應的delete,否則記憶體洩漏(memory leak)。 new[]配對delete[],new配對delete,配錯是未定義行為。delete之後不要再用那個指標(懸空指標 dangling pointer)——delete p;之後寫cout << *p;編譯得過、可能還印出看似正常的數字,但那塊記憶體已經還掉了,內容隨時會變。習慣上delete完馬上p = nullptr;,之後誤用會直接 crash,比印出假資料好抓。delete p;當p是nullptr時什麼都不會發生(安全的空操作),所以解構子裡不必先寫if (p != nullptr)。注意這跟「對空指標解參考*p會當掉」是兩回事。
指標與陣列的關係
陣列名稱在大多數運算式裡會自動轉成「指向第 0 個元素的指標」(陣列和指標仍是不同型別:sizeof(a) 是整個陣列的大小,sizeof(p) 只是一個指標的大小):
1 | int a[5] = {10, 20, 30, 40, 50}; |
指標算術:p + 1 是「下一個元素」的位址,不是「位址加 1 個 byte」。編譯器會自動乘上 sizeof(int)。
這也解釋了為什麼「陣列傳進函式要另外傳長度」——傳過去的只是一個指標,長度資訊在傳遞過程中就消失了:
1 | void f(int a[], int n); // 這裡的 int a[] 其實等同 int* a |
指標的指標
指標也是變數,自己也住在某個信箱裡、也有位址,那就能再用另一個指標記住它。「指向 int* 的指標」型別寫成 int**。
兩個關鍵:
new T[n]回傳的型別是T*:T是int時new int[n]回傳int*;T是int*時new int*[n]回傳的就是int**——配出來那排格子裡裝的是指標,不是整數。a[i]取出第 i 個int*(第 i 列的開頭),再[j]才走到那一列的第 j 格;也就是a[i][j]等同*(*(a + i) + j)。
最常見的用途是動態二維陣列:先配一排指標,每個指標再各自指向一列 int。
1 | int n = 2, m = 3; |
1 | a ──▶ ┌─────────┐ |
指標當參數
1 |
|
跟參考(int&)效果一樣,只是語法比較囉唆。現代 C++ 的習慣:能用參考就用參考,指標留給「可能沒有值(nullptr)」或「要做指標算術」的場合。
-> 與 this
指向物件的指標,取成員要用 ->:
1 | struct Point { int x, y; }; |
11/12 重載 ++ 時用過的 *this,現在可以完整解釋了:this 是每個成員函式裡都有的隱藏指標,指向呼叫它的那個物件;*this 就是解參考之後的「物件本身」。
1 | class Counter { |
回傳指標的函式
前面說過「函式不能回傳區域陣列」,因為區域變數住在 stack,函式一結束那塊空間就被收回去。但 new 要來的記憶體住在 heap,只認 delete,函式結束不會動它,所以可以安全回傳:
1 |
|
輸出:
1 | 0 1 4 9 16 |
責任歸屬:記憶體是函式配的,卻要呼叫端
delete[]。寫這種函式一定要在註解裡寫明「呼叫端負責釋放」,不然漏掉就是洩漏。
淺拷貝與深拷貝(本節最重要的觀念)
當類別內部有 new 出來的資源時,預設的複製行為會出事。下面這個 MyArray 其實就是簡化版的 vector——vector 內部做的正是這些事:
1 |
|
編譯器自動產生的複製行為是淺拷貝(shallow copy):只把 data 這個「位址」複製過去,兩個物件指向同一塊記憶體。正確做法是深拷貝(deep copy):自己配一塊新的,把內容一個一個抄過去。
1 | 淺拷貝(預設): 深拷貝(自己寫): |
實際跑上面那段程式,終端機會看到:
1 | free(): double free detected in tcache 2 |
注意崩潰的時機:a、b 活著時完全正常,是離開 main、兩個解構子各跑一次 delete[] 的那一刻才爆炸——出事的地方離寫錯的地方很遠,所以難抓。(10/22 說解構子「真正重要的用途留到指標那節」,就是指這個。)
要做深拷貝,需要自己寫三個函式,合稱 Rule of Three(三法則)。
其中拷貝建構子是新東西:長得像建構子,但參數是
const 自己的型別&。它在三種場合被自動呼叫:
①MyArray b = a;或MyArray b(a);② 物件傳值進函式時 ③ 函式回傳物件時(這條課本與考題會寫,但實測印不出來——g++ 會把回傳值直接就地建構在呼叫端,省掉這次複製)。
這也是為什麼 10/01 說「大物件要用const&傳」——不然每次呼叫都做一次深拷貝。
下面是補完版(比上面那個壞掉的版本多了初始化、operator[]、size(),方便驗證):
1 |
|
三個容易被跳過的細節:
operator=回傳MyArray&並return *this,是為了讓a = b = c能成立——跟 11/12 講<<要回傳ostream&同一個理由。if (this == &other)不能省。other是參考(別名),&other取到的就是它代表的那個物件的位址;兩個指標用==比是在問「指向同一個地方嗎」。少了這行,a = a;會先delete[]掉自己的data,再從已經還掉的記憶體抄回來,抄到垃圾。- 為什麼
other.data碰得到 private?private管的是「哪段程式碼」能碰,不是「哪個物件」能碰。只要程式寫在MyArray類別裡面,就看得見任何一個MyArray物件的 private 成員,包括參數傳進來的別人——所以拷貝建構子不需要friend。
口訣:只要類別裡有 new,就要想到「解構子、拷貝建構子、指派運算子」這三個。 少寫任何一個,程式都可能在某個時候莫名崩潰。
進階補充:
operator=更講究的寫法是先new好新的、抄完,最後才delete[]舊的——萬一new失敗(丟出例外),物件還保有原本的資料,不會留下一個指向已釋放記憶體的data。本課程範圍內上面的寫法就可以。
C 風格字串(C-string)
在 string 類別出現之前,C++ 用「以 '\0' 結尾的 char 陣列」表示字串:
1 |
|
輸出:
1 | 5 |
雷區 ②:
cout對char*是特例
本篇第一節cout << p;印的是位址,但s、t這種char*或 char 陣列,cout會從那個位址一路印到'\0'為止,也就是印出整串字。想看它的位址得寫cout << (void*)s;。
函式(需 <cstring>) |
作用 |
|---|---|
strlen(s) |
長度(不含 '\0') |
strcpy(dest, src) |
複製 |
strcat(dest, src) |
串接 |
strcmp(a, b) |
比較,相同回傳 0;a < b 回傳負數 |
const 放在指標前面:const char* s 讀作「s 指向的內容不可以被改」,跟 10/08 的 const int a[] 是同一件事(int a[] 本來就等同 int* a)。所以 strcpy(char* dest, const char* src) 裡,要被寫入的 dest 不能加 const,只被讀取的 src 要加。字串字面值 "Hello" 存在唯讀區,用指標接它一定要寫 const char* s = "Hello";。
雷區 ③:C-string 不能用
=和==
1
2
3 char a[10] = "abc", b[10];
b = a; // 編譯錯誤
if (a == b) { } // 編譯得過,但比的是「位址」不是內容!一定要用
strcpy和strcmp。這就是string類別存在的理由——它讓=、==、+都正常運作。
雷區 ④:陣列大小要夠
char s[5] = "Hello";連編譯都過不了——g++ 會報error: initializer-string for 'char [5]' is too long,因為"Hello"連同結尾的'\0'要 6 格。真正危險的是編譯器擋不住的那種:char s[5];之後strcpy(s, "Hello");編譯一聲不響,執行時卻把第 6 個位元組寫到別人家去。strcpy、strcat都不檢查目標空間夠不夠,這是 C 語言最經典的安全漏洞來源。
兩者互轉(這段用到 string 和 strlen,兩個 include 都要:#include <string>、#include <cstring>):
1 | string cpp = "Hello"; |
c_str() 回傳的 const char* 指向 string 內部那份以 '\0' 結尾的資料:只能讀不能改,也不需要、更不可以 delete;cpp 一改動或消失它就失效,別留著到處用。(12/03 開檔案時還會再遇到它。)
命令列參數:argc 與 argv
main 可以寫成 int main(int argc, char* argv[]),接收你在終端機上打的參數:執行 ./demo hello 123 時,argc 是 3(參數個數,程式名自己算一個,所以至少是 1),argv 是一排 C 風格字串——argv[0] 是 "./demo"、argv[1] 是 "hello"、argv[2] 是 "123"。參數進來都是字串,要當數字用得自己轉(atoi(argv[2]),在 <cstdlib>)。這學期的題目都從 cin 讀輸入,認得這個寫法就好。
本週重點回顧
- 指標就是存位址的變數:
&a取位址、*p取出指向的內容;int* p, q;只有p是指標。 new配delete、new[]配delete[],漏掉就洩漏、配錯就是未定義行為;delete後把指標設成nullptr(而delete nullptr本身是安全的空操作)。- 陣列名在運算式裡會自動轉成指向第 0 格的指標(兩者型別仍不同,
sizeof的結果不一樣),p[i]等於*(p + i);p + 1是「下一個元素」而不是「下一個 byte」。int**是「指向int*的指標」,動態二維陣列就靠它。 - 預設的複製是淺拷貝(兩個物件共用同一塊記憶體,會 double free);只要類別裡有
new,就要自己寫解構子、拷貝建構子、指派運算子(三法則)做深拷貝。 - C 風格字串是以
'\0'結尾的 char 陣列,不能用=和==,要用strcpy/strcmp;const char*表示「指到的內容不能改」。
本週練習題
Q1. 動態陣列的統計
讀入 n(1 ≤ n ≤ 1000),用 new int[n] 配置陣列,讀入 n 個數字後輸出最大值、最小值與平均(兩位小數),最後正確釋放記憶體。
1 | 輸入: |
參考解答
1 |
|
Q2. 用指標寫 swap
寫 void swapPtr(int* a, int* b),用指標交換兩個變數,並與參考版本比較差異。
參考解答
1 |
|
差別:指標版函式內要寫 *、呼叫時要寫 &。指標型別雖然能表示「沒指向任何東西」(nullptr),但這個 swapPtr 一進來就解參考,兩個參數都必須指向真正存在的變數,傳 nullptr 會直接 Segmentation fault——想允許空值就得自己先 if (a == nullptr) 檢查。參考版兩邊都乾淨,而且參考天生不會是空的,這正是 10/01 推薦用參考的理由。
Q3. 深拷貝練習
完成 class IntVector:內部用一個 int* 加上兩個整數——目前元素個數與容量(兩者不同:容量是已經配好的格子數,元素個數是真正塞了幾格),支援 push_back(滿了就把容量加倍)、operator[]、size(),並正確實作解構子、拷貝建構子與指派運算子。寫一段 main 驗證複製後兩個物件互不影響。
參考解答
1 |
|
三個說明:
data[n++] = x;等同於兩行data[n] = x; n++;——後置++的值是加之前的n,所以是「寫進目前的最後一格之後,長度再加一」。看不習慣就拆成兩行,完全等價。grow()是 private:它是內部實作細節,外面不需要知道也不該呼叫。- 拷貝一個空的
IntVector時,new int[o.cap]會執行到new int[0]——這是合法的,會拿到一個不能解參考、但可以delete[]的指標,所以不用特別加判斷。
Q4. 動態二維陣列
讀入 n、m,用〈指標的指標〉那節的方法配置 $n \times m$ 的二維陣列,讀入內容後輸出每列總和,最後正確釋放。
1 | 輸入: |
參考解答
1 |
|
記憶法:配置是「先外層再內層」,釋放是「先內層再外層」——先把外層砍掉就找不到內層了。
Q5. 不用 <cstring> 自己實作
自己寫 int myStrlen(const char* s)、void myStrcpy(char* dest, const char* src)、int myStrcmp(const char* a, const char* b),並驗證結果與標準函式的正負號一致。
參考解答
1 |
|
三個函式的骨架都是「從頭走到 '\0' 為止」——myStrcpy 最容易錯的是忘記最後補 dest[i] = '\0'。
實驗課題型加練
Q6–Q8、Q10 的題型取自去年(2025)第 9、10 週實驗課的課堂練習(今年的投影片還沒出,題目可能會換),Q9 是我自己出的補充題。去年指標這週實驗課出了**鏈結串列(linked list)**的堆疊與佇列——這個資料結構課本要到 Ch17 才教,但實驗課直接拿它來練 new/delete 和 ->,所以這裡先用兩題把它講完。題目不是我原創的:練的東西跟去年那幾題一樣,但數值、規則、範例資料和解答都是我自己重寫的,不是原題。
Q6. 給值與給址的差別
宣告 int i1 = 3, i2 = 5, i3 = 7 與三個指標 p1、p2、p3 分別指向它們。依序執行 p2 = p1、*p1 = *p3、i3 = 20、p3 = p2,每一步之後印出三個變數的值與三個指標指到的值,最後判斷 p1 == p3 與 *p2 == i2 是否成立。要能口頭說明每一步發生了什麼。
參考解答
1 |
|
輸出:
1 | start i1=3 i2=5 i3=7 *p1=3 *p2=5 *p3=7 |
四句話對應四種操作:p2 = p1 改指向,i2 完全沒動,只是沒人指著它了;*p1 = *p3 透過指標改值,改到的是 i1(此時 p2 也指著 i1,所以 *p2 跟著變);i3 = 20 直接改值,p3 指的那格跟著變;p3 = p2 之後三個指標都指著 i1,所以 p1 == p3 為真(比的是位址),而 *p2 是 i1 的 7、不是 i2 的 5。實驗課檢查時常會被指著某一行問「這時候 p2 指誰」,要答得出來。
Q7. 用鏈結串列做堆疊
鏈結串列是「每個節點除了存資料,還存一個指向下一個節點的指標」的資料結構:
1 | struct Node { |
節點不放在陣列裡,而是需要時 new 一個、用 next 串起來,所以長度不必事先決定。**堆疊(stack)**是「後進先出」:只從同一端放入(push)與取出(pop)。用鏈結串列實作時只要記住最頂端的節點 top:push 是「新節點的 next 指向舊的 top,再把 top 換成新節點」;pop 是「top 往下移一格,把原本的頂端 delete」。
寫 class Stack,提供 push(int)、pop()(空的回傳 false)、display()(從頂端印到底),並在解構子釋放所有節點。不能用陣列或 vector。反覆讀入 push 值/pop,每次操作後印出內容,end 結束。
1 | 輸入: |
參考解答
1 |
|
三個最容易寫錯的細節:push 裡先 n->next = top 再 top = n,順序反了舊的串列就丟了;pop 先用 old 記住頂端、移動 top,最後才 delete old——delete 完再去讀 top->next 就是本篇雷區的懸空指標;解構子把 pop 呼叫到空為止,程式結束時才不會漏記憶體。走訪用 for (Node* p = top; p != nullptr; p = p->next),這個形狀之後所有鏈結串列題都一樣。
Q8. 用鏈結串列做佇列
**佇列(queue)**是「先進先出」:從尾巴放入、從頭取出,所以要同時記住 head 和 tail 兩個指標。寫 class Queue,介面與 Q7 相同(push、pop、display、解構子),不能用陣列或 vector。
1 | 輸入: |
參考解答
1 |
|
跟堆疊只差兩個邊界狀況,也正是最容易寫錯的地方:推進空佇列時 head 和 tail 都要指向新節點(不然 tail->next 會對 nullptr 解參考);取出最後一個之後 tail 也要清成 nullptr,否則它指著已經 delete 的節點,下一次 push 就寫到壞掉的記憶體。
Q9. 迷你資料庫(char**)
第一行是資料筆數 n。接著是指令:INSERT 長度 換行後接一個那個長度的字串,或 OUTPUT(把所有資料倒序印出並結束)。限制:只能用 char** 與 new 配置空間,不能用任何陣列宣告(char rows[100][100]、char* rows[100] 都不行)。
1 | 輸入: |
參考解答
1 |
|
char** rows 是「指向(指向字元的指標)的指標」:外層 new char*[n] 配 n 個指標,每收到一筆再 new char[len + 1] 配那一筆的空間,+1 是給 '\0'。cin >> rows[count] 會把一個單字(讀到空白為止)連同結尾的 '\0' 寫進 char 陣列——它不檢查空間夠不夠,所以一定要先配好 len + 1 格。釋放順序跟本週 Q4 的二維陣列一樣:先每一列、再外層。這題的重點就是把「陣列的陣列」換成「指標的指標」寫一次。
Q10. 關鍵字計數(strtok)
第一行是一段英文(不超過 10000 字元),之後每行一個關鍵字(最多 10 個),讀到輸入結束。印出每個關鍵字在文章中出現幾次:必須整個單字相同(make 不算 makefile)、不分大小寫。單字的定義:字母、數字和單引號以外的字元(空白、, . ; : ! ? " ( ) -)都算分隔符號,所以 don't 是一個字、well-known 是兩個字。請用 C 風格字串與 <cstring> 處理。
<cstring> 有一個切單字的工具 strtok(字串, 分隔字元集合):第一次呼叫傳入字串,它會把第一個單字的結尾改成 '\0' 並回傳單字開頭;之後每次傳 nullptr 就接著切下一個,切完回傳 nullptr。它會直接改壞原字串,所以要切的字串不能是 const。
1 | 輸入: |
參考解答
1 |
|
先把文章和關鍵字全部轉小寫,比較就只剩 strcmp。cin.getline(text, MAX_TEXT) 是 C 風格字串版的 getline(讀進 char 陣列、要給上限)。分隔字元集合 SEP 裡的 \" 是跳脫的雙引號;單引號故意不放進去,don't 才會是一個字。makefile 只算 1 次而 make 算 3 次,就是「整個單字相同才算」的意思——strtok 切出來的是完整的單字,strcmp 比的是整個字串。
← 11/12|運算子重載、friend 與 string(Ch 8、Ch 9) | 回總覽 | 11/26|分離編譯與命名空間(Ch 11) →
說些什麼吧!