【C 高階】徹底理解數組與指針

0. 前言

本文將深入淺出地講述數組與指針之間的共性與關聯,適合有一定 C/C++ 功底的同學進階學習。文中的程序均在 64 位環境下運行,且程序運行的結果會以註釋的方式呈現在代碼中以便閱讀。

全文學習約需 15 分鐘。


1. 數組和指針的本質

數組類型和指針類型都是 C 的特殊數據類型,這裏的“特殊”是相對於整型、浮點型等基本數據類型來說的。

一般地,數組類型變量統稱爲數組,指針類型變量統稱爲指針。

數組和指針均不能單獨被定義,即不存在純數組類型變量或純指針類型變量。兩者定義時除顯式爲數組或指針外,還必須顯式一種基本數據類型。例如以下定義的數組和指針都屬於整型類型:

int nums[] = {0, 1, 2};
int* p = &nums;

數組和指針兩者的本質如下:

  • 數組爲一組相同數據類型的元素的集合,數組大小等於元素大小乘以元素個數,可以使用運算符“[]”從下標 0 開始訪問數組中的元素。
  • 指針爲存儲某一地址的變量,指針大小爲一個位寬內存大小(例如 32 位機上爲 4 字節)。通過指針能夠訪問該內存地址,並能夠按指針所屬的數據類型對以該地址起始的內存進行解析,俗稱指針指向內存。

請牢記數組和指針的本質定義,這是徹底理解數組與指針兩者之間的共性與關聯的關鍵。


2. 數組的基礎語法

先回顧一下數組的基礎語法。

現有以下一維數組:

int nums[2] = {4, 5};

變量名稱爲 nums,所屬類型爲整型數組即 int[2],這裏的“2”指明瞭該數組中有兩個整型元素;{4, 5} 爲元素列表,表示使用列表中的元素初始化數組;使用“[]”從下標 0 開始訪問數組所包含的元素,nums[0] 所指代的元素爲“4”,nums[1] 所指代的元素爲“5”。

定義數組時存在如下規則:

  1. 當數組的元素個數 N 大於元素列表中元素的個數 M 時,將會在元素列表的尾部補充 (N - M) 個“0”。例如 int nums[3] = {1}; 等效於 int nums[3] = {1, 0, 0};。因此,在定義數組時常見情況是元素列表爲 {0},表示數組的所有元素都初始化爲“0”。
  2. 當數組的元素個數 N 小於元素列表中元素的個數 M 時,編譯器(GCC)會發出警告,並只使用元素列表的前 N 個元素初始化數組。例如 int nums[2] = {1, 2, 3}; 等效於 int nums[2] = {1, 2};
  3. 當省略數組的元素個數時,編譯器會根據元素列表的元素個數自推導出數組的元素個數。例如 int nums[] = {1, 2};,編譯器將自推導爲 int nums[2] = {1, 2};
  4. 元素列表可以使用“""”括起來,表示該數組爲字符串,即數組的每個元素都爲字符型,並會在字符串結尾隱式地添加字符 '\0' 作爲字符串的結尾。例如 char str[] = "123"; 等效於 char str[] = {'1', '2', '3', '\0'};

二維數組是在一維數組的基礎上進行再集合,簡單來說就是二維數組的每個元素都是一維數組,且這些子數組具備相同的數據類型、元素個數。

以整型二維數組爲例,其採用矩陣方式呈現如下:

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

變量名稱爲 nums,所屬類型爲整型二維數組即 int[3][3],第一個“3”指明該二維數組有 3 個數組元素即有 3 個一維數組,第二個“3”指明每個子數組的元素個數爲 3 個。

使用“[][]”從下標 0 開始訪問數組所包含的元素,第一個“[]”將決定訪問第幾個子數組,第二個“[]”將決定訪問子數組的第幾個元素。例如,nums[0][1] 所指代的元素爲第一個子數組的第二個元素“2”,nums[1][0] 所指代的元素爲第二個子數組的第一個元素“4”,依次類推。

在定義二維數組時,仍遵守以上定義數組的規則,但此時編譯器不會根據子數組的元素列表的元素個數自推導出子數組的元素個數。簡單來說,第一個“[]”能夠不顯式指明數組元素即子數組的個數,但第二個“[]”必須顯式指明子數組中元素的個數。

還需要說明的是,定義數組時,元素列表中表示子數組的 {} 其實是非必須的,花括號只是提高程序閱讀性的技巧。如以下定義二維數組仍是合法的:

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

