目录

从头学C(3): 类型、运算符与表达式

第二章 类型、运算符与表达式

变量和常量是程序处理的两种基本数据对象,声明语句说明变量的名字和类型,以及指定变量的初值:

  • 对象的类型决定该对象可取值的集合以及可以对该对象执行的操作。
  • 运算符指定将要进行的操作。
  • 表达式则把变量与常量组合起来生成新的值。

2.1 变量名

C语言对于变量、常量、函数的命名的确是有限制的,主要如下规定:

  • 名字只能由字母、下划线_和数字组成;
  • 名字不能以数字开头,只能是字母或者下划线开头,不过由于标准库中通常以下划线开头来命名,所以变量名不要以下划线开头;
  • 字母大小写是有区别的,所以countCountCOUNT绝不是一回事;
  • 在传统的C语言用法中,变量名使用小写字母,符号常量名全部使用大写字母。

书中第二段对于内部名和外部名的描述让人看起来觉得很混乱,因为ANSI C中并没有内部名(Internal Name)和外部名(External Name)的解释,即便有多年编程经验的朋友可能也不会太在意这种问题,不过对于我们初学者来说,不弄清楚估计饭都吃不下吧。

中文版的原文是:

对于内部名而言,至少前31个字符是有效的。函数名与外部变量名包含的字符数目可能小于31个,这是因为汇编程序和加载程序可能会使用这些外部名,而语言本身是无法控制加载和汇编程序的。对于外部名,ANSI标准仅保证前6个字符的唯一性,并且不区分大小写。类似于if、else、int、float等关键字是保留给语言本身使用的,不能把他们用作变量名。所有关键字中的字符都必须小写。

英文版的原文是:

At least the first 31 characters of an internal name are significant. For function names and external variables, the number may be less than 31, because external names may be used by assemblers and loaders over which the language has no control. For external names, the standard guarantees uniqueness only for 6 characters and a single case. Keywords like if, else, int, float, etc., are reserved: you can’t use them as variable names. They must be in lower case.

Google一下才找到,原来在ISO 9899:1989的第六节中是这么说的:

The implementation shall treat at least the first 31 characters of an internal name(a macro name or an identifier that does not have external linkage) as significant. Corresponding lowercase and uppercase letters are different. The implementation may further restrict the signification of an external name(an identifier that has external linkage) to six characters and may ignore distinctions of alphabetical case for such names. Theses limitations on identifiers are all implementation-defined

可见,内部名是指不含外部链接的标示符或宏名字,而外部名是指带有外部链接的标示符。举个简单的例子,比如在file_1.c中:

int global_variable;

int main(void)
{
        int local_variable;
        extern int extern_variable;

        return 0;
}

其中extern_varialbe是在file_2.c中定义的。那么global_variablelocal_variable是内部名,而extern_variable是外部名,因为只有extern_variable是在编译的时候要去链接file_2.c才能生成有效的目标代码。

而关于外部名ANSI仅保证前6个字符唯一性的问题,我们也通过例子来看下,比如我们在file_1.c中同时声明了:

extern int MyFavoriteItem;
extern int MyFavoriteThing;
extern int myfavorite;

但有些系统(似乎称为“编译器”更合理些)可能只会把它们当成6个字符的变量名写入到目标文件(object-file)中,因为它不知道如何处理比6个字符更长的变量名。所以在编译器看来,这三个变量实际上是可能被声明为:

extern int myfavo;
extern int myfavo;
extern int myfavo;

这是重复声明,一般C编译器是能捕获到这种错误,并返回一个错误信息的,也就是说编译是通不过的。

但是注意:上面这一段只是说明编译器必须能处理至少31个字符的内部名和至少6个字符的外部名,然而时至今日,当前的编译器对于外部名和内部名的长度限制已经没有差别了(C99中对外部名的长度也支持到31位了)。

不能使用关键字作为变量名,C99中规定的关键字有如下37个:

auto      break     case       char      const
continue  default   do         double
else      enum      extern     float     for
goto      if        inline     int       long
register  restrict  return     short     signed
sizeof    static    struct     switch    typedef
union     unsigned  void       volatile  while
_Bool     _Complex  _Imaginary

一个好的变量名应该是让人一眼就能看出其用途。通常局部变量一般使用较短的变量名(如ijk等常用于循环控制变量),而外部变量通常使用较长的名字,一般还会通过下划线_来分割单词(如之前的my_getline函数)。

