运算符和类型转换#

一个例子#

有了变量,我们就可以进行算术运算了,下面是一个例子。

/*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结果是-INFINITY0.0 / 0.0结果是NaN。否则为未定义行为。

取余#

Attention

取余只能运用在整数类型上。右操作数不能是0。

取余运算与商有密切关系。如果q = a / b,那么a % ba - 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. 直觉上,ba大1,应该输出1. 但是,由于ab都是很大的数, 加上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 longunsigned long long
(整数) longunsigned long
(整数) intunsigned
(整数) shortunsigned short
(整数) charsigned charunsigned char
低(布尔) _Bool

整数转换#

整数类型之间可以进行整数转换,规则如下:

  • 如果转换前的值能被转换后的类型表示,则值不变
  • 若转换到是无符号的类型,其位宽为\(N\),则转换后的值是原值对\(2^N\)取模的结果。

Implementation-defined

对于转到有符号类型,且转换后的类型不能表示原值的情况,为实现定义。

隐式转换(Implicit Conversion)#

隐式转换跟随“语义”发生。存在以下类型的隐式转换,它们都根据算术类型转换规则进行:

  • 整数提升
  • 通常算术转换
  • 赋值转换

前二者都只将类型“扩大”,而赋值转换则比较任意。

整数提升(Integer Promotions)#

整数提升是指转换等级小于int类型的值都会转换为intunsigned中的一者。 如果int能涵盖所有原来类型的值域则选用int,否则为unsigned

以下情况存在整数提升:

  • 一元算术运算符 +-~的右表达式
  • 移位运算符<<>>的左右表达式

通常算术转换2#

如果算术运算符(除移位运算符之外)的两侧拥有不同的类型,则两边进行通常算术转换, 直到都变为一个公共的类型。结果表达式的类型就是公共的类型。 简而言之,通常算术遵循以下原则:

  • 小于int的整数类型进行整数提升
  • 转换为较高等级的类型
  • 整数类型如果分别为有符号S和无符号U,则:
    1. S和U的类型如果一样等级,则转换为U。
    2. 若否,则如果S能涵盖U的值域,则转换为S。
    3. 否则,转换为对应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;
}

  1. 操作数还应该包含泛型选择。 

  2. 这里不讨论虚、复数浮点数的情况。 

  3. 一个这种情况例子是long long(64位) 与unsigned long(64位),它们不同等级,且long long无法涵盖unsigned long。 此时它们都转化为unsigned long long。