Skip to content

Latest commit

 

History

History
873 lines (504 loc) · 31.3 KB

[snowming]-2021-8-15-【C基础】课时10:数组和指针.md

File metadata and controls

873 lines (504 loc) · 31.3 KB

本章要点:

  • 关键字:static
  • 运算符:&*(一元)
  • 如何创建并初始化数组
  • 指针、指针和数组的关系
  • 编写处理数组的函数
  • 二维数组

关于数组的基本概念

什么是数组?

数组由数据类型相同的一系列元素组成。

注: 这里的数据类型是“基本数据类型”。如:float、char、int...... 普通变量可以使用的类型,数组元素都可以用。

需要使用数组时,通过声明数组告诉编译器:

  • 数组内含多少元素
  • 这些元素的类型

编译器根据这些信息正确地创建数组。

一些数组声明

/* 一些数组声明 */
int main(void)
{
    float candy[365];  //内含365个float类型元素的数组
    char code[12]; /*内含12个char类型元素的数组*/
    int states[50]; /*内含50个int类型元素的数组*/
}
  • 方括号([])表示candy、code、states都是数组。
  • 方括号中的数字表明数组中的元素个数。

访问数组中的元素

要访问数组中的元素,通过使用数组下标数(也称为索引)表示数组中的各元素。

数组的编号从0开始。

初始化数组

数组被用来储存程序需要的数据。在这种情况下,在程序一开始就初始化数组比较好。

C中初始化数组的语法

int main(void)
{
    int powers[8] = {1,2, 4,6,8 , 16,32 ,64}; /* 从ANSI C开始支持这种初始化*/
}
  • 逗号和值之间可以使用空格。
  • 不支持 ANSI 的编译器会把这种形式的初始化识别为语法错误,在数组声明前加上关键字 static 可解决此问题。

使用const关键字把数组设置为只读

比如:

const int days[MONTHS] = {31,28,31,30,31,30,31,31,30,31,30,31};
  • const 关键字声明和初始化数组。

这样把数组设置为只读之后,程序只能从数组中检索值,不能把新值写入数组。

const 使得程序在运行过程中不能修改该数组中的内容。

存储类别警告

数组和其他变量类似,可以把数组创建成不同的存储类别(storage class)。

这里描述的数组属于自动存储类别,意思是:

  1. 这些数组在函数内部声明;
  2. 声明时未使用关键字 static

不同的存储类别有不同的属性,所以不能把本章的内容推广到其他存储类别。对于一些其他存储类别的变量和数组,如果在声明时未初始化,编译器会自动把它们的值设置为0。

而对于自动存储类别的数组,如果初始化失败,那么某元素的值是内存相应位置上的现有值,所以是不确定的值。

当初始化列表中的项数与数组的大小不一致

title

当初始化列表中的值少于数组元素个数时,编译器会把剩余的元素都初始化为0。也就是说,如果不初始化数组,数组元素和未初始化的普通变量一样,其中储存的都是垃圾值;但是,如果部分初始化数组,剩余的元素就会被初始化为0。

自动匹配项数和数组大小

title

  • 如果初始化数组时省略方括号中的数字,编译器会根据初始化列表中的项数来确定数组的大小。
  • 确定数组元素的个数可以这样计算:size of days / size of days[0],可以避免人工算错数组的项数,让程序找出数组的大小,避免初始化的个数超过数组的大小导致储存垃圾值。
  • 自动计数的弊端在于:无法察觉初始化列表中的项数有误。(逻辑错误等)
  • size of 运算符给出它的运算对象的大小(以字节为单位);
  • 所以 size of days 是整个数组的大小(以字节为单位);
  • sizeof days[0] 是数组中一个元素的大小(以字节为单位);
  • 所以数组元素的个数 = 整个数组的大小/单个元素的大小

指定初始化器(C99)

  • C99的新特性:可以初始化指定的数组元素,比如,仅仅初始化数组中的最后一个元素。对于传统C初始化语法,必须初始化最后一个元素之前的所有元素,才能初始它。

传统初始化语法:

int arr[6] = {0,0,0,0,0,212};

指定初始化器:

int arr[6] = {[5]=212};    //把arr[5]初始化为212,第6项

title

