分析表达式

对象

C像其它语言一样,明显地区分‘对象’和‘值’。值是短暂、易逝的——如果你不保存它,它就会消失。为了保存它们,你要把它们保存在对象中。

在C99标准中,对象的定义是:

执行环境中的数据存储区域,它们的内存代表值。除了位域,对象由一个会多个连续的字节序列组成,其数目、顺序和编码方式通过显式制定,或者由实现定义。在被引用时,一个对象需要被解释为具有一个特定的类型;参见6.2.2.1。

组成对象的字节中的原始位有特别的名字:它们是存储于那个对象中值的表示。在一些情形下,对每个值确切地存在一个表示,反之亦然,但是这不总是真的。表示和值的关系简单或复杂则有硬件定义,一些硬件使用具有过度装饰的表示。

对象通常由标识符建立,但是C允许匿名对象的存在,特别是那些由malloc()函数产生的。

对象的存储期特性

对象通常有一个在建立时设定的特定的类型(比如int或double),并且在该对象的生命期内一直存在。如同上述定义所暗示,也有一些隐秘进行的方式。另一方面,对象总有一个特定的生存期,亦称为存储期限。

当一个对象与一个普通的标识符关联,它的存储期或是静态或为自动。当你声明一个普通的标识符时,就创建了一个对象。该对象的存储期限依赖于,它们是在哪里及如何定义的。这个存储期限也与初始值——如果有的话有关。

任何具有静态存储期的对象‘始终存在’,并总有一个初始值,即使你没有给它一个初值。这种对象在你的程序进入main()函数时创建,一直存在内存中直到程序退出。 (C标准没有规定程序退出后会怎样——或许它们即使在那时还存在内存中)如果你没有为静态对象提供初值,编译器会当作你赋了一个整型常量零给它。这表示静态int变量被初始化为0,double是0.0,指针是NULL。当然如果你想让其它程序员知道,你确实想把它们设为零,你可以显式初始化它们。在许多系统上,这会令执行程序稍微大一些(根据被初始化的变量)。

一个自动对象,换言之,仅当当它们在函数或块开始处被自动创建,直到变量所在的块退出期间存在(通过正常控制流、从函数返回或其它任何方式退出块)。如果你不想自己初始化这样的变量,它的初值可能是垃圾值[1],或没用的东西,甚至可能是被‘污染的’。你必须在以任何方式使用前,为这样的变量赋值。 (一些程序员喜欢对每一个这类变量赋值,即使它们没有特别的值可以赋给它们。正因为这样,任何在变量设置正确的初始值之前使用它导致的缺陷,至少都是一样的。在一些计算机上,垃圾值通常从一个变为另一个。另一方面,一些编译器能察觉对为初始变量的使用,并产生一个编译时诊断,从而在程序被测试之前捕获这个缺陷——所以一些程序员选择让它们未初始化。)

第三类存储期限成为分配的存储期。这些对象由malloc()函数创建,并且存在内存中直到出现一个对应的free()调用。像自动存储对象一样,它们也有垃圾初始值。使用分配存储对象需要一个指针。

一个误解的关键字

C关键字static的含义现在看来应该很明显了。如果有一个静态存储期限,和一个static关键字,它们应该相对应。它们确实是——不过不完全这样。

普通变量能在任何函数或块的外部和内部被声明。 (关于声明的确切的约束在C89和C99之间是不同的)因为自动对象仅在它们的函数或块执行时存在,它表示只有在函数或块之内的有自动存储期。它又隐含指函数或块外部的变量具有静态存储期,确实是这样的。因此只有函数和块作用域的变量需要这个关键字,在这样的声明中使用static关键字,改变对应变量的存储期为static——所以我们可以说static关键字具有静态存储期。函数外部的变量没有对这个关键字的需要(或者对这些函数本身来说真的是)。但是这个关键字仍然可以应用于它们;在此,它完全是另一种含义。

它真正的意义是,不论何时一个C程序员谈论一个‘静态变量’,你必须仔细问清楚,他是指一个静态对象,还是static关键字的另一种含义。

对象和值

为了讨论C中的表达式,我们需要三种信息:

C语言把表达式分为,我称之为‘对象’和‘值’的两大类。(ANSI/ISO C标准分别使用术语‘左值’和‘值’。许多计算机科学家对后者使用‘右值’这个名字。技术上一个左值‘指定’一个对象,一个右值是我们大部分人认为的一个普通值。)一个对象实际上,是存储值的空间。普通变量是对象的最简单的例子,但不是唯一的。

C语言的一个值实际上指两个东西,因为每一个C的值也具有一个类型。值是短暂的:它们仅存在于单个表达式中,它们是易逝的。如果一个特定的值要被用于任何用途,它必须被存储在一处(或者被打印,这仅表示把它存储在用户的头脑中,而不是计算机内存中)。换言之,对象在它们的对象生命期内存在(它可能是被运行的程序中的几行代码)。

对象也有类型,一般而言,任何对象仅能持有兼容对象类型的值。未被赋值的自动对象包含一个垃圾值,C允许垃圾值是被任意污染的。这对malloc()分配的对象同样成立。因为这个原因,在检查任何特定对象的值之前,确保它已经被赋值是很重要的。注意static对象总是有一个初始值,即使你未曾赋予一个。

对象的值

像在许多其它计算机语言中一样,在C中当你需要一个值,你可以指定一个对象来代替。换言之,语言区分‘对象上下文’和‘值上下文’,因此当你写:

    x = y;

机器得到y的值并置入x中。大体上说,对象的值相当明显。它的类型由对象的类型决定,并且它的确切的值是你最近存储给它的。这种情形不是数组的规则。

规则

C中的数组对象有一个特别的、基础的规则。这个规则本质上是专有的,并且简单地说必须被记住。它得出了一个主要事实:C没有数组类型的值。(对此有一个例外,我将在稍后说明。) C具有数组对象——只是没有这种值。例如int a[5];声明一个普通的包含五个int的数组。逻辑上这个数组的值应该是这五个存储在数组中的int值——但是不是这样的。相反,数组的‘值’是一个指向数组首元素的指针。

一条规则包围它们
一条规则找到它们
一条规则指出它们
在绑定它们的语言中
——向J. R. R. Tolkien致歉

为了谈论值,我们当然需要知道它的类型——一个指向int的指针与一个指向double的指针截然不同。因此规则对于数组对象也成立:

在值上下文中,一个‘T类型数组’的对象,被转换为一个‘T类型指针’的值,指向该数组的首元素,即是数组下标为0的元素。

这里的类型T是任意有效数组元素类型。有效类型包括数组类型本身;能构造一个‘包含三个包含四个浮点数的数组的数组’类型的对象是可能的。那个规则把它变为一个‘指向含有四个浮点数数组的指针’类型的值——不是浮点数**而是浮点数 (*)[4]。那个规则仅适用一次,因为之后你不再有一个对象了。为了触发该规则,你必须把新值变成另一个对象。

(注意数组的尺寸,即使是已知的,在那个规则执行之后会消失。这也是为何它常被去掉和遗忘。)

结构和联合对象具有结构和联合类型值,指针具有指针值。(当然函数有返回类型,但是函数不是对象。事实上尝试得到一个函数的值而不调用它,导致一个类似数组和指针的规则。)

注意C总是按值传递所有参数。因为一个数组的值实际上是一个指向其首元素的指针,从而得出C没有数组类型参数。因此当你声明一个这样的函数:

    void f(char s[]);

你实际上声明f()拥有一个‘指向char类型指针’的参数,而不是一个char类型数组(未指定大小)。这种‘类型改写’规则将在稍后作更多讨论。

对象的对象

我一直提到‘对象上下文’和‘值上下文’,以及‘对象’和‘值’。如何得知你正使用哪一个上下文?

如上所述,在一个简单、普通的赋值如x = y,赋值操作符左边是‘对象上下文’。这就是为何即使赋值前x的值是3,编译器也知道如何去操作x而不是3(明显不合理)。C有相当多的操作符要求对象上下文,包括所有类似+=的赋值操作符和增量和减量操作符++和--。因为所有这些都需要改变一些对象的值,它们必须代表对象而不是其值。

还有两个其它的地方,表达式需要是对象上下文。一元操作符&(取地址)取得对象的地址。为了能这样做,它需要对象而不是其值。它表示如果arr是一个数组,&arr令arr处于对象上下文,并且那个规则不适用。最后一个特别的情况时sizeof操作符。它也使用对象上下文,所以它能得到指定对象的大小。

