一文看懂数组和指针

ObjectKaz Lv4

在看懂数组和指针的关系之前,我们来回顾一下数组和指针的基本知识吧~~

基本知识回顾

一维数组

定义和初始化

基本格式

1
数据类型 标识符[数组长度] = {初始值,初始值,...};
  1. 数组长度必须是常量。
  2. [数组长度]只是个修饰符,而不是类型的一部分。

只定义但不初始化

这时数组中每个元素的初值都是不确定的:

1
int ai[10]; // 元素值都是随机值

如果是静态的,则每个元素的初值为0:

1
static int sai[10];

定义并初始化

  1. 初始化全部元素:
1
2
int a1[5] = {1,2,3,4,5};
// 1,2,3,4,5
  1. 初始化前面几个元素,则剩下的元素会被初始化为0:
1
2
int a2[5] = {1,2,3};
// 1,2,3,0,0

初始化部分元素的个数必须小于等于数组大小!

  1. 当数组有元素时,可以省略数组大小:
1
int a3[] = {1,2,3};

访问数组

访问和修改数组元素
用数组名+下标访问和修改可以修改数组元素。

注意,编译器不会检查数组越界。

1
a1[1] = 100;

二维数组

定义和初始化

基本格式

1
2
//数组长度必须是一个常量
数据类型 标识符[一维的长度][二维的长度] = 初始化列表;

跟上面的一维数组的定义和初始化差不多的,你可以只定义而不初始化,也可以定义的时候初始化。

对于初始化语句,你可以进行分开赋值,也可以进行连续赋值:

1
2
3
4
5
6
//连续赋值
int arr1[2][3] = {1,2,3,4,5,6};//arr1:{{1,2,3},{4,5,6}}
int arr2[2][3] = {1,2};//arr2:{{1,2,0},{0,0,0}}
//分开赋值
int arr3[2][3] = {{1,2,3},{4,5,6}};//arr3:{{1,2,3},{4,5,6}}
int arr4[2][3] = {{1,2},{4,5}};//arr4:{{1,2,0},{4,5,0}}

在多维数组的定义和初始化中,只能省掉一维的长度。

1
2
//得到 int[2][3]
int arr[][3] = {1,2,3,4,5,6};

访问数组

访问和修改数组元素
用数组名+下标访问和修改即可。

1
arr[1][1] = 100;

指针

在计算机内存中,每个字节都有一个唯一的标识,这个标识就是地址。
指针变量的值是一个地址。根据这个地址,可以访问存储器相应位置的数据。

指针的定义和初始化

基本格式

1
类型 *标识符 = 初始地址;

其中,指针指向的类型称为基类型

指针只能指向同一基类型的变量。唯一的例外是基类型为 void 的指针可以指向任意类型的数据。

定义语句中 * 只是一个修饰符,并不是类型的一部分。

1
int *pi,i;//pi是指向整型数据的指针,i是整型变量

指针的解引用

通过指针变量的值去访问内存中相应位置的数据。

通过 * 符号,便可访问和修改指针指向单元的数据:

1
2
3
int i = 1;
int *pi = &i;
*pi = 3;

指针作函数参数

指针本身作为一个变量,仍然是 按值传递 。但是它传递的是其他变量的地址,因此在函数内可以通过传入的地址去修改变量的值。

下面是一个交换数据的算法:

1
2
3
4
5
6
7
void swap(int *a,int *b)
{
int temp;
temp = *a;
*a = *b;
*b = temp;
}

数组名?是指针还是数组

我想各位应该见过这种,将数组名赋值给指针的写法吧:

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

那,我们是不是就可以说 a 就是指针了?那可不一定!

我们先不谈数组和指针这种抽象的玩意儿,先看看 整型和浮点型吧:

1
double d = 10;

10 是整型,但却赋给了 double 型的变量。我想大家应该也清楚,这中间发生了类型转换。

对于指针和数组,这同样是这样。

数组和指针,实际上是两个类型。 我们可以通过 sizeof 运算符来看出数组和指针的区别:

