iT邦幫忙

2

C 語言-從放棄到入門 CH4 : 指標 (上)

c
  • 分享至 

  • xImage
  •  

指標 (pointer) 是著名的 C 語言大魔王,因為它很難安全的使用(C++ 11規格甚至有smart pointer來管理指標)。筆者猜想有幾個導致的原因 (鬼故事) :

  1. 指標儲存的是記憶體位址,但我們難以留意記憶體位址的相關狀態 : 函數執行完,指向的變數脫離生存範圍而被自動釋放,存取該記憶體位址變的非法等等。
  2. 偏愛自行管理記憶體 : 這可以有很合理的理由,儲存的內容大到stack放不下,因此需動態的calloc為我們在heap區段新增一塊記憶體位址,或需用ralloc等;不過大部分我們只是酷愛這種很hack的行為,這樣筆者認為不如適當的用陣列取代,讓系統自動管理記憶體的釋放。
  3. 符號的易混淆 : 初學時面對的問題大概是這種,同樣的符號*,放在變數聲明(聲明指標變數)和表達式(取得指向位址的值)卻有不同意義;而這違反了不同功能 應賦予 不同外觀 的設計原則。

ok, 嚇完了還未學習過指標的讀者,如果你還沒關閉視窗或開始搜尋rust語言,相信你的勇氣應該足夠看完整篇對指標的介紹 (勇氣可佳!!)


先從變數的記憶體位址簡單介紹一下,C/C++程式設計師被賦予其中一項強大的能力/責任,就是可以取得變數的記憶體位址 和 決定如何解釋儲存於該位址的值;首先,&取址符號,可以讓我們取得變數的記憶體位址double var = 3.14; &var; (&var是變數var的記憶體位址),而指標則是儲存記憶體位址複合資料型態(composed data type),因此可以在然地和CH2介紹的資料型態相複合,例如聲明一個指向整數變數的位址的指標聲明為int* ptr;若沒有特別指定資料型態則聲明為void* ptr;,代表儲存任意資料型態的記憶體位址。

類似於聲明指標變數,我們同樣可以藉由放置*符號於變數前,來取得指向位址所儲存的值*ptr;;先下一個小範例感受一下:

#include<stdio.h>

int main(){
    double var = 3.14;
    void* ptr = &var;
    printf("value stored in pointed address : %p\n", ptr);
    printf("address : %p\n", &var);
    // 報錯!!
    printf("corresponding value : %f", *ptr);
}

恩,*ptr所在的這行給出了error: invalid use of void expression,往前看有個警告warning: dereferencing ‘void *’ pointer,這個報錯的原因在於我們尚未決定如何解釋儲存於該位址的值(由於指標只記錄位址,並沒有位址資料型態的資訊),這個例子我們儲存著double變數,因此我們需要提供位址儲存的值是什麼資料型態,編譯器才能正確地解讀var變數的值;實際上只要將 void 指標轉型為指向double變數位址的指標 (還記得CH3介紹的顯示轉型方法void*ptr ; (double*)ptr;) 就可以存取變數值,修改一下上面的例子 :

#include<stdio.h>

int main(){
    double var = 3.14;
    void* ptr = &var;
    printf("value stored in pointed address : %p\n", ptr);
    printf("address : %p\n", &var);
    printf("corresponding value : %f\n", *(double*)ptr);
    
    // or you can directly declare double pointer..
    double* doub_ptr = &var;
    printf("double pointer value : %f", *doub_ptr);
}

上面的例子告訴我們兩件事,第一、如果你很清楚指標要儲存的資料型態為何,最好直接聲明指向該資料型態位址的指標(例如指向整數的指標int* int_ptr;、指向字元的指標char* c_str;),指向任意資料型態的指標void* ptr;通常用於不預設處理對象的資料型態;第二、指標的轉型面臨和資料型態轉型一樣的風險,例如將指向double變數的指標轉型為int* ptr = &var;,與CH3介紹的情況一樣,轉型產生的錯誤結果將再重演...

為了簡便解釋,我們可以將var 聲明為float,據IEEE-754標準,浮點數由三個部分組成,sign(1 bit)、exponent(8 bits)、mantissa(24 bits),因此每個二進位值在各個區段都意義,例如在exponent是指數加倍mantissa的值;但當我們轉型為整數址標,這個區段不再有特殊意義,再看3.14於單精度浮點數的16進位碼是4048f5c3,直接轉成int (當成mantissa)是1078523331,與我們程式結果的錯誤輸出一致,Fail Successfully ~ www


我們認識指標的概念後,陣列是什麼? 它會讓我們的工作更輕鬆嗎? 會的!
陣列(array) 是一個儲存一連串同樣資料型態變數位址的指標,既然是一連串變數,是否意味著要儲存多個位址呢? 幸好答案是否定的,從陣列的聲明可以窺知一二 int arr[ ] = {1, 3, 5, 7, 9};,根據右側的初始化列表(initialized list),編譯器知道要為其配置5個連續的整數空間,並只要儲存指向第一個值的位址即可;存取第三個值5時會表示arr[3-1] (index從0開始),由於陣列的記憶體配置從我們來看是連續的,因此底層只是基於第一個值的位址 加上 兩個整數所佔空間的位移量,這部分的細節完美的隱藏在陣列的操作,而這帶給我們許多便利。

