Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

指標與參考

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++ 指標與參考:把記憶體攤在你面前〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。