1.2 关于指针

1.2.1 恶名昭著的指针究竟是什么

关于“指针”一词,在 K&R 中有下面这样的说明(第 5 章“指针和数组”的开头部分):

指针是一种保存变量地址的变量,在 C 中频繁地使用。

其实在表达上,这样的说明是有很大问题的。总会让人感觉,一旦提起指针,就要把它当作变量的意思。实际上并非总是如此。

此外,在 C 语言标准中最初出现“指针”一词的部分,有这样一段话:

指针类型(pointer type)可由函数类型、对象类型或不完全的类型派生,派生指针类型的类型称为引用类型。指针类型描述一个对象,该类对象的值提供对该引用类型实体的引用。由引用类型 T 派生的指针类型有时称为“(指向)T 的指针”。从引用类型构造指针类型的过程称为“指针类型的派生”。这些构造派生类型的方法可以递归地应用。

这段话的内容也许会让你一头雾水*。那就让我们先关注第一句话吧,那里出现了“指针类型”一词。

* 既然是标准,那总要有点标准的范儿吧。

提到“类型”,立刻会让人想起“int 类型”、“double 类型”等。同样,在 C 语言中也存在“指针类型”这样的类型。

“指针类型”其实不是单独存在的,它是由其他类型派生而成的。以上对标准内容的引用中也提到“由引用类型 T 派生的指针类型有时称为‘(指向)T 的指针’”。

也就是说,实际上存在的类型是“指向 int 的指针类型”、“指向 double 的指针类型”。

因为“指针类型”是类型,所以它和 int 类型、double 类型一样,也存在“指针类型变量”和“指针类型的值”。糟糕的是,“指针类型”、“指针类型变量”和“指针类型的值”经常被简单地统称为“指针”,所以非常容易造成歧义,这一点需要提高警惕*

* 至少本书还是尽力将这些说法进行区别的,但有时候,无论怎么写也做不到自然地表述想要表达的意思,最后只好投降……非常抱歉。

要 点

先有“指针类型”。

因为有了“指针类型”,所以有了“指针类型的变量”和“指针类型的值”。

比如,在 C 中,使用 int 类型表示整数。因为 int 是“类型”,所以存在用于保存 int 型的变量,当然也存在 int 型的值。

指针类型同样如此,既存在指针类型的变量,也存在指针类型的值。

因此,几乎所有的处理程序中,所谓的“指针类型的值”,实际是指内存的地址

变量的内容是保存在内存的某个地方的,“某个地方”的说法总是会让人产生困惑,因此,就像使用“门牌号”确定“住址”一样,在内存中,我们也给变量分配“门牌号”。在 C 的内存世界里,“门牌号”被称为“地址”。

为了帮助理解这一点,还是写一个程序来验证一下。

1.2.2 和指针的第一次亲密接触

下面我们通过实际编程来尝试输出指针的值(参照代码清单 1-1)。

代码清单 1-1 pointer.c

  1. 1: #include <stdio.h>
  2. 2:
  3. 3: int main(void)
  4. 4: {
  5. 5: int hoge = 5;
  6. 6: int piyo = 10;
  7. 7: int *hoge_p;
  8. 8:
  9. 9: /*输出每个变量的地址*/
  10. 10: printf("&hoge..%p\n", &hoge);
  11. 11: printf("&piyo..%p\n", &piyo);
  12. 12: printf("&hoge_p..%p\n", &hoge_p);
  13. 13:
  14. 14: /*将hoge 的地址赋予hoge_p*/
  15. 15: hoge_p = &hoge;
  16. 16: printf("hoge_p..%p\n", hoge_p);
  17. 17:
  18. 18: /*通过hoge_p 输出hoge 的内容*/
  19. 19: printf("*hoge_p..%d\n", *hoge_p);
  20. 20:
  21. 21: /*通过hoge_p 修改hoge 的内容*/
  22. 22: *hoge_p = 10;
  23. 23: printf("hoge..%d\n", hoge);
  24. 24:
  25. 25: return 0;
  26. 26: }

