声明:本文是配合《自制编程语言》一书出版,对书中所引用Web内容的整理及翻译,由北京图灵文化发展有限公司授权刊载,未经许可请勿转载。

原日文网页版“配列とポインタの完全制覇”与出版后的「C言語 ポインタ完全制覇」在内容上并不完全相同,这里对应截取中文简体版「C言語 ポインタ完全制覇」(《征服C指针》)的内容。前桥和弥著,吴雅明译,人民邮件出版社,2013年2月第1版。

写在前面

在C 语言的学习中,指针的运用被认为是最大的难关。

关于指针的学习,我们经常听到下面这样的建议:

“如果理解了计算机的内存和地址等概念,指针什么的就简单了。”

“因为C 是低级语言,所以先学习汇编语言比较好。”

果真如此吗?

正如那些C 语言入门书籍中提到的那样,变量被保存在内存的“某个地方”。为了标记变量在内存中的具体场所,C 语言在内存中给这些场所分配了 编号(地址)。因此,大多数运行环境中,所谓的“指针变量”就是指保存变量地址的变量。

到此为止的说明,所有人都应该觉得很简单吧。

理解“指针就是地址”,可能是指针学习的必要条件,但不是充分条件。 现在,我们只不过刚刚迈出了“万里长征的第一步”。

如果观察一下菜鸟们实际使用C指针的过程,就会发现他们往往会有如下困惑。

  • 声明指针变量int *a;……到这里还挺像样的,可是当将这个变量作为指针使用时,依然悲剧地写成了*a
  • 给出int &a;这样的声明(这里不是指C++编程)。
  • 啥是“指向int的指针”?不是说指针就是地址吗?怎么还有“指向int的指针”,“指向char的指针”,难道它们还有什么不同吗?
  • 当学习到“给指针加1,指针会前进2个字节或者4个字节”时,你可能会有这种疑问:“不是说指针是地址吗?这种情况下,难道指针不应该是前进1个字节吗?”
  • scanf()中,在使用%d的情况下,变量之前需要加上&才能进行传递。 为什么在使用%s的时候,就可以不加&
  • 学习到将数组名赋给指针的时候,将指针和数组完全混为一谈,犯下“将没有分配内存区域的指针当做数组进行访问”或者“将指针赋给数组”这样的错误。

出现以上混乱的情形,并不是因为没有理解“指针就是地址”这样的概念。其实,真正导演这些悲剧的幕后黑手是:

  • C 语言奇怪的语法
  • 数组和指针之间微妙的兼容性

某些有一定经验的C 程序员会觉得C 的声明还是比较奇怪的。当然也有一些人可能并没有这种体会,但或多或少都有过下面的疑问。

  • C 的声明中,[]*的优先级高。因此,char *s[10]这样的声明意为 “指向char的指针的数组”——搞反了吧?
  • 搞不明白double (*p)[3];void (*func)(int a);这样的声明到底应该怎样阅读。
  • int *a 中,声明a 为“指向int 的指针”。可是表达式中的指针变量前*却代表其他意思。明明是同样的符号,意义为什么不同?
  • int *aint a[]在什么情况下可以互换?
  • 空的[]可以在什么地方使用,它又代表什么意思呢?

本书的编写就是为了回答以上这样的问题。

很坦白地说,我也是在使用了C语言好几年之后,才对C的声明语法大彻大悟的。

世间的人们大多不愿意承认自己比别人愚笨,所以总是习惯性地认为“实际上只有极少的人才能够精通C语言指针”,以此安慰一下自己那颗脆弱的心。

例如,你知道下面的事实吗?

  • 在引用数组中的元素时,其实a[i]中的[]和数组毫无关系。
  • C里面不存在多维数组。

如果你在书店里拿起这本书,翻看几页后心想:“什么呀?简直是奇谈怪论!”然后照原样把书轻轻地放回书架。那么你恰恰需要阅读这本书。

有人说:“因为C语言是模仿汇编语言的,要想理解指针,就必须理解内存和地址等概念。”你可能会认为:

“指针”是C语言所特有的、底层而邪恶的功能。

其实并不是这样的。确实,“C指针”有着底层而邪恶的一面,但是,它又是构造链表和树等“数据结构”不可缺少的概念。如果没有指针,我们是 做不出像样的应用程序的。所以,凡是真正成熟的开发语言,必定会存在指针,如Pascal、Delphi、Lisp 和Smalltalk 等,就连Visual Basic 也存在指针。 早期的Perl 因为没有指针而饱受批评,从版本5 开始也引入了指针的概念。 当然,Java 也是有指针的。很遗憾,世上好像对此还存有根深蒂固的误解。

在本书中,我们将体验如何将指针真正地用于构造数据结构。

“指针”是成熟的编程语言必须具有的概念。