指定初始化器的两个重要特性:

  1. 如果指定初始化器后面有更多的值,如 [4]=31,30,31,那么后面这些值将被用于初始化指定元素后面的元素。days[4]被初始化为31之后,days[5]被初始化为30,days[6]被初始化31。
  2. 如果再次初始化指定的元素,那么最后的初始化将会取代之前的初始化。比如days[1]一开始被初始化为28,又被指定初始化器[1]=29初始化为29。
  3. 【初始化的通用特性】在初始化一个元素后,未初始化的元素都会被设置为0。

未指定元素大小的指定初始化

int stuff[] = {1,[6]=23}; //stuff数组有7个元素,编号为0~6
int staff[] = {1,[6]=4,9,10}; //staff数组有9个元素,编号为0~8

给数组元素赋值

声明数组后,可以借助数组下标(或索引)给数组元素赋值。

/* 给数组的元素赋值 */
#include <stdio.h>
#define SIZE 50
int main(void)
{
	int counter, evens[SIZE];

	for (counter = 0; counter < SIZE; counter++)
	{
		evens[counter] = 2 * counter;
	}
}

这段代码中使用循环给数组的元素依次赋值。

一些无效的数组赋值

  • 不允许把数组作为一个单元赋值给另一个数组
  • 除初始化之外不允许使用花括号列表的形式赋值
  • oxen[SIZE]数组的最后一个元素是oxen[SIZE-1],注意不要下标越界
#define SIZE 5
int main(void)
{
    int oxen[SIZE] = {5,3,2,8}; /*初始化时候可以使用花括号列表的形式赋值*/
    int yaks[SIZE];
    
    yaks = oxen; /*不允许把数组作为一个单元赋值给另一个数组*/
    yaks[SIZE] = oxen[SIZE]; /*数组下标越界,一个5大小的数组索引应该为0~4*/
    yaks[SIZE] = {5,3,2,8}; /* 初始化之外使用花括号列表的形式赋值无效*/
}

数组边界

使用数组时,要防止数组下标超出边界。编译器不会检查出这种下标越界的错误。

title

此程序创建了一个内含4个元素的数组,然后错误地使用了-1~6的下标。

在C标准中,使用越界下标的结果是未定义的。这意味着程序看上去可以运行,但是运行结果很奇怪,或异常中止。

使用越界的数组下标会导致程序改变其他变量的值,不同的编译器运行该程序的结果可能不同,有些会导致程序异常中止。

指定数组的大小

  • C99标准之前,声明数组时只能在方括号内使用整型常量表达式
  • C99引入变长数组(简称VLA):
float a8[n];  // C99之前不允许
float a9[m];  // C99之前不允许

多维数组

多维数组是以数组为元素的数组。

多维数组声明

float rain[5][12];
  • 主数组有5个元素(每个元素表示一年)
  • 每个元素也是一个数组,内含12个元素(每个元素表示一个月)

这个声明表示声明一个内含5个数组元素的数组,每个数组元素内含12个float类型的元素。

在计算机内部,二维数组是按顺序储存的,从第1个内含12个元素的数组开始,然后是第2个内含12个元素的数组,以此类推。

初始化二维数组

title

当值的个数不匹配

title

初始化二维数组的两种方法

title

int sq[2][3] = {{5,6},{7,8}};

这表示声明并初始化二维数组sq,这个二维数组有两个元素,每个元素是一个 int xxx[3] 的数组。但是初始化的时候子数组里面的元素没有完全初始化,所以每个子数组没有初始化的元素填充0。

int sq[2][3] = {5,6,7,8};

这表示声明并初始化二维数组sq,这个二维数组有两个元素,每个元素是一个 int xxx[3] 数组。所以是这样的:

s0 s1 s2 t0 t1 t2

但是计算机里面是依次写入数组,就是先写第一个数组,然后写入第二个数组。这里这个二维数组在初始化时候省略了内部的花括号,就会按照先后顺序逐行初始化,直到用完所有的值。因为一共有6个空,只有4个值,所以依次填入就是:

5,6,7,8,0,0

const关键字

如果一共数组里存储的数据应该是不能修改的,那么声明该数组的时候我们可以使用 const 关键字,这样在运行时程序就不能改变该数组。

如:

const float rain[YEARS][MONTHS] = {};

其他多维数组