下面是我的环境(FreeBSD 3.2-RELEASE 和 gcc version 2.7.2.1)里输出的结果。

  1. &hoge..0xbfbfd9e4
  2. &piyo..0xbfbfd9e0
  3. &hoge_p..0xbfbfd9dc
  4. hoge_p..0xbfbfd9e4
  5. *hoge_p..5
  6. hoge..10

第 5~7 行声明了 int 类型变量 hogepiyo 和“指向 int 的指针”类型的变量 hoge_p。如果理解 hoge_p 的声明有困难,不妨先单纯地将它理解成“指向 int 的指针”类型的变量(请参照本节的补充内容)。

int 类型的变量 hogepiyo,在声明的同时分别被初始化为 5 和 10。

在第 10~12 行,使用地址运算符&,输出各变量的地址。在我的环境中,变量在内存中保存成下面这样(请参照图 1-1)。

1.2 关于指针 - 图1

图 1-1 变量的保存状况

总觉得在我的环境中,变量是按照声明的逆向顺序保存在内存中的。可能会让人感觉有些奇妙,其实这是常见的现象,不要太在意。

要 点

变量不一定按照声明的顺序保存在内存中。

前面曾经提到,因为存在“指针类型”,所以存在“指针类型的变量”和“指针类型的值”。这里输出的“地址”,是指“指针类型的值”。

另外,以上的例子在使用 printf()输出指针的值时,使用了参数%p。很多人都使用过%x 这样的参数。遗憾的是,这种使用方式是错误的。关于这点的解释,请参照 1.2.3 节。

在第 15 行,将 hoge 的地址赋给指针变量 hoge_p。因为 hoge 的地址是 0xbfbfd94e4,这时内存变成图 1-2 所示的状态。

1.2 关于指针 - 图2

图 1-2 将指向 hoge 的指针的值赋给 hoge_p

像这样,指针变量 hoge_p 保存了另外一个变量 hoge 的地址,我们认为“hoge_p 指向 hoge”。

此外,对 hoge 变量实施&运算得到“hoge 的地址”。有时候也称“hoge 的地址”的值为“指向 hoge 的指针”(此时的“指针”指的是“指针类型的值”)。

在我的环境里,变量是按照声明的逆向顺序保存在内存中的。根据不同的环境,内存中变量位置的顺序可能有所不同,纠结于究竟 hogepiyohoge_p 以什么样的顺序排列是没有意义的。图 1-2 也可以用图 1-3 的表现方式。

1.2 关于指针 - 图3

图 1-3 图 1-2 的另一种表现方式

上图更能直接地表现“hoge_p 指向 hoge”这个含义。

在第 19 行,使用解引用*,“顺藤摸瓜”输出 hoge 的值。

在指针前面加上*,可以表示指针指向的变量。因为 hoge_p 指向 hoge,所以*hoge_p 等同于 hoge。一旦要求输出*hoge_p,就会输出 hoge 中保存的值 5。

因为*hoge_phoge 表示同一个事物,通过*hoge_p 输出 hoge 的值之外,还可以赋值。在第 22 行,通过将 10 赋给*hoge_p,修改了 hoge 的值。在第 23 行输出 hoge 的值,运行结果为 10。

指针的基本知识就介绍到这里。以下是整理出的要点。

要 点

  • 对变量使用&运算符,可以取得该变量的地址。这个地址称为指向该变量的指针。

  • 指针变量 hoge_p 保存了指向其他变量的地址的情况下,可以说“hoge_p 指向 hoge”。

  • 对指针变量运用运算符,就等同于它指向的变量。如果 hoge_p 指向 hoge\hoge_p 就等同于 hoge

 

补充 关于十六进制

在说明地址概念的时候,世上的 C 语言入门书籍中经常使用“门牌号 100”这样极其小的十进制值。

确实,对于初学者来说,可能这样更容易入门。但是本书偏执地使用了十六进制来说明。这是因为,如果想要了解地址的真正面目,把地址实际地表示出来才是最好的方式。本书例程中输出的所有地址,全部是通过我的环境实际运行程序后获得的。

