/

重新看懂指標與陣列之間的交互關係

C 語言中,指標與陣列之間的關係一直是一個初學者很難理解的坑。又或是很多人只知道寫法,但卻從來沒有理解過背後的原因。相信各位理解了原因之後,在撰寫 C 語言時會對自己操作這個語言更加有自信。這篇文章重新試著釐清這指標與陣列之間的關係,希望對大家能有幫助。

陣列宣告與指標宣告

陣列可以用以下的方式宣告:

  1. 宣告但是尚未初始化

    1
    int arr_init0[3]; // 宣告但是尚未初始化,此時陣列中的值是沒有意義的。
  2. 宣告並且初始化

    1
    int arr_init1[3] = {1, 2, 3};
  3. 不指定大小,大小會依照後面元素個數來決定

    1
    int arr_init2[] = {1, 2, 3};
  4. C99 新增。指定特定元素,其他未被指定元素會被設定為 0

    1
    int arr_init3[] = {[2] = 2};

指標的宣告,需要關鍵字 *,該關鍵字可以緊鄰變數或是型態,本質上沒有差別:

1
2
int* ptr1;
int *ptr2;

但若是我們宣告指標卻不指定初始值,這件事是非常危險的!

1
2
int *ptr1;
printf("%p\n", ptr1); // 0x1125ce025

該指標在未宣告的情況下,編譯器會直接指定該位置上本來就存有的值(這個值沒有意義,是作業系統殘留的值),如果沒有特別注意,操作這個位置會發生錯誤,甚至覆寫到需要的資料。

我們會建議指標在宣告時還沒有特定的空間或位置可以指定時,先使用 NULL 來指定。

1
2
int *ptr = NULL;
printf("%p\n", ptr); //0x0

陣列索引與指標

陣列的變數名稱其實就是一個指標,指向陣列開頭元素的記憶體位置。這也就是為什麼陣列索引會從 0 開始計算,因為索引的意義其實是與起始位置的位移量。我們可以用以下範例看到起始位置的值就是直接對陣列名稱取值(dereference)。

1
2
3
int arr[3] = {1, 2, 3};
printf("Address: %p, Value: %d\n", arr, *arr);
// Address: 0x7ffee98be05c, Value: 1

如果對該變數遞增,會發現記憶體位置相差了 4 格,原因是因為我們在宣告陣列時,指定了陣列的型態是 int,如此以來我們在進行取值的動作時,CPU 才知道下一個位置在哪邊。有趣的是,一般我們所使用的取值動作 arr[1] 其實就等於 *(arr + 1)

1
2
printf("Address: %p, Value: %d\n", arr + 1, *(arr + 1));
// Address: 0x7ffee1dca060, Value: 2

有了以上的概念之後,不難理解為何我們在設定函數的參數引述時,可以將陣列設定為:

1
void foo(int *arr);

或是這樣設定:

1
void foo(int arr[]);

這兩種指定方式是一模一樣的(對於編譯器來說)。在函式原型中,參數的名稱是沒有意義的,只有型態有意義,所以也可以指定為:

1
void foo(int *);

但是陣列名稱與指標也不能說是完全一模一樣的東西。

陣列傳遞與指標

在 C 語言中,傳遞陣列就是傳遞陣列的起始記憶體位置,所以我們可以用指標來接收陣列:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include 

void foo(int *);

int main() {
int arr[3] = {1, 2, 3};
printf("%lu\n", sizeof arr); // 12
foo(arr);
}

void foo(int *ar) {
printf("%lu\n", sizeof ar); // 8
}

以上程式會印出:

1
2
12
8

先印出 12 的原因是,陣列在宣告時就已經知道了陣列長度,int 型態在我的作業系統中佔用了 4 bytes,所以 4x3=12。

8 的話則是代表指標變數本身佔據了 8 bytes 的空間。非常合理,因為我的電腦是 64-bit 的作業系統,要可以完整定址全部空間需要 8 bytes(64 / 8 = 8)。

所以為什麼剛才提到:「陣列名稱與指標也不能說是完全一模一樣的東西」。用 sizeof 進行操作時,會發現兩者還是有一點差別!(不過在沒有進行參數傳遞前,幾乎是沒有差別的。)

由以上的例子可以發現,陣列的變數名稱可以進一步得知陣列長度的,只要使用 sizeof 運算子即可:

1
2
3
int arr[3] = {1, 2, 3};
int len = sizeof arr / sizeof arr[0];
printf("%d\n", len); //3

sizeof 後面如果不是接基本型態,是不需要括號的。如果接上基本型態才需要括號,像是:

1
2
3
4
5
printf("%lu\n", sizeof(char));   //1
printf("%lu\n", sizeof(short)); //2
printf("%lu\n", sizeof(int)); //4
printf("%lu\n", sizeof(long)); //8
//請注意!不同型態的空間大小是由編譯器依照作業系統去分配以及實作,所以有可能不同電腦上面的結果不一致。

對於函式來說,它只有辦法得知陣列起始記憶體位置,無法得知總長度。或是可以直接說,對於函數來說,他並不知道傳進來的東西是一個陣列,只知道是一個記憶體位置,指向的型態也知道,其他事情對於這個函數來說都無法得知。

這也是為何我們時常在接收陣列時,會額外接收一個參數,用來表示陣列的總長度。

1
void foo(int *arr, int n);

二維陣列

在宣告一維陣列時,可以直接填上元素,不指定陣列大小,可是在二維陣列這樣操作的話,會發生錯誤:

1
2
3
4
5
6
7
8
int td_arr[][] = {{1, 2}, {3, 4}, {5, 6}};

/*
app.c:7:12: error: array has incomplete element type 'int []'
int td_arr[][] = {{1, 2}, {3, 4}, {5, 6}};
^
1 error generated.
*/

我們要先重新理解二維陣列,二維陣列不過是一個陣列,該陣列的值也是一個陣列({1, 2}, {3, 4}, {5, 6}),沒有多特別,僅此而已。

陣列只接受第一層不指定大小而已,用後面的元素個數自己推算(一維陣列只有一層,所以你可能會認為一維陣列比較聰明)。

所以我們應該要告訴編譯器,內層陣列的大小,這樣他才有辦法幫我們將所需要的空間準備好,我們應該這樣子撰寫程式:

1
int td_arr[][2] = {{1, 2}, {3, 4}, {5, 6}};

第一層有幾個元素可以不用指定(就像一維陣列),但是我們需要告訴編譯器,內容陣列的寬度有多大。我們總共花了 24 bytes 的空間,4 bytes(int size) x 6(elements) = 24。

接下來,我們嘗試將二維陣列中我們需要的值取出:

1
2
printf("%d\n", *td_arr[1]);  //3
printf("%d\n", (*td_arr)[1]);//2

[] 的優先權比 * 還要高,所以在第一個範例中我們會先找到 td_arr 中的第二個元素(第 1 個是索引 0)然後取值。第二個元素就是 {3, 4},在文章前段的一維陣列有講過直接取值就是對第一個元素(索引 0)取值。所以 {3, 4} 的第一個元素就是 3

第二個範例則是先取值,我們會拿到 td_arr 的第 1 個元素(索引 0),也就是 {1, 2} 接下來取出第二個元素(索引 1),得到 2

也可以將上述寫成是不含有 [] 的表示法,如下:

1
2
printf("%d\n", **(td_arr + 1));//3
printf("%d\n", *(*td_arr + 1));//2

前面段落也有提到 []*(arr + offset) 的寫法可以互相替換,就不再贅述。

指標與二維陣列

接下來我們要理解如何用指標去操作二維陣列,首先我們需要先宣告一個指向二維陣列的指標:

1
int (*td_ptr)[2];

該宣告的意思是宣告一個指標,指向大小為 2 的陣列,該陣列內容為 int 型態。

為何不直接寫:

1
int *td_ptr[2]; // 可以看成 int *(td_ptr[2]);

原因是因為優先權帶來的影響並不同([] 的優先權較大),以上宣告的意思是產生一個大小為 2 的陣列,陣列內容是兩個指向 int 的指標。如下:

{ptr1, ptr2}

宣告完指標之後,我們可以將該指標,指向我們的二維陣列:

1
2
3
int td_arr[][2] = {{1, 2}, {3, 4}, {5, 6}};
int (*td_ptr)[2];
td_ptr = td_arr;

接著一樣可以用指標來操作該陣列:

1
2
printf("%d\n", td_ptr[2][0]); //5
printf("%d\n", td_ptr[2][1]); //6

如果需要設定函數原型的話:

1
void foo(int (*ar)[2]);

或是:

1
void foo(int arr[][2]);

皆可以用來接收。

我想看到這邊,如果你沒有其他疑問的話。應該可以稍微理解為何我們在傳遞二維陣列時,會使用這樣子的寫法了!這樣子理解的話也可以推廣到多維陣列中,像是:

1
2
void foo(int arr[][2][3][4][5][6]);
void foo(int (*arr)[2][3][4][5][6]);

後記

希望這篇文章可以讓大家更加理解 C 語言陣列與指標撰寫過程中的背景原因,在網路上看到太多文章只有提到宣告或是使用的方式,但是卻沒有加以描述任何原因,導致很多人只知道寫法但不清楚為何應該這樣子撰寫。

希望大家看完文章有所收穫 😄!