iT邦幫忙

2

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

c
  • 分享至 

  • xImage
  •  

上一章我們介紹了指標的基本概念,是時候將其進一步延伸了。
還記得我們提過,在C語言中記憶體對程式設計師而言是裸露的,系統會根據對應資料型態分配所占用的位元數(例如:int至少佔16bits),並將變數值儲存於對應的位置;配合陣列的概念,在儲存一連串同樣資料型態的變數時,我們可以藉由每一陣列值對應的index來隨機存取想要的數值,資料型態所占用的位元數則無需我們計算,只需像陣列一樣給出index即可,我們看一個小例子:

#include <stdio.h>

int main() {
    // index :   [0][1][2]
    int arr [] = {1, 3, 5};
    
    // same as int * ptr = arr;
    int * ptr = &arr[0];
    ptr = (ptr+2); // index 3-rd element
    printf("from pointer : %d\n", *(ptr));
    printf("from arr : %d\n", arr[2]);
    
    printf("access arr[1] from pointer : %d\n", *(ptr-1));
}

上一章介紹陣列時提過,arr本身幾乎等同於一個指向第一個變數值 1 記憶體位址的指標,所以可以直接賦值給ptr,不過我們也可以先存取到變數值arr[0],再複習一下取址符號 &arr[0] 同樣的來取得第一個變數值的記憶體位址。

關鍵在於*(ptr+2),想必聰明的讀者已能透過上一章介紹的內容輕易的解讀出它的意義,由於ptr是指向int的指標,系統自然知道(ptr+2)是往後位移2個整數 (在colab上是int佔32bits,而我們不需顯示的說明是位移64bits的記憶體位址)再取值,而這樣的效果等同於arr[2],就可讀性與便利性而言,讀者可能也逐漸理解為何上一章結尾筆者仍建議 "盡可能地使用陣列取代部分指標的工作"。

既然指標進行的是記憶體位址的位移,可以往後(ptr+2),自然的也可以往前了(ptr-1);反觀,陣列定義時即可用正整數的index存取所有範圍的數值,沒有用arr[-1]嘗試非法存取其他變數範圍的需求,自然地在C語言中arr[-1]就比較少見,不過ptr-1仍是很常見的位移操作手段。

就K&R教科書花了許多篇幅講解指標的這個特性,我們可以嘗試再比較一下陣列與指標在其他任務上的操作;例如逐一遍歷陣列中的所有儲存值:

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

int main(){
    int arr [] = {1, 3, 5, 7, 9, NULL};
    int * ptr = arr;
    
    printf("1. array version traversal\n")
    // len(arr) - 1 to skip NULL dummy value, that's the endpoint for pointer
    for(int idx=0 ; idx < len(arr)-1 ; ++idx)
        printf("%d, ", arr[idx]);
        
    printf("\n2. pointer version traversal\n")
    for(; *ptr != NULL ; ++ptr)
        printf("%d, ", *ptr);
}

可以看到,採用陣列完成仍是較為理想的;或許採用指標在位移和存取數值上看起來比較優雅,但對於何時抵達一連串儲存值的盡頭,指標顯然比較不容易處理這個議題(我們藉由在結尾放置 NULL值來提供hint);如果陣列長度如同教科書的範例永遠是固定的(例如一年總是12個月、英文字母總是26個、神秘數字總是42),則這個問題就沒有這麼嚴重了。

#include<stdio.h>

int main(){
    const int N = 5;
    // type conversion to composed literal (int []), just kind of array..
    int * ptr = (int []){1, 3, 5, 7, 9};
    
    for(int idx=0 ; idx < N ; ++idx, ++ptr){
        --(*ptr);  // you can remove ( ), but why not keep it ~
        printf("%d, ", *ptr);
    }
}

既然指標指向的陣列長度是固定的,我們就可以不依賴end point值(NULL)做結尾判斷,但需另外設置一個counter(idx變數)紀錄已遍歷的數值個數;這類分化同一事務的作法是否恰當(計數器自己紀錄自己的、指標自己位移自己的),則看交由各位讀者自行判斷了(至少筆者不喜歡www)。