尽管如此,为什么C 的指针却让人感觉格外地纠结呢?理由就是,C语言混乱的语法,以及指针和数组之间奇怪的兼容性。

本书旨在阐明C 语言混乱的语法,不但讲解了“C 特有的指针用法”,还针对和其他语言共有的“普遍的指针用法”进行了论述。

解读C的声明

我认为像

int *hoge_p;

还有

int hoge[10];

这样的声明方式很奇怪。

对于这种程序的声明方式,可能也有很多人感觉不到有什么别扭的地方。那就再看下面的这个例子:

char *color_name[] = {
    “red”,
    “green”,
    “blue”,
};

这里声明了一个“指向char的指针的数组”。

正如2.3.2节中介绍的那样,可以像下面这样声明一个“指向将double作为参数并且返回int的函数的指针”,

int (*func_p)(double);

关于这样的声明,在K&R中有下面这样一段说明:

int *f();  /* f:返回指向int指针的函数*/

int (*pt)();  /* pf: 指向返回int的函数的指针*/

这两个声明最能说明问题。在这里,因为*是前置运算符,它的优先度低于(),为了让连接正确地进行,有必要加上括号。

首先,这段文字中有谎言。

声明中*()[]并不是运算符。在语法规则中,运算符的优先顺序是在别的地方定义的。

先将这个问题放在一边。如果你老老实实地去读这段文字,该会嘀咕“是不是搞反了”。如果说

int (*pf)();

是指向函数的指针,使用括弧先将星号(指针)括起来是不是很奇怪?

关于这个问题的答案,等你明白过来就会觉得非常简单。C语言本来是美国人开发的,最好还是用英语来读*

﹡在K&R中,登载了dcl这个解析C的声明的程序,同时也记载了程序的输出结果,但是日语版并没有对这一段进行翻译,而是一成不变地转载了英文原文。

以上的声明,如果将pf作为起点以英语的顺序来读,应该是下面这样,

pf is pointer to function returning int

翻译成中文,则为

pf为指向返回int的函数的指针。

POINT:用英语来读C的声明。

在这里,我告诉大家一个阅读C语言声明的方法:机械地向前读。

为了把问题变得更简单,我们在这里不考虑const和volatile。

  1. 首先着眼于识别符(变量名或者函数名)。
  2. 从距离识别符最近的地方开始,依照优先顺序解释派生类型(指针,数组,函数)。优先顺序说明如下,
    1. 用于整理声明内容的括弧
    2. 用于表示数组的[],用于表示函数的()
    3. 用于表示指针的*
  3. 解释完成派生型,使用“of”或“to”或“returning”将它们连接起来。
  4. 最后,追加类型指定符(在左边,int、double这些)。
  5. 英语不好的人,可以将顺序反过来用日语(或者中文) 解释。

数组元素个数和函数的参数属于类型的一部分。应该将它们作为附属于类型的属性进行解释。

比如,

int (*func_p)(double);

1) 首先着眼于识别符。

int (*func_p)(double);

英语的表达为:

func_p is

2) 因为存在括号,这里着眼于*。

int (*func_p)(double);

英语的表达为:

func_p is pointer to

3) 解释用于函数的(),参数是double。

int (*func_p)(double);

英语的表达为:

func_p is pointer to function(double) returning

4) 最后,解释类型指定符int。

int (*func_p)(double);

英语的表达为:

func_p is pointer to function(double) returning int

5) 翻译成中文:

func_p是指向返回int的函数的指针。

使用和上面相同的方式,我们在下面的表中解读各种各样的声明(表3-1),

表3-1 解读各种各样的C语言声明

C语言 英语的表达 中文的表现
int hoge; hoge is int hoge是int
int hoge[10]; hoge is array(元素数10) of int hoge是int的数组(元素数10)
int hoge[10][3]; hoge is array(元素数10) of array(元素数3) of int hoge是int数组(元素数10)的数组(元素数3)
int *hoge[10]; hoge is array(元素数10) of pointer to int hoge是指向int的指针的数组(元素数10)
int (*hoge)[3]; hoge is pointer to array(元素数3) of double hoge是指向int的数组(元素数3)的指针
int func(int a); func is function(参数为int a) returning int func是返回int的函数(参数是int a)
int (*func)(int a) func is pointer to function(参数为int a) returning int func_p是指向返回int的函数(参数为int a)的指针

正如大家看到的这样,C语言的声明不能从左往右顺序解读(无论是英语、中文,还是日语),而是左右来回地解读。

K&R中指出,在C语言中,变量的声明仿效结果表达式的语法。可是,勉强地去模拟本质上完全不同的事物,结果就是“四不像”。

“使声明的形式和使用的形式相似”是C(还有从C派生的C++,Java*等语言)特有的奇怪的语法

*其实,大部分Java的声明语法还是能做到这点的。

K&R中同时也记载了下面这段文字,

