指標 Pointer

這章我們要來講一個非常重要的觀念:指標!

指標常常是剛接觸程式語言的初學者非常頭痛的一個主題,就連我也不意外。當初在大學的時候非常討厭寫程式其中一個大原因就是因為搞不懂指標到底在幹嘛,以及為什麼要用指標。

但是想讓自己在程式語言的能力上更上一層樓的話,學會指標的觀念是不可或缺的!而這也可以說是初學與進階的分水嶺。

指標之所以如此複雜是因為我們需要了解碰觸到實際記憶體的位置。

而這也是指標之所以這麼強大的原因,因為正確並有效地使用指標可以讓我們的程式碼運行的更快,同時也使用較少的資源。

但是,正也因為我們需要碰觸到實際記憶體,所以當我們不正確的使用指標時,可能會造成電腦當機,或是反而讓程式使用了更多不必要的資源。

因此,學會理解並正確使用是非常重要的!今天就讓我們來學習這個技能!

變數

在開始講指標之前,我們要先來看在程式中,變數是如何被儲存的。

變數 那章中,我們了解到電腦有一部分記憶體叫 RAM。每當我們宣告一個變數,這些變數就會被儲存在 RAM 中,當程式需要使用變數時,就會到 RAM 裡面將變數裡的資料提取出來。

那麼問題來了,程式要如何知道這個變數是被儲存在 RAM 中的哪裡呢?

其實,當一個變數被宣告並儲存在 RAM 的同時,這個變數就會擁有一個地址 address。這麼一來,當程式提取這個變數時,只需要知道相對應的地址,就可以到 RAM 裡面的資料。

舉例來說,當我們宣告一個變數 x

int x = 0;

在程式執行完這行後,變數 x 便會在 RAM 中佔據一塊記憶體空間,並且擁有一個地址。

假如說這個變數 x 的地址是 100。那麼每當這個變數需要被提取時,程式就會到 RAM 中地址 100 的地方提取資料。

你可能會問:「誒但是到目前為止我都沒有叫程式到特定地址提取資料啊!」

而這就是很方便的地方,編譯器 會自動幫我們做好這些事!在編譯器編譯程式碼的時候,它就會將地址與變數做連結。而我們人類只需要看到符號就好,這是非常人性化的地方。

取址運算子 &(address-of operator)

了解了地址的概念後,我們現在應該就知道,每一個變數都會有相對應的地址。

雖然因為方便性的關係,每個變數的地址被隱藏了起來,但我們仍然有辦法可以取得地址。那就是透過取址運算子 &

我們直接來看實際例子:

int main() {

    int x = 0;
    std::cout << x << '\n';  // 印出 x 的值
    std::cout << &x << '\n'; // 印出 x 的地址

    return 0;
}

執行以上程式碼,我們應該會看到類似以下的輸出

0
0038CAD0

為什麼我說是類似呢?因為當變數被儲存在記憶體時,地址是隨機分配的。

所以這次執行的結果有機率下一次執行的結果是不一樣的,更不用說是不同的電腦了。

那麼這個 0038CAD0 是什麼意思呢?其實這是 16 進位制的表示方法,前面的 0x 經常會被省略。不懂進位制的人沒有關係,目前為止並不影響我們理解,只需要知道這是被用來表達地址的方式就好了。我們有空再來用另一章講進位制。

解引用運算子 *(dereference operator)

但是單單拿到地址其實沒什麼用,我們真正有興趣的是被儲存在那個地址的值。

幸運的是,我們可以透過解引用運算子 * 來做到這件事。

一樣讓我們直接來看實際例子:

int main() {

    int x = 0;
    std::cout << x << '\n';     // 印出 x 的值
    std::cout << &x << '\n';    // 印出 x 的地址
    std::cout << *(&x) << '\n'; // 印出 x 的值

    return 0;
}

執行以上程式碼,我們應該會看到類似以下的輸出

0
0038CAD0
0

有看到嗎?x*(&x) 所輸出的值是一樣的!*(&x) 就是在告訴程式碼說:「請提取 * 位在地址 &x 的資料。」

基本上,使用 *(&x) 是沒什麼意義的,因為我們可以直接使用變數 x 來取值。但是,了解到 &* 的意義對於了解指標是非常重要的!

接下來,讓我們來看指標到底是什麼。

指標 Pointer

所以到底什麼是指標呢?

指標是一個物件,它的值其實是一個記憶體地址,通常是不同變數的地址。利用指標,我們可以儲存其他物件的地址以供未來使用。

一樣,我們直接來看實際範例:

int main() { 
    
    int x = 0; 
    int* ptr; // 未初始化的指標,含有隨機的地址 
    int* ptr1 = &x; // 一個含有變數 x 的地址的指標

    std::cout << *ptr1 << std::endl; // 輸出變數 x 的值 
    return 0; 
}

int* 就是代表我們現在要創建一個指向整數 int 的指標。

還沒初始化的指標系統會隨機給予一個地址,因為我們並不知道這個地址是哪裡,所以如果隨意使用 * 解引用未初始化的指標的話可能會導致程式執行錯誤。

因此,建議的方式是每當宣告一個指標時,同時初始化它