对于那些对指针还是不太明白的读者来说,一定也要像我这样将例程实际地敲一遍,然后通过自己的环境确认一下究竟会输出什么东东。当然,通过你自己的环境输出的地址肯定和我的环境中输出的不同,但是其中的原理是一样。

哦?你说你不懂十六进制?——不好意思,你应该事先学习一下这方面的知识。

 

补充 混乱的声明——如何自然地理解声明?

通常,C 的声明像

  1. int hoge;

这样,使用“类型 变量名;”的形式进行书写。

可是,像“指向 int 的指针”类型的变量,却要像下面这样进行声明:

  1. int hoge_p;

似乎这里声明了一个名为\hoge_p 的变量,而实际上,这里声明的变量是 hoge_phoge_p 的类型是“指向 int 的指针”。

因为这种声明方式不太好理解,所以有人提出将*靠近类型这一侧进行书写,如下:

  1. int hoge_p;

的确,这种书写方式符合“类型 变量名;”的形式。但是在同时声明多个变量的情况下就会出现破绽:

  1. /声明两个“指向int 的指针”?——其实不是/
  2. int hoge_p, piyo_p;

此外,数组也是 C 的一种类型,比如

  1. int hoge[10];

这样的写法,就不符合“类型 变量名;”的形式。

说一些题外话,Java 在声明“int 的数组”时,通常写成

  1. int[] hoge;

的形式,这样好像是符合“类型 变量名;”的形式。至少在这一点上,Java 的语法比起 C 显得更为合理。可是,Java 为了让 C 程序员更容易地将程序向 Java 移植,竟然也兼容 int hoge[]这样的写法。这种不伦不类的做法倒还真像 Java 的风格。

Java 可以通过在使用 new 进行实例化的时候定义数组元素的个数,所以这里没有元素个数的声明。

我们换个角度考虑问题,对于

  1. int hoge_p;

这个声明,因为当 hoge_p 指向 hoge 的时候,\hoge_phoge 可以同等地使用,所以有人可能会产生下面的想法。

你们看,一旦在 hoge_p 之前追加*,就可以和 int 变量 hoge 同样使用呢。也就是说,这个声明意味着 hoge_p 之前追加上*后成为 int 类型了。

这种思考方式,确实也有它一定的道理(比如数组也同样可以这么说),那么,如果写成

  1. int *&hoge;

这样,hoge 是可以作为 int 类型的变量来声明的吧?尝试一下就会明白,这里会发生一个语法错误。

其次,在声明中出现 const 的时候,这种观点也会出现破绽(表达式中是不可以出现 const 的),声明指向函数的指针时同样会出现问题。

以我的经验来看,一切关于“如果这样考虑,是不是就可以很自然地解释 C 的声明了?”的尝试都是徒劳的。为什么这么说,因为 C 语言的语法本来就是不自然、奇怪而又变态的

在第 3 章会详细地说明 C 的声明语法。姑且带着问题继续往下阅读吧。

 

补充 关于 int main(void)

在 C 语言标准中,关于 main()函数的使用只有如下两种方式:

  1. int main(int argc, char *argv[])

或者

  1. int main(void)

尽管如此,还是可以在一些入门书籍中遇到

  1. void main(void)

这样的写法,这是错误的。确实,就算是这么写,很多程序也能动起来。但是在有些环境下,编译器可能会报告一些警告信息。

main 函数返回一个 int 类型的值,因此在处理的最后必须有 return (现在的很多编译器都会提示没有 return 的警告)。

本书所有例程的 main 函数的末尾都写了 return 0;

返回 0 表示通知运行环境程序“正常结束”。

 

题外话 hoge 是什么?

本书的例程中,经常使用 hogepiyo 作为变量的名称。

这是啥?很多人会有这样的疑问。在日本,hoge 这个名字使用非常广泛。在为变量和文件的取名感到苦恼的时候,大家经常使用 hoge 这个词。

通常都会给变量取一个有意义的名字,但因为本书是单纯讲解 C 语法的书,所以很多地方使用了没有实际意义的单词。当然了,就算使用了“a”、“b”这样的变量名称,编译器也不会抱怨什么,但是这种一个字母的变量名用在面向初学者的书中,似乎不太合适。

