3.4 解读 C 的声明(续)

3.4.1 const修饰符

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

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

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

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

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

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

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

  1. char *my_strcpy(char *dest, const char *src)
  2. {
  3. *src = 'a'; ERROR!!
  4. }

如果将 src 自身定义为只读,需要写成下面这样:

  1. char *my_strcpy(char *dest, char * const src)
  2. {
  3. src = NULL; ERROR!!
  4. }

如果将 srcsrc 指向的对象都定义为只读,可以写成下面这样:

  1. char *my_strcpy(char *dest, const char * const src)
  2. {
  3. src = NULL; ERROR!!
  4. *src = 'a'; ERROR!!
  5. }

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

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

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

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

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

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

也就是说:

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

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

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

  • 遵从 3.1.2 节中提到的规则,从标识符开始,使用英语由内向外顺序地解释下去。

  • 一旦解释完毕的部分的左侧出现了 const,就在当前位置追加 read-only。

  • 如果解释完毕的部分的左侧出现了数据类型修饰符,并且其左侧存在 const,姑且先去掉数据类型修饰符,追加 read-only。

  • 在翻译成中文的过程中,英语不好的同学请注意:const 修饰的是紧跟在它后面的单词。

因此,

  1. char * const src

可以解释成:

src is read-only pointer to char

3.4 解读 C 的声明(续) - 图1src 是指向 char 的只读的指针

  1. char const *src

可以解释成:

src is pointer to read-only char

3.4 解读 C 的声明(续) - 图2src 是指向只读的 char 的指针

此外,容易造成混乱的是

  1. char const *src

  1. const char *src

意思完全相同

3.4.2 如何使用const?可以使用到什么程度?

很多人习惯在函数注释的参数说明部分,使用(i)(o)(i/o)等标记1

1 (i)指用于输入的参数,(o)指用于输出的参数,(i/o)指用于输入输出的参数。——译者注

这里举一个有些矫揉造作的例子。

  1. /*********************************************************
  2. * void search_point(char *name, double *x, double *y)
  3. *
  4. * 功能:将名称作为key,检索“点”,返回坐标
  5. * 参数:(i) name 名称(检索key)
  6. * (o) x x 坐标
  7. * (o) y y 坐标
  8. *********************************************************

唉,每次要对函数做这样的注释,是不是有点麻烦?于是有很多人会将别的函数的注释一成不变地复制过来,事后还鬼话连篇说自己忘了修改了。其实这些人就没把注释当回事儿,他们认为直接看代码就什么都可以明白,老是揪着注释的问题不放,简直就是没事找事

在这里的注释中,虽然标记了各参数是(i)还是(o),但编译器可不会注意到这些。

对此,search_point 原型使用下面的方式进行声明:

  1. void search_point(char const *name, double *x, double *y);

当你错误地向 name[i]赋值的时候,编译器会很负责任地向我们提出警告。因此,比起在注释中标记什么(i)或者(o),使用 const 可靠性会提高很多*

* 如果不用 const,而是使用 (i) 并且很放心地将指针就这样传递给了函数,之后即使变量被改写了,你也很难马上发现。

对于上面的 char const *name,是不能将它赋予 char*类型的变量的(除非强制转型)。其中的理由显而易见:如果单纯地将它赋予 char*类型的变量,之后就可以改写 name 所指向的对象的内容,const 的意义就丧失殆尽了。

同理,将 char const *类型的指针传递给使用 char*作为参数的函数,也是不允许的。因此,一旦给指针类型的参数设定了 const,当前层次以下的函数就必须全部使用 const*

* 在一些通用函数中,如果本应该是 const 的参数却没有加上 const,会经常导致调用方无法使用 const

假设有这样一个结构体:

  1. typedef struct {
  2. char *title; /*标题*/
  3. int price; /*价格*/
  4. char isbn[32]; /*ISBN*/
  5. } BookData;

将上面这个结构体作为输入参数的函数原型,可以写成下面这样:

  1. /*注册书的数据*/
  2. void regist_book(BookData const *book_data);

因为使用了 const,所以 book_data 所指向的对象是禁止改写的。好吧,现在可以放心地将 BookData 传递给这个函数了……

