C语言 数组和指针笔记
虽然在C++中完全不需要处理C语言中原始数组和原始指针等,C++提供了很多更好的替代实现,但是还是顺手整理一下吧。
数组基础
一维数组
定义数组 1
int a[5];
在定义时可以赋初值,此时可以省略数组长度,例如 1
int a[] = {1,2,3,4,5};
可以用如下方式获取数组长度 1
int len = sizeof(a)/sizeof(a[0]);
如果我们不赋初值,则数组的初值是随机的,这可能导致程序BUG。
需要通过索引读写数组的元素,数组的索引从0开始,例如 1
2
3int a[5];
a[0] = 100;
print("%d",a[1]);
C语言不会检查索引的合法性,对越界的索引进行的读写操作是未定义的危险行为,需要结合下面的指针理解。
二维数组
定义二维数组 1
int a[2][3];
二维数组赋初值 1
int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
二维数组通过行索引和列索引读写元素 1
2
3int a[2][3];
a[0][1] = 100;
print("%d",a[0][0]);
C语言保证数组始终在内存中连续分布,无论一维,二维还是高维数组。
对于多维数组在内存中按照行优先的顺序拼接:保证在索引的最后一个分量发生变化时,数据在内存中是相应的连续变化,例如
1
2int a[2][3];
// a[0][0] -> a[0][1] -> a[0][2] -> a[1][0] -> a[1][1] -> a[1][2]
高维数组和二维数组在原理上没什么区别,但是记号比较繁琐,这里省略。
指针基础
下面的内容仅仅是针对普通指针的,C语言对函数指针有不一样的处理。
一级指针
定义指针 1
int *p; // pointer -> int
指针需要使用一个类型匹配的地址(对变量使用&
运算符即可获取它的地址)进行初始化,让指针指向这个地址
1
2int x = 5;
int *p = &x;
可以在初始化时将指针指向空地址,这表明指针是一个空指针
1
int *p = NULL;
使用解引用运算*
可以访问指针指向的值 1
2
3int x = 10;
int *p = &x;
printf("%d",*p);
指针的移动通过加减整数或自增自减实现,例如 1
2
3
4int x = 10;
int *p = &x;
p = p + 2;
p++;
这会使得指针指向不同的地址,每次移动的字节长度是指向类型的字节长度,例如
1
2
3
4
5
6
7char s1 = 'a';
char *p1 = &s1;
assert((int)(p1 + 1) == (int)p1 + sizeof(char));
double s2 = 0;
double *p2 = &s2;
assert((int)(p2 + 1) == (int)p2 + sizeof(double));
二级指针
定义一个二级指针 1
int **pp; // pointer -> pointer -> int
初始化一个二级指针时需要使用一个类型匹配的一级指针的地址
1
2
3int x = 10;
int *p = &x; // pointer -> int
int **pp = &p; // pointer -> pointer -> int
获取二级指针指向的值,需要使用两次解引用运算 1
2
3
4int x = 10;
int *p = &x; // pointer -> int
int **pp = &p; // pointer -> pointer -> int
printf("%d",**pp);
使用一次解引用运算得到的结果可以用来给一级指针赋值 1
2
3
4int x = 10;
int *p = &x; // pointer -> int
int **pp = &p; // pointer -> pointer -> int
int *q = *pp; // pointer -> int
多级指针与二级指针在原理上没什么区别,这里省略。
指针常量vs常量指针
辨析一下指针常量和常量指针的概念,考虑下面几个定义 1
2
3
4
5char tmp;
char* const p1 = &tmp; // const pointer -> char
char const* p2; // pointer -> const char
const char* p3; // pointer -> const char
const char* const p4 = &tmp; // const pointer -> const char
其中
p1
是一个指针常量,即const
修饰自身,表示指针自身不可被修改,但是指向内容char
可以修改;p2
和p3
是一样的,表示常量指针,即const
修饰char
,表示指针自身可以被修改,但是指向内容const char
不可修改;p4
是常量指针常量,指针自身不可修改,指向的内容也不可修改。
理解的关键在于const
会和右侧的语义相结合,先结合到变量就修饰指针自身,否则就修饰指针指向的类型。
对于二级指针如果再加上常量的修饰,结果就更多了(\(2^3=8\)种组合)
1 | // [000] 结果一:全是可变的 |
由于指向常量的指针是允许指向变量的,指向变量的指针却不允许指向常量,在实际使用中不会按照严格匹配的类型进行赋值,实际情况会更加复杂。
数组+指针
指针数组vs数组指针
辨析一下指针数组和数组指针的概念:
- 指针数组:一个数组,其中的元素类型是指针
- 数组指针:一个指针,指向一个数组
指针数组的定义如下 1
2int x = 1, y = 2, z = 3;
int *arr[3] = {&x, &y, &z}; // (int *) [3]
注意语法解析时[]
比*
的优先级更高,因此这里定义的arr
首先是一个数组,每一个元素的类型是int *
。
下面的运算可以获取指针数组中元素指向的内容 1
int value_x = *arr[0]; // x
这里的运算顺序:先获取数组元素(即指针),再解引用。
数组指针的定义如下 1
2int arr[5] = {1, 2, 3, 4, 5};
int (*p)[5] = &arr; // pointer -> int [5]
注意这里使用小括号()
改变了语法解析的优先级,因此p
首先是一个指针,然后指向的类型是int [5]
。
对数组指针解引用就可以得到数组,可以继续对其运算,例如获取元素
1
int first_element = (*p)[0]; // a[0]
这里同样需要小括号()
改变优先级:先解引用,再获取数组元素。
数组索引退化
数组在编译器处理时,部分操作会直接退化为指针操作。
一维数组的名称相当于指向数组首位元素的一级指针常量 1
2int a[4] = {1,2,3,4};
int * const b = a; // const pointer -> int
此时对a
和b
进行读写操作是等价的
1
2
3
4
5a[0] = 100;
*b = 100;
a[1] = 200;
*(b+1) = 200;
但是它们并不是完全一致的,因为编译器将int [4]
和int * const
视作不同的类型。
数组索引操作总是会被编译器处理为对应指针的移动和解引用
1
2
3
4
5
6
7int a[4] = {1,2,3,4};
int * const b = a; // const pointer -> int
a[2] = 100;
*(a+2) = 100;
b[2] = 100;
*(b+2) = 100;
这些读写语句是等价的,甚至还有2[a]
这样的语法,导致下面所有的读写语句的效果都是等价的
1
2
3*(a+2) = 100;
a[2] = 100;
2[a] = 100;
二维数组的名称相当于指向数组首行数组的一级指针常量(即数组指针常量)
1
2int a[10][5];
int (* const b)[5] = a; // const pointer -> int [5]
对二维数组的索引需要理解为两次解引用操作,分别加上对应层次上的指针移动
1
2
3
4a[2][3] = 100;
*(*(a + 2) + 3) = 100;
*(*(b + 2) + 3) = 100;
其中+2
是指向数组int [5]
的指针移动,因此每次移动的单位长度为5*sizeof(int)
,
+3
是指向元素int
的指针移动,因此每次移动的单位长度为sizeof(int)
,对于a[2][3]
的总偏移量为
1
2 * 5 * sizeof(int) + 3 * sizeof(int)
如果对数组越界访问,编译器并不会检查索引的合法范围,索引操作仍然会根据上述运算规则继续进行指针的偏移。
数组传参退化
C语言在函数调用时将数组作为参数传递时,考虑到运行效率,并不会自动传递数组的长度, 数组用于函数传参时始终会自动退化为对应的指针,并且始终丢弃第一个维度的长度信息。
一维数组int [n]
用于函数传参时,会自动退化为指向元素的指针int *
,丢弃数组第一个维度的长度信息n
。
考虑下面三个函数的例子,它们实际上没有什么区别。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void func1(int arr[5]) { printf("%d\n", arr[2]); }
void func2(int arr[]) { printf("%d\n", arr[2]); }
void func3(int *arr) { printf("%d\n", *(arr+2)); }
int main() {
int arr[5] = {1, 2, 3, 4, 5};
func1(arr);
func2(arr);
func3(arr);
return 0;
}
尤其注意是func1
的定义中即使写了数组长度,但是实际上编译器不会保留,数组传递给函数会始终退化为一个指针,通常的做法是附带传递第一维的长度参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void printArray(int arr[], int length) {
for (int i = 0; i < length; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int arr[5] = {1, 2, 3, 4, 5};
printArray(arr, 5);
return 0;
}
二维数组int [m][n]
用于函数传参时,会自动退化为指向行数组的指针int (*)[n]
,丢弃数组第一个维度的长度信息m
。
考虑下面三个函数的例子,它们实际上没有什么区别。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void func1(int arr[3][4]) { printf("%d\n", arr[1][2]); }
void func2(int arr[][4]) { printf("%d\n", arr[1][2]); }
void func3(int (*arr)[4]) { printf("%d\n", arr[1][2]); }
int main() {
int arr[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
func1(arr);
func2(arr);
func3(arr);
return 0;
}
这里二维数组int [m][n]
相当于数组指针int (*)[n]
,这和int **
是完全不一样的,前者还含有行数组的长度信息:
- 前者的直接移动以
int [n]
的长度为单位,一次解引用后得到的是数组int [n]
,二次解引用是对数组的访问 - 后者的直接移动以
int *
的长度为单位,一次解引用后得到的是指针int *
,二次解引用可以得到指向的数据
如果将除了第一个维度之外的维度省略,得到的int [][]
并不是一个合法类型。
补充
数组索引炫技
下面这个函数就使用了各种数组索引的花样 1
2
3int func(int a){
return 0[(1[(int [2][1]){3,a}])];
}
显然func
是一个接受整数参数a
并返回整数的函数,可以将其展开为更清晰的形式
1
2
3
4
5
6
7int func(int a){
int temp[2][1] = {{3}, {a}};
int *row = 1[temp]; // temp[1]
int value = 0[row]; // row[0]
return value;
}
虽然看起来很复杂,但是实质就是原样返回输入的参数 1
int func(int a){ return a; }
C++替代
在C++中建议使用std::array
完全替代数组,此时不会存在传参时的类型退化等麻烦
1 |
|
在C++中还可以使用下面的写法来避免传参过程中的类型退化,限制必须传入指定长度的数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void func(int (&arr)[5]){
//...
}
int main(int argc, char *argv[]) {
std::cout << "hello,world\n";
int a3[3];
int a5[5];
int *p = a3;
// func(a3); // compile error
func(a5);
//func(p); // compile error
return 0;
}
这里的语法有点不直观,函数参数是int[5]
的引用类型,可以使用类型别名来提高可读性
1
2
3
4
5
6using Array5 = int (&)[5];
// or typedef int (&Array5)[5];
void func(Array5 arr){
//...
}