2.2 数据类型及长度

C语言只提供了以下4种基本类型:

  • char:字符型,占一个字节,可以存放本地字符集中的一个字符
  • int:整型,通常反映了所用机器中整数的最自然长度
  • float:单精度浮点型
  • double:双精度浮点型

在目前主流的机器中,int型一般是32位,float也是32位,double是64位。

另外C语言还支持在这些基本数据类型的前面加上一些限定符,以满足不同长度的变量的需求。限定符主要包含以下这些:

  • short
  • long
  • signed
  • unsigned

其中shortlong用于限定整型(int),比如:

short int sh;
long int counter;

像上面这种声明的方式,int一般可以省略,大部分人一般也是这么做的,看起来更简练些。

这几种类型的长度是有限制的:short <= int <= long,而且无论什么编译器都应该遵循:

  • shortint类型至少16位;
  • long类型至少32位。

类型限定符signedunsigned可用于限定char及任何整型,区别在于:

  • unsigned是无符号数,即只能为正数或者0,其取值范围为0 ~ 2^(n-1)(其中n为该类型占用的位数)。比如unsigned char类型其取值范围就是0~255。
  • signed是有符号数,它不仅能表示正数和0,还能表示负数,其取值范围为-2^(n-1) ~ 2^(n-1)-1。比如signed char类型其取值范围为-128~127,其最高位bit7充当符号位,bit7=1时为负数,bit7=0时为正数。
  • 而不带signedunsignedchar类型是否带符号则取决于具体的机器,但ASCII码中能打印的字符都是正值。

floatlonglong double(表示高精度的浮点数)和int类型一样,也需要取决于具体的机器。

有一个办法可以很容易地确定各个类型在当前环境下的长度,见下面代码:

#include <stdio.h>

/* print the data length */
int main(void)
{
    printf("char\t: %d\n",sizeof(char));
    printf("short\t: %d\n",sizeof(short));
    printf("int\t: %d\n",sizeof(int));
    printf("long\t: %d\n",sizeof(long));
    printf("float\t: %d\n",sizeof(float));
    printf("double\t: %d\n",sizeof(double));
    printf("long double\t: %d\n",sizeof(long double));

    return 0;
}

没错,就是利用标准库中的sizeof函数,该函数返回这个类型或数据所占的字节数(1个字节等于8个bit)。比如在我的机器中,该程序执行的结果如下:

char    : 1
short   : 2
int     : 4
long    : 8
float   : 4
double  : 8
long double     : 16

2.3 常量

整数常量

  • 一般数值不太大的整数常量,如1234,属于int类型,计算时也会被当成整型来处理
  • 如果一个整数太大,超过了一个int类型所能表示的最大值,则会被当成long类型来处理
  • 通过以字母lL结尾,来显示表明一个整数为long类型,比如1234L,5678l
  • 无符号常量以字母uU结尾,如123u,456U
  • ulUL为后缀的整数常量,则会被当成unsigned long型来处理

我们前面看到的都是以十进制表示的整型数,其实整型数还可以以八进制(用的不多)和十六进制(表示地址、寄存器的时候用得比较多)的形式表示:

  • 以数字0开头的整型常量表示它为八进制
  • 0x0X开头的整型常量表示它为十六进制

例如十进制数(31),表示为八进制是(037),表示为十六进制是(0x1f)。

想起当初网上一个很流行的关于程序员的段子,说:“为什么程序猿总是分不清“万圣节和圣诞节?” 那是因为“Oct 31 = Dec 25”(八进制的31等于十进制的25)。

注意:八进制和十六进制的常量也可以用后缀L表示long类型,用后缀U表示unsigned类型。比如0xFUL就是一个unsigned long(无符号长整型)类型的常量。

浮点数常量

  • 包含一个小数点(如123.4)或一个指数(如1e-2)的常量是浮点数常量,或者两者都有(如123.4e-2)
  • 没有任何后缀的浮点数常量是double类型
  • 后缀为fF表明为float类型
  • 后缀为lL表明为long double类型

字符常量

以单引号括起来的单个字符,就是一个字符常量也是一个整数,它的数值就是这个字符在机器字符集(通常是ASCII码表)中的数值。比如字符'0'代表数值48,而与数值0没有任何关系。