为了也能明确地表示那些没有意义的变量,我们使用具有 4 个字母的 hoge

谁也不知道是哪个大侠最先使用 hoge 这个单词的。目前最有力的说法是,在 20 世纪 80 年代前半期,hoge 在日本各地被同时频繁地使用起来,详细的说明请参见:

“关于 hoge 的网页”

http://kmaebashi.com/programmer/hoge.html

原始网页已经取消不能访问了。承蒙作者吉田先生的允诺,此网页的内容已转载入我的网页中。

在美国,和 hoge 这个单词一样,foobar 等单词经常被使用。偶尔可以在 OS 的操作手册等资料中看见它们的身影。

1.2.3 指针和地址之间的微妙关系

在本章 1.2.1 节中,有下面一句话:

几乎所有的处理程序中,所谓的“指针类型的值”,实际是指内存的地址

对于这句话,有人也许会产生下面的疑问。

【常见疑问之 1】

归根结底,指针就是地址,地址就是内存中被分配的“门牌号”。所以,指针类型和 int 类型应该是一回事吧。

实际上,从某种意义来看,这种认识也不无道理。

在 C 语言前身的 B 语言中,指针和整数是没有区别的。此外,虽然我们经常使用 printf()%p 来表示指针,实际上包括我的运行环境在内,使用%x 也可以很好地表示地址。对不太擅长十六进制的人来说,通过使用%d,也能利用十进制的方式来确认地址的内容。

很可惜,这里说的运行环境并不具有普适性。其实在很多的运行环境中,int 类型和指针类型的长度并不相同,此外,由于 Intel 8086 的功能限制,在直到最近还被广泛使用的 MS-DOS 中,是通过将 16 位的值分成两组来表示 20 位的地址的1

1 8086 是分段寻址的,具体来说是指一个物理地址由段地址(segment selector)与偏移量(offset)两部分组成,长度各是 16 位。其中段地址左移 4 位(即乘以 16)与偏移量相加即为物理地址。——译者注

还有——不,还是先回答下一个问题吧。

【常见疑问之 2】

指针就是地址吧。那么,指向 int 的指针也好,指向 double 的指针也好,它们有什么不一样吗?有必要去区分它们吗?

在某种意义上,这种说法也有一定道理。

对于大部分的运行环境来说,当程序运行时,不管是指向 int 的指针,还是指向 double 的指针,都保持相同的表现形式(偶尔也会有一些运行环境,它们对于指向 char 的指针和指向 int 的指针有着不一样内部表示和位数)。

不仅如此,ANSI C 还为我们准备了“可以指向任何类型的指针类型”—— void*类型。

  1. 1: int hoge = 5;
  2. 2: void *hoge_p;
  3. 3:
  4. 4: hoge_p = &hoge; ←这里不报错
  5. 5: printf("%d\n", *hoge_p); /*打印输出hoge_p 指向的变量的值*/

以上代码中的第 4 行是不会报错的。

但是,像第 5 行这样在 hoge_p 前附加*……在我的环境里会出现下面的警告:

  1. warning: dereferencing 'void *' pointer
  2. invalid use of void expression

只需稍微考虑一下,就知道出现这样的错误是意料之中的。如果仅仅告之内存地址,却没有告之在那个地址上保存的数据类型,当然是不能取出值来的。

如果将上面的第 5 行修改成下面这样,不但可以顺利地通过编译,甚至可以正常地运行。

  1. 5: printf("%d\n", *(int*)hoge_p); /*将hoge_p 强制转换成int* */

这里通过将“所指类型不明的指针”hoge_p 强制转型成“指向 int 的指针”,来告之编译器类型信息,由此可以取出 int 类型的值。

但每次都这样写是比较繁琐的,不妨事先写成以下的声明:

  1. int *hoge_p;

因为编译器可以记住hoge_p 是指向 int 的指针”,所以只需要简单地在 hoge_p 前面添加*,就可以通过指针间接取值。

之前也提到,在大部分的运行环境里,不管是“指向 int 的指针”,还是“指向 double 的指针”,在运行时都是相同的事物。可是,通过在 int 类型的变量之前加上&来取得它的指针,随后利用指针间接取出来的值,不出意外肯定是 int 类型。为什么?因为 intdouble 的内部表示完全不同。