懂得那个规则,可以很容易就明白sizeof持有对象上下文,数组和指针是非常不同的,通过运行一个简单的例子程序:

    #include <stdio.h>
    int main(void) {
      int a[5];

      printf("sizeof a   = %lu\n", (unsigned long)sizeof a);
      printf("sizeof &a  = %lu\n", (unsigned long)sizeof &a);
      printf("sizeof a+0 = %lu\n", (unsigned long)sizeof (a+0));
      return 0;
    }

它几乎总是输出两个不同的数字,对于前两行输出。(如果它打印两个相同的数字,则可能是你的编译器有问题。五个int值可能恰好和一个‘指向一个具有5个int的数组的指针’有同样的大小。在这种情形下,改变5为其它常量则应该产生两个不同的数字。在过去一些糟糕的编译器在数组名上应用了那个规则,符合sizeof操作符的要求。)

第一个sizeof操作符得到整个数组的大小,不应用那个规则。第二个sizeof操作符得到&操作符运算结果的大小,即是一个指向一个‘五个int数组’的指针的大小。虽然最后一行输出使用相加操作符+,没有给数组上加任何东西。因为数组的值是一个指向其首元素的指针,表达式(a + 0)首先必须得出这个值——一个指向int的指针——sizeof操作符然后打印一个指向一个int值的指针的尺寸。

上述程序打印的最后两个数字,极有可能相同。即是说一个指向整个数组的指针,和一个指向数组首元素的指针可能尺寸相同。事实上大多数现代机器真地只有一个,或者有时是两个不同的指针尺寸。许多更老的机器有许多不同种类的指针,一个在这些机器上的C编译器可能使用它们全部;在这些机器上,可能更容易知道&a和(a + 0)有不同的类型。然而在现代机器上,你必须采取另一种方法才能得知。

最容易的是简单地观察诊断信息,或者根本就没有,在正确或错误的程序上产生。一个类似这样的正确程序:

    #include <stdio.h>
    int main(void) {
      int a[5];
      int (*p1)[5];
      int *p2;

      p1 = &a;
      p2 = a; /* same as (a+0) -- is on the right hand side */
      return 0;
    }

不需要任何诊断。另一方面,如果你在第一和第二个赋值之间交换一元&操作符,程序违反了一个约束,需要一个诊断(一个优秀的编译器应该产生两个诊断,每个错误的赋值都有一个)。

另一个方法是观察指针运算的效果。然而那是一个有关可移植的技巧;我们把这个留在后面。

分析表达式

现在你知道了关于类型和值,对象和值,你也准备好写下关于一个表达式每部分的信息。在前面你写下一对<类型,值>信息。现在你要写下这个三元组:<对象或值,类型,名字或值>

假设你写下这些声明:

    int i;
    int *ip;
    int a1[5];
    int a2[3][5];
    int (*ap)[5];

这里的变量i的类型是int,ip是‘指向int的指针’,a1是‘五个int的数组’,a2是‘三个五个int数组的数组’,ap是‘指向五个int的数组的指针’。它们都没有值,即是它们全部都可能含有垃圾值。所以我们最好给它们赋值:

    i = 42;
    ip = a1;
    *ip = i;
    ap = a2;

第一行非常简单。左边是变量名i。这是一个:

    <object, int, i>

同样地,右边是一个整型常量42,为:

    <value, int, 42>

赋值操作符=要求其左边是一个对象,右边是一个值。这是它确切已有的。如果需要,它之后转换右边的值(42)为所要求的类型。在本例中42已经是正确的类型,所以这不会出什么问题。最后它把值赋给对象——所以i变成42。

第二行也没有什么真正的不同。赋值操作符左边和右边分别是:

    <object, pointer to int, ip>
    <object, array 5 of int, a1>

当然赋值操作符在右边需要一个值——所以现在需要得到a1的值,即是应用那个规则。

那个规则去掉数组的大小(5),只考虑元素类型(int)。‘T类型数组’变成一个‘T类型指针’指向该数组的首元素,即是下标为0的那个。因此<object, array 5 of int, a1>变成<value, pointer to int, &a1[0]>。现在左边和右边都有正确的格式,再一次地,它们也有正确的类型。赋值开始从而把ip指向a1[0]。

