运算符和类型转换#
一个例子#
有了变量,我们就可以进行算术运算了,下面是一个例子。
/*arith_op.c*/
#include <stdio.h>
int main(void)
{
int lhs = 10, rhs = 20;
printf("%d\n", lhs + rhs);
return 0;
}
30
当然,我们希望类型之间可以相互转换,下面给出了一个例子:
/*type_conv.c*/
#include <stdio.h>
int main(void)
{
int var_int = 1;
float var_float = (float)var_int;
printf("%f\n", var_float);
return 0;
}
1.000000
运算符(Operator)和表达式(Expression)#
表达式是由操作数(operand)和运算符组成。其中,操作数可以是:
- 字面量、常量
- (已声明的)标识符
- 另一个表达式
等1。而运算符负责串联起操作数,组成新的表达式。
表达式表示了一个值。左值表达式还表示了这个值的对象标识,可供赋值。 如果我们像数学中那样将运算符看成一个函数的话(如 \(a + b\) 看成 \(f_+(a, b)\))。 我们也可以说运算符“返回”了一个值。
所以赋值运算符返回的值是什么?
赋值运算符返回的值是被赋值的那个值。如a = 2
返回的就是2,a = b
返回的就是b
的值。
赋值运算符是右结合的,也就是说a = b = c
如同先处理b = c
,
再处理a = c的值
。
赋值表达式是右值表达式,不能被赋值。(a = b) = c
是非法的。
运算符还可能会有除了返回值以外的其他作用,称为副作用(side effect)。 赋值运算符就是典型的具有副作用的运算符。其返回值是所赋之值,副作用是给变量赋值。
算术运算(Arithmetic Operation)#
算术运算是C中的一类最基本的运算。它们返回值是运算的结果,没有副作用。 C语言中有以下的算术运算符:
- 一元运算符
- 正号
+
- 负号
-
- 正号
- 四则运算与取模
- 加法
+
- 减法
-
- 乘法
*
- 除法
/
- 取模
%
- 加法
- 位运算
- 逐位非
~
- 逐位与
&
- 逐位或
|
- 逐位异或
^
- 逐位左移
<<
- 逐位右移
>>
- 逐位非
Attention
所有的算术表达式都是右值。它们无法被赋值。
一元(Unary)算术运算符#
一元算术运算符
+ 表达式
- 表达式
一元算术运算符将表达式保持原值(正号+
)或取相反数(符号-
),并进行整数提升
(见下)。
以下是一个例子:
#include <stdio.h>
int main(void)
{
int var = -1;
printf("%d\n", var);
return 0;
}
这里将整数字面量和负号结合起来初始化一个变量。这样可以初始化一个负数值。
Tip
在赋值的时候,我们可以将一个变量的值经过计算后结果重新赋给这个变量。如:
a = -a;
意思是取出a
的值,变为相反数,再赋值给a
。也就是,a
变为原来的相反数。
四则运算符与取模运算符#
四则运算符与取模运算符
表达式 + 表达式
表达式 - 表达式
表达式 * 表达式
表达式 / 表达式
整数表达式 % 整数表达式
在左右表达式都是算术类型的情况下,四则运算符都如同在数学中的定义,只有以下不同:
除法#
对于除法来说,如果它的左右表达式的类型都是整数,那么结果为代数商(非分数),向零截断。
如以下的例子:
#include <stdio.h>
int main(void)
{
int foo = -7 / 3, bar = 7 / 3;
printf("%d %d\n", foo, bar);
return 0;
}
Undefined-behavior
整数除法中,如果被除表达式是0,则会是未定义行为。
会输出:-2 2
。
如果左右表达式类型为浮点数,则会算出一个浮点数。如以下的例子:
#include <stdio.h>
int main(void)
{
float foo = -7.f / 3.f;
printf("%f\n", foo);
return 0;
}
会输出:-2.333333
。
Attention
如果编译器的浮点数算术遵循IEEE-754,则非零浮点数除以0.0
结果是INFINITY
,
除以-0.0
结果是-INFINITY
,0.0 / 0.0
结果是NaN
。否则为未定义行为。
取余#
Attention
取余只能运用在整数类型上。右操作数不能是0。
取余运算与商有密切关系。如果q = a / b
,那么a % b
与a - q * b
相等。
对于正整数,这就是通常意义上的余数。同样,这适用于负数的情况。
这个例子会输出-1:
#include <stdio.h>
int main(void)
{
int foo = -7 % 3;
printf("%d\n", foo);
return 0;
}
不难发现,取余结果的符号与左操作数同号。
浮点无穷大计算#
浮点无穷大遵循以下的规律运算: - 同号无穷大相减或异号无穷大相加得出NaN。 - 无穷大乘0得出NaN,否则根据符号得出(相应符号)无穷大。 - 无穷大除以有限值得到(相应符号)无穷大,有限值除以无穷大得到(相应符号)零。
NaN计算#
如果NaN参与了任何的四则运算,结果都是NaN。 NaN的相反数是(另一)NaN。
溢出(Overflow)#
由于C语言的整数类型是存在范围的的,所以会遇到整数溢出的情况(分为上溢和下溢)。
对于无符号整数的情况,如果它的位宽为\(N\),则结果就会是对\(2 ^ N\)的模。
如以下的例子:
#include <stdio.h>
int main(void)
{
unsigned a = 65536u;
unsigned b = a * a;
printf("%u\n", b);
return 0;
}
在32位的环境下得到的是0.因为 $$ 65536 ^ 2 = (2 ^ {16}) ^ 2 = 2 ^ {32}. $$ 模\(2 ^ {32}\)刚好是0.
Implementation-defined
对于有符号整数,溢出后的结果是实现定义的。
Attention
这里的溢出对于运算的中间结果也成立。
浮点数在中间计算的过程中一般float
量都是扩到double
上运算(视编译器而定)。
较难发生溢出的情况。当然,浮点数还会有另外的问题:
浮点运算的不精确性#
看以下的例子:
#include <stdio.h>
int main(void)
{
double a = 1e300;
double b = a + 1.f;
printf("%f\n", b - a);
return 0;
}
这个例子会输出0.000000. 直觉上,b
比a
大1,应该输出1. 但是,由于a
和b
都是很大的数,
加上1
来说,对尾数没有改变。所以相减会等于0.
在精确度要求不高,数量级相差不大的计算中,浮点数还是有其优势的。
位运算符#
位运算符是在整数表达式上对整数的位进行操纵运算符。
无符号整数的二进制表示
如同十进制的表示,每一个无符号整数也可以用二进制表示。二进制的每一位表示2的某个幂。
从左向右依次是:$$ \cdots, 2^3, 2^2, 2^1, 2^0 .$$
如5的二进制表示可以表示为:\(101_{2}\),其意义是\(2^2 + 2^0 = 5\)。
在32位的unsigned int
类型中就存储为:
0000 0000 0000 0000 0000 0000 0000 0101
计算机内部就是这么储存无符号整数的。对于有符号整数数,有多种内部表示方法,在此不赘述。
逐位逻辑运算符#
逐位逻辑运算符就是对整数的位进行操纵。
逐位逻辑运算符
~整数表达式
整数表达式 & 整数表达式
整数表达式 | 整数表达式
整数表达式 ^ 整数表达式
取反操作将所有的位取反,也就是说将每一位0变成1、1变成0. 取反操作的真值表如下:
输入 | 输出 |
---|---|
0 | 1 |
1 | 0 |
什么是真值表(truth table)?
每一种位运算都可以由输入输出决定。真值表列出所有可能的输入,并给出了对应的输出。 每一个单元格都是0(假)或1(真),所以叫真值表。
我们来试一下以下的程序:
#include <stdio.h>
int main(void)
{
unsigned a = 3;
printf("%u\n", ~a);
return 0;
}
在32位下会输出4294967292. 我们来看看3(上)和4294967292(下)的二进制表示:
0000 0000 0000 0000 0000 0000 0000 0011
1111 1111 1111 1111 1111 1111 1111 1100
已经很明白了。
接下来我们介绍逐位与(&
)、逐位或(|
)和逐位异或(^
),它们都是二元运算符。
Attention
C语言中^
代表逐位异或,不是乘幂。
与的真值表如下:
输入 | 输入 | 输出 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 0 |
1 | 0 | 0 |
1 | 1 | 1 |
“与”的意思直观理解就是:当两个位都为1(真)时,输出1(真)。其他情况都是0(假)。 这正是“与”的字面意思。
或的真值表如下:
输入 | 输入 | 输出 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 1 |
“与”的意思直观理解就是:当两个位有一个为1(真)时,就输出1(真)。这正是“或”的字面意思。
来看一个例子:
#include <stdio.h>
int main(void)
{
unsigned a = 5;
unsigned b = 3;
printf("%u %u\n", a | b, a & b);
return 0;
}
输出7 1
. 来看看这些数的内部表示:
0...0 0011 -> 3
0...0 0101 -> 5
0...0 0111 -> 7: 3 | 5
0...0 0001 -> 1: 3 & 5
我们可以利用与和或做一下掩码操作。掩码就是找一个特殊的数,用它和其他的数做与运算, 能够保持那个数的某些位,而其他的位统统变为0. 这个数的设计也特别简单: 需要保留的位设为1,其他设为0,这就是做掩码的数。
Exercise
设计一个保留整数最右边三位的掩码。你可以使用9(1001)来试一下,应该与这个数的与是1. (提示:这个数在本节已经出现过。)
或运算的一个应用是:将一个数所需要的位设置为1,其他位保持不变。这个数的设计也特别简单: 需要设置的位设为1,其他设为0。
最后是异或运算,这是它的真值表:
输入 | 输入 | 输出 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
异或运算的逻辑是:如果两个位只有一个是1(真),则输出1(真),否则为0(假)。
使用异或运算进行原地交换
使用异或运算可以原地交换两个整数的值,不需要辅助变量,下面是一个例子:
#include <stdio.h>
int main(void)
{
unsigned a = 5;
unsigned b = 3;
a = a ^ b;
b = b ^ a;
a = b ^ a;
printf("%u %u\n", a, b);
return 0;
}
你可以想想这种方法的原理。
移位运算符#
逐位逻辑运算符
整数表达式 << 整数表达式
整数表达式 >> 整数表达式
移位运算符将整数的所有位向左/向右移动。它的右表达式表示了要移动多少位。 右移在右侧的位补零,无符号整数左移在左侧补零,超出的部分(对于无符号整数)则截断。
#include <stdio.h>
int main(void)
{
unsigned a = 5;
printf("%u %u\n", 5 << 2, 5 >> 1);
return 0;
}
输出是20 2
.来看看这些数的内部表示:
0...0 0000 0101 -> 5
0...0 0001 0100 -> 20: 5 << 2
0...0 0000 0010 -> 2: 5 >> 1
Undefined-behavior
如果右表达式是负数,或者超出左表达式类型的位宽的,则是未定义行为。
左移/右移\(N\)位相当于乘/整数除\(2^N\),对于有符号类型的正数也是如此。 它提供了一个乘除2的幂的好方法,通常来说,移位运算比乘除运算要快。
Undefined-behavior
如果对于无符号整数,在整数提升后(见下)仍然无法表达移位后的值,则是未定义行为。
Implementation-defined
对于负数,移位的结果是实现定义的。大多数编译器选择符号位扩展,会保留正负性。
优先级(Precedence)与结合性(Associativity)#
和数学中一样,运算符具有优先级。运算符的优先级根数学中的类似。 我们把现在遇到的运算符列举如下:
优先级 | 运算符 | 结合性 |
---|---|---|
高 | + (正号)、- (负号)、~ |
右 |
* 、/ 、% |
左 | |
+ (加号)、- (减号) |
左 | |
<< 、>> |
左 | |
& |
左 | |
^ |
左 | |
| |
左 | |
低 | = |
右 |
结合性指的是同级运算的优先级。左结合说明最左边的运算符优先、从左至右, 右结合说明最右边的运算符优先、从右至左。
我们可以用小括号()
包围表达式,使它最优先,如同数学中那样。
小括号
(表达式)
Attention
[]
和{}
不能像()
那样作为提高优先级使用,它们有别的意义。
Good-practice
通常,除了=
、%
和四则运算之外,我们都使用小括号来表示优先级。
因为这些负号的优先级不是显然的,也省得我们背优先级表。
类型转换(Type Conversion)#
尽管C语言不允许通过赋值取改变一个值的类型,也不允许类型不匹配的类型相互赋值。 但是我们可以使用类型转换将一个类型的值转换为另外的类型。
类型转换分为两种:显式转换和隐式转换。顾名思义,显示转换需要我们显示地去指定, 而隐式转换不需要我们去指定,编译器会为我们自动生成。
即使有了类型转换机制,在C中仍然存在不能完成的类型转换。好在,算术类型都存在互相的转换。
算术类型转换规则#
C语言中的算术类型转换时,遵守以下的规则:
整数与浮点类型的相互转换#
- 整数向浮点类型转换时,若能够用浮点数表示则保留原值,若不能则转换为临近的值。
- 浮点数向整数转换时,向零取整。
Undefined-behavior
如果这种转换后的值不能被转换后的类型表示,则行为未定义。通常发生于超出范围的情况。
如将1e300
转换为整数类型。
算术类型的转换等级#
算术类型的转换等级从某种程度表示了某个类型的范围大小。通常来说,等级越高,范围越大。
总的来说转换等级有以下的规律:
- 浮点类型等级比所有整数类型高
- 整数类型有符号和对应的无符号类型等级相等
以下是类型转换等级列表:2
等级 | 类型 |
---|---|
高(浮点) | long double |
(浮点) | double |
(浮点) | float |
(整数) | long long 、unsigned long long |
(整数) | long 、unsigned long |
(整数) | int 、unsigned |
(整数) | short 、unsigned short |
(整数) | char 、signed char 、unsigned char |
低(布尔) | _Bool |
整数转换#
整数类型之间可以进行整数转换,规则如下:
- 如果转换前的值能被转换后的类型表示,则值不变
- 若转换到是无符号的类型,其位宽为\(N\),则转换后的值是原值对\(2^N\)取模的结果。
Implementation-defined
对于转到有符号类型,且转换后的类型不能表示原值的情况,为实现定义。
隐式转换(Implicit Conversion)#
隐式转换跟随“语义”发生。存在以下类型的隐式转换,它们都根据算术类型转换规则进行:
- 整数提升
- 通常算术转换
- 赋值转换
前二者都只将类型“扩大”,而赋值转换则比较任意。
整数提升(Integer Promotions)#
整数提升是指转换等级小于int
类型的值都会转换为int
或unsigned
中的一者。
如果int
能涵盖所有原来类型的值域则选用int
,否则为unsigned
。
以下情况存在整数提升:
- 一元算术运算符
+
、-
、~
的右表达式 - 移位运算符
<<
和>>
的左右表达式
通常算术转换2#
如果算术运算符(除移位运算符之外)的两侧拥有不同的类型,则两边进行通常算术转换, 直到都变为一个公共的类型。结果表达式的类型就是公共的类型。 简而言之,通常算术遵循以下原则:
- 小于
int
的整数类型进行整数提升 - 转换为较高等级的类型
- 整数类型如果分别为有符号S和无符号U,则:
- S和U的类型如果一样等级,则转换为U。
- 若否,则如果S能涵盖U的值域,则转换为S。
- 否则,转换为对应S的无符号类型3
直观上理解,通常算术转换就是对左右两边进行范围扩大到一致,且倾向于选择无符号类型。
例如以下的表达式:
1 / 10.0
得到的结果是double
类型的0.1(约值)。虽然除号左边是一个整数,但是由于右边是一个浮点类型,
所以左边转化为浮点类型,并执行浮点的除法运算。
而以下的表达式:
65536ll * 65536
不会溢出,得到结果是long long
类型的4294967296(\(2^{32}\))。
以下的表达式:
-1 * 65536u
则会变为unsigned
类型。
赋值转换#
赋值和初始化时,右表达式的类型会根据算术类型转换规则转化到左表达式的类型(可以不是扩大)。
如下面的赋值和初始化都是合法的:
char a = 'a';
unsigned u_one = 1;
float f_one = 1;
long long ll_one = 1;
ll_one = 1.0;
Exercise
试阐明每一行存在的类型转换和其规则。
以下的代码片段:
float a = 1 / 3;
会将a
初始化为0.0
而非三分之一。这是因为,赋值号右边的表达式中,除号两边都是整数,
所以是整数除法,结果是int
类型的0
,再转化为float
类型,为0.0
。
显式转换(Type Cast)#
显示类型转换
(类型) 表达式
不同于隐式转换,显示类型转换则是在代码中显式地将要转换的类型标识出来。 由于这种类型转换在何处都能用,不像隐式转换受“语境”影响,可以强制转换类型, 所以也叫强制类型转换。
下面是一个显示类型转换的例子:
int a = 3;
float b = (float)a;
float c = (float)5;
显示转换的优先级于正负号相当。下面的例子展示了如何将计算两个整数的浮点商。
int x = 1, y = 3;
float q = (float)x / y; /* q is 0.33 */
显示转换可以完成算术类型转换中所有的转换,除此之外还可以做到其他的转换, 这在我们之后接触到其他的类型就会说到。
本章练习#
Exercise
计算 \(\frac{1}{7}\)的值,精确到6位小数。
0.142857
#include <stdio.h>
int main(void)
{
printf("%f", 1 / 7.0);
return 0;
}
Exercise
定义一个int
型变量input
并赋值为一个三位正整数(任选)。
计算它的(十进制)每一位数之和并输出。
对于input
为123的情况:
6
#include <stdio.h>
int main(void)
{
int input = 123;
int sum = input / 100 + input / 10 % 10 + input % 10;
printf("%d", sum);
return 0;
}
Exercise
不同的浮点类型精度不一样,(float)0.1
与(double)0.1
相差多少?
将结果乘以65536并输出。(保留6位小数)
0.000098
#include <stdio.h>
int main(void)
{
printf("%f", (0.1f - 0.1) * 65536);
return 0;
}
(注:这里直接使用了float
类型的字面量0.1f
)
Exercise
定义int
类型变量input
并赋值为一个正整数,使用位运算解决以下问题:
- 它除以16的整数商是多少?
- 它除以16余多少?
- 它是否为奇数(是则输出1,否则输出0)
结果用空格分隔。
(提示:\(16 = 2 ^ 4\),15的二进制为1111。)
对于input
为1001时:
62 9 1
#include <stdio.h>
int main(void)
{
int input = 1001;
printf("%d %d %d", input >> 4, input & 15, input & 1);
return 0;
}