因此,如今的运行环境,像下面这样取得指向 double 类型变量的指针,之后将其赋给指向 int 的指针变量,编译器必定会提示警告。

  1. int *int_p;
  2. double double_variable;
  3. /*将指向double 变量的指针赋予指向int 的指针变量(恶搞!) */
  4. int_p = &double_variable;

顺便说一下,在我的环境里出现了下面的警告:

  1. warning: assignment from incompatible pointer type

下面的“指针运算”这一小节,会进一步说明“编译器会帮我们记住指针指向什么样的类型”的重要意义。

1.2.4 指针运算

C 语言的指针运算功能是其他语言所没有的。

指针运算是针对指针进行整数加减运算,以及指针之间进行减法运算的功能。

我们先来看一看下面这个例程(参照代码清单 1-2)。

【注意 1】

严格地说,代码清单 1-2 的程序并不符合 C 语言标准。

对于指针加减运算,标准只允许指针指向数组内的元素,或者超过数组长度的下一个元素。指针运算的结果也只是允许指针指向数组内的元素,以及超过数组长度的下一个元素(关于这一点,请参照 4.3.2 节的补充内容“指针可以指向数组最后元素的下一个元素”)。标准没有对除此之外的情况做出任何定义。在下面的例程中,因为对不是指向数组的指针 hoge_p 进行了加法运算,所以它在这一点上违反了 C 语言标准

标准写道:“一个指向非数组对象的指针,和指向只包含一个元素(类型和前者相同)的数组的第一个元素的指针,具有相同的意义”。因此,只要你不做加 2 以上(包括 2)的加法运算就不会出现错误。

在大多数的环境下,这个程序是可以运行的。为了有效地说明后面的问题,比起严格遵守标准,我还是选择了这个简单、直接的例程。

 

代码清单 1-2 pointer_calc.c

  1. 1: #include <stdio.h>
  2. 2:
  3. 3: int main(void)
  4. 4: {
  5. 5: int hoge;
  6. 6: int *hoge_p;
  7. 7:
  8. 8: /*将指向hoge 的指针赋予hoge_p */
  9. 9: hoge_p = &hoge;
  10. 10: /*输出hoge_p 的值*/
  11. 11: printf("hoge_p..%p\n", hoge_p);
  12. 12: /*给hoge_p 加1*/
  13. 13: hoge_p++;
  14. 14: /*输出hoge_p 的值*/
  15. 15: printf("hoge_p..%p\n", hoge_p);
  16. 16: /*输出hoge_p 加3 后的值*/
  17. 17: printf("hoge_p..%p\n", hoge_p + 3);
  18. 18:
  19. 19: return 0;
  20. 20: }

我的环境中的结果如下:

  1. hoge_p..0xbfbfd9e4 最初的值
  2. hoge_p..0xbfbfd9e8 1 后的值
  3. hoge_p..0xbfbfd9f4 1 之后再加3 的值

第 9 行,将指向 hoge 的指针赋予 hoge_p,第 11 行输出 hoge_p 的值。我的环境里,hoge 被保存在门牌号为 0xbfbfd9e4 的地址中。

在第 13 行,使用运算符++,给 hoge_p 加 1。

输出结果……0xbfbfd9e4 变成了 0xbfbfd9e8,为什么不是增加了 1,而是增加了 4 呢?

在第 17 行,给加 1 后的 hoge_p 再加上 3,输出的结果由 0xbfbfd9e8 变成了 0xbfbfd9f4,增加了 12。

这就是指针运算的特征。在 C 语言中,对指针进行加 1 运算,地址的值会增加当前指针所指向数据类型的长度。例程中的 hoge_p 是 “指向 int 的指针”,因为我的环境中 int 类型的长度为 4,所以给地址加 1,指针前进 4 个字节,给地址加 3,指针就前进 12 个字节。

要 点

对指针加 N,指针前进“当前指针指向的数据类型的长度×N”。

【常见疑问之 3】