值得一提的是,在例子中筆者帶出了一個簡化陣列賦值給指標的作法;以往我們會先定義一個陣列變數,在將其位址賦值給指標ptr,而這一切可以藉由複合常量(composed literal)的支援進行簡化,它的聲明如同陣列一樣,我們可以對初始化列表{1, 3, 5, 7, 9}使用(int [])來進行顯示的轉型;背地裡,系統會在記憶體產生一個匿名的陣列(因為是由初使化列表轉型、尚未賦值給任何變數),接著宣告一個指向此匿名陣列的指標ptr;而我們也可以自由地更改裡面的內容(範例中我們將每個值減1),即使它被稱為常量。

介紹了指標的位移和陣列的優勢,我們可以藉由對兩者概念的理解,適當的將位移與陣列結合,輕易實現一些trick:

#include<stdio.h>

int main(){
    int arr [ ] = {1, 3, 5, 7, 9};
    printf("sizeof arr : %d vs. sizeof int : %d\n", sizeof(arr), sizeof(arr[0]));
    // not safty : (*(&arr + 1))[-1]), *(*(&a + 1) - 1)
    
    int* arr_end = (int *)(&a + 1);
    printf("The last element of arr : %d", arr_end[-1]);
}

我們想要存取陣列的最後一個值最好的作法當然是arr[sizeof(arr)/sizeof(arr[0])],雖然這個做法在沒有採用巨集時似乎不太優雅,但sizeofarrarr[0]的差異卻給了我們一個轉機! 我們知道sizeof(arr)的尺寸會返回整個陣列的長度,因此當我們對arr的取址並往後位移1格時,事實上(&arr+1)已經讓我們移動到陣列末尾的位址,此時將此位址轉型為指向資料型態 (int*)而非指向陣列的指針時,根據sizeof(arr[0])的尺寸則會返回一個整數資料所占用的長度,我們對位於尾端的位址往前 *(arr_end-1)移動一個整數,剛好就是陣列最後元素的起始位址,這巧妙的trick很多時候也被寫成一行外星密碼(*(&arr + 1))[-1]*(*(&a + 1) - 1),在理解的同時也請注意安全。

巧妙的trick或許有用,卻是經不起時間考驗的;這句話不一定是指著技術本身有問題,而是將人為因素考量進去的經驗。不過在這裡,(*(&arr + 1))[-1]*(*(&a + 1) - 1)確實是技術上不安全的! 你可以儲存一個陣列尾端的指標 *(&a + 1),但對這個位址取值 (解參考, deference)的行為時常是未定義的,也就是運氣好時它可能成功(例如陣列後面剛好不是程式非法存取的記憶體區段),運氣不好時則會莫名的失敗。

然而,陣列也有不適用的時候;將陣列作為參數傳遞至函數時,他會退化為一般的指標!!

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

void array_decay(int arr []){
    printf("prnt len of arr : %ld", len(arr));
}

int main()
{
    int arr [] = {1, 3, 5, 7, 9}; 
    printf("len of arr : %ld\n", len(arr));
    
    array_decay(arr);
    return 0;
}

從運行結果可以看出,原本陣列arr占用了20 bytes(5個int)的空間,將陣列傳遞至函數時則退化成普通的指針,指針的大小只佔 8 bytes(這個數值還會隨著具體的機器而不同),既然指針的大小已經與陣列不符,巨集後續採用sizeof進行的運算就全盤皆錯了。要在函數中得知陣列的實際長度,只能在傳入陣列前先進行計算,並將長度作為整數參數一併傳入函數了。

關於為何陣列傳入函數會退化成指標,請參閱傳送們


回顧指標的概念,指標聲明了一個儲存其他變數記憶體位置的變數,既然指標自身也是變數,它就應該也有記憶體位置;而巧妙的是我們能用另一個指標來儲存這個指標的記憶體位置,形成了指標的指標(多重指標)。
除了上ㄧ章提到儲存字串陣列時會使用多重指標外,另一個典型的例子就是多維陣列了:

#include <stdio.h>
#include <stdlib.h>