1
2
3
int a[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = a;
printf("%d, %d",sizeof(a),sizeof(p)); // 40, 8

可以看到, a 的大小是 40 个字节,p 的大小是 8 个字节。

注意,这个程序是在 64 位环境下编译的,如果使用的 VC++ 6.0,指针占用4个字节。

如果各位有学习C++的话,应该知道,C++里面有一个引用类型。我们可以将数组赋值给数组的引用,但不能赋值给指针的引用:

1
2
int (&ra)[10] = a; // ok
int *(&rp) = a; // error:cannot bind non-const lvalue reference of type 'int*&' to an rvalue of type 'int*'

数组引用的存在,说明了数组名就是数组类型。

数组和指针的关系

数组到指针的退化

我们还是继续探讨这行代码:

1
int *p = a;

既然数组和指针是两个类型,那么这行代码肯定发生了数组到指针的转换。没错,就是这样!

数组和指针有一个重要的关系: 数组类型在传递时,会转换成指向第一个元素的指针。这种关系我们称为 数组到指针的退化

不过,这有一些例外:

对数组类型取地址

1
2
int arr[10];
printf("%p",&arr);

sizeof 获取数组类型的大小

1
2
int arr[10];
printf("%d",sizeof(arr)); // 40

将字符串字面量(实际上是一个字符数组)用于数组初始化

1
char arr[100] = "6666666"; // "6666666" 是一个字符数组

这是一个更为严谨的表述:

任何数组类型的左值表达式,当用于异于

  • 作为取地址运算符的操作数
  • 作为 sizeof 的操作数
  • 作为用于数组初始化的字符串字面量

的语境时,会经历到指向其首元素指针的隐式转换。结果为非左值。

引用自 https://zh.cppreference.com/w/c/language/array

数组和指针的这种关系使得数组在传递的过程中,只需要传递首元素地址,而不必传递整个数组,不然这得多耗费时间。毕竟,C语言一开始设计出来是要做操作系统的~~ 这样操作,提高了语言的执行效率。

函数参数中的数组

前面提到,数组在传递过程中,传递的是首元素地址,而不是整个数组。 参数作为两个函数之间传递数据的重要纽带,传递的自然也是指针这玩意儿。

形参中的数组,在编译的时候会被编译器替换成指向首元素的指针。所以函数形参中定义的数组类型,其行为和指针是一摸一样的。

不过,指向首元素的指针并不需要数组的大小,所以,形参中的数组参数自然就可以省掉数组大小了:

1
2
3
4
function sort(int arr[])
{

}

为什么不能对数组整体进行赋值?

存储结构

一个程序的内存区域,被分成了四块:

  • 堆区 heap:供程序员动态分配的一段空间。

  • 栈区 stack:供系统自动分配和回收的空间,通常是局部变量。

  • 数据区 data:程序一运行就会分配的空间,通常是全局变量和静态变量。

  • 代码区 code:程序的代码所在区域。这些代码都是编译后的机器码,一条机器指令由操作码和操作数组成。

    为了保证安全,代码区的内容通常不允许被修改。

此外,在CPU内还有很多的寄存器,用于临时存放操作数和计算的结果。但寄存器是被所有程序共用的,它不能长时间保存数据。

左值和右值

在C语言中,存在着左值和右值的说法。

左值,顾名思义,就是可以放在赋值号的左边的值。同样的,右值就是只能放在赋值号的右边的值。

不过这只是两个名字的来历,和他们的功能还是有一些区别的。

左值(lvalue) 通常是一些变量和函数。所有的左值都拥有地址。

下面的 ciifunc 都是左值。

1
2
3
4
5
6
const int ci = 1234;
int i;
int func()
{
return 0;
}

一般来说,只要左值没有被 const 限制,它就可以放在赋值语句的左边。

右值(rvalue) 是不能取地址的一些量,通常是代码中直接出现的字面量,或者运算过程中得到的临时值。

代码中直接出现的字面量往往会存储在
内存的代码段,为了保证程序的安全,不被恶意篡改,C语言不允许对它们取地址。

而运算的临时结果一般在CPU的寄存器里,不在内存,所以他们压根就没有地址。

这是一个很经典的错例:

1
2
int a,b;
a + b = 13;

这个例子的错误就在于 a+b 的结果是一个右值,它不能取地址,那么计算机也就不知道该把 13 这个变量赋值到哪里了。

需要注意的是,一条机器指令占用的空间是有限的,而字符串字面量可能会占用很多的空间。所以,字符串字面量会存放在内存的数据段,而不是代码段。它也名副其实的成为了左值:

1
char *pc = "hello world";

不能对数组整体进行赋值

类型转换实际上是一种运算,它和加法、减法、乘法、除法类似。所以类型转换的结果实际上是一个右值。

对于数组来说,尽管它可能是左值,即使它在赋值号左边,它仍然会转换成指向第一个元素的指针。这个指针是一个右值,不能取地址。因此,它就不能放在赋值语句的左边。

数组仍然是可复制的

或许有人会这样想,既然不能对数组整体进行赋值,那么数组肯定就不能复制了。这可不一定哦!

前文提到,不能对数组整体进行赋值是因为它转换成了右值,而不是它不能复制。

当数组放在结构体里面,结构体里面的数组就可以复制了:

1
2
3
4
struct {
int arr[5];
} a = {1,2,3,4,5}, b;
b = a; //ok

不过,考虑到性能问题,不推荐直接在结构体里使用数组,因为在传递过程中,整个结构体会被反复复制。通常的做法是使用指针指向已存在的数组,或者手动分配一个数组空间:

1
2
3
4
struct {
int *arr;
} a;
a.arr = (int*)calloc(sizeof(int),10);

用类型的角度看变量

在C语言中,无论是 intdouble 型的变量,还是带 const 的变量、数组和函数,他们都是变量,而且都有类型。

复杂变量的类型

有部分内容后面会再次提到哒~~

C语言中,判断一个复杂变量(如数组、指针)的类型,非常简单。

只需要将变量名舍去,便得到变量的类型。


栗子

定义一个指针 p,那么它的类型就是 int *

1
int *p;

定义一个数组 arr,那么它的类型就是 int [10]

1
int arr[10];

对于函数,不仅要省掉函数名,还需要省掉参数名。


栗子

这里定义一个函数 myFloor,它的类型是 int (double)

1
2
3
4
int myFloor(double num) 
{
return (int)num;
}

需要注意的是,变量的定义中,*[] 是修饰符,而不是类型的一部分,因此这些符号在定义时只能修饰一个变量.


栗子

下面一行代码,p是指针,而 i 是一个整型的变量:

1
int *p,i;

如果类型再复杂一点,带上了括号,那该怎么办?

让我们先看一个栗子。


栗子

比如说有 int (*p)[20]; 这个定义:

首先,从 p 开始读,遇到了 * ,噢,它是一个指针。

然后遇到了括号,看看括号外面的内容,右边有一个 [10] 说明这个指针指向了由10个元素的数组。

最后遇到了 int,说明数组的元素是 int 型。

这说明,它是一个指针,指向了含有10个元素的 int 数组。


这是判断复杂变量类型的一个普遍的套路。

判断复杂变量的类型时,从变量名开始分析,顺序一般是从右往左。

当编译器解析到 * 时,表示这个变量是一个指针,之后解析的内容便是指针指向的内容。

对于数组也是这样,当编译器解析到 [数组大小] 时,表示这个变量是一个数组,后面解析到的内容便是数组元素的类型。

*[] 同时出现的时候,那编译器到底先解析 * 还是 [] 呢?答案是,先解析 []

类似的问题还有很多,我们大体可以总结一下优先级的顺序:

符号含义
[]数组长度声明
()括号或函数参数表
*指针声明
&引用声明(C++)

好在C语言中,常见的复杂类型也就这几种,我们把它们整理一下:

类型说明
int *指向 int 的指针
int [M]含有M个元素的 int 数组
int [M][N]含有M*N个元素的二维数组,元素类型为 int
int *[M]含有M个元素的一维数组,元素类型为 int *
int (*)[M]一个指针,指向含有M个元素的一维数组
int **指向 int * 的指针
double (int, int)一个函数,参数为 intint,返回值为 double
double (*)(int, int)一个指针,指向一个函数,参数为 intint,返回值为 double

二维数组的类型

对于二维数组,我们可以把它看成一个一维数组,每个元素又是一个一维数组。
例如,下面有一个二维数组:

1
int arr[10][88];

我们把它看成有 10 个元素的一维数组,每个元素又是有88个元素的数组,所以:

arr 的类型是 int [10][88]

arr[0] 又是一个数组,它的类型是 int [88]

指向数组的指针

数组在传递的过程中,会转换成指针,来提高数据传输的效率。那么当对数组本身去地址时,会得到什么呢?

这其实像极了“种瓜得瓜,种豆得豆”。对任何一个变量取地址,得到的是指向它的指针。

尽管数组在传递过程中会转换成指针,但对数组取地址,就免疫了这种“奇怪的行为”,得到的并不是指向数组第一个元素的指针,而是指向整个数组的指针:

1
2
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int (*pa)[10] = &arr;

指向数组的指针只是形式看起来有一些复杂,但它和普通的指针没有什么区别。

函数类型和函数指针

函数类型也是一种及其特殊的类型。

它的类型由函数参数和返回值组成。


栗子

这里定义一个函数 myFloor,它的类型是 int (double)

1
2
3
4
int myFloor(double num) 
{
return (int)num;
}

也正因如此,它并不能区分函数里面有多少指令。所以,函数类型的长度是不确定的。所以,不能对函数使用 sizeof 运算符

1
sizeof(myFloor);  //invalid application of 'sizeof' to a function type

尽管有些编译器并不会报错,但这样的行为也是没有意义的。

函数类型在作为值进行传递的过程中,会被转换成指向自身的函数指针。 不过,在函数调用时,不会发生这样的转换。

在计算机底层中,函数的调用是通过跳转到函数的入口地址来实现的。

然而,对它取地址也会得到指向自身的指针。

对它解引用,则函数会先转换成指向自身的函数指针,再转换成函数,最后又转换回指向自身的指针。

所以,便有了这样的一些等价关系,结果都是指向函数自身的指针:

1
myFloor <=> &myFloor <=> *myFloor;

所以,在给函数指针赋值的时候,取地址运算符是可选的

1
2
3
4
5
int (*fp)(double);

// 两种写法是一样的
fp = myFloor;
fp = &myFloor;

不过,函数指针也有怪异的等价关系:

1
fp == *fp;

*fp 得到的是函数,然后又转换成了指向这个函数的指针

需要注意的是,对函数指针取地址得到的是指向函数指针的指针,而不是自身。

函数指针的一个妙用,就是将函数作为另一个函数的参数。


栗子

比如说,我们可以给用户提供一个自定义排序的接口。

给定两个数字 ab

如果希望 ab 前面,就返回 -1

如果希望 ab 后面,就返回 1

如果希望 ab 保持原来的顺序,就返回 0

这个排序函数的原型大概就是这样:

1
void sort(int *arr, int n, int (*func)(int, int));

在函数内,可以对函数指针进行调用,而传入的函数却是可以自定义的:

1
func(1,2);

另一个用法,就是函数指针数组。但这个用法不太多见。

函数指针数组的定义类似这样(返回值为 void,没有参数):

1
void (*afp[数组大小])();

可以尝试使用之前的套路分析一下这个看起来很复杂的变量定义。

下面通过一个例子,康康它的用处~~


栗子(选读)

这个例子有些难度,可以跳过(如果不理解的话)

比如说,一个程序有一个菜单,表示了不同的功能:

1
2
3
4
5
6
7
8
9
10
11
12
0.Exit
1.Input record
2.Calculate total and average score of every course
3.Calculate total and average score of every student
4.Sort in descending order by total course of every student
5.Sort in ascending order by total course of every student
6.Sort in ascending order by number
7.Sort in dictionary order by name
8.Search by number
9.Search by name
10.Statistic analysis for every course
11.List Record

我们将每个操作定义成函数。这些函数均不接受任何参数,返回值为 void,例如:

1
2
3
4
void inputRecord()
{

}

我们可以使用一个函数指针数组,用下标代表操作的序号,值表示要执行的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void (*funList[12])() = {
exitProgram,
inputRecord,
calculateCourse,
calculateStudent,
sortDescByTotal,
sortAscByTotal,
sortAscByID,
sortAscByName,
findID,
findName,
statistic,
listRecord
};

然后,根据传入的序号(假设为 choice,int型),我们就可以直接调用相应的函数:

1
funList[choice]();

指针和 const 的恩爱情仇

对于一个普通的变量来说,为它加上 const 是十分简单的:

1
2
const int ci1 = 10;
int const ci2 = 10;

这说明 ci1ci2 定义后就不能修改它的值。

实际上,这两种定义方式是相同的,但在实际使用时,我们一般把 const 放在前面。

但对于一个指针来说,给它加上 const 是非常复杂的。因为这会存在三种情况:

  • 指针本身的值不可以修改,但是它指向的值可以修改。
  • 指针本身的值可以修改,但是它指向的值不可以修改。
  • 指针本身的值和它指向的值都不可以修改。

而且,在哪里加上 const 也是一个需要讲究的问题:

1
2
3
4
int i = 2333;
const int *pci = &i;
int * const cpi = &i;
const int * const cpci = &i;

事实上,三种情况都是存在的,分别对应着这三种定义形式。


栗子

我们先来康康 const int *pci = &i; 这一个,使用之前的套路进行分析:

编译器先解析 *,表示这是一个指针;

编译器再解析 int,表示这是一个指向整型的指针;

编译器最后解析 const,表示这是一个指向常整型的指针。

这说明,指针 pci 本身的值可以修改,但是它指向的值不可以修改。


不过,也许有人会问,那 i 是不是会变成常量了?这可不是这样。

指针定义中出现的 const,只会影响通过指针的访问方式,而不会改变原来的访问方式。

所以,i 还是原来的亚子。

我们再看一个栗子:


栗子

我们再来康康 int * const cpi = &i; 这一个,依然使用之前的套路进行分析:

编译器先解析 *,表示这是一个指针。
编译器再解析 int,表示这是一个指向整型的指针。
编译器最后解析 const,表示这是一个指向常整型的指针。

这说明,指针 pci 本身的值不可以修改,但是它指向的值可以修改。


看完这两个栗子以后,估计各位也都应该知道 const int * const cpci = &i; 这行定义的分析方法了吧。

总结一下:

定义类型指针本身可修改?指向的内容可修改?
const int *p;const int *×
int * const p;int * const×
const int * const p;const int * const××

使用 typedef 大法简化类型定义

typedef的语法很简单,只需要在类似变量定义的语句之前加上一个 typedef 就行了。

可能你们印象中的 typedef 只是简单地为类型定义一个别名:

1
2
typedef int Integer;
Integer i;

但这似乎并没有什么卵用,反而还把一个简单的问题搞复杂了。

但是,它真正的意义是用来简化复杂类型的声明的。比如说:

1
2
typedef int (*ArrayPointer)[10];
ArrayPointer ap;

相比这个:

1
int (*ap)[10];

变量的定义是不是看起来更简单了~~

尤其是当一个程序多处用到同一个复杂类型的时候,使用它会带来极大的便利。只需要修改类型定义,所有定义的变量的类型全部都会发生变化。

不过,需要注意一点,typedef并不是简单的复制粘贴,typedef里面的 *[] 等都是类型的一部分

我们来看一个栗子:


栗子

1
2
typedef const int *ConstPointer;
ConstPointer p1,p2;

如果这仅仅是简单的复制粘贴,那么上面两行代码相当于:

1
const int *p1,p2;

结果,p1 是指针,而 p2 是整型的变量。

但实际上并不是这样。如果你尝试对 p2 赋值一个整数,便会报一个 Warning

1
2
3
typedef const int *ConstPointer;
ConstPointer p1, p2;
p2 = 100; // warning: assignment to 'ConstPointer' {aka 'const int *'} from 'int' makes pointer from integer without a cast

从编译器给出的错误信息中可以看出,p2 实际上并不是 int,而是和 p1 的类型一摸一样。


指针的应用

引用变量

占用空间大的变量(如结构体和数组),在传递过程中需要频繁复制(比如结构体的赋值,向函数传递结构体,从函数返回结构体)。这对于程序的性能是有较大损耗的。

为了避免这种性能损耗,可以采用指针传递的方式。指针的长度是固定的,它在传递过程中的性能损耗是很小的。

迭代数组

首先需要提一下,指针存储的不就是一个变量的地址,但为啥会有 int *,double * 这么多类型呐?

要从内存中获取一段数据,需要指定它的起始地址和终止地址。而终止地址也可以根据起始地址和大小算出来。

而且从内存中获取的数据,并不一定是最终我们想看到的数据,所以这些数据可能还需要进行转化,才能变成我们想看到的。

这就是指针有很多类型的原因。

每种类型的指针有一个 偏移量 ,它是指针所指向类型的大小。

指针本身是可以参与算术计算的,但是它和整数运算不一样。
例如有指针 p 和整数 ip+i 得到的是指针,其值实际上为 p的值 + i * 偏移量

这种操作,和访问数组有着千丝万缕的联系。

数组在计算机中,是以一块连续的空间进行存储的。

比如一个数组:int arr[3] = {1,2,3};,它的存储结构类似这样:

地址
0x000000201
0x000000242
0x000000283

而当有一个指针 int *p = arr; 时,访问 p+1 相当于 p 的值 0x00000020 加上了4,结果一看,是 0x00000024,刚好是 第2个元素的地址。

也就是说,指针保存了所访问数组的位置,且可以通过算术运算来改变位置。

通过指针,我们便可以访问整个数组,且无需告知数组的首地址和长度。这种保存一种数据结构状态且无需关系其实现细节的“指针”,我们称为 迭代器

但指针并不是完整的迭代器,因为它不能检测数组越界。所以,在迭代数组的时候,需要传入一个指向数组最后一个元素的下一个元素(代表不存在的东西),来避免越界访问。

一个典型的例子就是字符串的操作,传入的是字符指针,而不是字符数组和字符的位置。当我们遇到 \0 时,便认为它到达字符串结尾。

  • 标题: 一文看懂数组和指针
  • 作者: ObjectKaz
  • 创建于: 2021-01-11 07:52:00
  • 更新于: 2021-01-11 08:05:08
  • 链接: https://www.objectkaz.cn/616a942a9a05.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。