指针就是地址吧,给指针加 1,指针难道不应该前进 1 个字节吗?

这是最常见的疑问了。理解这一点的前提,需要先弄清楚 C 语言中指针和数组之间有什么样的微妙关系,以及为什么 C 中会存在指针运算这样奇怪的功能。

关于这些问题,稍后会进行说明,目前还是让我们带着疑问往下走吧。

1.2.5 什么是空指针

空指针是一个特殊的指针值。

空指针是指可以确保没有指向任何一个对象的指针。通常使用宏定义 NULL 来表示空指针常量值。

空指针确保它和任何非空指针进行比较都不会相等,因此经常作为函数发生异常时的返回值使用。另外,对于第 5 章的链表来说,也经常在数据的末尾放上一个空指针来提示:“请注意,后面已经没有元素了哦。”

在如今的操作系统下,应用程序一旦试图通过空指针引用对象,就会马上招致一个异常并且当前应用程序会被操作系统强制终止*。因此,如果每次都使用 NULL 来初始化指针变量,在错误地使用了无效(未初始化)的指针时,我们就可以马上发现潜在的 bug。

* 并不是所有的操作系统都能对空指针引用进行错误处理的。像 DOS 这样没有内存保护功能的操作系统也就罢了,连 UNIX 居然也允许通过空指针引用对象。

通常,我们可以根据指针指向的数据类型来明确地区别指针的类型。如果将“指向 int 的指针”赋给“指向 double 的指针”,如今的编译器会报出前面提到的警告。但是,只有 NULL,无论对方指向什么类型的变量,都可以被赋值和比较。

偶尔会见到先将空指针强制转型,然后进行赋值、比较操作的程序,这不但是徒劳的,甚至还会让程序变得难以阅读。

补充 NULL、0 和'\0'

经常有一种错误的程序写法:使用 NULL 来结束字符串。

  1. /
  2. 通常,C 的字符串使用'\0'结尾,可是因为strncpy()函数在 src 的长度大于len
  3. 的情况下没有使用'\0'来结束,所以一板一眼地写了一个整理成C 的字符串形式的
  4. 函数(企图)
  5. /
  6. void my_strncpy(char dest, char src, int len) {
  7. strncpy(dest, src, len);
  8. dest[len] = NULL; ←使用NULL 来结束字符串!!
  9. }

上面的代码,尽管在某些运行环境下能跑起来,但无论怎样它就是错误的。因为字符串是使用“空字符”来结束的,而不是用空指针来结束。

在 C 语言标准中,空字符的定义为“所有的位为 0 的字节称为空字符(null character)”(5.2.1)。也就是说,空字符是值为 0 的字符。

空字符在表现上通常使用'\0'。因为'\0'是常量,所以实际上它等同于 0。也许有些吓到你了,'\0''a'呀什么的,它们的数据类型其实并不是 char,而是 int

如果是C++,就不是这个结论了。

另外,在我的环境中,NULL 在 stdio.h 里的定义如下:

  1. #define NULL 0

看到这个,你可能会说:“说来说去,那还不都是 0 嘛。”确实在大部分的情况下是这样的,但背后的事情却异常复杂。

正如前面说的那样,写成'\0'和写成常量的 0 其实是一样的。使用'\0'只不过是习惯使然。如果想让代码容易读,遵从习惯是非常重要的。

0 当作空指针来使用,除了极其例外的情况,通常是不会发生错误的。

但是,如果在字符串的最后使用 NULL,就必然会发生错误。

标准允许将 NULL 定义成(void\)0,所以在 NULL 被定义成(void*)的时候,如果使用 NULL 来结束字符串,编译器必然会提示警告。

看到刚才的关于 NULL 的定义,可能有人会产生下面的推测:

啥呀?所谓空指针,不就是为 0 的地址嘛。

在 C 中,为 0 的地址上应该是不能保存有效数据的吧?放什么都起不到任何作用,这没什么大不了的。

这种推测好像颇有道理,但也是有问题的。

确实在大多数的环境中,空指针就是为 0 的地址。但是,由于硬件状况等原因,世上也存在值不为 0 的空指针。