對於三維數組、四維數組等多維數組,其原理與二維數組相同,均爲對上一維數數組的再集合。在工程中一般至多使用二維數組,因爲數組每增加一維其實際元素個數將增長數組元素個數的倍數,其所消耗的內存空間是非常巨大的。


3. 指針和數組的運算

在講述數組與指針之間的共性前,需先了解關於數組和指針的運算。

指針存儲着內存中的某個地址,當對任意數據類型的指針進行加減運算時,實際上都是對該地址值進行運算,俗稱指針偏移。

那麼,指針偏移的基本單位是多少呢?實踐出真理:

int nums[] = {1, 2, 3};
int* p = &nums[0];
printf("p = %p\n", p);                  // print: p = 0x7ffc3836cee0
printf("p + 1 = %p\n", p + 1);          // print: p + 1 = 0x7ffc3836cee4
printf("*(p + 1) = %d\n", *(p + 1));    // print: *(p + 1) = 2

可見,pp + 1 的差值爲 4 字節,剛好一指針所屬的整型類型的大小,p + 1 爲返回 p 指向地址偏移 4 字節後的地址。

實際上,指針偏移的基本單位爲指針所屬的數據類型大小,指針的加減運算爲指針所存儲的地址加減所運算的 n 個基本單位的字節。簡單來說,如上的 p + 1 等價於 (size_t)p + 1 * sizeof(int)。這裏強制轉化爲 size_t 表示爲內存地址類型。

在程序中,當數組名稱單獨出現時,其值是數組的起始地址,該地址也是第一個元素的起始地址。例如:

int nums[] = {1, 2, 3};
printf("nums = %p\n", nums);                // print: nums = 0x7ffeedc61450
printf("&nums = %p\n", &nums);              // print: &nums = 0x7ffeedc61450
printf("&nums[0] = %p\n", &nums[0]);        // print: &nums[0] = 0x7ffeedc61450

那麼,對數組直接進行加減運算,會得到什麼呢?

實踐是檢驗真理的唯一標準:

int nums[] = {1, 2, 3};
printf("nums = %p\n", nums);			// print: nums = 0x7ffe9a7bb480
printf("nums + 1 = %p\n", nums + 1);	// print: nums + 1 =0x7ffe9a7bb484
printf("&nums[1] = %p\n", &nums[1]);	// print: &nums[1] = 0x7ffe9a7bb484

可見,數組的加減運算與指針是類似的。由於數組 nums 所屬的數據類型爲整型,因此,nums + 1 等價於 (size_t)nums + 1 * sizeof(int),得到的是數組 nums 的第二個元素的地址。

那麼對數組地址和指針地址進行加減運算與對數組和指針直接進行加減運算是否一致呢?答案是否定的。

試驗如下:

int nums[] = {1, 2, 3};
printf("nums = %p\n", nums);			// print: nums = 0x7ffd7ecdaf60
printf("nums + 1 = %p\n", nums + 1);	// print: nums + 1 = 0x7ffd7ecdaf64
printf("&nums = %p\n", &nums);			// print: &nums = 0x7ffd7ecdaf60
printf("&nums + 1 = %p\n", &nums + 1);	// print: &nums + 1 = 0x7ffd7ecdaf6c

int* p = nums;
printf("p = %p\n", p);			        // print: p = 0x7ffd7ecdaf60
printf("p + 1 = %p\n", p + 1);	        // print: p + 1 = 0x7ffd7ecdaf64    
printf("&p = %p\n", &p);			    // print: &p = 0x7ffd7ecdaf58
printf("&p + 1 = %p\n", &p + 1);	    // print: &p + 1 = 0x7ffd7ecdaf60  

很明顯,&nums&nums + 1 的差值爲 12 字節,恰好爲數組的大小;而 &p&p + 1 的差值爲 8 字節,恰好爲指針的大小。這當然不是偶然!

實際上,數組地址 &nums 對應的數據類型爲整型數組類型“int[3]”而非整型“int”,指針地址 &p 對應的數據類型爲整型指針“int*”而非整型“int”。因此,&nums + 1 將等價於 (size_t)&nums + 1 * sizeof(int[3]),得到的是數組 nums 所佔據內存的結尾地址的一下個地址。&p + 1 將等價於 (size_t)&p + 1 * sizeof(int*),得到的是指針 p 所佔據內存的結尾地址的一下個地址。


4. 數組與指針的共性

通常來說,使用運算符“*”可以訪問指針所指地址上的內容。例如:

int val = 5;
int* p = &val;
printf("*p = %d\n", *p);             // print: *p = 5