使用字符常量的好处是:程序员无需关心这个字符在机器字符集中代表的具体数值,可以写出易读的代码。所以字符常量一般是用于和其他字符进行比较,虽然它完全可以像整数一样参与计算。

某些字符(特别是非打印字符)可以通过转义字符序列表示为字符和字符串常量,比如'\n''\t'等等。虽然看起来像两个字符,但它实际上只表示一个字符。ANSI C语言中的全部转义字符序列如下所示:

\a 响铃符           \\   反斜杠
\b 回退符           \?   问号
\f 换页符           \'   单引号
\n 换行符           \"   双引号
\r 回车符           \000 八进制数
\t 横向制表符       \xhh 十六进制数
\v 纵向制表符

其中 '\000''\xhh'分别是八进制和十六进制形式的字符常量,000代表1-3个八进制数字(0~7),hh代表一个或两个十六进制数字,以下:

#define VTAB '\013'    /* ASCII纵向制表符 */
#define BELL '\007'    /* ASCII响铃符 */

#define VTAB '\xb'    /* ASCII纵向制表符 */
#define BELL '\x7'    /* ASCII响铃符 */

是等价的

另外,字符常量'\0'表示值为0的字符,也是空字符(null),通常用它代替数字0来强调某些表示表达式的字符属性。

注意:关于常量表达式(只包含常量的表达式)必须要知道的是,它是在编译时求值,而不是在程序运行时才求值。

字符串常量

字符串常量是用双引号括起来的0个或多个字符组成的序列,比如"This is a string"""(空字符串)。有几点要注意:

  • 双引号并不是字符串的一部分,它只是起限定字符串的作用;
  • 转义字符序列在字符串中依然适用,比如在字符串中\"就表示双引号字符;
  • 编译时可以将多个字符串常量连接起来,所以"hello," " world"等价于"hello, world"

从本质上看,字符串常量就是字符数组。字符串内部使用一个空字符'\0'作为串的结尾,因此,存放字符串的物理存储单元需要比字符串双引号中的字符数多一个单元来存放串的结束符'\0'

字符常量与字符串常量的区别:字符常量用的是单引号,而字符串用的是双引号。拿'x'"x"来举例,

  • 字符常量'x":是一个整数,代表字符x在机器字符集中对应的整数值
  • 字符串常量"x":是包含一个字符'x'以及一个结束符'\0'的字符数组

枚举常量

枚举是一个常量整型值的列表。例如:

enum boolean { No, Yes };

在没有显式说明的情况下,enum类型中第一个枚举名的值为0,第二个的值为1……以此类推。

也可以显式的指定所有枚举名的值,比如:

enum escapes { BELL = '\a', BACKSPACE = '\b', TAB = '\t'};

如果只指定了部分枚举名的值,那么未指定值的枚举名将依着最后一个指定值向后递增,如:

enum months { JAN = 1,FEB, MAR, APR, MAY, JUN,  /* FEB值为2, MAR值为3 …… 以此类推 */
              JUL, AUG, SEP, OCt, NOV, DEC }; 

注意:不同枚举中的名字必须不能相同(有点类似符号常量,不能定义名字相同的符号常量),但是同一枚举中不同的名字可以具有相同的值。

枚举的好处在于:建立了常量值与名字之间的联系,调试时也可以直接打印出枚举变量的值,而且和#define相比,优势在于常量值可以自动生成。

2.4 声明

一个声明指定一种类型,所以同一类型的多个变量是可以放在一起进行声明的,比如:

int lower, upper, step;
char c, line[100];

当然,也可以拆开单独声明,比如:

int lower;
int upper;
int step;
char c;
char line[100];

这样做的好处是方便给每个变量后面添加注释,便于以后修改。

变量的初始化

声明的同时可以对变量进行初始化,比如:

char esc = '\\';
int i = 0, j, k;
int limit = MAXLINE + 1;
float eps = 1.0e-5;

通过一个等号=的赋值表达式,就完成了变量的初始化。

如果变量不是自动变量(比如外部变量等),则只能进行一次初始化,从概念上讲,应该是在程序开始执行之前进行,并且初始化表达式必须为常量表达式。

