Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

指標與參考

C++ 指標與參考:把記憶體攤在你面前

從失靈的 swap 出發,理解位址、指標、參考、nullptr 與懸空指標——以及為何這是 C++ 的招牌

當你想知道「那個變數到底住在哪裡」

假設你正在寫一支 C++ 程式,要交換兩個整數的值。在 Python 裡你大概會直接 a, b = b, a,乾淨俐落,完全不用煩惱「值到底放在哪」。但在 C++ 裡,如果你寫一個函式 void swap(int a, int b),呼叫它之後會發現——原本的兩個變數紋風不動。函式裡的交換明明成功了,外面卻毫無變化。

這個「失靈的 swap」是幾乎每個 C++ 學習者都會撞上的牆。要翻過它,你必須理解 C++ 對「記憶體」的態度:在 C++ 裡,每個變數都有一個明確的位址(address),你可以拿到它、傳遞它、透過它去改別人的值。這就是指標(pointer)參考(reference)的世界。

相較於 Python 把記憶體管理藏在背後(你永遠看不到物件的真實位址,只能看到 id() 這種抽象代號),C++ 把記憶體攤在你面前。這既是它最讓人卻步的地方,也是它能寫出作業系統、遊戲引擎、資料庫核心的根本原因。讓我們動手把這層面紗掀開。

C++ 指標與參考概念示意圖

變數住在哪裡:位址與取址運算子 &

在 C++ 中,每個變數在程式執行時都被放在記憶體的某個位置,這個位置有一個編號,就是它的位址。你可以用取址運算子 & 拿到它:

#include <iostream>
using namespace std;

int main() {
    int age = 20;
    cout << "age 的值是:" << age << '\n';
    cout << "age 的位址是:" << &age << '\n';
    return 0;
}

執行後 &age 會印出類似 0x7ffd3a2b4c1c 的十六進位數字。這個數字每次執行可能不同(作業系統會隨機配置),但重點是:它是真實存在的記憶體位置

相較於 Python,這裡有個關鍵差異。Python 的變數名稱只是貼在物件上的標籤,物件本身飄在堆積(heap)裡,你碰不到它的位址。而 C++ 的 int age = 20; 是實實在在地在記憶體劃出一塊 4 位元組的空間,把 20 放進去——這是靜態型別 + 編譯帶來的確定性:編譯器在編譯期就知道 age 要佔多大、放哪種型別。

指標:宣告與解參考 *

指標就是一個專門用來存「位址」的變數。 既然位址也是一種資料,當然也能存起來、傳來傳去。宣告指標時,在型別後面加 *

#include <iostream>
using namespace std;

int main() {
    int age = 20;
    int* p = &age;      // p 是「指向 int 的指標」,存的是 age 的位址

    cout << "p 存的位址:" << p << '\n';     // 等同 &age
    cout << "p 指向的值:" << *p << '\n';    // 解參考:取出 age 的值,印出 20

    *p = 25;            // 透過 p 去改 age
    cout << "age 現在是:" << age << '\n';   // 印出 25
    return 0;
}

這裡有兩個 *,意義完全不同,初學者一定要分清楚:

  • 宣告時的 *int* p 表示「p 是一個指標」,是型別的一部分。
  • 使用時的 **p解參考(dereference),意思是「順著 p 存的位址走過去,拿出那裡的東西」。

換句話說,p 是「地址」,*p 是「住在那個地址的人」。*p = 25 就是「把住在那個地址的值改成 25」——而那個地址正是 age,所以 age 真的被改了。

慣例提醒:int* pint *p 在語法上完全等價。本文採 int* p(星號貼著型別)以強調「指向 int 的指標」是一個整體型別。但要小心 int* a, b; 這個陷阱——這裡只有 a 是指標,b 是普通 int!這是少數 int *a, *b; 寫法反而更清楚的場合。

指標與陣列:C++ 的底層真相

在 C++ 裡,陣列名稱在大多數情境下會「退化(decay)」成指向第一個元素的指標。這揭露了陣列的底層本質:一塊連續的記憶體。