第三行有一些复杂并更有趣。赋值操作符的左边是它本身——一个表达式。在你能明白这个赋值做什么和是什么之前,你必须计算出子表达式。

子表达式由前缀一元解引用操作符*和变量名ip组成。解引用操作符要求一个值,并且那个之必须是某些指针类型。但是ip不是一个值;它是一个<object, pointer to int, ip>。指针一点都不神秘——它们像其它普通变量一样具有值,并且一个指针的值就是它的值。我们刚好在片刻之前设置了那个值:它指向a1[0]。因此<object>变成<value, pointer to int, &a1[0]>。

现在一元操作符*有它所需要的,一个‘T类型指针’的值。解引用操作符简单地跟随指针——它最好不是垃圾值或NULL——去找到它所指向的对象。解引用的结果是一个<object>并且类型为T。那个对象的名字可能有一点问题,但是在这种情况下,我们知道它正好是a1[0],所以这个解引用操作符最后的结果是<object, int, a1[0]>。(我们也可以说它是<object, int, *ip>。)

这一行*ip = i;的右边自然是三元组<object, int, i>。左边是一个对象;右边的值是i的值,即是<int, 42>;所以这个赋值把*ip(即是a1[0])设为42。

最后一行留给读者作为练习。

值和表示

不论何时一个值存储在一个对象中,它都要表现为某些表示。通常这种表示只是那个对象中所有的位。可能有未使用的位,或者一些未参与到值的位,然而你必须对它们有一些特定的模式——例如Data General Eclipse坚持每个指针中的一些特定的位,包含一个只有操作系统允许操作的保护环数。在缺失这些位的时候(或者简单地忽略它们),任何特定位模式表示的值通常依赖于类型。在许多机器上,类型必须被提供给使用这些位的指令。例如在普通的80x86 CPU上,一个内存中的32位空间,可能被看作一个整型或一个浮点数类型的值。表示0x3ff00000的位模式在被当作32位整型时,在被当作一个32位浮点数表示1.875。C编译器总是会使用正确的指令,因此能正确解释这个值——除非你欺骗它。有两种定义好的方式去欺骗编译器:指针和联合。后者不在这里描述。

C语言在关于表示上,作了一个相当大的许诺:如果你获得任意普通对象obj的地址,转换那个指针为一个unsigned char *类型值,打印obj的sizeof值,这将与填充(padding)位一起,打印那个对象中的所有表示位。指针转换和后续解引用,是另编译器把表示位解释为unsigned char的诡计。

这个技巧可以非常有用,亦非常有限。问题是,它显示一个值存储在一个特定对象中的表示。它不必要是它的实际表示,甚至在你尝试的那个机器上。例如一些机器有一个double值0.0的表示数目的可怕数字(例如有可能4,503,599,627,370,496种方式表示0.0)。更糟糕的是,一种机器上的表示可能与另一种机器上的没有共同之处。

那么关于表示需要记住的事情是:

最明显的例子是——因为它们经常出现——涉及字节顺序(即endianness(大小端),因‘格列佛游记’中因天气不同,决定从大端或小端吃鸡蛋的人物命名。)。 浮点数表示曾常用于捕获错误,但是今天许多机器使用IEEE 8xx,所以它更少遇到不相符的情况。但是甚至指针类型也可以有不同的表示。相同的Data General Eclipse有两个机器级别的指针表示,叫做‘字节指针’和‘字指针’。为转换一个字指针到字节指针,机器必须执行一个指令。这个指令向左移动字地址一位,在低位引入一个0。所以结果字节指针指向两字节字中的第一个(0号)字节。为了进行相反转换,机器向右移动字节地址,丢弃字节偏移位。(一个字指针中的最高端位,是一个C编译器不使用的特别的‘间接’位。) 在Eclipse上,int*使用一个字指针,但是char*使用一个字节指针。把相同的机器字地址放入这两种不同的指针,产生两种不同的表示。


1. 一些系统总是用零清空内存。在这些系统上,未初始化的变量值可能从零或NULL开始。这不是一个语言属性,它仅仅是这个系统为了更有帮助,和/或确保程序不会访问到上一个程序,遗留在内存中的敏感信息(比如口令)。


(This Chinese translation isn't confirmed by the author, and it isn't for profits.)

Translator : jhlicc@gmai1.c0m
Origin : http://www.torek.net/torek/c/expr.html