我的理解是:通常程序开始执行是以main函数为入口,而作为外部变量,是在main函数外面进行声明的,而此时如果要对此外部变量进行初始化操作,由于没有进入main函数,变量表达式的值是不确定的,因此只能用常量表达式对该外部变量进行初始化。显然,这个初始化的操作是在进入main函数之前就完成了。

默认情况下,外部变量和静态变量(static类型)都将被初始化为0,而没有经过显式初始化的自动变量,其值是未定义值(即无效值),注意不是0!!!

const修饰符

任何变量都可以使用关键字const来修饰,该修饰符指定变量的值不能被修改。对数组而言,const指定数组所有元素的值都不能被修改。

const double e = 2.71828182845905;
const char msg[] = "warning: ";

关于const的作用,以下内容来自百度:

1、可以定义const常量,具有不可变性。 例如:

const int Max = 100;
Max++; /* 会产生错误 */

2、便于进行类型检查,使编译器对处理内容有更多了解,消除了一些隐患,例如:

void f(const int i)  /* 编译器就会知道i是一个常量,不允许修改 */
{
    声明部分

    语句部分
}

3、可以避免意义模糊的数字出现,同样可以很方便地进行参数的调整和修改。 同宏定义一样,可以做到不变则已,一变都变!

4、可以保护被修饰的东西,防止意外的修改,增强程序的健壮性。 还是上面的例子,如果在函数体内修改了i,编译器就会报错:

void f(const int i) 
{
        声明部分

        i = 10;       /* 编译器会报错 */
        语句部分
}

5、可以节省空间,避免不必要的内存分配。 因为const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不像#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝,而#define定义的常量在内存中有若干个拷贝。 例如:

#define PI 3.14159 //常量宏 
const double Pi=3.14159; //此时并未将Pi放入RAM中 ...... 
double i=Pi; //此时为Pi分配内存,以后不再分配! 
double I=PI; //编译期间进行宏替换,分配内存 
double j=Pi; //没有内存分配(因为第三行已经分配过了)
double J=PI; //再进行宏替换,又一次分配内存!

6、提高效率。 编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。

但是不能把const修饰的变量简单的认为是一个常量,不然会出现意想不到的错误,关于其详细用法和注意事项,此处就不一一罗列。

2.5 算术运算符

二元运算符包括:+(加)、-(减)、*(乘)、/(除)、%(取余)。

要特别注意的是,整数做除法的结果会被截去小数点部分(即舍位),如前面摄氏温度和华氏温度转换的程序中,5/9会得到结果0,而5.0/9.0则会得到一个带小数点的浮点数。

取余运算符是求两个数相除后的余数,能被整除时则结果为0,比如 10 % 3 = 110 % 5 = 0。根据这个特性,我们可以用下面的语句来判断一个年份是否为闰年:

if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0)
    printf("%d is a leap year\n", year);
else
    printf("%d is NOT a leap year\n", year);

要注意的是:取余运算符不能用于计算浮点型(floatdouble)。

另外,在有负操作数的情况下,整数除法的截取方向、取余数结果的符号取决与具体机器的实现,这和处理上溢、下溢的情况是一样的。我机器的计算结果如下:

#include <stdio.h>

int main(void)
{
    printf("%d\n", 7/3);           //结果为2
    printf("%d\n", (-7)/3);        //结果为-2
    printf("%d\n", 7/(-3));        //结果为-2
    printf("%d\n", (-7)/(-3));     //结果为2

    printf("%f\n", 7.0/3.0);       //结果为2.333333
    printf("%f\n", (-7.0)/3.0);    //结果为-2.333333
    printf("%f\n", 7.0/(-3.0));    //结果为-2.333333
    printf("%f\n", (-7.0)/(-3.0)); //结果为2.333333

    return 0;
}

关于运算符的优先级,我们会学习过所有运算符后,统一列举排序。

2.6 关系运算符与逻辑运算符

关系运算符包括:>>=<<===!=

其中==!=的优先级比其他四个关系运算符要低一级。

逻辑运算符包括:&&(逻辑与运算符)、||(逻辑或运算符)、!(逻辑非运算符)。

其中&&||比较特殊,由这两个运算符连接的表达式按照从左到右的顺序求值,在知道结果值为真或为假后,立即停止计算。比如:

int i, j;

i = 表达式A  && 表达式B && 表达式C;
j = 表达式D  || 表达式E || 表达式F;