敏銳的讀者應該察覺到陣列與指標的關聯,事實上int arr[3];幾乎等同於int * const arr = calloc(3, sizeof(int));,也就是程式執行時會先在堆積(heap)區域(在4G記憶體的電腦,上限約至3G)要一塊連續的記憶體;此範例中我們採用calloc,因此也會同時初始化記憶體內容(填0或NULL),爾後聲明一個一經賦予指向位址不可變更(const關鍵字會於後續章節介紹)指向位址的指標;並且於後續所在函數或程式結束時,系統也會自動釋放(auto free)該記憶體區塊,而不會繼續保留(這點就與malloccalloc等自行管理的細節不一樣了),導致記憶體洩漏的額外問題。

這樣看來陣列確實方便許多,不過偶爾也有一些陷阱;讀者可能上面的簡短範例有些疑惑:為何我們大多讓系統動態決定陣列大小,也就是採用int arr[] = {1, 3, 5};的方式來聲明陣列,而非顯示地用變數動態儲存陣列的大小,例如int N = 3; int arr[N] = {1, 3, 5}; ? 一方面當然是初始化列表({1, 3, 5})已經提供了足夠的訊息,而另一方面則是編譯器不允許我們這麼做!

#include<stdio.h>

int main(){
    int const N = 3;
    int arr [N] = {1, 3, 5};
}

由於我們將陣列聲明的長度用一個變數儲存,這基本上代表此陣列的長度在賦值前是動態可變的,如果程式運行後才發現N==1,則陣列分配的空間就變得不足以儲存初始化列表的值(int arr[1] = {1, 3, 5};),因此編譯器在無法確定我們分配的陣列記憶體有多少,又面臨我們對其用初始化列表賦值時,就會產生錯誤error: variable-sized object may not be initialized。解決的方法仍是採用之前的作法int arr[] = {1, 3, 5};,動態的讓系統決定,而這樣的缺點在於如何取得陣列的長度,我們可以如下採用sizeof的作法取得陣列長度,因此在大多情況下並不會造成困難。

請注意,如範例所示,即使我們將變數聲明為不可變更的(const),編譯器仍不允許我們這麼做!

回顧CH2資料型態的介紹,對於 c_string 這類 char* 以指針為本的字串型態,我們也可以藉由定義字元指標陣列 (char* arr[]) 來輕易的進行操作,而這種聲明方式也適當的隱藏了儲存指標記憶體位置的指標(簡稱 指標的指標) 這個概念,讓我們目前使用這些語法時不需太過拘泥於細節:

#include<stdio.h>
#define len(arr) sizeof(arr)/sizeof(arr[0])

int main(){
    char* arr [] = {"Arrow", "Button", "Cycle", "DINO"};
    
    printf("1. arr version traversal\n");
    // len(arr) - 1 to skip NULL dummy value, that's the endpoint for pointer
    for(int idx=0 ; idx < len(arr) ; ++idx)
        printf("%s, ", arr[idx]); 
}

完成了指標的介紹,對C語言熟悉的讀者可能意識到有許多細節沒有提及,例如 位址的位移量計算、callocfree等輔助我們管理記憶體資源分配與釋放的函數,但筆者邀請讀者嘗試從另一個思維來看;基於陣列提供的許多便利機能(自動管理記憶體index隨機存取特定的變數),我們更應該在多數可行的情況下盡量採用陣列,而非總是自行進行一些細節的操作--尤其是自行管理記憶體,一方面避免指標的汙名化,另一方面也省去許多基於這方面細節所產生的bug。

事實是,有一些常見的情況下,我們仍需要自行管理記憶體,例如從函數取得多個同樣資料型態的返回值,由於變數在函數結束時即脫離其生存範圍,一般的情況下變數資源會被釋放掉;我們仍需要藉由calloc索取儲存內容,這個內容即使函數結束也不會被釋放,也才能在主程式繼續使用 (反過來說,忘記釋放就是一場記憶體洩漏的災難了)。這部分的內容,可能會到後面的章節再說明。

另一方面,也有一些關於指標的概念是值得進一步作介紹的,例如 指標位移、指標的指標等;其中有些概念未必具有實用性質,而是助於我們理解某些底層機制的運作 (例如 struct的記憶體模型可用不同的指標位移解釋它如何儲存不同的資料型態),算是滿足求知的慾望...;有一些概念則具有實用性質,來彌補實務遇到的棘手問題,(例如陣列傳遞至函數所產生的退化現象,使得我們採用指標的指標來構建多維陣列反而較容易);希望在下一章能帶給讀者足夠的收穫。

預告 : C 語言-從放棄到入門 CH4 : 指標 (下)


update log : 2024/12/07 15:09,

  1. 補充 c_string 陣列範例
  2. 陣列的陷阱
  3. 補充結尾預告

圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言