同時,指針支持使用運算符“[]”從下標 0 開始訪問指針所指地址的偏移地址。簡單來說,p[0] 等價於 *(p + 0)p[1] 等價於 *(p + 1)

以下實驗得以驗證:

int nums[3] = {9, 8, 7};
int* p = nums;
printf("p[0] = %d\n", p[0]);			// print: p[0] = 9
printf("*p = %d\n", *p);				// print: *p = 9
printf("p[1] = %d\n", p[1]);			// print: p[1] = 8
printf("*(p + 1) = %d\n", *(p + 1));	// print: *(p + 1) = 8
printf("p[2] = %d\n", p[2]);			// print: p[2] = 7
printf("*(p + 2) = %d\n", *(p + 2));	// print: *(p + 2) = 7

那麼訪問數組中的元素時,使用指針方式與使用數組方式完全一致嗎?使用反彙編查看一下:

int nums[3] = {0};
int* p = nums;
{
	nums[1] = 5;
2e:	c7 45 e4 05 00 00 00 	movl   $0x5,-0x1c(%rbp)
	p[1] = 5;
35:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
39:	48 83 c0 04          	add    $0x4,%rax
3d:	c7 00 05 00 00 00    	movl   $0x5,(%rax)
	*(p + 1) = 5;
43:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
47:	48 83 c0 04          	add    $0x4,%rax
4b:	c7 00 05 00 00 00    	movl   $0x5,(%rax)
51:	b8 00 00 00 00       	mov    $0x0,%eax
}

依上可知,p[1] 雖然與 nums[1] 形似,但存在本質上的區別。p[1] 完全等價於 *(p + 1),先定位到 p 所指向的內存,再定位到其偏移地址,最後完成賦值操作。而 nums[1] 能夠直接定位到目標元素完成賦值操作,在效率上明顯高於指針的方式。

可見,數組與指針的共性如下:

  • 兩者均支持使用運算符“[]”訪問某目標內容。數組訪問的是其自身的某一元素,指針訪問的是爲基於指針所指地址的偏移地址上的內容。
  • 兩者獨立出現時,均返回的是某一內存地址。數組爲數組本身的起始地址,指針爲所指向的內存地址。

5. 數組和指針不應混爲一談

正是數組與指針存在以上共性,使許多人把指針和數組混淆使用,在常規情況下混淆使用數組和指針一般也不會產生什麼問題,只會降低一些運行效率。但在某些特定的場景下,數組和指針必須嚴格區分。

以下爲一個經典的案例:

/* other.c */
int nums[3] = {9, 8, 7};
/* main.c */
#include <stdio.h>

extern int* nums;

int main(void)
{
    printf("nums[0] = %d\n", nums[0]);
}

以上代碼的原意是在文件 mian.c 中使用文件 other.c 所定義的全局數組。程序在編譯期間並沒有發生任何的警告或報錯,能夠順利得到可執行文件,但執行時會發現直接產生了段錯誤!這是什麼原因導致的呢?

熟悉鏈接過程的朋友應該知道,在 C 中不同文件的相同變量是依賴於其編譯得到的符號來進行鏈接,現使用 nm 命令來查看 mian.c 與 other.c 編譯後的符號表:

star@ubuntu:/mnt/hgfs/share/pj_c$ nm object/main.o 
0000000000000000 T main
                 U nums
                 U printf
star@ubuntu:/mnt/hgfs/share/pj_c$ nm object/other.o 
0000000000000000 D nums
star@ubuntu:/mnt/hgfs/share/pj_c$

可見,兩源文件中變量 nums 編譯後得到的符號均爲 nums,於是鏈接器能夠把兩者關聯起來。但問題在於,在 main.c 中 nums 爲整型指針,在 other.c 中 nums 爲整型數組,鏈接器並不會區分變量的數據類型,只按照相同的符號進行鏈接。這樣導致的後果是,在程序中,main.c 中指針 nums 的值與 other.c 中數組 nums 中的值相等,即把 other.c 中數組 nums 本身對應內存上的值作爲了 main.c 中指針 nums 所指向的內存的地址!

試驗如下:

/* other.c */
int nums[3] = {9, 8, 7};
/* main.c */
#include <stdio.h>

extern int* nums;

int main(void)
{
    printf("nums = %p\n", nums);    // print: nums = 0x000000000009
}

此時,假如在 main.c 中使用“*”或者“[]”訪問指針 nums 所指內存,等同於訪問地址爲 0x000000000009 的內存,那麼必然會發生非法的內存訪問,產生段錯誤。