还有三维数组或更多维的数组。这些多维数组的语法规则同二维数组。

声明一个三维数组

int box[10][20][30];

理解三维数组

  • 可以把一维数组想象成一行数据,把二维数组想象成数据表,把三维数组想象成一叠数据表。例如,把 int box[10][20][30]; 这个声明的三维数组想象成由10个二维数组(每个二维数组都是20行30列)堆叠起来。
  • 另一种理解三维数组的方法是:把三维数组看作数组的数组。也就是说,box内含10个元素,每个元素是内含20个元素的数组。这20个数组元素中的每个元素是内含30个元素的数组。或者,可以简单地根据所需的下标值去理解数组。

处理多维数组

通常,处理三维数组要使用3重嵌套循环,处理四维数组要使用4重嵌套循环。对于其他多维数组以此类推。


划重点部分

指针和数组

指针:提供一种以符号形式使用地址的方法。指针能有效地处理数组。

先记住一个表示法:

数组名是数组首元素的地址。

//&是地址运算符
shuzu == &shuzu[0];  //数组名是该数组首元素的地址

以上两种数组首元素地址的表示方法,都是常量。所以在程序的运行过程中不会改变。但是可以把它们赋值给指针变量,然后可以修改指针变量的值。

如下程序,给指针变量分别加1、2、3:

title

注:

  1. 转换说明%p通常以十六进制显示指针的值。
  2. 地址是十六进制的,因此 dd 比 dc 大1, a1 比 a0 大1。

程序输出分析

  • 数组首元素地址本身是一个常量,但是把数组首元素地址赋值给一个指针变量(* pti* ptf),就可以改变此指针变量的值了。
  • pointers + 0这一行打印的是两个数组开始的地址,下一行打印的是指针加1后的地址。
  • 为什么加了1之后是这个结果呢?要结合对象类型来看。
  • 在C中,指针加1指的是增加一个存储单元。对数组而言,这意味着加1后的地址是下一个元素的地址,而不是下一个字节的地址。
  • 系统中,地址按字节编址,short类型占用2字节,double类型占用8字节(可以用sizeof查看)。
  • 因为pti的类型是short,所以指针每加1,其值每次递增2字节。也就是short数组这边,78+2字节=7A,7A+2字节=7C,7C+2字节=7E;
  • 同理,因为ptf的类型是double。所以指针变量每加1,其值每次递增8字节。也就是:double数组这边,a0+8字节=a8,a8+8字节=b0,b0+8字节=b8。
  • 这就是为什么必须声明指针所指向对象类型的原因之一。只知道地址不够,因为计算机要知道储存对象需要多少字节,否则指针变量就无法正确地取回地址上的值。

数组和指针加法总结

title

指针和数组的关系

注: dates 是指针变量

dates + 2 == &dates[2]    //相同的地址
*(dates + 2) == dates[2]  //相同的值

分析

记住前提知识点:

数组名是数组首元素的地址。

//&是地址运算符
shuzu == &shuzu[0];  //数组名是该数组首元素的地址

以上两种数组首元素地址的表示方法,都是常量。所以在程序的运行过程中不会改变。但是可以把它们赋值给指针变量,然后可以修改指针变量的值。

所以,

  • dates 是(数组名赋值给的)指针变量,指针变量的值是它所指向对象的地址。
  • dates + 2,指针变量+2,也就是增加了2个存储单元。对于数组来说,这意味着下两个元素的地址。所以dates[0]→dates[2],加上&符号就是其地址。
  • *(dates + 2)意味着存储在当前指针变量对应地址往后两个存储单元的地址的值,它等于同类型的dates数组的下标2元素的值。

C语言标准中借助指针描述数组

C 语言标准在描述数组表示法时借助了指针。

也就是说,定义 ar[n] 的意思是 *(ar + n)。可以认为 *(ar + n) 的意思是「到内存的 ar 位置,然后移动 n 个单元,检索存储在那里的值」。

总之,要站在内存的角度思考指针和数组,就会发现都是一样的,不同的表示法而已。

*的优先级高于+

title

指针表示法替换数组表示法

用数组表示法的程序:

title

替换成对应的指针表示法:

title

days[index]替换成*(days+index),结果都是一样的。

