虽然在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
3
int 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
3
int a[2][3];
a[0][1] = 100;
print("%d",a[0][0]);

C语言保证数组始终在内存中连续分布,无论一维,二维还是高维数组。 对于多维数组在内存中按照行优先的顺序拼接:保证在索引的最后一个分量发生变化时,数据在内存中是相应的连续变化,例如

1
2
int 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
2
int x = 5;
int *p = &x;

可以在初始化时将指针指向空地址,这表明指针是一个空指针

1
int *p = NULL;

使用解引用运算*可以访问指针指向的值

1
2
3
int x = 10;
int *p = &x;
printf("%d",*p);

指针的移动通过加减整数或自增自减实现,例如

1
2
3
4
int x = 10;
int *p = &x;
p = p + 2;
p++;

这会使得指针指向不同的地址,每次移动的字节长度是指向类型的字节长度,例如

1
2
3
4
5
6
7
char 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
3
int x = 10;
int *p = &x; // pointer -> int
int **pp = &p; // pointer -> pointer -> int

获取二级指针指向的值,需要使用两次解引用运算

1
2
3
4
int x = 10;
int *p = &x; // pointer -> int
int **pp = &p; // pointer -> pointer -> int
printf("%d",**pp);

使用一次解引用运算得到的结果可以用来给一级指针赋值

1
2
3
4
int x = 10;
int *p = &x; // pointer -> int
int **pp = &p; // pointer -> pointer -> int
int *q = *pp; // pointer -> int

多级指针与二级指针在原理上没什么区别,这里省略。

指针常量vs常量指针

辨析一下指针常量和常量指针的概念,考虑下面几个定义

1
2
3
4
5
char 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可以修改;
  • p2p3是一样的,表示常量指针,即const修饰char,表示指针自身可以被修改,但是指向内容const char不可修改;
  • p4是常量指针常量,指针自身不可修改,指向的内容也不可修改。

理解的关键在于const会和右侧的语义相结合,先结合到变量就修饰指针自身,否则就修饰指针指向的类型。

对于二级指针如果再加上常量的修饰,结果就更多了(\(2^3=8\)种组合)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// [000] 结果一:全是可变的
int **p1;

// [001] 结果二:所指一级指针指向的数据是不可变的
const int **p2;
int const **p2; // or

// [010] 结果三:所指向的一级指针是不可变的
int *const *p3;

// [011] 结果四:二级指针本身是可变的,所指一级指针是不可变的,一级指针指向的数据是不可变的
const int tmp1 = 55;
const int *tmp2 = &tmp1;
const int *const *p4 = &tmp2;
int const *const *p4 = &tmp2; // or

// [100] 结果五:二级指针本身是不可变的
int *tmp;
int **const p5 = &tmp;

// [101] 结果六:二级指针本身是不可变的,所指向的一级指针是可变的,一级指针指向的数据是不可变的
int tmp1 = 55;
const int *tmp2 = &tmp1;
const int **const p6 = &tmp2;
int const **const p6 = &tmp2; // or

// [110] 结果七:二级指针本身是不可变的,所指向的一级指针是不可变的
int tmp1 = 55;
const int *tmp2 = &tmp1;
int *const *const p7 = &tmp2;

// [111] 结果八:二级指针本身是不可变的,所指一级指针是不可变的,一级指针指向的数据是不可变的
const int tmp1 = 55;
const int *tmp2 = &tmp1;
const int *const *const p8 = &tmp2;
int const *const *const p8 = &tmp2; // or

由于指向常量的指针是允许指向变量的,指向变量的指针却不允许指向常量,在实际使用中不会按照严格匹配的类型进行赋值,实际情况会更加复杂。

数组+指针

指针数组vs数组指针

辨析一下指针数组和数组指针的概念:

  • 指针数组:一个数组,其中的元素类型是指针
  • 数组指针:一个指针,指向一个数组

指针数组的定义如下

1
2
int x = 1, y = 2, z = 3;
int *arr[3] = {&x, &y, &z}; // (int *) [3]

注意语法解析时[]*的优先级更高,因此这里定义的arr首先是一个数组,每一个元素的类型是int *

下面的运算可以获取指针数组中元素指向的内容

1
int value_x = *arr[0];  // x

这里的运算顺序:先获取数组元素(即指针),再解引用。

数组指针的定义如下

1
2
int 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
2
int a[4] = {1,2,3,4};
int * const b = a; // const pointer -> int

此时对ab进行读写操作是等价的

1
2
3
4
5
a[0] = 100;
*b = 100;

a[1] = 200;
*(b+1) = 200;

但是它们并不是完全一致的,因为编译器将int [4]int * const视作不同的类型。

数组索引操作总是会被编译器处理为对应指针的移动和解引用

1
2
3
4
5
6
7
int 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
2
int a[10][5];
int (* const b)[5] = a; // const pointer -> int [5]

对二维数组的索引需要理解为两次解引用操作,分别加上对应层次上的指针移动

1
2
3
4
a[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
#include <stdio.h>

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
#include <stdio.h>

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
#include <stdio.h>

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
3
int func(int a){
return 0[(1[(int [2][1]){3,a}])];
}

显然func是一个接受整数参数a并返回整数的函数,可以将其展开为更清晰的形式

1
2
3
4
5
6
7
int 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
2
3
4
#include <array>

int a[4];
std::array<int,4> b;

在C++中还可以使用下面的写法来避免传参过程中的类型退化,限制必须传入指定长度的数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

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
6
using Array5 = int (&)[5];
// or typedef int (&Array5)[5];

void func(Array5 arr){
//...
}