偶尔会有人在获得一个结构体之后,先使用 memset()将它的内存区域清零然后再使用。此外,虽然 C 语言提供了动态内存分配函数 malloc()calloc(),但是抱着“清零后比较好”的观点,偏爱 calloc()的人倒有很多。这样也许可以避免一些难以再现的 bug。

使用 memset()calloc()将内存区域清零,其实就是单纯地使用 0 来填充位。通过这种处理,当结构体的成员中包含指针的时候,这个指针能不能作为空指针来使用,最终是由运行环境来决定的。

顺便说一下,对于浮点数,即使它的位模式为 0,值也不一定为 0

整数类型还好,但是我还是感觉依赖环境编出来的代码是不干净的。

说到这里,

哦,原来这样啊,所以要使用宏定义的 NULL 呢。对于空指针的值不为 0 的运行环境,NULL 的值应该被 #define 成别的值吧。

可能会有人产生以上的想法。实际上,这种想法也是有偏差的,这涉及问题的内部根源。

比如,尝试编译下面的代码:

  1. int p = 3;

在我的环境里,会出现以下警告:

  1. warning: initialization makes pointer from integer without a cast

因为 3 无论怎么说都是 int 型,指针和 int 型是不一样的,所以编译器会提示警告。尽管在我的环境里指针和 int 的长度都是 4 个字节,但还是出现了警告。如今的编译器,几乎都是这样的。

继续,让我们尝试编译下面的代码:

  1. int p = 0;

这一次没有警告。

如果说将 int 型的值赋予指针就会得到一个警告,那么为什么值为 3 的时候出现警告,值为 0 的时候却没有警告呢?简直匪夷所思!

这是因为在 C 语言中,“当常量 0 处于应该作为指针使用的上下文中时,它就作为空指针使用”。上面的例子中,因为接受赋值的对象为指针,编译器根据上下文判断出“0 应该作为指针使用”,所以将常数 0 作为空指针来读取。

无论如何,编译器都会针对性地对待“需要将 0 作为指针进行处理的上下文”,所以即便是空指针的值不为 0 的情况下,使用常量 0 来代替空指针也是合法的。

此外,如上所述,有的环境中像下面这样定义 NULL

  1. #define NULL ((void)0)

ANSI C 中,根据“应该将 0 作为指针进行处理的上下文”的原则,将常量 0 作为指针来处理。因此,显式将 0 强制转型成 void\是没有意义的。但是在某些情况下,编译器也可能会理解不了“应该将 0 作为指针进行处理的上下文”。

这些情况是:

  • 没有原型声明的函数的参数

  • 可变长参数函数中的可变部分的参数

ANSI C 中,因为引入了原型声明,只有在你确实做了原型声明的情况下,编译器才能知道你“想要传递指针”。

可是,对于以 printf()为代表的可变长参数函数,其可变部分的参数的类型编译器是不能理解的。另外糟糕的是,在可变长参数的函数中,还经常使用常量 NULL 来表示参数的结束(比如 UNIX 的系统调用 execl() 函数)。

以上情况下,简单地传递常量 0,会降低程序的可移植性。

因此,通过使用宏定义 NULL 来将 0 强制转型成 void*,可以显式地告之编译器当前的 0 为指针