在上面的例子中,ptr1 含有變數 x 的地址,因此若我們要取得變數 x 的值,我們可以解引用 ptr1

我們可以利用下面的圖來理解指標和變數的關係。

值得注意的是,int* 代表這個指標指向整數 int 型態。

因此,若我們將宣告為指向整數型態的指標指向浮整數 float 型態的話,那麼就會出錯。

比如說

int main() {

    float x = 0.5;
    int* ptr = &x;      // 錯誤
    float* ptr1 = &x;   // 正確
    return 0;
}

因為這裡變數 x 擁有浮整數型態,因此在宣告指標時,我們也必須使用 float* 來告知程式這個指標會指向擁有浮整數型態的變數。

指定指標 Pointer Assignment

那麼指標可以做什麼事呢?我們基本上可以對指標做兩件事:

  1. 將指標指向另一個物件
  2. 更改指標指向的物件的資料
將指標指向另一個物件
int main() {

    int x = 0;
    int* ptr = &x; // ptr 現在指向 x

    std::cout << *ptr << '\n'; // 輸出 x 的值: 0

    int y = 1;
    ptr = &y;      // ptr 現在指向 y

    std::cout << *ptr << '\n'; // 輸出 y 的值: 1

    return 0;
}

在上面的例子中,我們宣告了兩個變數 xy,還有一個指標 ptr

一開始,我們將指標指向變數 x,因此,在輸出指摽指向的物件的值時,我們會看到 0

後來,我們將指標改為指向變數 y,所以在後來輸出時才會看到 1

更改指標指向的物件的資料
int main() {

    int x = 0;
    int* ptr = &x; // ptr 指向變數 x

    std::cout << x << '\n';    // 輸出 0
    std::cout << *ptr << '\n'; // 輸出 0

    *ptr = 1; // 將 ptr 指向的物件,也就是變數 x,所擁有的值改為1

    std::cout << x << '\n';    // 輸出 1
    std::cout << *ptr << '\n'; // 輸出 1

    return 0;
}

在上面的例子中,我們首先賦予指標 ptr 變數 x 的地址,並輸出變數 x 以及指標 ptr 所指向的物件的值。

可以看到,兩個都是輸出 0。之後,我們將指標 ptr 指向的物件的值改為 1,然後輸出兩個值。可以看到,現在兩個都輸出 1 了!

這個例子提供了一個很重要的概念:

這是初學程式的新手經常忽略的一個觀念,就是在透過解引用運算子 * 更改一個指標的值時,我們動用到的其實是原物件的值!因此原物件所擁有的資料也會被更動!

指標的大小

接下來讓我們來講講指標的大小。

我們可以透過內建函數 sizeof() 來查看變數的大小。

int main() {

    char* ptr1{};      // char 大小為 1 byte
    int* ptr2{};       // int 大小為 4 bytes
    double* ptr3{};    // doubles 大小為 8 bytes

    std::cout << sizeof(ptr1) << '\n';  // 輸出為 4
    std::cout << sizeof(ptr2) << '\n';  // 輸出為 4
    std::cout << sizeof(ptr3) << '\n';  // 輸出為 4

    return 0;
}

我們可以看到,不管是指向什麼資料型別的指標,所佔用的記憶體大小都是一樣的!而這也是指標之所以這麼常見的原因之一。

我們可以想像一下如果有一個資料型別佔據了 10 GB,代表每當我們宣告一個變數時,這個變數就會佔用 10 GB 的記憶體,宣告十個就會佔據 100 GB 了。

其實,我們可以宣告一個指向該資料型別的指標,將指標指向該變數,然後再透過該指標對該物件進行讀取與修改,將原本所需要的 100 GB 降成只需要 4 byte,這樣就可以大大的減少資源的浪費了!

空指標 Null Pointer

空指標就是代表宣告的指標目前並未還沒有指向任何記憶體的位置,也就是還未分配地址給這個指標。

我們可以藉由以下的方式來宣告一個空指標:

int* ptr = nullptr;

空指標常常被用作特殊值或預設值,來表示當前的指標並沒有指向有效的物件或記憶體位址,或是也可以用來初始化指標。

但是空指標也有一個危險性,那就是當我們試圖解引用 * 一個空指標或試圖訪問它指向的記憶體位址時,通常會被編譯器判定為錯誤,這可能導致程式崩潰或產生意外行為。

空指標錯誤是程式開發中常見的錯誤之一,而且這類錯誤可能很難被發現和修復。

為了避免空指標的問題,我們應該正確初始化指標,在解引用 * 之前進行指標驗證,並在發現問題時處理空指標情況。

比如說,我們可以像下面的例子一樣利用 if else 和比對來進行驗證。

了解空指標並正確處理它們對於要寫出可靠和穩定的軟體非常重要。適當使用空指標並正確處理它們有助於提高軟體的可靠性和穩定性。

if (ptr == nullptr) {
    // 當前指標為空指標
    // 做後續錯誤處理
} else {
    // 當前指標有指向有效的地址
    // 可以進行解引用 * 以取得該物件的值
}

總結

呼!花了一大篇終於講完了什麼是指標!

指標這個概念雖然困難但是非常重要!了解指標的意義會讓你對於程式設計的理解提升一個檔次。

那就希望這篇文章讓你們更了解指標啦!