虽然在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 [][]并不是一个合法类型。

数组作为返回值?

在 C 语言中,数组除了在传参过程中会退化为指针,而且直接不允许作为函数的返回值出现,下面这种语法是无效的

1
int func()[4]; // compile error

即使通过别名定义的方式也无法绕过

1
2
3
typedef int arr4[4];

arr4 func(); // compile error

因此无法通过返回数组达到返回多值的目的,满足语法要求的替代方案有很多,例如:

  • 返回指向(非动态分配)数组的指针;(最不推荐,因为这涉及到指向的数组的生命周期问题,除非返回的是函数体内的静态数组,否则都是非常危险的)
  • 返回指向动态分配数组的指针,由调用方自行负责释放;
  • 返回结构体,将数组作为结构体的成员即可;
  • 使用输入输出参数,调用方直接在参数中提供一个指向数组的指针。

补充

C的数组索引炫技

下面这个函数就使用了各种数组索引的花样

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 语言中,我们无法要求函数的参数是一个指定大小的数组,因为数组会自动退化为指针,长度信息会丢失, 但是在 C++ 中,我们可以通过传递数组的引用实现这一点,此时函数的参数类型是数组的引用,这个类型会保留数组的长度信息。 例如

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

void func(int (&arr)[5]) {
//...
}

int main(int argc, char *argv[]) {
int a5[5];
func(a5); // ok

int a3[3];
// func(a3); // compile error

int *p = a3;
// func(p); // compile error

return 0;
}

可以用类型别名改善一下可读性

1
2
3
4
5
using Array5Ref = int (&)[5];

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

与之类似的,虽然 C 语言完全不允许函数的返回值是数组类型,但是我们可以通过返回数组的引用来实现这一点。 例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int (&getArray())[5] {
static int arr[5] = {1, 2, 3, 4, 5};
return arr;
}

int main() {
auto &arr = getArray();

for (int i = 0; i < 5; ++i) { std::cout << arr[i] << " "; }

return 0;
}

可以用类型别名改善一下可读性

1
2
3
4
5
6
using Array5Ref = int (&)[5];

Array5Ref getArray() {
static int arr[5] = {1, 2, 3, 4, 5};
return arr;
}

这里的数组引用类型实际上会牵连出很多语法细节的问题,它导致类型的解析变得更加复杂,尤其是把这些和面向对象等 C++ 的语法结合起来时,会有非常多的麻烦,例如override的位置问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Base {
virtual int (&CArray())[5] = 0;
};

// (1)
struct Derived : Base {
int (&CArray())[5] override {
static int arr[5]{11, 22, 33, 44, 55};
return arr;
}
};

// (2)
struct Derived : Base {
int (&CArray() override)[5] {
static int arr[5]{11, 22, 33, 44, 55};
return arr;
}
};

这两种做法将 override 关键词放在了不同位置,不同的编译器给出了不同的测试结果:在MSVC和clang中只有前者可以编译通过,在gcc中只有后者可以编译通过。

当然,使用类型别名就不存在两种写法的问题了,在三大编译器中都可以编译通过

1
2
3
4
5
6
7
8
using Array5Ref = int (&)[5];

struct Derived : Base {
Array5Ref CArray() override {
static int arr[5]{11, 22, 33, 44, 55};
return arr;
}
};

C++ 看起来并不喜欢这些做法,modern C++ 提供了更好的,可读性更高的做法来实现对应的功能。 例如,在 C++ 中可以使用std::array替代 C 语言中的数组,此时不会存在传参时的类型退化等麻烦

1
2
3
4
#include <array>

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