关于这个话题,在 C 语言 FAQ(http://www.catnet.ne.jp/kouno/c_faq/c_faq.htm)中,也花费了一章的笔墨进行 了讨论。

1.2.6 实践——swap函数

到这里为止,已经对指针进行了大致的介绍,但是关于指针的用处还没有解释。

在这里,我们使用经常用于展示指针使用方法的例程——招牌的 swap 函数来进行下面的说明。

下面这个函数试图交换两个 int 类型变量的值,虽然这个例子总让人觉得不太自然,但我们这里还是使用了这个例子。

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

让我们调用一下这个函数。

  1. int x, y;
  2.  
  3. x = 5;
  4. y = 10;
  5.  
  6. swap(x, y);
  7.  
  8. printf("x..%d y..%d\n", x, y);

通过运行以上的调用,我们发现 xy 的值并未交换。

调用 C 的函数,参数传递往往是传值,这种方式传递的是参数的副本。

可能会有人这样想:

啥?这本书也谈传值的问题?以前俺买的那些 C 语言入门书中也有这个内容呢。也罢,姑且先听你说说看,所谓的传值究竟是怎么回事?

为了这部分读者,我换个角度来说明一下。

这个例子中的 swap 函数,有两个 int 型的参数。所以,也一定可以通过下面的方式调用这个函数:

  1. swap(3, 5);

那么,在 swap 这一边,我们先排除形参(这里是 ab)在调用的时候被设定值的情况,将它们和通常的局部变量同样对待。当然,赋值也是可以的。

假设给 ab 赋值会让调用方的变量给 xy 带来影响,那么像 swap(3,5)这种方式的调用,究竟会发生什么呢?常量 3 变成 5,5 变成 3?绝不可能。

顺便提一下,有一些 C 语言之外的其他语言,给函数的形参赋值是会影响到调用方的实参的。在以前的 FORTRAN 中,所有的参数都是这样的。在将常量作为参数进行传递的时候,稀里糊涂地给形参赋了值——结果有可能惨不忍睹。在 Pascal 中,为了不给调用方的变量带来影响,在定义函数的时候,特别地指定参数为变量参数。如果试图给变量参数指定常量,编译器会报错。

C 语言里完全没有这样的现象。无论如何,函数的形参都和调用时被设定值的局部变量一样。如果不是这样的话,就会背负 FORTRAN 那样的危险,或者像 Pascal 那样在语法下做些文章。从 C 语言的发展过程来看,采取 Pascal 那样麻烦的方式是不可能的。

因此,在 C 里面想要改写调用方的变量,可采取传递指针的方式。

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

调用方式为:

  1. swap(&x, &y);

在本例中,向函数传递指向 xy 的指针(也就是地址)。尽管指针是通过传值的方式进行传递的,但由于在 swap 中使用了*运算符,所以通过指针可以间接访问到 xy。向 swap 传递的是地址,xy 自身并没有移动。

给大家举一个例子。有一位从不信任部下,甚至神经质得有点让人讨厌的上司,每当他向部下分配任务的时候,总是将复制后的文档交给部下。这些部下无论多么地努力,也不可能调换这位上司的文件柜中存在的“文档 A” 和“文档 B”的内容。除非上司这样吩咐他的部下:“给我将书柜某个地方的文档 A,以及书柜某个地方的文档 B 调换一下!”如果告知了文档的“地点”,这些部下就能调换文档内容了。就是这么一回事。

如果换个方式说明这一小节开头的那个例子,就好像冷不丁地向 swap 函数要求“帮我把 5 和 10 交换过来”。

换成其他的函数,

  1. a = 5;
  2. func(a);

  1. func(5);

你不认为它们是一样的吗?

后面的那个例子向 swap 函数提出了“请交换这里的变量和那里的变量”的要求。显然这个要求是可以满足的。

说个题外话,如果仅仅是需要交换整型变量的值,完全不使用临时变量也是可以的。比如使用下面的宏定义:

  1. #define SWAP(a, b) (a += b, b = a - b, a -= b)

在这种方式(还可以使用异或运算符)下,在颠倒使用同一个变量时,这个程序是不能正常运行的。比如你写了 SWAP(a[i],a[j]),并且恰巧 i == j,那我只能恭喜你中招了。当然,如果你能担保这种情况永远不可能出现,使用这个宏也未尝不可。

如果到现在为止,对以上内容还是不太明白,请阅读第 2 章。第 2 章会具体讲解当提到形参是实参的副本时,实参究竟被复制到哪里,以及怎样实现复制。

补充 形参和实参

几乎所有的 C 语言的入门书籍中,都会讲解“形参”和“实参”的概念。但是它们还是经常被轻易混淆。

实参是调用函数时的参数。

  1. func(5); ←这里的5 是实参。

形参是接受实参的一方。

  1. void func(int hoge) ←这里的 hoge 是形参
  2. {
  3. }

后面会经常出现“形参”、“实参”这样的词,请大家一定注意不要混淆它们。