double** make_matrix(int h, int w, double init_value){
    double **mtx = calloc(h, sizeof(double*));
    for(int idx=0; idx<h ; ++idx){
        mtx[idx] = calloc(w, sizeof(double));
        for(int jdx=0 ; jdx<w ; ++jdx)
            mtx[idx][jdx] = init_value;
    }
    return mtx;
}

int main()
{
    int h=3, w=2;
    double** mtx = make_matrix(h, w, 3.14);
    for(int idx=0; idx<h ; ++idx){
        for(int jdx=0 ; jdx<w ; ++jdx)
            printf("%f ", mtx[idx][jdx]);
        printf("\n");
    }
    // forgot to free(mtx)...
}

我們撰寫了一個函數make_matrix來創建矩陣,如上ㄧ章所提,回傳ㄧ個在函數內創建的陣列就需運用calloc自行分配與管理記憶體,這樣主程式main所接收的多維陣列才能繼續使用。

malloc是最常聽聞的內建函數,用於輔助我們分配ㄧ個匿名的記憶體區塊,它的函數聲明為 void *malloc(size_t size),要求你直接計算完總佔用的尺寸(nitems*sizeof(items_type))再傳遞參數給它,而它會返回ㄧ個void*指標指向分配的匿名記憶體區塊;基於CH3提到的隱式轉換之ㄧ,我們只需聲明適當的指標變數來接收void*指標就會自動轉型。

然而malloc的缺點在於它所分配的匿名記憶體區塊並不會初始化,所以取得後還需要按需求初始化裡面的數值;雖然這並不構成麻煩 (範例子中我們也是直接根據給定的init_value初始化),不過calloc卻能更貼心的幫我們先將匿名記憶體區塊的值初始化為0或NULL,因此大部分仍推薦採用calloc函數;此外它的函數介面也更清晰 void *calloc(size_t nitems, size_t size),它將要分配幾個值(nitems)與分配的資料型態所佔位元(size, 或是sizeof(items_type))相區分。

理解分配的函數後,多重指標也只是依序地要求分配空間,或指向已創建的陣列;這邊我們創建的對象是矩陣,因為只有兩個維度,首先將h(height)個double指標賦值給雙重指標double** mtx,爾後再用1個for迴圈遍歷這h個指標變數,對於每個指標變數,我們再同樣分配擁有w(width)個值的記憶體區段給它們即可;為便於同時初始化數值,筆者額外用了第二個for迴圈將w個值按需求初始化;講解到這裡可以說是有點畫蛇添足了,聰明的讀者只要熟悉前面指標的概念,上面的程式碼恐怕只是小試身手的層級了 ~


很高興看到C語言親和的ㄧ面,遺憾的是有時候我們要對ㄧ個不確定維度的多維陣列進行操作(多維指標),這時我們也就不能像上述範例,預先撰寫for迴圈了;這時我們回到開頭的ㄧ個概念:"記憶體對我們是裸露的",因此實務上比較採用直接宣告ㄧ個陣列,陣列直接包含所有維度的值,而將各維度的資訊(例如每ㄧ個維度有多少元素)當作參數一併傳給函數;活用開頭介紹的位移來靈活地存取、操作不同維度的資訊。

然而,在建構更加嚴謹、大型的程式,我們寧可建議讀者直接採用ㄧ些知名的開源庫,例如(GNU Scientific Lib, GSL)就提供了完整測試多應用場景良好的介面設計等性質的多維陣列物件(2維即為矩陣)、常用的數值計算函數,讓我們不需重複造輪子!

或許你曾和筆者ㄧ樣,覺得只要寫python的script-kid才會call別人的函數庫,都已經寫到C語言了還要用別人的lib嗎? 這樣不夠hack喔! 對於累積經驗等自行開發的專案,自行撰寫程式碼,並進行對應的測試是ㄧ個很好的方向;不過大型程式常考驗的更是整體的軟體架構、軟體品質的管控、減少技術的負債等,這些不是我們需要浪費生命去踩雷的,相信其他世界上的co-worker,一起建構更好的軟體,我想才是更有意義的 ~

預告 CH5:ㄧ些開頭沒介紹的入門概念?!


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

尚未有邦友留言

立即登入留言