代码分析

  • 这里,days是数组首元素的地址,days+index 是元素 days[index] 的地址,而 *(days+index) 是该元素的值,相当于days[index]
  • 指针表示法和数组表示法是两种等效的方法。编译器编译这两种写法生成的代码相同。
  • 总之,要站在内存的角度思考指针和数组,就会发现都是一样的,不同的表示法而已。

函数、数组和指针

假设要编写一个处理数组的函数,该函数返回数组中所有元素之和,待处理的是名为marbles的int类型数组。

应该如何调用该函数?可能的函数调用为:

total = sum(marbles) //参数是函数名

那么函数的原型是什么?

int sum(参数类型 marbles);

如何去表达marbles的类型为数组呢?

因为,数组名是该数组首元素的地址,所以实际参数marbles是一个储存int类型值的地址,应该把赋值给一个指针形式参数,即:该形参是一个指向int的指针:

int sum(int * ar); //对应的函数原型

获取数组元素个数的信息

title

在函数代码中写上固定的数组大小

int sum(int * ar)
{
    int i;
    int total = 0;
    
    for (i = 0; i < 10; i++) //假设数组有10元素
        total += ar[i];      //ar[i]与*(ar + i)相等
    return total;
}

把数组大小作为第2个参数

int sum(int * ar, int n)  //用n表示数组元素个数,更加具有通用性
{
    int i;
    int total = 0;
    
    for (i = 0; i < n; i++)
    {
        total += *(ar + i);
    }
    return total;
}

int ar[]

title

声明数组类型的形参

title

程序分析

title

title

使用指数形参

title

title

要注意:

  • 因为start是指向int的指针,start递增1相当于其值递增int类型的大小(的字节)。
  • sump() 函数使用第2个指针来结束循环。

title

指针运算符的优先级

title

  • 一元运算符*++的优先级相同,但是结合律是从右往左。
  • 第一行:*p1表示data数组的首元素的地址对应的值,所以是100;*p2表示data数组的首元素的地址对应的值,所以是100;*p3表示moredata数组的首元素的地址对应的值,所以是300.
  • 第二行:*p1++,一元运算符*++的优先级相同,但是结合律是从右往左,所以相当于*(p1++)p1是data数组的首元素的地址,这里的加1是加上一个存储单元,注意是p1++而不是++p1,所以是相当于*p1先取p1指针地址存储的值,然后再递增指针,所以结果为100;
    *++p2*++的优先级相同,但是由于结合律从右往左,所以相当于先递增指针,然后取值。指针递增一个存储单元,相当于data[1]的值,也就是200;
    (*p3)++,虽然结合律从右往左,但是加上了括号改变了顺序,先取p3指针的值,然后加1,这个式子的结果到取完值为止,而不是计算加1后p3的值,所以结果是300(moredata数组首元素的值)。
  • 第三行:计算的是经过++之后被改变的值。p1经过*p1++的改变,相当于p1++地址上的值,即data[1]就是200;p2同理,总之根据结合律,是先递增至真,再求值;p3就是先求指针对应的值,再递增1,所以是301。

title

指针表示法和数组表示法

处理数组的函数实际上用指针作为参数,但是在编写这样的函数时,可以选择使用指针表示法还是数组表示法。

title

指针操作

title

指针的一些基本操作包括:

1、赋值

title

2、解引用

title

3、取址

title

4、指针与整数相加

加的单位其实是存储单元: title

5、递增指针

递增指针之后指针的地址没有变,也就是 ptr1++ 之后 &ptr1 没有变,因为变量不会因为值的变化就移动位置。 title title

6、指针减去一个整数

只能指针减整数,不能整数减指针: title

7、递减指针

title

8、指针求差

title

9、比较

指针的值就是该指针指向的地址,而不是储存在指针指向地址上的值。 title


两种减法

上面提到的减法有两种:

  1. 可以用一个指针减去另一个指针得到一个整数(两个指针求差计算的是同一个数组中两元素之间的距离);
  2. 用一个指针减去一个整数得到另一个指针(减去的整数单位是存储单元)。

编译器不会检查指针是否指向数组元素

title

不要解引用未初始化的指针

title

保护数组中的数据

title

title

  • 注意:该程序中两个函数的返回类型都是void,虽然muly_array函数更新了dip数组的值,但是并未使用return机制。

const的其他内容