因此,在 mian.c 中的 nums 必須聲明爲數組類型:extern int nums[];


6. 數組指針和指針數組

當數組與指針關聯爲複合數據類型時,將產生數組指針和指針數組這兩種數據類型。

數組指針和指針數組是中文中比較拗口的說法,很容易把這兩個說法混淆。其實,只要對名稱進行定語、狀語劃分,區別這兩個名稱是非常容易的。

  • 數組指針:數組類型的指針,數組爲狀語,指針爲定語,即數組指針仍然是指針,只是指針指向的內存需要按數組類型進行解析。
  • 指針數組:指針類型的數組,指針爲狀語,數組爲定語,即指針數組仍然爲數組,只是該數組所屬類型爲指針類型,這說明該數組的每個元素都是指針類型。

如果仍然覺得不好理解與記憶,可以類比“整型數組”和“整型指針”這兩名稱,現只是把“整型數組”中的“整型”替換爲“指針”以及把“整型指針”中的“指針”替換爲“數組”而已。

指針數組的定義方法爲:

int* nums[] = { ... };

數組指針的定義方法爲:

int (*nums)[n] = { ... };

其中,“n” 爲指針所屬的數組類型的元素個數,不能省略。

可見,哪一運算符與變量先結合,將決定變量本質爲數組還是指針。由於運算符“[]”的優先級高於運算符“*”,因此不使用 () 輔助定義數組與指針結合的複合數據類型時,實際定義的是指針數組。

以下實驗將作進一步的驗證:

int (*nums_A)[10];
printf("sizeof(nums_A) = %lu\n", sizeof(nums_A));       // print: sizeof(nums_A) = 8 
int* nums_B[10];
printf("sizeof(nums_B) = %lu\n", sizeof(nums_B));       // print: sizeof(nums_B) = 80 (8 字節 * 10)

7. 數組指針和指針數組的應用

7.1 數組指針的應用

以二維數組爲例介紹數組指針的用法:

int nums[][3] = {
	{1, 2, 3},
	{4, 5, 6},
	{7, 8, 9}
};
int (*p)[3] = nums;

printf("p[0][1] = %d\n", p[0][1]);      // print: p[0][1] = 2
printf("p[1][2] = %d\n", p[1][2]);      // print: p[1][1] = 6

數組指針 p 中存放着二維數組 nums 的起始地址,即 p 指向了 nums

如上所示,p[1] 等價於 p + 1(size_t)p + 1 * sizeof(int[3]),此時得到的是二維數組中第二個子數組 nums[1] 的起始地址。現使用整型數組 q 替代 p[1]int[3] q = p[1];(僞代碼),則 p[1][] 可視爲 q[],此時可以從下標 0 開始訪問第二個子數組 nums[1] 中的元素,因此 q[2]p[1][2] 指代的是 nums 第二個子數組的第二個元素即“6”

在工程中,數組指針一般用於接收傳參爲多維數組的函數的形參類型。

以二維數組作爲傳參爲例:

void Fun_A(int (*p)[3])
{
	printf("p[0][2] = %d\n", p[0][2]);		    // print: p[0][2] = 3
}

void Fun_B(int* p)
{
	// printf("p[0][2] = %d\n", p[0][2]);		// error: subscripted value is neither array nor pointer nor vector
	printf("p[1] = %d\n", p[1]);				// print: p[1] = 2
	printf("p[4] = %d\n", p[4]);				// print: p[4] = 5
}

int main()
{
	int nums[][3] = {
		{1, 2, 3},
		{4, 5, 6},
		{7, 8, 9}
	};

	Fun_A(nums);
	Fun_B(nums);
}

可見,如果形參類型爲普通指針時,在函數中只能以“一維數組”的方式對待形參。實際上,函數傳參的是二維數組的地址值,傳參過程俗稱數組“退化”爲指針。

當形參的類型爲普通的指針時,形參 p 不支持運算符“[]”二次訪問,二維數組將被“退化”爲“一維數組”。簡單來說,在 Fun_B() 中,整型指針 p 指向了 nums 對應的內存首地址,並按整型進行解析。例如 p[4] 等價於 *((size_t)p + 4 * sizeof(int)),對應了 nums[1][1]

當形參的類型爲數組指針時,形參 p 能夠支持運算符“[]”二次訪問,二維數組在函數 Fun_A 中仍形爲“二維數組”。在 Fun_A() 中,整型數組指針 p 指向了 nums 對應的內存首地址,並按整型數組 int[3] 進行解析。例如 p[1][2] 等價於 ((int[3])((size_t)p + 1 * sizeof(int[3])))[2] (非標準寫法),對應了 nums[1][2]