第三行先计算表达式A,如果为真,才计算表达式B;如果为假,则直接返回0,就不会去计算表达式B和C。因为表达式A为假就可以判断这整个表达式的结果了。

第四行先计算表达式D,如果为假,才计算表达式E;如果为真,则直接返回1,同样不会去计算表达式E和F。同样也是因为表达式D为真就可以判断这个整个表达式的结果。

&&的优先级比||的优先级要高,但它们都比关系运算符的优先级要低,所以下面的表达式就不需要额外的加上圆括号了(当然加上也不会有错):

i < lim-1 && (c = getchar()) != '\n' && c != EOF

!=运算符的优先级比赋值运算符=要高,所以c=getchar()的圆括号不能略去。

根据定义,在关系表达式或逻辑表达式中:

  • 如果关系为真,则表达式的结果为数值1;
  • 如果关系为假,则表达式的结果为数值0;

运算符(逻辑非运算符)的作用顾名思义,就是取”非”值,将操作数0转换为1,而将非0的操作数转换为0。该运算符经常用于条件判断中,比如

if(!valid)

而一般不使用下面这种形式:

if(valid == 0)

可能也是考虑到前者表达的字面意思更直观一点。

2.7 类型转换

C语言中存在着不同类型的操作数,它们之间如果要做运算,也需要通过一些规则先把它们转换为某一种相同的类型。

一般来说,“自动转换”是把“比较窄”的操作数转换为“比较宽”的操作数,并且不丢失信息的转换。例如在计算float变量+int变量时,int变量的值会自动转换为float类型。(因为float比较“宽”嘛,并且float类型可以完整保留这个int变量的全部信息)

对于非法的表达式,编译器会报错。例如不允许把float类型的表达式作为数组的下标。

对于可能导致信息丢失的表达式,编译器可能会给出警告,但这些表达式并不是非法的。例如把float类型赋给int型,至少会丢失小数部分的信息。

char类型是比较小的整型,用它来实现一些字符转换会非常方便。来看下面这个atoi的例子。

#include <stdio.h>
/* atoi: 将字符串s转换为相应的整型数 */
int atoi(char s[])
{
    int i, n = 0;

    for(i = 0; s[i] <= '9' && s[i] >= '0'; ++i)
    {
        n = 10 * n + ( s[i] - '0');
    }
    return n;
}

int main()
{
    char *a;
    int i;

    printf("Please input a string with Number include(Such as 43a1): ");
    scanf("%s", a);
    printf("a = %s\n", a);

    i = atoi(a);
    printf("i = %d\n", i);
}

表达式 s[i] – '0'可以计算出s[i]中存储的字符所对应的的数字值,这是因为字符'0''1'等在字符集中是一个连续的递增序列。

再看下面这个lower函数的例子。它将ASCII字符集中的字符映射到对应的小写字母。

#include <stdio.h>
/* lower: 把字符c转换为小写形式;只对ASCII字符集有效 */
int lower(int c)
{
    if(c >= 'A' && c <= 'Z')
        return c + 'a' - 'A';
    else
        return c;
}

int main()
{
    char *s;
    int i;

    printf("Please input a string with capital letters inside(Such as 12AaC1): ");
    scanf("%s", s);
    printf("ori = %s\n", s);

    printf("des = ");
    for(i = 0; *(s+i) != '\0'; ++i)
        printf("%c", lower(*(s+i)));
    printf("\n");
    return 0;
}

在ASCII字符集中,大写字母和对应的小写字母作为数字值来说,具有固定的间隔,并且字母表也是连续的,因而可以直接使用类似c+'a'-'A'的表达式。

