第二章

类型、运算符与表达式

本章主要围绕这一部分展开,主要为变量与常量,数据类型之间的转换,运算符等内容

变量名

接下来讲讲变量名的格式

相较于Python的宽松,C语言对于变量名的要求变得比较严格

首先变量名只能有字母和数字组成(C11标准后支持Unicode字符,但说实话正常不会用到),并且第一位必须为字母,而不能为数字,一般来讲下划线_是被看作字母的,一般用于比较长的变量名中,增强其可读性

由于库例程(由编程语言的标准库、第三方库或自定义库预先实现的、具备特定功能的函数(或子程序),例如printf())一般以下划线开头,所以变量名一般不以下划线开头

一般来讲,变量使用的字符为小写字母,而符号常量使用的字母为大写字母(如第一章的MAXLINE)

接下来需要说明一点的事变量名不能与程序内部已有的关键字重复(例如elseiffor等),这些是保留给语言本身用的,这些关键字的字符必须都是小写

一般来讲,选择的变量名要尽可能的表达出这个变量的作用(比如说line等),尽量不要使用抽象的变量名(如a,b,c)

局部变量一般使用较短的变量名,循环变量(如for语句中)要使用较短的变量名,而外部变量使用较长的名字

数据类型及长度

C语言里面有一些数据类型:

数据类型 长度
char(字符型) 占用一个字节,存放本地字符集的一个字符
int(整型) 通常整型反映了机器中整数的最自然长度
float(单精度浮点型) 可以显示小数点后的位数,这里需要说明一下单精度和双精度的区别,单精度说明这个的精度只能确保到小数点后6~7位,而双精度则可以确保到小数点后15~17位。此外,双精度使用的内存要比单精度要高
double(双精度浮点型)
本地字符集:指当前操作系统、编译器或运行环境默认使用的字符与二进制数据的映射规则,规定了一个字母对应的二进制数据

限定符

在声明的时候,还可以在前面加上一些限定符shortlong来限定整型

1
2
short int nums;
long int nums;

需要说明的一点是,如果选择上面这种类型的声明,则可以把int省略

1
2
short nums;
long nums;

接下来来说明这两种有什么不同

short类型的长度为16位(大概6.5万个数),而long类型长度有32位(大概有43亿个数)

可以看到,long类型要远远大于short类型

int类型可以为16位或者32位,需要注意的是各个编译器可以根据硬件特性选择合适的长度,但要遵循一定的限制

shortint至少为16位,而long至少为32位,并且short不能长于int类型,而int类型不得长于long类型

接下来将另一个限定符:signedunsigned

这两个限定符可以限定char类型或者是任何整型:

1
unsigned int nums;

signed限定符范围包括了负数,比如说signed short的范围就是 -32768~32767unsigned就是只有正数部分以及0,也就是0~65535

这里补充的一点是一般来讲不填就是默认为signed(也就是支持正负数),所以一般signed可以省略

unsigned类型遵循了算数模2$^n$定律,n为该类型占用的位数

什么是算数模2的n次方定律?
指的是整数在模 2ⁿ(即对 2 的 n 次方取余数)运算下的一系列特殊规律
对于任意整数 x,x mod 2ⁿ的结果等价于 x 的二进制表示中最后 n 位所对应的数值。

举个例子:70 mod 32的计算过程如下
70 转换为二进制:1000110
32 为2的5次方,所以n为5,取最后五位(00110)

接下来从右往左看:
0 x 1 = 0
1 x 2 = 2
1 x 4 = 4
0 x 8 = 0
0 x 16 = 0

结果为6,所以 70 mod 32 结果为 6

常量

接下来讲讲2.3 常量

首先让我们先明白各个常量的表示方法

类似于1 2 3 4 5 6的整数常量属于int类型

long类型不同于int类型,会在数字的最末尾加一个l或者L,例如123123123l

如果有些数字大到超过了int类型则归于long类型,也会在数字的最后面加一个l

无符号常量unsignedu或者U结尾,如果是unsigned long,则会以ul或者UL结尾

接下来是浮点数常量,浮点数常量包含一个小数点或者一个指数(1e-2),当然两者都有也可以

有些浮点数常量没有后缀,被称为double类型,如果有f或者F后缀,则为float类型

如果一个浮点数出现了l或者L后缀,则为long double类型

进制表示

接下来讲讲进制的表示方法

整形数除了使用十进制外,还可以使用到八进制或者十六进制

其规则可以通过十进制类比:

十进制是满十加一,以此类推八进制就是满八进一,十六进制就是满十六进一

这时候就有人要问了,阿拉伯数字只有10个,怎么表示出16个数字啊

十六进制使用了字母作为后面10~15的表示

例如:A代表10,B代表11,C代表12,D代表13,E代表14,F代表15

接下来利用这个就可以转化数字了

例如说十进制20就是八进制24,十六进制的14


讲解完规则会就可以步入正题了

首先第一个,在C语言中,带前缀0的整型常量表示为八进制形式,例如上面的十进制的20就是八进制的024

而十六进制的前缀通常为0x或者0X,上文的十进制20就是十六进制的0x14

这些常量都可以加上上文提到的后缀L或者U

字符常量

接下来讲讲字符常量,需要说明一点的是,一个字符常量就是一个整数,书写的时候一般把一个字符扩在单引号里面

例如'0',这些字符在机器字符集中的数值就是字符常量的值,比如说在ASCII中,'0'对应的值为48(这个值跟数值的0没关系)

一般来讲,字符常量是与其他字符来比较,当然也可以参加运算(比如说上一章的c - '0'

在一些字符中,可以通过转义来表示字符和字符串常量,比如说换行符\n

这些看起来好像是多个字符,但由于一个字符常量就是一个数,所以其实是一个字符

另外,反斜杠还有很多用法,比如说可以这样表示一个八进制的数字:\000,这里需要注意的是

长度最大就是3位,不可以超过3位

另外还有一种表示十六进制的方法是\xhh,这里的h表示0~F的任意一个数

ANSI C语言全部的转义字符序列如下所示:

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

字符常量'0'表示属性为0的字符,也就是空字符null,通常用'\0'来代替0

常量表达式

常量表达式是仅仅包含常量的表达式,这种表达式在编译的时候就会求值(比如说20 + 30、123)

这种表达式可以出现在任何常量可以出现的地方

字符串常量

字符串常量指的是用双引号围起来的字符序列,比如说"hello world"

双引号内部允许没有字符:""

需要注意的一点是,双引号并不作为字符串的内容,只是告诉编译器这个被围起来的地方是字符串而已

那些在字符常量中使用的转义类型也可以使用在字符串中,比如说可以在字符串中使用\"来表示双引号

这有什么作用呢

举个例子:

1
s = "He is a "dreamer".But I don't believe him"

上面的句子中,由于句子内部dreamer加了双引号导致了句子被错误切割为He is a .But I don't believe him两部分

这时候便可以使用到字符常量来表示句子中的双引号:

1
s = "He is a \"dreamer\".But I don't believe him"

现在就是一个完整的句子了

在上一章中有提及到字符串的结束以\0为标志,这里便不多概述

需要补充的一点是,由于这个原因,C语言对字符串的大小并没有限制(因为无论如何都得在最后面加上\0作为结尾)

枚举常量

接下来是枚举常量,枚举是一个常数整型值的列表,一般用enum表示

1
enum boolean {NO, YES};

如果你没有进行显式声明那么enum的第一个值为0,之后第二个为1,第三个为3,以此类推

如果你只是制定了部分枚举名的值,那么那些为指定的会根据最后一个指定的值往后递增

1
enum WEEK {SUNDAY = 1, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY}

这样,往后的结果就会是:MONDAY = 2,TUESDAY = 3,以此类推

需要注意的是不同枚举中的名字必须互不相同,比如说不能出现多个MONDAY,但是同个枚举不同的名字可以指定同一个值

比如上面我可以指定全部的星期全为一,这样在用户输入星期的时候可以根据枚举的值的不同转换到不同的模块上

补充点:#define 和 enum的不同

虽然说两者都可以让常量值和名字之间相关联,但是两者有一些不同

像是#define就没办法自动迭代,需要自己一个一个输入,并且你输入的常量值编译器是不检查是否存在问题的

但是enum会检查这种变量中储存得知是否为该枚举的有效值

另外,如果你尝试使用调试程序打印#define的值,就会发现会直接打印出对应的值,而不是值对应的名字

但是enum在调试中则会显示出这个值的名字,这也是为什么枚举会比较有优势

声明

接下来讲讲声明,这里强调一个点:所有变量都要先声明之后使用,虽然有时可以通过上下文隐式声明

接下来讲讲一些声明的方法:

1
2
int num1, upper, lower;
float step, way, maxline;

上面这种声明的方法可以一次性声明多个变量

如果想要一个一个声明也是可以的

1
2
3
4
5
6
int num1;
int upper;
int lower;
float step;
float way;
float maxline;

这种声明方式虽然说占用比较大的空间,但是好处是添加注释的时候比较方便

此外,在声明的时候你还可以对这些变量初始化:

1
2
int step = 20;
int upper = 30;

如果这个变量不是自动变量(局部变量)的话,那么只能进行一次初始化操作,并且初始化还必须是常量表达式

如果是自动变量的话,那么每次进入函数或者是程序块的时候,显示表达式的自动变量就会被初始化一次

没有经过显式初始化的自动变量的值为未定义值(也就是无效的值)

const

const是一个限定符,如果被这个限定符限定,那么这个变量将无法被修改

如果用const限定一个数组,那么这个数组的所有元素的值都无法被修改

1
const double e = 2.71828182845905;

当然,这个限定符还可以搭配数组参数使用,表明函数不能修改数组元素的值

算数运算符

接下来讲讲算数运算符:+、-、*、/、%

加减乘除这里不做介绍,这里主要介绍%,意思是取模,也就是求余数

1
x % y

代表x除于y后得到的余数,如果为整除的话那么计算的结果为0

需要注意的一点是这个计算符并不适用于float 或 double类型,这一点需要特别注意


接下来说明一个K&R的历史遗留问题,里面提及了这一句话:

在有负操作数的情况下,整数除法截取的方向以及取模运算结果的符号取决于具体机器的实现,这和处理上溢或下溢的情况是一样的。

这里由于K&R的发布原因,在标准C99之后统一了截取方向为向零截取,也就是直接截断小数部分

而取模结果的符号必须与被除数一致

说白了就是在C99标准下:

1
2
-5 % 3 = -2
5 % -3 = 2

具体公式如下:被除数 = 除数 × (被除数/除数) + (被除数%除数)

关系运算符与逻辑运算符

接下来讲讲关系运算符和逻辑运算符

关系运算符包括下面几种:> >= , <=

这几种的优先级是相同的,不会遇到说哪一个的优先级更高的情况

仅次于这些运算符的是相等性运算符:== !=

这里的运算符优先级为:算术运算符 > 关系运算符 > 相等性运算符

优先级指的是在一条式子中谁先谁后的关系

比如说:i < lim - 1

这里由于算数运算符优先于关系运算符的原因,会优先计算lim - 1

其次就是赋值运算符小于关系运算符

这时候可能有回想起一个在第一章经常用的表达式(c = getchar()) != EOF

这里也是因为这个原因,如果不加括号会先检测输入内容是否为EOF,如果是的话变量c就是1,否则就是0


接下来介绍逻辑运算符&& ||

&&表示为and,而||表示为or

其中&&的优先级要大于||,还有一点是逻辑运算符的优先级要比关系运算符和相等性运算符的优先级低


在关系表达式或逻辑表达式中,如果关系为真的话,那么表达式的结果值为1,反之为0

通过逻辑非运算符可以将翻转这个结果:

1
2
bl = 1;
if(!bl) //bl为0的时候执行

一般来讲不写成这样子:

1
2
bl = 1
if(bl == 0)

类型转换

本节问题

  1. 如何实现类型的转换
  2. 实现类型的转换需要注意什么

接下来讲讲如何实现类型之间的转换

一般来讲,类型转换通常将小范围的数转换为大范围的数,例如把int转为float

举个例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int atoi(const char s[]);

int main(void) {
printf("%d",atoi("2"));
}

int atoi(const char s[]){
int n = 0;

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

// 输出:2

在上面这里例子中,我们可以看到一个这里将字符串转换为整型数

接下来来讲讲是及如何实现的

首先先让我们分析一下输入的是什么内容:"2",可以看到是一个字符串

而看到函数的定义,可以看到参数的类型属于char,也就是字符

由于char只能为一个字符,所以我们得想到必须得把输入的内容一个字符一个字符地加上去

这里如何叠加先按下不表,继续回到思路

所以如何才能让这个一个字符一个字符地慢慢读取呢?

这时候可以类比一下Python的列表

1
2
3
4
5
6
7
8
9
10
11
12
13
list_step = [x for x in "hello"]
print(list_step)

for i in range(len(list_step)):
print(f"第{i + 1}项是{list_step[i]}")

# 输出
# ['h', 'e', 'l', 'l', 'o']
# 第1项是h
# 第2项是e
# 第3项是l
# 第4项是l
# 第5项是o

可以看到,这里输入的内容就被逐一拆分到列表里面了,需要的时候直接读取对应的项数皆可

那么按照这个道理,我们也可以把字符串中的每一项都加到一个新的数组中,之后依次转换,最后把结果拼合

需要明白一个点:字符串的本质是一个以"\0"结尾的char数组这也就是为什么参数为char的原因

这时候就有人要问了,诶,前面不是有提到说char只能存储一个字符吗,确实,char类型只能存储一个字符,但这里是一个char数组,可以存储多个字符,举个例子

1
2
char s = "123" // 错误,必须为一个字符,这是一个char变量
char s[] = "123" // 正确,这是一个数组,是一个char数组

理解完这个后,我们便可以利用这个道理来存储字符串了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 函数的定义是什么样的,那么显式声明得是怎么样的,这里定义是数组,那么声明就得是数组
// 但是前文也说过了,名字可以不同
// 这里用了const限定,那么之后也得用const限定
int atoi(const char s[]);

int main(void) {
printf("%d", atoi("123"));
return 0;
}

int atoi(const char s[]) {
int n = 0;
// char s[i]是一个ASCII值,要减去字符0的ASCII值('0')才可以得出结果
for (int i = 0;s[i] >= '0'&& s[i]<='9';++i) {
n = 10 * n + (s[i] - '0');
}
return n;
}

其他杂项的内容已经讲过了,这里讲一下核心,如何实现更新数字

这里用了一个很简单的方法,我们不妨思考一下我们输入的是什么?对,我们输入的是一个一串数字

既然是数字,就肯定有位数,那么如何才能实现进位?进位其实也就是在原先的后面加上一个0,所以可以用10 * n来解决

那么要怎么实现数字的更新呢,由于每次转换得到的数字是位于0 ~ 9之间的,也就是说不可能实现跨位数

既然无法实现跨位数,所以只能在个位数做功夫。这时候不妨想想看,怎么样就可以让这个新数字无损地加上去

没错,就是把新的数字的那一位令为0,之后把新的数字加上去就可以实现不破坏数字了

这也就是这个的来历:n = 10 * n + (s[i] - '0')

s[i] - '0'代表新的数字

10 * n代表新的数字的那一位初始化为0

之后相加,由于新的数字不可能造成进位,所以不会修改原有的数字(例如不会使得350变成360,如果会的话就进位了)

这就合理的把新的数字加了上去


隐式算数类型转换

首先先科普一个概念,什么是二元运算符

二元运算符指的是具有两个操作数的运算符,比如说加减乘除,这些运算符如果想要正常运行则必须要有两个操作数

说明完成后进入正题


在C语言中,很多情况下都会进行隐式的算数类型转换,什么意思呢?

比如说如果在二元运算符中,两个操作数的类型是不同的话(比如说一个为int,一个为float),那么那些较低范围的类型会被转换为较大的数字类型(上文的int会转换为float)

如果将一个较长的整数转换为一个较短的整数或char类型时,超出的高位部分将会被抛弃

举个例子:

1
2
3
4
5
int i = 1000;
float f = 1234.1234;

i = f;
f = i;

这样的话,由于i是更小的范围,使得小数点后面的数被舍弃了,而在这之后f重新赋值为i,结果可以1看到小数点后的数字都没有了

强制类型转换

所有的表达式都可以使用强制类型转换,也就是说可以把一个类型强制转为其他的类型

具体的表达式如下:(类型名) 表达式

举个例子:

函数sqrt()的参数为double,如果如果传进去的类型是整数的话会导致出现问题(假设这里为n)

1
2
int n;
sqrt(n);

这样会导致出现问题

这时便可以使用强制类型转化了,以这里为例的话:

1
2
int n;
sqrt((double) n);

这样,n就被转换为符合规范的类型了

需要注意的一点是,强制类型转换只是生成一个新的n,原来的n并没有发生改变

在一般的情况下,函数的参数是通过函数原型声明的,通过函数声明,可以自动将参数进行强制类型转换

在 K&R 涉及的早期C标准中,如果没有显式声明函数原型(如 double sqrt(double);),编译器可能无法自动转换参数类型,此时强制转换就成了必须

自增运算符与自减运算符

本节问题

  1. 自增运算符的前后缀有什么区别,又有什么共同点
  2. 在使用的时候要注意什么问题
  3. 自增运算符的前缀等于后缀的什么

由于自增运算符跟自减运算符的使用方法完全一致,这里笔记仅使用自增运算符作为示例

自增运算符可以用于变量的递增,每次使用自增运算符会使得变量的值增加1,在前面的许多例子中已经有使用过

C语言同样支持以下格式来实现递增:

1
n += 1;

使用自增运算符的格式如下:

1
2
3
++i // 前缀用法
i++ // 后缀用法

前缀与后缀

接下来讲讲前缀和后缀的共同点:前缀和后缀都可以使得变量增加1

这两者的不同点是中间步骤的不同

前缀的用法是先让变量的值加一,之后再使用变量的值

而后缀的用法是先使用变量的值,之后再使得变量的值加一

具体的差异如下所示:

1
2
3
4
5
6
7
8
9
int main() {
char s[] = "123";
int i = 0,o = 0;
printf("%d, %d\n",s[++i] - '0',s[o++] - '0');
printf("%d, %d\n",i,o);
}

// 输出:2, 1
// 1, 1

可以看到,虽然最后两个变量的结果都是1,但是在使用的时候却出现了截然不同的结果

使用前缀的变量++i返回的结果是数组中的第二项2

而使用后缀的变量o++返回的结果是数组中的第一项1

这也证实了之前的说法

有个形象化的记忆是,前缀的++可以代表为先加,之后在使用变量
而后缀由于变量在前++在后,可以看成先使用变量再加

需要注意的一个点是,自增运算符只能用于变量,不可以用于表达式

例如(i + o)++不被允许的

如果在使用自增运算符的时候进需要递增变量,那么前后缀没有任何差别

但是如果需要用到具体的值的时候,这两者便有了差异(比如之前的例子)


启发

通过上面的例子中我们可以想到一些做法

比如我想把一个输入的字符串拷贝到一个新的数组中,可以怎么办

首先大体思路为将原数组的值一个一个拷贝进去新数组

有了大体思路后便可以开始着手了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int copylist(const char s[]);
char to[1000];

int main() {
copylist("1233321");
printf("%s",to);
return 0;
}

int copylist(const char s[]){
int i = 0;

while ((to[i] = s[i]) != '\0')
++i;
return 0;
}

如果使用后缀则如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int copylist(const char s[]);
char to[1000];

int main() {
copylist("1233321");
printf("%s",to);
return 0;
}

int copylist(const char s[]){
int i = 0;
int o = i;

while (s[i] != '\0')
to[i++] = s[o++];
return 0;
}

虽然最后变量的值是相等的,但是从上面的例子可以看出其中间的过程是不同的

一般来讲,前缀和后缀可以在某些程度上等同

1
2
s[i] = c;
++i;

等同于

1
s[i++] = c;

特殊拓展:未定义行为

这里可能会犯一个错误,也就是这个地方:to[i++] = s[o++];,看起来好像没什么,但是你可能在脑海中有过一点思考:这里用两个变量不是在浪费空间吗,直接to[i++] = s[i++];不可以吗?

诶,还真不可以

这里就要提到一个新的概念:未定义行为(Undefined Behavior)

什么是未定义行为呢?在C语言中,如果你在同个语句对单个变量进行多次修改,那么这个行为就是未定义的

也就是说C语言没有规定编译器要怎么做,不同的编译器对同一个未定义的语句可能有不同的结果

比如说上面这个例子:编译器可能先计算to[i++],之后再计算s[i++];也有可能反过来,先计算s[i++],之后再计算to[i++]

所以这里也就是为什么要用两个变量的原因

高效与低效

前缀和后缀虽然看起来差不多,但实际上两者的效率是有差别的,一般而言:前缀要比后缀效率更高

这是为什么呢?从底层讲的话,前缀是直接修改后引用,而后缀是先创建一个变量的副本,而后去修改原有的值,最后返回创建的副本

则后缀要比前缀多一个副本的开销

位运算符

本节问题

  1. 位操作符有哪些?要怎么用
  2. 如何利用位操作符得到想要的位数

在C语言中提供了六个运算符,分别是:& | ^ << >> ~

接下来逐一介绍其用途

&(按位与)

按位与&是一个运算符,等价于逻辑门中的AND(与门),其基本格式如下:

1
x & y;

其中我们把x称为左操作数y称为右操作数,具体的使用方法是,将左操作数和右操作数分别转化为二进制数

之后将两者比较,如果两者位数不相同,那么短的数将用0补全缺的位数

而按位与的核心功能是,如果两个数均为1,那么结果为1,否则为0

举个例子:

1
2025 & 31

首先将两者分别转成二进制,分别为1111110100111111,由于右操作数的位数小于左操作数,则左操作数将缺的位数用0补全:

最后两者对比前的结果如下:

1
2
2025:11111101001
31:00000011111

之后找到符合规则的则为一,不符合规则的则为0

结果如下:

1
00000001001

之后开始化简,直到第一位为1,最后结果为1001,也就是十进制的9

也就是说,2025 & 31的输出结果为9


当然,右操作数除了使用十进制,还可以使用其他的进制

这里除了补充这一点之外,还有其他的内容

通过按位与的特性,我们似乎可以想到什么

既然只有两个为1的时候才是1,否则为0,那么是不是可以想到什么东西

诶,如果给的右操作数的二进制均为1,那么是不是就可以起到保留指定位数的作用

事实确实如此,以上面的例子为例,这里如果转换一下,保留左操作数的前5位,那么也可以使用这样的格式

具体的思路便是要保留几位就输入多少个1,因为没有提及的位均会被0所补充,而在按位与中,只要两个操作数中的任意一个数在某个位上的数字为0,那么这个数字的这个位便为0,而又由于省略会一直省略到第一个为1的位,这就导致了只有在输入多少个1的这里面才会出现1,其他情况全不会出现

|(按位或)

按位或同样是一种运算符,对应逻辑门中的或门(OR),其运算规则为:如果出现一个值为1,那么这个值为1,否则为0

1
2025 | 46;

接下来开始逐一介绍其流程:

首先照例,先转换成二进制:

1
2
2025:11111101001
46:101110

与前文的按位与运算符一样,位数不够用0补充

最后比较前两者如下:

1
2
2025:11111101001
46:00000101110

接下来按照规则开始比较,也就两者中如果两个数均为0,那么这个值为0,否则为1

最后得到结果为:11111101111

当然,这个数字是二进制,我们要转成十进制,也就是2031

^(按位异或)

接下来讲讲按位异或,对应逻辑门中的异或门(XOR),其核心逻辑为:如果两个值相同,则为这个值为0,否则为1

具体的表示方法为:

1
2025 ^ 46;

首先先转换为二进制的格式:

1
2
2025:11111101001
46:101110

之后进行补位后比较,得到结果数为:11111000111,对应十进制的1991


接下来讲讲这个运算符有什么实际的用法

首先先用最简单的话讲一下这个是干什么的:相同则为0,不相同则为1

利用这个特性我们可以想到什么?

假设我们把右操作数全部设置成1,(这里默认位数相同,不需要补零)

那么左操作数是一个任意的数字会怎么样?

1
2
3
75 ^ 127;

// (省略printf语句)输出为52

接下来我们把这三个数字均转换为二进制,看看发生了什么,由于这个运算符并不会造成实际上的位数缺失,如果位数减少那就是第一个数字为0了,这里将位数补全

1
2
3
左操作数:1001011
右操作数:1111111
输出结果:0110100

通过仔细观察可以发现,原先为1的值变成了0,而原先为0的值变成了1,换句话说就是整个二进制均被镜像了一遍

那么如果均为0呢?

再次思考可以发现,如果为左操作数的那一位是0的话,由于与右操作数的全部都为零相同,导致那个位置的值为0;而如果为1话,由于不相同,所以结果的值为1,这就导致了最后的结果是相同的,依旧为左操作数

1
2
3
左操作数:1001011
右操作数:0000000
输出结果:1001011

无临时变量交换数据

接下来的内容需要知道两个知识点:

  • 一个数按位异或它本身的结果为0(a ^ a = 0;)
  • 一个数按位异或0的结果是它本身(a ^ 0 = a;)

利用这个技巧,让我们思考一下如何在不借助临时变量的前提下交换两个变量的数据

不妨令这两个变量为a(1001011)b(1101001)

接下来让我们思考一下,如果一个数被按位异或了,那么再按位异或会怎么样

我们让结果的变量为左操作数(即a = a ^ b)

我们先思考一个点,0和1是怎么来的

0是由于两个值相同得来的,而1是由于两个值1不同得来的

那么,如果先进行a = a ^ b,此时a被按位异或,原本相同的值为0,不相同的值为1,这时用右操作数作为左操作数

被按位异或的数(以下称为-a)作为右操作数,如果原先的a值为1,b值为1,那么-a的值就是0

如果原先的a值为0,b值为0,那么-a的值就是0

如果原先的a值为1,b值为0,那么-a的值就是1

如果原先的a值为0,b值为1,那么-a的值就是1

那么可以看到,可以反向解出原先没有被按位异或的a

此时b的值就是a原来的值,这就完成了交换,之后想要交换出b也是同理

由于这里两个数分别是a(b)和-a,这里的a是由两数交换而成的,那么反过来,以-a作为左操作数,便可以转换出之前的左操作数b

具体的代码为:

1
2
3
a = a ^ b;
b = a ^ b;
a = a ^ b;

从中可以总结出一条规律a ^ b ^ b = b

那么这个有什么用呢,可以用于原地的快速排序,这是一个很关键的点

<< 和 >>(移位运算符)

接下来讲一下移位运算符

首先是左移运算符<<,具体结构如下:

1
x << y;

功能是将x的二进制向左移动y位,具体例子如下:

1
100 << 2;

首先把左操作数转成二进制:1100100

之后向左移动两位变成:110010000

所谓向左移动,其实就是在末尾加上多少个零

这里由于二进制的原因,向左移动y位,等价于x 乘以 2$^y$次方

这里的移动两个单位,等价于乘以4(2 $^2$),所以结果为400(110010000)

接下来是 右移位符>> 这个运算符简单来讲就是吃掉多少位

其结构为:

1
x >> y;

效果为x的二进制末尾y个数被消去

依旧举个例子来分析

1
100 >> 2;

首先转成二进制:1100100,之后消去最末尾的两位:11001 00

得到结果为11001(25)

这里就有人要问了,啊,既然上面左移是乘,那么这里就是除了

当然不是!,如果消去的位中带有1,那么则不满足这个关系!

例如:

1
103 >> 2;

103的二进制为:1100111,而消去后两位的结果为:11001

结果还是25!,这也就说明了并不满足之前说的除的规则

~(按位取反)

接下来是按位取反,对应逻辑门中的非门(NOT),其基本的功能如名字所示,把1变成0,把0变成1

由于运算符是个一元运算符,所以没有左右操作数的概念,基本用法如下:

1
~x;

举个例子来说明:

1
~100;

首先先转成二进制:1100100,这里需要新增一个新的概念:补码

简单来说就是缺几位就补几位,现在基本上为32位(int类型),所以其实是长这样的

1
00000000 00000000 00000000 01100100

接下来全部取反,得到

1
11111111 11111111 11111111 10011011

之后看他的最大一位,也就是10011011中的第一个1,由于这里是1,所以这个数为负数

接下来取反需要减掉1,也变成了:

1
11111111 11111111 11111111 10011010

之后再按位取反,得到原码:

1
00000000 00000000 00000000 01100101

也就是101,最后加上一开始检测的负号,最后结果为-101

这里其实就可以发现了,按位取反的结果始终为 -(原数 + 1)

拓展内容

这里在按位与的右操作数的内容补充一点

首先在上面的笔记中使用的有操作数类型为十进制,实际上可以用其他进制表示

比如以0开头就是八进制:01770377

以0b开头就是二级制:0b11111

以0x开头就是十六进制:0xFF

赋值运算符与表达式

接下来讲讲赋值运算符

一般来讲,如果一个表达式如果左边的变量出现在右边的表达式,那么可以进行一定的缩写,例如:

1
i = i + 10;

可以缩写成以下格式

1
i += 10

此时+=被称为运算运算符

一般来讲,大部分的二元运算符都有一个相对应的运算符,比如说:

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

赋值运算符有什么优点呢?首先,由于阅读习惯,使用赋值运算符会更容易阅读,其次,赋值表达式还可以有利于编译器产生更加高效的代码

K&R例子

这里来解释一下K&R给出的例子:统计x中出现1的二进制位数

首先开始讲思路,既然要找出二进制的所有1,一个简单的思路就是直接遍历,将原本十进制的数转成二进制,之后依次检测是不是1,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
int bitcount2(unsigned x) {
int a = 0;

for (int i = 0;x != 0;++i) {
if (x % 2) {
++a;
}
x /= 2;
}
return a;
}

这里是如果余数为1(恰好对应True)那就执行循环

当然,我们也可以用到前文的位运算符

1
2
3
4
5
6
7
8
int bitcount(unsigned x) {
int b;
for (b = 0; x != 0; x >>= 1) {
if (x & 01)
b++;
}
return b;
}

接下来让我解析一下思路,首先先看循环条件,需要特别注意(同时也是核心)的一点是这里:x >>= 1

根据上文可以得知这里为:x = x >> 1,也就是向右进1位,等价于消去二进制的最后一位,以此来达到消位的作用

而看看循环体内部,首先条件可能第一眼看着比较奇怪,但是!我们需要想到一个上文提到的点:最高位1则为负数,最高位0则为正数,这里正是利用了这一点,明确了为正数

而这里的按位与又是什么作用呢,让我们实际演算一下,假设现在给的参数x为1000

1
10001111101000

进行补位等操作后,直接进行运算:

1
2
3
10001111101000
010000000001
结果:0000000000

可以看到结果居然为0!这是由于01即使补位后其他位数仍是0,按照按位与的规则,可以得知必须两个均为1才为1

这就导致了真正参与检测的只有最后一位!,前面的无论怎么样结果都为0

这样如法炮制,每次运算都消去一位,最后直到x为0结束循环

接下来补充一点,可能这里看到参数为:unsigned感觉有点奇怪,为什么要使用这个

这是因为如果选择带符号的signed,并且此时x为负的情况下,由前文可知,负数最大位始终为1,这就导致了永远也没办法跳出循环!

拓展内容:算术右移和逻辑右移

接下来简单讲一下什么是算数右移什么是逻辑右移

举个例子,上文的unsigned执行的便是逻辑右移,因为在没有位数的时候不会在最左边添加代表符号的位数

而上文提到的signed是算数右移,因为当没有位数的时候,会在最左边添加上代表符号的位数,这也就是为什么负数无法跳出循环的原因

条件表达式

接下来讲讲条件表达式,首先从一个简单的if语句引入

1
2
3
4
5
6
7
int a, b, z;
if (a > b){
z = a;
}
else{
z = b;
}

如果使用条件表达式,可以简化为以下的结构:

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

是不是第一眼就找不着北了?没有关系,让我们对照原文一个一个分析

首先最简单的就是这里z = 一个简单的赋值表达式,所以我们把等号右边的表达式单独拉出来分析

(a > b) ? a : b可以看到,这里括号内部的内容,恰好与if语句的条件是一致的

所以,可以判断问号左边的内容相当于判断,而接下来问号右边的内容呢?

仔细观察,原先哪里出现了ab,是的,就在if语句的内部出现,所以可以推导,这两边分别对应两种情况

所以综上所述,让我们总结一些条件表达式的语法:

1
判断条件 ? 如果为真则为 : 否则则为;

诶,这时候就有人灵机一动了,那我能不能多套几层问号

答案是可以的,但是结构会非常抽象

我们具体举个例子:

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

看起来是不是晕晕的?转换为if语句则如下

1
2
3
4
5
6
7
8
9
10
11
if (a > b){
if (a == 1||b == 1){
z = a;
}
else{
z = b;
}
}
else{
z = b;
}

这样看,只有语句为单个if-else的时候,使用条件表达式才比较合适

用法

这时候可能有人要问了,啊这个有什么用呢

除了使代码变得更加简洁外,还有一个用途:宏定义

那么什么是宏定义呢,这里不多拓展,简单来讲就是这个语句:#define 语句 要代替的文本

例如上一章经常用到的#define MAXLINE 1000,就是将所有的MAXLINE替换为1000

由于宏定义只能使用单个语句,不能使用一大块的代码来表示,所以类似条件表达式这种单行的表达式就十分的好用

举个例子:

1
#define MAX(a,b) (((a) > (b)) ? (a):(b))

这里带上括号的原因是a和b不一定表示一个数,还有可能是个表达式,所以要加上括号

运算符优先级与求值次序

这一节的内容主要是优化为主

首先需要声明一点,在C语言中并没有指定同一个运算符中的多个操作数的计算顺序(除了&& || ?:和",")

注:这里的","指的是逗号运算符,不是printf("%s;%s",to,from);中的参数分隔符

比如说:

1
a = line() + nums();

在这个语句中,line()可以先计算,也可以在nums()后再计算

这就导致了一个问题,如果这两者中有一个变量是两者共享的(全局变量),那么谁先后运行就会导致另一个的结果发生改变

为了解决这个问题,可以用临时变量保存中间的结果

类似的,各参数的求值顺序也是没有规定的

比如说:

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

这里会先计算自增呢还是会先计算power()呢,在不同编译器有不同的表现

所以这里可以改成以下格式

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

副作用

什么是副作用呢,简单来说就是在对表达式求值的时候,顺带把变量的值修改了

那会导致什么呢,以下面的例子为例

1
a[i] = i++;

问题是,这里的下标是哪一个?原先的还是新的一个

所以说,在任何的编程语言中,如果代码的执行结果与求值顺序相关,则都是不好的程序设计语言

如果你不知道怎么解决这种冲突的话,那就不要试着为了炫技来使用某种特殊的实现方式