7.2 指針數組的應用

以二維數組爲例介紹指針數組的用法:

int nums[][3] = {
	{1, 2, 3},
	{4, 5, 6},
	{7, 8, 9}
};
int* p[] = {nums[0], nums[1], nums[2]};

printf("p[0][1] = %d\n", p[0][1]);      // print: p[0][1] = 2
printf("p[1][2] = %d\n", p[1][2]);      // print: p[1][2] = 6

int* q = p[1];
printf("q[2] = %d\n", q[2]);            // print: q[2] = 6

指針數組 p 的每個元素的數據類型都是整型指針即 int*,在定義數組時顯示地使用了二維數組 nums 中的各個子數對指針數組中的指針元素進行了初始化。

如上所示,p[1] 將訪問數組的第二個指針元素,該指針存放的是第二個子數組 nums[1] 的起始地址。現使用整型指針 q 替代指針元素 p[1]int* q = p[1];,則 p[1][2] 可轉換爲 q[2]q[2] 又等價於 *((size_t)q + 2 * siezof(int)),因此 q[2]p[1][2] 指代的是 nums 第二個子數組的第二個元素即“6”。

在工程中,指針數組一般用於統一管理一組同類的資源,使用指針數組中的指針元素可以訪問其所關聯的資源。

以下示例程序爲使用指針數組 PicBuf 來管理所申請的不同圖片類型的緩存的句柄:

enum Sizes
{
    Size_1KB = 1 * 1024,
    Size_10KB = 10 * 1024,
    Size_100KB = 100 * 1024,
    Size_1M = 1 * 1024 * 1024,
};

enum PicType
{
    Face,
    Body,
    Car,
    PicTypeNum,
};

void InitPicBuffer(char* PicBuf[PicTypeNum])
{
    PicBuf[Face] = (char*)malloc(Size_1M);
    memset(PicBuf[Face], 0, Size_1M);

    PicBuf[Body] = (char*)malloc(Size_100KB);
    memset(PicBuf[Body], 0, Size_100KB);

    PicBuf[Car] = (char*)malloc(Size_100KB);
    memset(PicBuf[Car], 0, Size_100KB);
}

void FreePicBuffer(char* PicBuf[PicTypeNum])
{
    for (int i = 0; i < PicTypeNum; i++)
    {
        free(PicBuf[i]);
    }
}

int main(void)
{
    char* PicBuf[PicTypeNum] = {NULL};
    InitBuffer(PicBuf);
    // ...
    FreeBuffer(PicBuf);
}

main() 中, InitPicBuffer()FreePicBuffer() 之間可以使用 PicBuf[] 根據下標來訪問所申請的不同類型圖片的內存資源。使用枚舉作爲下標重命名能夠直觀地展示了資源的名稱。關於枚舉的使用方法可見另一篇博文:【C 高階】- 枚舉,這裏就不再贅述。

其實可以不使用指針數組,只使用普通類型的指針的方法來完成系統資源申請後的管理,但這樣設計後指針將得不到統一管理。而使用指針數組有利於統一管理,例如 InitPicBuffer() 完成了統一的資源申請並初始化,FreePicBuffer() 完成了統一的資源釋放。


8. 總結

  • 數組爲一組相同數據類型的元素的集合,指針爲存儲某一地址的變量。

  • 關於數組和指針的運算,以整型數組 int nums[n] 和整型指針 int* p 爲例。numsp 對應的數據類型均爲整型 intnums + 1 等價於 (size_t)nums + 1 * sizeof(int)p + 1 等價於 (size_t)p + 1 * sizeof(int)&nums 對應的數據類型爲整型數組 int[n]&p 對應的數據類型爲整型指針 int*&nums + 1 等價於 (size_t)&nums + 1 * sizeof(int[n])&p + 1 等價於 (size_t)&p + 1 * sizeof(int*)

  • 雖然數組和指針使用運算符“[]”時形態上一致,數組和指針都返回某一內存地址,但兩者存在本質上的區別。數組與指針不應混爲一談。

  • 數組指針爲數組類型的指針,指針指向的內存按數組類型解析。指針數組爲指針類型的數組,數組的每個元素都爲指針。

對於數組和指針,可以通過筆試題《【C 高階】- 數組和指針筆試題精選》加深學習與理解。


更多 C 高階系列博文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章