#include <iostream>
using namespace std;

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    int* p = arr;           // arr 退化成 &arr[0],不需要寫 &

    cout << *p << '\n';     // 10
    cout << *(p + 1) << '\n'; // 20,往後移一個 int
    cout << *(p + 2) << '\n'; // 30

    // 用指標走訪整個陣列
    for (int i = 0; i < 5; i++) {
        cout << *(p + i) << ' ';
    }
    cout << '\n';           // 輸出:10 20 30 40 50
    return 0;
}

注意 p + 1 不是「位址加 1 個位元組」,而是「位址加 1 個 int 的大小」(通常是 4 位元組)。編譯器知道 pint*,所以會自動乘上 sizeof(int)。這就是為什麼 arr[i] 其實只是 *(arr + i) 的語法糖——你甚至可以寫出 3[arr] 這種看起來很怪但合法的寫法(因為 *(arr + 3)*(3 + arr) 相等)。

相較於 Python 的 list(背後是動態的物件指標陣列,能裝任何型別、能自動成長),C++ 原生陣列是固定大小、同型別、緊湊排列的連續記憶體。這讓它存取速度極快($O(1)$ 隨機存取且對快取友善),但也意味著越界存取不會報錯,而是直接讀到隔壁的記憶體——這是 C++ 的危險與威力所在。

參考(reference):指標的安全替身

C++ 還有一個指標的近親:參考(reference)。參考是某個已存在變數的別名(alias)——同一塊記憶體的另一個名字。宣告時用 &(注意這裡的 & 是型別的一部分,不是取址):

#include <iostream>
using namespace std;

int main() {
    int score = 90;
    int& ref = score;       // ref 是 score 的別名

    ref = 100;              // 等同 score = 100
    cout << score << '\n';  // 輸出:100

    cout << &score << '\n'; // 兩者位址相同,因為是同一塊記憶體
    cout << &ref << '\n';
    return 0;
}

參考和指標的差別:

指標 int* p 參考 int& r
必須初始化 否(但強烈建議) 是,宣告時就要綁定
可改指向別人 否,一旦綁定終身不變
可以是 null 是(nullptr
使用語法 *p 解參考 直接當變數用

簡單說:參考是「綁死的、不會是空的指標」,語法更乾淨。當你只是想「給某個變數取個別名」或「讓函式能改外面的值」時,優先用參考;當你需要「可以指向不同對象、可以是空、需要做指標算術」時,才用指標。

nullptr:明確表達「什麼都不指向」

指標可以不指向任何東西,這時應該讓它等於 nullptr

#include <iostream>
using namespace std;

int main() {
    int* p = nullptr;       // 明確表示「目前不指向任何有效記憶體」

    if (p == nullptr) {
        cout << "p 是空指標,不能解參考\n";
    } else {
        cout << *p << '\n';
    }
    return 0;
}

nullptr 是 C++11 引入的關鍵字,取代了舊時代的 NULL(其實只是 0 的巨集)和裸寫 0。為什麼要特別搞一個關鍵字?因為 NULL 是整數 0,在函式重載時會搞混(編譯器分不清你要呼叫吃 int 的版本還是吃指標的版本)。nullptr 有專屬型別 std::nullptr_t,永遠被視為指標的「空」,語意明確。

鐵則:解參考 nullptr 是未定義行為(undefined behavior),通常直接讓程式崩潰(segmentation fault)。 因此在解參考前養成檢查的習慣。相較於 Python 存取 None.foo 會丟出乾淨的 AttributeError,C++ 的空指標解參考可能直接讓整個程式爆掉,甚至更糟——靜默地讀寫到不該碰的記憶體。

終於:修好那個失靈的 swap

回到開頭的問題。C++ 函式預設是傳值(pass by value)——把引數複製一份進函式,函式裡改的是副本,外面當然不變。要讓函式能改到外面的變數,有兩種正統做法:

#include <iostream>
using namespace std;

// 做法一:傳指標
void swapByPointer(int* a, int* b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

// 做法二:傳參考(C++ 慣用,更乾淨)
void swapByReference(int& a, int& b) {
    int tmp = a;
    a = b;
    b = tmp;
}

int main() {
    int x = 3, y = 7;

    swapByPointer(&x, &y);          // 要明確取址
    cout << x << ' ' << y << '\n';  // 7 3

    swapByReference(x, y);          // 直接傳,像普通變數
    cout << x << ' ' << y << '\n';  // 3 7
    return 0;
}

兩種版本效果相同,但參考版是現代 C++ 的慣用寫法:呼叫端 swapByReference(x, y) 看起來就像在傳普通變數,沒有滿天飛的 &*。事實上,C++ 標準函式庫的 std::swap 就是用參考實作的。

這也是為什麼大型函式常用 const T&(常數參考)傳遞物件——既避免複製大物件的成本,又承諾「我只讀不改」。

動手寫一段:用傳參考統計一筆成績

來寫一個完整的小程式:傳入一個分數,函式同時算出「等第」並透過參考把「是否及格」回傳給呼叫端。

#include <iostream>
#include <string>
using namespace std;

// 回傳等第字串,並透過參考 passed 告知是否及格
string grade(int score, bool& passed) {
    passed = (score >= 60);
    if (score >= 90) return "A";
    if (score >= 80) return "B";
    if (score >= 70) return "C";
    if (score >= 60) return "D";
    return "F";
}

int main() {
    int scores[3] = {95, 58, 73};

    for (int i = 0; i < 3; i++) {
        bool ok = false;
        string g = grade(scores[i], ok);   // ok 會被函式改寫
        cout << "分數 " << scores[i]
             << " 等第 " << g
             << (ok ? " (及格)" : " (不及格)") << '\n';
    }
    return 0;
}

// 輸出:
// 分數 95 等第 A (及格)
// 分數 58 等第 F (不及格)
// 分數 73 等第 C (及格)

這裡 bool& passed 讓函式既能「回傳等第字串」又能「順手改寫呼叫端的 ok」——一個函式回傳兩種資訊,這正是參考的典型用法。試著動手把 bool& 改成普通 bool,重新編譯執行,你會發現 ok 永遠是 false:因為改的是副本。這個對照能讓你牢牢記住「傳值 vs 傳參考」的差別。

為什麼指標是 C++ 的招牌

你可能會問:既然參考這麼好用,為什麼還需要指標?因為指標能做到參考做不到的事,而這些事正是 C++ 之所以強大的核心:

  1. 動態記憶體管理:程式執行時才決定要多少記憶體(new / delete),參考做不到。
  2. 可變指向:指標可以今天指這、明天指那,是建構鏈結串列、樹、圖的基礎。
  3. 可為空:用 nullptr 表達「沒有」,例如「樹的葉節點沒有子節點」。
  4. 零成本的底層存取:直接操作硬體記憶體、與 C 程式碼互通、實作高效資料結構。

相較於 Python 把記憶體管理完全自動化(垃圾回收幫你清理,你永遠不碰指標),C++ 把這份控制權交給你。這就是 C++ 的設計哲學——零成本抽象(zero-cost abstraction):你不用為沒用到的功能付出代價,而當你需要極致控制時,語言不會擋你的路。代價是:你得自己負責不要把記憶體搞砸。

常見錯誤與重點回顧

初學者最常踩的雷,列在這裡反覆對照:

  1. 解參考空指標或未初始化指標int* p; *p = 5; 是災難——p 是垃圾值,寫進去等於亂射記憶體。宣告指標時若還沒有對象,先設 nullptr,解參考前先檢查。
  2. 混淆 int* p, q;:這宣告的是「一個指標 p 和一個普通 int q」,不是兩個指標。要兩個指標請寫 int *p, *q; 或分兩行。
  3. 以為傳值能改外面:函式參數沒加 &(參考)或沒傳位址(指標),函式裡的修改不會反映到呼叫端。
  4. 參考必須初始化int& r; 直接編譯失敗。參考宣告當下就要綁定對象,且終身不換。
  5. 回傳區域變數的位址或參考:函式結束後區域變數就消失了,回傳指向它的指標/參考會變成懸空(dangling),後續存取是未定義行為。

一句話總結三個核心符號:宣告時的 * 造指標、宣告時的 & 造參考、使用時的 * 是解參考、使用時的 & 是取址。同一個符號在不同位置意義不同,這是 C++ 語法最容易讓人混淆的地方,多寫幾次就能內化。

深入探討(研究所視角)

指標算術的精確語意

指標算術(pointer arithmetic)的單位是「被指型別的大小」,而非位元組。對 T* pp + n 在數值上等於 (char*)p + n * sizeof(T)。這讓陣列走訪可以寫得極簡潔,但也有嚴格規範:標準只允許指標指向陣列內的元素,或指向陣列「尾後一個位置(one-past-the-end)」end 指標可以拿來比較與計算,但不可解參考。超出這個範圍做算術或比較,即使沒崩潰,也是未定義行為。

兩個指向同一陣列的指標相減,得到的是它們相隔幾個元素(型別為 std::ptrdiff_t),這正是 STL 迭代器 end - begin 計算容器大小的底層機制。理解這點,你就能看懂 std::vectorstd::sort 等是如何在指標/迭代器抽象上做到 $O(1)$ 的距離計算。

const 指標 vs 指標 const:從右往左讀

const 與指標的組合是經典考點,訣竅是從右往左讀

int x = 1, y = 2;

const int* p1 = &x;       // 指向 const int 的指標:不能透過 p1 改值,但 p1 可改指向
// *p1 = 5;   ❌ 錯誤
p1 = &y;                  // ✅ 可以

int* const p2 = &x;       // const 指標:p2 不能改指向,但可透過它改值
*p2 = 5;                  // ✅ 可以
// p2 = &y;   ❌ 錯誤

const int* const p3 = &x; // 兩者皆 const:既不能改指向,也不能透過它改值

把宣告倒著唸:const int* p1 是「p1 is a pointer to const int」(值是常數);int* const p2 是「p2 is a const pointer to int」(指標本身是常數)。const T& 常數參考同理——這是傳大物件進函式又保證不被修改的標準做法,兼顧效率與安全。

懸空指標:C++ 最危險的陷阱

懸空指標(dangling pointer)指向一塊已經被釋放或已失效的記憶體。三種典型成因:

// 成因一:回傳區域變數位址
int* bad() {
    int local = 42;
    return &local;       // ❌ 函式結束後 local 消失,回傳的指標懸空
}

// 成因二:use-after-free
int* p = new int(10);
delete p;                // 記憶體歸還
// *p = 20;              // ❌ 懸空,未定義行為
p = nullptr;             // ✅ 釋放後立刻設 nullptr 是好習慣

// 成因三:指向已超出作用域的物件
int* q;
{
    int tmp = 5;
    q = &tmp;
}                        // tmp 在此銷毀
// *q;                   // ❌ q 懸空

懸空指標可怕在於它通常不會立刻崩潰——那塊記憶體可能還沒被覆寫,程式照常跑,直到某天被其他資料寫入,才在毫不相關的地方爆出難以追查的 bug。這類 use-after-free 也是真實世界中大量安全漏洞的根源。

相較於 Python/Java 靠垃圾回收保證「只要還有人指著,物件就不會消失」,C++ 把生命週期管理交給你。但現代 C++(C++11 起)強烈建議不要裸用 newdelete,而是用智慧指標(smart pointer)std::unique_ptr 表達獨佔所有權、std::shared_ptr 表達共享所有權(引用計數)、std::weak_ptr 打破循環引用。它們透過 RAII(Resource Acquisition Is Initialization)在物件離開作用域時自動釋放,把「手動管理記憶體」的大半危險消弭於無形——這正是現代 C++ 能在保有底層控制力的同時,寫出安全程式碼的關鍵。

理解了裸指標的危險,你才會真正體會智慧指標的價值。下次當你看到 std::unique_ptr<Node> 時,你會知道它背後正是一個被嚴格管控生命週期的指標——這就是 C++ 把「危險的力量」馴服成「安全的抽象」的方式。

AI 共讀助教正在陪你讀:C++ 指標與參考:把記憶體攤在你面前
嗨!我是這篇文章的共讀助教,只根據〈C++ 指標與參考:把記憶體攤在你面前〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。