如果尝试改变使用const关键字保护的数组的值,编译器将生成一个编译器错误消息:

title

指向const的指针不能用于改变值

title

只能把非const数据的地址赋给普通指针

double rates[5] = {1.11,2.22,3.33,4.44,5.55};
const double locked[4] = {0.08,0.075,0.0725,0.07};

//有效,把非const数据的地址赋给const指针
const double * pc = rates; 

//有效,把const数据的地址赋给const指针
pc = locked; 

//有效,把非const数据的地址赋给const指针
pc = &rates[3];  

//有效,把非const数据的地址赋给普通指针
double * pnc = rates; 

//无效!把const数据的地址赋给普通指针
pnc = locked; 

//有效,把非const数据的地址赋给普通指针
pnc = &rates[3]; 

从上面的例子可以看出:

  1. 把const数据或非const数据的地址初始化为指向const的指针或为其赋值是合法的。(指向const的指针不能用于改变值、表明不会使用指针改变数据);
  2. 把const数据的地址赋给普通指针(非const)指针是不合法的,这样的话就导致可以通过指针改变const数组中的数据;
  3. 所以只能把非const数据的地址赋值给普通指针,因为把数据地址赋值给普通指针的话、就可以通过指针改变数据(比如交换数据的例子)。

使用const声明并初始化一个不能指向别处的指针

title

title

  • const double * pc = rates; 使得不能通过该指针pc去修改指向地址上的值(但是值自己可以变);
  • double * const pc; 使得该指针pc不能更改它所指向的地址。
  • 关键是const的位置。

指针和多维数组

处理多维数组的函数要用到指针。

title

程序分析

  • 这是一个二维数组 int zippo[4][2],数组名 zippo 是该数组首元素的地址。数组 zippo 的首元素是一个内含两个int元素的数组,所以zippo是这个内含两个int值的数组的地址。地址为000000C2F14FFC68。给指针或地址+1,其值会增加对应类型大小的数值。因为zippo指向的对象占用了两个int的大小,由第一行打印的sizeof(int)可知,一个int 4 byte,两个int就是8 byte,末尾8+8=16,在16进制中进一位,所以就是000000C2F14FFC70
  • zippo[0]是一个内含两个整数的数组,所以可以把zippo[0]看作数组名,zippo[0]的值和它首元素的地址(即&zippo[0][0]的值相同)。即:zippo[0]是一个占用1个int大小对象的地址。由于zippo[0]这个数组和zippo[0][0]这个整数都开始于同一个地址,所以zippo和zippo[0]作为首元素值相同。所以zippo[0]也等于000000C2F14FFC68。zippo[0]作为数组zippo[0][0]的首元素,数据类型为指针或地址,它+1就会加上一个数据单元,就是一个int的大小4byte,8+4=12=C,所以zippo[0]+1=000000C2F14FFC6C。
  • *zippo代表该数组首元素zippo[0]的值,但是zippo[0]本身也是一个int类型值的地址(想象成zippo[0]数组的首元素),该值的地址是&zippo[0][0],所以*zippo=&zippo[0][0]=zippo[0],但是数据单位是1 int。
  • zippo[0][0] = 2
  • *zippo[0]的话,因为zippo[0]是zippo[0]这个内层数组的首元素的地址,所以*zippo[0]就等于zippo[0][0]就等于2。
  • 作为二维数组,zippod是地址的地址,必须解引用两次才能获得原始值。**zippo*&zippo[0][0]等价,就相当于zippo[0][0],也就等于2。
  • zippo[2][1]相当于第3个数组的第2个元素(0基),也就是3。
  • *(*(zippo+2)+1))的话,要以数组的角度去想。首先可以看出是两层*取值,那么括号里面的也要看成一个地址,其实更准确的说是地址的地址。等价于zippo[2][1],所以为3。

总结

  • zippo[0]*zippo完全相同。
  • 对二维数组解引用两次,得到储存在数组中的值。
  • zippo→二维数组首元素的地址(每个元素都是内含两个int类型元素的一维数组)。
  • zippo+2→二维数组的第三个元素(即一维数组)的地址。
  • *(zippo+2)→二维数组的第三个元素(即一维数组)的首元素(一个int类型的值)的地址。(即zippo[2]是zippo[2]这个数组的数组名)
  • *(zippo+2)+1→二维数组的第三个元素(即一维数组)的第2个元素(也是一个int类型的值)的地址。
  • *(*(zippo+2)+1)→二维数组的第3个一维数组元素的第2个int类型元素的值,即数组的第3行第2列的值(zippo[2][1])。