不幸的是,我们发现被传递的数据中,书的标题(book_data->title)所指向的内容是可以被改写的

之所以发生这样的事情,是因为根据指定的 const 而成为“只读”的对象只是“book_data 所指向的对象自身”,而不包括“book_data 所指向的对象再向前追溯到的对象”(参照图 3-16)。

3.4 解读 C 的声明(续) - 图3

图 3-16 const 的边界

因此,如果将结构体 BookData 修改成这样:

3.4 解读 C 的声明(续) - 图4

这一次,就算是上帝来了也改不了 title 所指向的对象了*

* Java 的 String 类也是不可变(immutable)的。这种方式当然有它的益处,但是有时候它也会给你带来麻烦。

正因为如此,很多人对 const 究竟能为现实中的编程提供多少便利持怀疑态度。

补充 const 可以代替#define 吗?

通常,C 语言使用预处理器的宏功能定义常量,就像下面这样:

  1. #define HOGE_SIZE (100)
  2. int hoge[HOGE_SIZE];

可是,预处理器是独立于 C 语言语法的,因此在调试的时候时常会出现一些问题。由宏定义自身的问题造成的错误往往爆发在使用它的地方,这给纠错工作带来很大的困难。

惹不起,总躲得起吧?大不了不使用“坑爹”的宏,是不是可以写成下面这样:

  1. const int HOGE_SIZE = 100;
  2.  
  3. int hoge[HOGE_SIZE];

亲,写成这样还是不行!!

尽管在 C 中,数组的元素个数必须为常量,但无论怎样,const 修饰的标识符不是常量,它只是“只读”而已。因此,上面的写法还是错误的

但是,ISO C99 没有这样的规定。

C++另当别论。

3.4.3 typedef

typedef 用于给某类型定义别名。

比如,

  1. typedef char *String;

通过以上的声明,以后对于“指向 char 的指针”可以使用“String”这个别名。

可以按照普通的变量声明的顺序来解释 typedef。对于上面的“String”,如果像对待变量名一样用英语的顺序进行解释,应该是下面这句话:

String is pointer to char

    3.4 解读 C 的声明(续) - 图5String 是指向 char 的指针

由此,String 作为“指向 char 的指针”这个类型的别名被声明。

之后,在使用 String 时,你可以写成这样:

  1. String hoge[10];

它的意思是:

hoge is array(元素个数 10of String;

      3.4 解读 C 的声明(续) - 图6hogeString 的数组(元素个数 10

如果将 String 和被定义成 String 类型的指向 char 的指针机械地进行置换,就会产生下面的解释:

hoge is array(元素个数 10)of pointer to char;

    3.4 解读 C 的声明(续) - 图7hoge指向 char 的指针的数组(元素个数 10

语法上,typedef 属于“存储类型修饰符”(参照 2.2.1 节的补充内容)。可是无论怎么看也看不出 typedef 指定了“存储类别”。其实与此无关,typedef 之所以被划分为存储类型修饰符,应该是由于指定类型的语法沿用了通常声明标识符的语法规则。

要 点

typedef 使用和通常的标识符声明相同的方式进行解释。

可是,被声明的不是变量或者函数,而是类型的别名。

平时在声明结构体的时候,我肯定会指定 typedef。顺便提一下,此时我会尽可能省略 tag*

* 也有人鄙视这样的风格……

  1. typedef struct {
  2. } Hoge;

这个声明没有什么特别的地方,关于结构体,假设写成下面这样:

  1. struct Hoge_tag {
  2. } hoge;

由于可以声明 struct Hoge_tag 类型的变量 hoge,如果将和这个变量名对应的部分置换成类型的名称,就变成了 typedef 的声明了。

此外,在声明变量的时候,可以像下面这样一次性声明多个变量:

  1. int a, b;

同样地,typedef 也可以一次声明类型的多个别名。

可是这么做,除了让声明难以阅读之外,你得不到任何好处。偶尔也会见到下面这样的声明:

  1. typedef struct {
  2. } Hoge, *HogeP;

这个声明其实和下面的声明效果相同:

  1. typedef struct {
  2. } Hoge;
  3. typedef Hoge *HogeP;