在标准头文件<ctype.h>中,定义了一组与字符集无关的测试和转换函数,后面我们也会用到。例如:

  • tolower(c)函数可以将字符c转换为小写形式
  • isdigit(c)函数用于判断字符c是否是一个数字(类似表达式 c >= '0' && c <= '9'

在将字符类型(char)转换为整型(int)时,需要注意的是:由于C语言没有指定char类型的变量是有符号(signed)还是无符号(unsigned)变量,因此如果char类型值的最左一位是1,那么在某些机器中会转换成负整数(进行了“符号扩展”),而在某些机器中则总是转换为正整数(左侧补0)。

C语言的定义保证了机器的标准打印字符集中的字符不会是负值,因此,在表达式中诸如'A''a''1'……等等字符总是正值。

为了保证程序的可移植性,如果要在char类型的变量中存储非字符数据,最好指定signedunsigned限定符。

C语言中,很多情况下会进行隐式的算术类型转换。通常如果一个二元运算符的两个操作数具有不同的类型,那么在进行运算之前会先把“较窄”的类型提升为“较宽”的类型,运算结果为较宽的类型(只要记住:程序不应丢失数据的所有信息)。

如果一个运算中没有unsigned类型的操作数,则只需参考以下5条标准即可:

  1. 如果其中一个操作数为long double,则将另一个操作数转换为该类型;
  2. 如果其中一个操作数为double,则将另一个操作数转换为该类型;
  3. 如果其中一个操作数为float,则将另一个操作数转换为该类型;
  4. charshort类型都将被转换为int类型;
  5. 如果其中一个操作数为long,则将另一个操作数转换为该类型;

要注意的是,虽然一般的数学函数(如标准头文件<math.h>中定义的函数)都使用双精度类型的变量,但float类型一来可以在使用较大数组时节省存储空间,二来也可以节省机器执行时间(双精度算术特别费时),所以float类型是不会自动转换为double类型的。

在赋值运算中,也要进行类型转换(将赋值运算符右边的值转换为左边变量的类型,而左边变量的类型便是赋值表达式结果的类型):

  1. 无论是否进行符号扩展,字符型变量都将被转换为整型变量;
  2. float类型转换为int类型时,小数部分将被截取掉;
  3. double类型转换为float类型时,是进行四舍五入还是截取则取决于具体的实现;

另外,在把参数传递给函数时,也可能进行类型转换。

在没有函数原型的情况下:

  • char和short类型都将被转换为int类型
  • float类型将被转换为double类型

而通常情况下,参数是通过函数原型来声明的。这样,当函数被调用时,声明将对参数进行自动强制转换。例如对于sqrt的函数原型

double sqrt(double);

当函数调用时temp = sqrt(2);会自动将整数2强制转换为double类型的值2.0。

关于强制类型转换

在任何表达式中,都可以使用一个称为强制类型转换的一元运算符强制进行显式的类型转换。在下面这条语句中,表达式将按照上述转换规则被转换为类型名所指定的类型:

(类型名)表达式

我们可以这样来理解这句话:表达式首先被赋值给了类型名所制定的类型的某个变量,然后再用该变量替换上面整条语句。

所以要注意,强制类型转换只是生成了一个指定类型的值,表达式本身的值并没有改变。另外,强制类型转换运算符和其他一元运算符具有相同的优先级。

标准库中包含一个可移植的实现伪随机数发生器的函数rand以及一个初始化中子数的函数srand。函数rand中就使用了强制类型转换。

#include <stdio.h>

unsigned long int next = 1;

/* rand: return pseudo-random integer on 0...32767 */
int rand(void)
{
    next = next * 1103515245 + 12345;
    return (unsigned int)(next/65536) % 32768;
}

/* srand: set seed for rand() */
void srand(unsigned int seed)
{
    next = seed;
}

int main()
{
    int i;

    srand(1000);
    for(i = 0; i < 10; ++i)
        printf("%d: random_value = %d\n", i, rand());

    return 0;
}

2.8 自增运算符与自减运算符

顾名思义,自增运算符是让自己加一,自减运算符是让自己减一。C语言提供的这两个一元运算符,就是使操作数分别递增1和递减1。

++--运算符的特别之处在于它们既可以作为前缀(如++n-–n),也可以作为后缀(如n++n–-),虽然最终结果都是使变量n的值递增1/递减1,但过程是不同的:

  • 作为前缀运算符,++n是现将n递增1,再使用n的值;
  • 作为后缀运算符,n++是先使用n的值,等本条语句执行完成后,才将n的值递增1;

假如n = 5的话,那么

x = n++;    //本条语句执行完成后,x等于5,n等于6

x = ++n;    //本条语句执行完成后,x等于6,n也等于6

可见,如果该条语句并不使用n的值,那么用前缀运算符和用后缀运算符,都能达到一样的效果;当如果该条语句需要用到n的值,就需要注意了。比如下面这个例子:

/* squeeze()函数:从字符串s中删除所有的字符c */
void squeeze(char s[], int c)
{
    int i, j;

    for (i = j = 0; s[i] != '\0'; i++)
        if (s[i] != c)        // 每当出现一个不是c的字符时
            s[j++] = s[i];    // 先拷贝该字符到数组下标为j的位置,然后才将j的
                              // 值递增1,以准备处理下一个字符
    s[j] = '\0';
}

实际上,上面的j++语句等价于:

if (s[i] != c) {
    s[j] = s[i];
    j++;
}

两种表达方法,孰优孰劣,高下立判!

上面这个例子也许还不足以展示这两个运算符的强大之处,我们来看最后这个例子。标准函数strcat(s, t)可以将字符串t连接到字符串s的尾端,而该函数假定字符串s中有足够的空间保存这两个字符串连起来的结果。我们可以自己尝试来写一个这样的程序:

/* strcat()函数: 连接字符串t到字符串s的尾部,s有足够的空间容纳字符串t */
void strcat(char s[], char t[])
{
    int i, j;

    i = j = 0;
    while (s[i] != '\0')    // 遍历到字符串s的尾部
        i++;
    while ((s[i++] = t[j++]) != '\0')    // 开始逐个字符的拷贝字符串t
        ;
}

你们可以感受一下。

2.9 按位运算符

C语言中提供了一类特殊的操作运算符,可以方便而直观的按bit位操作数据,为我们实际编程工作中提供了极大的便利。总共有6个位操作运算符,以下将依次学习。

由于是对bit位进行操作,因此只能用于整型操作数,包括带符号和不带符号的char、short、int、long类型。

& 按位与(AND)】

经常用于将某些bit位置为0,例如:

x = x & 0xFFFFFFF0;    // 将x的低4位(bit0 ~ bit3)置0,其他位不变

| 按位或(OR)】

经常用于将某些bit位置为1,例如

x = x | 0x0F;    // 将x的低4位(bit0 ~ bit3)置为1,其他位则不变

^ 按位异或(XOR)】

按位异或就是当两个操作数的对应位不相同时,将该位设置为1,否则,将该位设置为0。用得比较少,例如

x = 0xf3;    // 二进制1111 0011
y = 0xfc;    // 二进制1111 1100
z = x ^ y;    // 那么z就等于0x0f(二进制0000 1111)

<< 左移】

左移运算符用于操作数向左平移指定的位数,运算符左边是操作数,运算符右边则是该操作数要移动的位数。

因此表达式x << 2是将x左移2位,高位左移溢出的位丢弃,低位则以0填充

显然,在高位溢出丢弃的数里面不包含1的情况下,左移一位就代表该数乘以2,左移两位就,代表该数乘以2*2=4。

>> 右移】

右移运算符和左移运算符类似,是将操作数向右平移指定的位数。但这里要注意的是这个数据是否是带符号的。

对于unsigned类型的数据,右移时,高位以0填充,低位溢出的位丢弃

对于signed类型的带符号数,右移时,低位溢出的位都会丢弃,但对于高位填充时,有两种实现:

  • 某些机器会用符号位填充高位(这个叫“算术移位”)
  • 某些机器则直接用0来填充高位(这个叫“逻辑移位”)

那么肯定有人要问:为什么左移运算符就不需要考虑unsigned和signed类型呢?那是因为左移运算符都是“逻辑移位”。

~ 按位求反】

与上面5个运算符不同的是,按位求反是个一元运算符,用于求整数的二进制反码,即将操作数的各个二进制位上的1变成0,而0变成1。例如:

x = ~0xF;    // 那么你会得到 x = -16

编程时发现无论x声明为signed还是unsigned类型,x都等于-16,这个原因就靠各位自己去思考了。

2.10 赋值运算符与表达式

赋值运算符,最简单也是最重要的运算符,要想对变量进行赋值,可离不开这个小东西。

不过,要知道赋值运算符可不是仅仅只有等于号=这么一个孤家寡人,它还有很多其他的“兄弟”,比如:

+=  -=  *=  /=  %=  <<=  >>=  &=  ^=  |=

以上这些也都是赋值运算符。它们的形式都是op=,op代表对应的二元运算符,如+*/等。

如果expr1expr2是表达式,那么expr1 op= expr2等价于expr1 = (expr1) op (expr2)

要注意的是第二种形式的圆括号必不可少,因为x *= y + 1等价于x = x * (y + 1),而不是x = x * y + 1

下面看一个统计整形参数的值中二进制位为1的个数的例子:

/* bitcount: count 1 bits in x */
int bitcount(unsigned x)
{
    int b;

    for(b = 0; x != 0; x >>= 1)
        if(x & 0x01)
            b++;
    return b;
}

其中``x声明为无符号类型,是为了保证在将x进行右移运算时,无论在什么机器上运行,左边空出来的位都是用0(而不是符号位)填充。

这种形如op=的赋值运算符,其优点是显著的:

  • 符合人类的思维习惯;
  • 使代码更易于理解;
  • 有助于编译器产生高效代码。

最后要注意,赋值语句是具有值的,可用于表达式中。之前我们已经看到了:

while((c = getchar()) != EOF)

在对c进行赋值之后,便可直接对赋值语句的值进行判断。当然其他赋值运算符(如+=-=)也可以用在表达式中,虽然这种用法比较少见。

赋值表达式的值是赋值操作完成后的值,其类型是它的左操作数的类型。

2.11 条件表达式

如果要取ab中的最大值,用下面的代码的应该是非常直观的:

if(a > b)
    z = a;
else
    z = b;

但条件表达式(使用三元运算符"? :")提供了另外一种方法来实现同样的功能:

z = (a > b) ? a : b;

对于条件表达式expr1 ? expr2 : expr3,会计算expr1的值,

  • 如果expr1的值为真(即不为0),则计算expr2的值,并以expr2的值作为该条件表达式的值;
  • 如果expr1的值为假(即等于0),则计算expr3的值,并以expr3的值作为该条件表达式的值。

我们可以看到条件表达式实际上就是一种表达式,它可以用在任何允许使用表达式的地方。

不过要特别注意的是,如果expr2expr3类型不同,则表达式结果的类型由上文《2.7 类型转换》中提到的转换规则来决定。

举个例子,如果ffloat类型,nint类型,那么表达式(n > 0) ? f : nfloat类型,与“n>0是否为真”无关,因为int类型会被强制转换为float类型。

细心的同学肯定注意到上面expr1expr2expr3都没有加圆括号,而上面的n>0却加上了圆括号。实际上这个圆括号不是必须的,因为条件运算符?:的优先级非常低,仅高于赋值运算符的优先级,不过我们还是强烈建议使用圆括号,这样可以使表达式更易于阅读。

使用条件表达式可以写出很简洁的代码,比如下面这个例子:

for (i = 0; i < n; i++)
    printf("%6d%c", a[i], (i%10==9 || i==n-1) ? '\n' : ' ');

该段代码循环打印一个数组的n个元素,每行打印10个元素,每个元素以一个空格隔开,每一行用一个换行符结束(包括最后一行)。

2.12 运算符优先级与求值次序

下表总结了所有运算符的优先级和结合性,同一行的各个运算符拥有相同的优先级。有一些运算符之前的章节没有接触到,但后面的章节都会详细讲述。

./operators_and_prority.jpg
运算符优先级和结合性

+-用作一元运算符时,是作为符号位,*作为一元运算符是通过指针间接访问数据,&作为一元运算符是取对象地址。

要注意位运算符(&^|)比运算符==!=优先级低,因此,位测试表达式中,比如:

if((x & MASK) == 0)
    ...

其中的位测试表达式就必须用圆括号括起来才行。

下面再来看看求值顺序,C语言中没有规定同一运算符中多个操作数的计算顺序(当然&&||?:,运算符除外),因此类似x = f() + g()的表达式,f()g()谁先计算是不一定的。如果函数fg有可能会改变另一个函数所使用的变量,那么要特别注意x的结果会依赖这两个函数的计算顺序。

而且C语言中也没有指定函数各个参数的求值顺序,因此下列的语句:

printf("%d %d\n", ++n, power(2, n));   /* 错误! */

在不同的编译器中可能会产生不同的结果。为了避免这种错误,可以改成下面的方式:

++n;
printf("%d %d\n", n, power(2, n));

还有一个典型的令人困惑的例子:

a[i] = i++;

除了编译器,恐怕没人知道数组下标i是使用旧的值,还是使用新的值。不同的编译器可能会解释为不同的结果。

总之,在任何一种编程语言中,如果代码的执行结果与求值顺序相关,则都是不好的程序设计风格。因此在不确定是否会出现这种问题的情况下,最好不要尝试运用这类特殊的实现方式。