如果程序恰好使用一个指向二维数组的指针,而且要通过该指针获取值时,最好用简单的数组表示法,而不是指针表示法。

数组地址、数组内容和指针之间的关系

title

指向多维数组的指针

如何声明一个指针变量pz指向一个二维数组?

title

程序分析

title

上面这个程序演示了如何使用指向二维数组的指针。

可以看到:

虽然pz只是一个(指向二维数组的)指针,不是数组名,但是也可以使用pz[2][1]这样的写法(zippo[2][1])。


划重点:

可以用数组表示法或指针表示法来表示一个数组元素,既可以使用数组名,也可以使用指针名:

zippo[m][n] == *(*(zippo + m) + n)
pz[m][n] == *(*(pz + m) + n)


指针的兼容性

title

  • 通过非 const 指针更改 const 数据是未定义的。

C const 和 C++ const

title

函数和多维数组

编写处理二维数组的函数。通常使用数组表示法进行相关操作。

对于这样一个二维数组:

int junk[3][4] = {{2,4,5,8},{3,5,6,9},{12,10,8,6}};

title

程序分析

title

此程序中演示了三种等价的原型语法。

  • 数组名junk(即,指向数组首元素的指针,首元素是子数组)
  • 每个函数都把ar视为内含数组元素(每个元素是内含4个int类型值的数组)的数组。
  • ar和main()中的junk都使用数组表示法。因为ar和junk的类型相同。它们都是指向内含4个int类型值的数组的指针。

划重点:

不正确的声明

注意,这种声明不正确:

int sum2(int ar[][], int rows); //错误的声明

因为编译器会把数组表示法转换成指针表示法。例如,编译器会把 ar[1] 转换成 ar+1。编译器对 ar+1 求值,要知道ar所指向的对象大小。

此声明:

int sum2(int ar[][4], int rows); //有效声明

表示ar指向一个内含4个int类型值的数组(在我的系统中,ar指向的对象占16字节),所以ar+1的意思是“该地址加上16字节”。如果第2对方括号是空的,编译器就不知道该怎样处理。

也可以在第1对方括号中写上大小,如下所示,但是编译器会忽略该值:

int sum2(int ar[3][4], int rows); //有效声明,但是3将被忽略

title

变长数组(VLA)

在处理二维数组的函数中,只把数组的行数(外层数组的大小)作为函数的形参,而列数(内层数组的大小)却内置在函数体内。例如,函数定义如下:

#define COLS 4
int sum2d(int ar[][COLS], int rows)
{
    int r;
    int c;
    int tot = 0;
    
    for (r = 0; r < rows; r++)
        for (c = 0; c < COLS; c++)
            tot += ar[r][c];
    return tot;
}

title

sum2d()函数之所以能处理这些数组,是因为这些数组的列数固定为4(也就是内层函数的大小),而行数被传递给形参rows,rows是一个变量。

但是如果要计算6*5的数组(即6行5列),就不能使用这个函数,必须重新创建一个COLS为5的函数。因为C规定,数组的维数必须是常量,不能用变量来替代COLS。(也就是说,写在方括号内的数字必须是符号常量或者常量数字)。

创建能处理任意大小二维数组的函数

C99新增了变长数组(variable-length array,VLA),允许使用变量表示数组的维度。如下所示:

int quarters = 4;
int regions = 5;
double sales[regions][quarters]; //一个变长数组(VLA)

title

必须先声明数组维度的变量

title

省略原型中的形参名

title

程序分析

title

看起来,我的编译器不支持变长数组特性。

但是从上面的程序看出:以变长数组作为形参的函数既可处理传统C数组,也可处理变长数组。

如果编译器支持变长数组特性,该程序的输出应该是这样的:

title

变长数组的其他特性

title

title

复合字面量

复合字面量主要是为了解决数组没有等价的数组常量的问题。

title

程序分析

title

因为我的编译器支持C99,所以此程序可以运行。

title

关键概念

title

本章小结

title