深入解析C语言数组和指针
概述
指针是C语言的重点,同时也是让初学者认为最难理解的部分。有人说它是C语言的灵魂,只有深入理解指针才能说理解了C语言。暂且撇开这些观点不谈。这章是我在阅读《C和指针》这本书的读书笔记。在谈指针的同时我们也要谈谈数组,数组可以说和指针密不可分的,故把它俩放在一起谈。
一.指针
1.初级指针
内存和地址
硬件存储中有一个值得注意的地方是边界对齐。在要求边界对齐的机器上,整型值存储的起始位置只能是特定的字节,通常是2或4的倍数。对于程序员还要知道的是:(1)内存中每个位置由一个独一无二的地址标识;(2)内存中每个位置都包含一个值。
下面的例子显示了内存中的5个字的内容。
但是记住他们的地址太麻烦了,所以高级语言提供通过名字而不是地址来访问内存位置的功能,下面用名字代替地址:
这些名字我们称之为变量。名字和内存位置之间的关联并不是硬件提供的,而是编译器为我们实现的,硬件仍然通过地址访问内存位置。
值和类型
假设我们对上面的第三个位置声明如下:
float c=3.14;
我们可以看到这和c中存储的值并不一样,我们声明的是一个浮点数,二内存中显示c是一个整数。实际上声明并没有错,原因是每个变量中包含一连串的1或0。它可以被解释为浮点数也可以被解释为整数,这取决于它们被使用的方式。如果使用的是整型算数指令,那么它就被解释为整数,如果使用的是浮点型运算指令,那么它就是个浮点数。所以不能简单通过检查一个值得位来判断它的类型。
指针变量的内容
先看看这些变量的表达式:
int a = 112; int b = -1; float c = 3.14; int *d = &a; int *e = &c;
我们必须明确的是,一个变量的值就是分配给这个变量的内存位置所存储的值,即使是值针变量也不例外。所以a的值为112,b的值为-1,c的值为3.14,值得注意的是d的值是100而不是112,e的值是108而不是3.14。如果你认为d和e是指针所以就能自动获取存储于位置100和108的值那就错了。
间接访问操作符
通过一个指针访问它所指的地址的过程称为间接访问或解引用指针。操作符为*。根据前面的声明我们有:
我们可以知道,d的值为100。对d进行间接访问操作符时(*d),它表示访问内存位置100并查看那里的值。
NULL指针
标准定义了NULL指针,它作为一个特殊指针变量,表示不指向任何东西。要使一个指针变量为NULL,你可以给他赋一个零值。NULL指针是个很有用的概念,因为它给你一种方法,表示某个特定的指针目前并未指向任何东西。对于一个NULL指针进行解引用是非法的,因为它并未指向任何东西。如果你知道指针将被初始化为什么地址,就把它初始化为该地址,否则就把它初始化为NULL。
指针、间接访问和左值
回到早些时候的例子,给定下面声明:
int a; int *d = &a;
考虑下面表达式:
间接访问操作符所需要的操作数是个右值,但它所产生的结果是个左值。指针变量可以作为左值,并不是因为它们是指针,而是因为它们是变量。
指针、间接访问和变量
给出下面表达式:
*&a = 25;
这个表达式的含义是,把25赋值给a。&操作符取得a的地址,它是一个指针常量,接着*对他进行间接访问其操作数所表示的地址。操作数是a的地址,所以值25存储于a中。它实际等价于a=25。
指针常量
假定变量a存储于位置100,则下面表达式表示什么意思?
*100=25;
看上去是把25赋值给a,因为a是位置100所存储的变量,但实际上是错误的。因为字面值100的类型是整型,二间接访问操作只能作用于指针类型表达式。如果确实想把25存储于位置100,则必须进行强制类型转换,可进行以下操作:
*(int *)100=25;
指针的指针
考虑下面的声明:
int a=12; int *b=&a;
... c=&b;
上述声明是合法的,那么c是什么类型呢?显然它是一个指针,确切的说是"指向整型的指针"的指针,即指针的指针。那么表达式**c的类型就是int,注意*操作符具有从右向左结合性。所以c应该声明如下:
int a=12; int *b=&a; int **c=&b;
指针表达式
观察以下声明:
char ch='a'; char *cp=&ch;
这样,我们就有了两个变量,它们初始化如下:
图中还显示了ch后面那个内存的位置,因为我们所求值得有些表达式将访问到它。由于我们并不知道它的初值,所以用个问号表示。我们用黑色椭圆来表示一个数的右值,用方框来表示一个数的左值。例如表达式:
ch
当它用作右值使用时,表达式的值为'a',如下图所示:
当它当作左值使用时,它是这个内存的地址而不是该内存所包含的值。如下图表示:
接下来的表达式将以表格的形式出现,每个表的后面是表达式求值过程描述。
作为右值,这个表达式的值是变量ch的地址。这个表达式不是一个合法的左值,因为当表达式&ch进行求值时,它的结果应该存储与计算机的什么地方我们不清楚,素以它不是一个合法的左值。
我们之前有讨论过,它的右值就是cp的值,左值为cp所在的位置。
这个例子与&ch类似,但是这次我们取得是指针变量的地址。同理,这个结果的类型为指向字符的指针的指针。这个值得存储位置我们不清楚,所以它的左值是非法的。
加入了间接操作,右值及为它所指向的地址的值'a',左值为其指向的地址。
由于*的优先级比+高,所以先执行间接访问操作,得到它的值(虚线椭圆内)。我们取这个值得一份拷贝,并把它与1相加得到b。由于我们不清楚b的具体位置,所以它不是个合法的左值。优先级表格证实+的结果不能作为左值。
相比于上个表达式,我们添加了括号使它先执行加法操作。即把cp所指向的地址向后移动一个位置。取它的右值即为该地址所存储的值,左值即为该位置的地址。但是这个表达式所访问的是ch后面的那个内存位置,我们如何知道原先存储于那个地方的是什么东西?我们无法知道,所以这样的表达式是非法的。
前置++符,这个表达式我们增加指针变量cp的值。表达式的结果是增值后的指针的一份拷贝,因为前缀++先增加它的操作数的值再返回这个结果。这份拷贝的位置没有清晰定义,所以它的左值是非法的。
后缀++同样增加了cp的值,但它先返回cp的值的一份拷贝然后再增加cp的值。这样,这个表达式的值就是cp原来值得一份拷贝。
相比于前面两个表达式,我们添加了间接访问操作符。所以它的右值是ch后面的内存地址的值,而它的左值就是那个位置本身。
同理,该表达式的右值为ch内存地址里的值,左值为ch的位置。
这个表达式中,两个操作符都是从右向左,所以先对cp执行间接访问操作。然后,cp所指向的位置的值加1,表达式的结果是增值后的值的一份拷贝。
表达式先执行括号里的间接访问操作符,再执行后缀++,与前一个表达式类似,表达式得到的是ch里面的值增值前的原先值。
从右结合,我们先计算*++p得到ch位置后面一个存储空间的值,再把它的值加1。最后我们得到ch后面存储空间的值增值1后的一份拷贝。同理它的左值是非法的。
执行顺序为++(*(cp++)),最后表达式的右值为ch地址内的值增值1后的一份拷贝。它的左值是非法的。之后cp指向ch后面的位置。
指针的运算
C的指针的算数运算只包含以下两种形式:
(1)指针 +/- 整数
(2)指针 - 指针
标准定义第一种形式只能用于指向数组中的某个元素,和整数相加减就是让指针在数组中前后移动位置。值得注意的是,指针的移动是按数组中的类型决定的,假如数组类型是char类型,指针加一表示向后移动一个字节。而在int类型的数组中,指针加一是移动四个字节,并非一个,这个注意区分。
第二种形式的条件式两个指针都指向同一个数组,相减的结果是两个指针在内存中的距离。加入一个float类型的数组,每个类型占4个字节。如果数组的其实位置是1000,指针p1的值是1004,p2的值是1024。则p1-p2的结果是5。因为两个指针的差值(20)将除以每个元素的长度(4)。
对于指针的关系运算有:
< <= > >=
不过前提是它们都指向同一个数组中的元素。
2.高级指针
指向指针的指针
先看下面的声明,看你是否能了解它们的功能:
int i; int *pi; int **ppi; ppi=π *ppi=&i;
上述声明实际可用下面的图来说明:
下面的声明具有相同的效果:
int i='a'; int *pi='a'; int **ppi='a';
但是我们有时候并不知道变量i里面的具体值,例如链表的插入等操作。所以我们必须掌握第一种比较复杂的声明定义方法。当然我们应该尽可能的善用这些复杂的声明方式,除非它是必须的。
高级声明
首先看个简单的例子:
int f; //一个整型变量 int *f; //一个指向整型的指针
再看看下面的声明:
int * f, g;
实际上上述声明并没有声明两个指针,而是把f声明为指向整型的指针,把g声明为整型。
观察下面一些新的声明,看你是否能说出它们的具体含义
int f(); int *f(); int (*f) (); int *(*f) ();
第一行很容易理解,它把f声明为一个函数,它的返回值是一个整数。
第二行中,首先执行的是函数调用操作符(),因为它的优先级高于间接访问操作符。因此f是一个函数,它的返回值类型是一个指向整型的指针。
第三行中,先执行括号中的*f,再执行后面的函数调用(),所以f是一个函数指针,它所指向的函数返回一个整型值。因为函数存放于内存中的某个位置,所以完全可以拥有指向那个位置的指针,即函数指针。
第四行结合上个声明很好理解,f还是个函数指针,它所指向的函数返回值是一个整型指针。
接下来的声明我们引入数组:
int f[]; // 1 int *f[]; // 2 int f() []; // 3 int f[] (); // 4 int (*f[]) (); // 5 int *(*f[])(); // 6
第一行声明f是个整形数组。
第二行由于下标运算符优先级高于*,所以f是一个数组,它的元素类型是指向整型的指针。
第三行f是一个函数,它的返回值是一个整型数组。但是这个声明是非法的,因为函数只能返回标量,不能返回数组。
第四行似乎把f声明为一个数组,它的元素类型是返回为整型的函数。但是这个声明也是非法的,因为数组元素必须具有相同的长度,但不同的函数显然具有不同的长度。
第五行首先执行括号内的*f[],所以f是一个元素是某种类型的指针的数组。表达式末尾的()是函数调用操作符,所以f肯定是一个数组,数组元素的类型是函数指针,它所指向的函数的返回值是一个整型值。
第六行和上一行的区别是多了个*,所以这个声明创建了一个指针数组,自还真所指向的类型的返回值为整型指针的函数。
函数指针
先看以下的声明:
int f(int); int (*pf) (int)=&f;
上述声明创建函数指针pf,并把它初始化为指向函数f。其中初始化表达式中的&是可选的,因为函数名被使用时编译器总是把它转换为函数指针。
在函数指针被声明并且初始化后,我们可以使用三种方式调用函数:
int ans; ans=f(25); ans=(*pf)(25); ans=pf(25);
上述三年中调用的效果都是一样的。函数指针最常见的用途是把函数指针作为参数传递给函数以及用于转换表。
二.数组
1.一维数组
数组名
先看下面表达式:
int a[10]; int b[10]; int *c; c=&b[0];
对于第一行,a[4]表示一个整形,那么a的类型又是什么呢?答案是它表示数组元素的第一个地址,类型为取决于数组元素的类型,在此数组元素的类型为int,所以数组名a的类型为”指向int的常量指针“(注意是指针常量而不是指针变量)。只有在两种场合下,数组名并不用指针常量来表示——当数组名作为sizeof操作符或单目操作符&的操作数时。sizeof返回整个数组长度,取数组名地址所产生的是指向数组的指针。
表达式&b[0]是一个指向数组第一个元素的指针,也是数组名本身的值,所以等价于:
c=b;
但是以下表达式是错误的:
a=c;
a=b;
第一行,a为指针常量,而c是指针变量,不能把一个变量赋值给常量。第二行是非法的,不能用赋值符把一个数组的所有元素赋值到另一个数组,必须使用一个循环,每次赋值一个元素。
下标引用
对于上文的环境,有如下表达式:
*(b+3)
这个操作相当于把指向数组的第一个位置向后移动三个位置(间接访问),然后取其右值,相当于b[3](下标引用)。除了优先级之外,下标引用和间接访问完全相同。
对于以下表达式:
int array[10]; int *ap=array+2;
指针与下标
既然指针与下表表达式一样的,那么该用哪一种呢?结论是下标绝不会比指针更有效率,但是指针有时候会比下标更有效率。通过下面的例子说明:
例子1: int array[10],a; for(a=0;a<10;a+=1) array[a]=0; 例子2: int array[10],*ap; for(ap=array;ap<array+10;ap++) *ap=0;
例子1中为了对下标表达式求值,编译器在程序中插入指令,取得a的值,并把它与整形的长度相乘(即乘以4)。这个乘法需要花费一定的时间与空间。
例子2并不存在下标,但是也有乘法,这个乘法就是for语句中的ap++,同理1这个值必须与整形相乘,然后再与指针相加。但是区别是,因为每次循环都是执行1*4,所以这个乘法在编译时只执行一次,程序现在包含一条指令,把4与指针相加。程序在运行时并不执行加法运算。所以例子2效率比例子1更高。
我们有以下结论:
(1)当你根据某个固定数目的增量在一个数组中移动时,使用指针将比使用下标更有效率。
(2)声明为寄存器变量的指针通常比位于静态内存和堆栈中的指针效率更高。
(3)如果你可以通过测试一些已经成功初始化并经过调整的内容来判断循环是否应该终止,那么你就不需要使用一个单独的计数器。
(4)那些必须在运行时求值得表达式较之诸如&array[size]或array+size,前者代价往往比较高。
作为函数参数的数组名
通过前面的学习我们知道,数组名的值就是指向数组第一个元素的指针。所以当一个数组名作为参数传递给一个函数时,此时传递给函数的是一份该指针的拷贝。所以函数一颗自由的操纵它的指针形参,而不必担心会修改对应的作为实参的指针。但是也可以通过形参改变数组对应位置的值,从而更改数组。
声明数组参数
对于把数组名当作参数的函数,因为调用函数时实际传递的是一个指针,所以函数的形参实际上是个指针,所以以下两个声明都是正确的:
int strlen (char *string ); int strlen( char string[]);
值得注意的是第一种声明无法知道数组的长度,所以函数如果需要知道数组的长度,它必须作为一个显式的参数传递给函数。
不完整的初始化
int arr[5]={1,2,3,4,5,6}; int arr[5]={1,2,3,4};
第一个声明是错误的,数组的空间为5,无法把6个元素放到数组中。第二个声明是合法的,它为数组的前四个元素提供了初始值,最后一个元素初始化为0;
自动计算数组长度
int arr[]={1,2,3,4,5,6};
当声明中未说明数组长度时,编译器将根据数组中元素的个数分配恰好够装入全部元素的空间。
字符数组的初始化
char arr[]={'h','e','l','l','o'}; char arr[]={"hello"};
以上两种声明是一样的。
2.多维数组
当数组维数不止一个时,我们可以声明多维数组。
存储顺序
对于下面数组:
int arr[3];
它的存储结构如下:
当上面的数组中每个元素都是包含6个元素的数组时,它的声明为:
int arr[3][6];
它在内存中的存储形式为:
黑线方框表示第一维的3个元素,黄线表示第二维的6个元素。下标为arr[0][0]到arr[2][5],多维数组存储顺序按照最右边下标先变化的原则,即行主序。
数组名
多维数组的数组名也是个指针常量,但是和一维数组不同,多维数组的数组名是指向数组第一行的常量指针,而不是指向第一个元素。
下标
例如如下声明:
int arr[3][10];
指向数组的指针
下面有两个声明:
int a1[10], *p1=a1; int a2[3][10], *p2=a2;
第一个声明是合法的,a1是指向int类型的指针,声明p1也是指向整型的指针。第二个声明是不合法的,a2是指向整型数组的指针,而p2是指向整型的指针。所以正确的声明如下:
int (*p2)[10]=a2;
它使p2指向a2的第一行。下面的两个声明都是使p2指向a2的第一个整型元素:
int *p2=&a2[0][0]; int *p2=a2[0];
作为函数参数的多维数组
作为函数参数的多为数组名的传递方式和一维数组相同——实际传递的是数组的第一个元素。两者的区别是,多位数组的每个元素本身是一个数组,所以以下声明:
int arr[3][10]; ... fun(arr);
这里,参数arr的类型是指向包含十个整形元素的数组的指针。fun的原型为如下两种形式中的任何一种:
void fun(int (*arr)[10]); void fun(int arr[][10]);
初始化
多维数组的存储顺序是根据最右边的下标率的原则确定的。可用{}来包围每行元素,例如:
int array[2][3]={ {1,2,3}, {4,5,6} };
数组长度自动计算
在多维数组中,只有第一维才能根据列表初始化列表缺省的提供。剩下的几维必须显式的显示出来,这样编译器就能推断出每个子数组维数的长度。例如:
int arr[][5] = { {1,2,3}, {4,5}, {6,7,8,9} };
这样,编译器可以推断出最左边一维为3。
三.指针和数组
1.概念区分
指针和数组虽然密不可分,但是却不是相等的,考虑以下两个声明
int a[5]; int *b;
它们都具有指针值,它们都可以进行间接访问和下标引用操作。但是还是有很大的区别:
声明一个数组,编译器将根据数组的大小为它分配内存空间,而声明一个指针,编译器只为指针本身保留内存空间。在上述声明之后,表达式*a是合法的,但表达式*b却是非法的。*b将访问内存中某个不确定的位置,或者导致程序终止。另一方面,表达式b++可以通过编译,而a++却不行,因为a的值是个常量。对指针和数组的正确区分有助于理解c语言的结构语法。
再来看下面一个例子:
char arr[]="hello"; char *arr2="hello";
前一个声明表示字符数组,后一个声明表示字符串常量,它们的区别如下:
2.指针数组
看下面一个例子:
int *api[10];
因为下标引用的优先级高于间接访问,所以表达式先执行下标操作,所以api是某种类型的数组。对数组的某个元素执行间接访问操作后,得到一个整型,所以api的元素类型为指向整型的指针。下面是它的一个应用例子:
char const *keyword[]={ "how", "are", "you" };
参考文献
《C++ PRIMER》 中文版
《C和指针》