C的声明语法,特别是包含指向函数指针的语法,受到了严厉的批评。

在Pascal中,C的int hoge[10]可以这样声明,

var
    hoge: array[0..9] of integer

这种声明,从左向右用英语按顺序解读是完全没有问题的。

续・解读C的声明——const修饰符

const是通过ANSI C被追加的修饰符,它将类型修饰为“只读”。

名不符实的是,const__不一定代表常量__。const最主要被用于修饰函数的参数,将一个常量传递给函数是没有意义的。无论怎样,使用const修饰符(变量名),只意味着使其“只读”。

/* const参数的范例 */
char *strcpy(char *dest, const char *src);

strcpy是持有被const修饰的参数的范例,此时,所谓的“只读”是如何表现的呢?

做个实验应该很快就会明白,上面例子中的src这个变量没有定义为只读

char *my_strcpy(char *dest, const char *src)
{
    src = NULL;     ←即使对src赋值,编译器也没有报错
}

此时,成为只读的不是“src”,而是src所指向的对象。

char *my_strcpy(char *dest, const char *src)
{
    *src = ‘a’;     ←ERROR!!
}

如果将“src”和“src指向的对象”都定义为“只读”,可以写成下面这样,

char *my_strcpy(char *dest, const char * const src)
{
    src = NULL;     ←ERROR!!
    *src = ‘a’;     ←ERROR!!
}

在现实中,在指针作为参数的时候,const常用于将指针指向的对象设定为只读。

通常,C的参数都是传值。因此,无论被调用方将参数进行怎样的修改,都不会对调用方造成任何影响。如果想要影响调用方的变量(通过函数参数将函数内的一些值返回),你可以将指针作为参数传递给函数。

可是,上面的例子(my_strcpy)中,传递的是src这个指针。其本来的意图是想要传递字符串(也就是char的数组)的值,由于在C中数组是不能作为参数传递的,情非得已才不得不将指向初始元素的指针传递给函数(因为数组可能会很大,所以传递指针有益于提高程序的效率)。

产生的问题是,为了达到从函数返回值的这个目的,需要向函数传递一个指针,这种方式让人感觉有些混乱。

此时,考虑在原型声明中加入const,

尽管函数接受了作为参数的指针,但是指针指向的对象不会被修改。

也就是说,

函数虽然接受了指针,但是并不是意味着要向调用方返回值。

strcpy()的意图就是——src是它的输入参数,但是不允许修改它所指向的对象。

可以通过以下规则解读const声明,

  1. 遵从上一节中提到的规则,从标识符开始,使用英语由内向外按顺序解释下去。
  2. 一旦解释完毕的部分的左侧出现了const,就在当前位置追加read-only。
  3. 如果解释完毕的部分的左侧出现了类型指定符,并且其左侧存在const,姑且先去掉类型指定符,追加read-only。
  4. 在翻译成中文的过程中,英语不好的同学请注意:const修饰的是紧跟在它后面的单词

因此,

char * const src

可以解释成,

src is read-only pointer to char

src是指向char的只读的指针

char const *src

可以解释成,

src is pointer to read-only char

src是指向只读的char的指针

此外,容易造成混乱的是,

char const *src

const char *src

意思完全相同

将数组解读成指针

正如在前面翻来覆去提到的那样,在表达式中,数组可以解读成指针。

 int hoge[10];

以上的声明中,hoge等同于&hoge[0]

hoge原本的类型为“int的数组(元素数10)”,但并不妨碍将其类型分类“数组”变更为“指针”。

此外,数组被解读成指针的时候,该指针不能作为左值。

这个规则有以下的例外情况,

1) 数组为sizeof运算符的操作数

通过“sizeof表达式”的方式使用sizeof运算符的情况下,如果操作数是“表达式”,数组会被当成指针,此时,即使对数组使用sizeof,得到的结果也只是指针自身的长度。照理来分析,应该是这样的吧?可是,当数组成为sizeof的操作数时,“数组解读为指针”这个规则会被抑制,此时返回的是数组全体的尺寸。

2) 数组为&运算符的操作数

通过对数组使用&,可以返回指向整体数组的指针。在3.2.4中已经介绍了“指向数组的指针”。 这个规则已经被追加到ANSI C规则之中。此前的编译器,在对数组使用&的时候,大多会报错。因此,当时的程序在这一点上不会出现问题的。那么这个规则的制定有什么好处呢?我想应该是为了保持统一吧。

3) 初始化数组时的字符串常量

我们都知道字符串常量是“char的数组”,在表达式中它通常被解读成“指向char的指针”。其实,初始化char的数组时的字符串常量,作为在花括号中将字符分开写的初始化符的省略形式,会被编译器特别解释。

在初始化char的指针的时候,关于字符串常量的特别之处,需要引起注意。

数组和指针相关的运算符

本节内容请下载PDF查看。