C语言设计的初衷是在表达式中隐式且静默地改变操作数的整数类型。在某些情况下,语言强制编译器要么将操作数改变为更大的类型,要么改变它们的符号。
这样做的原因是为了防止算术运算中的意外溢出,同时也允许具有不同符号的操作数在同一表达式中共存。
不幸的是,隐式类型提升的规则带来的伤害远远超过好处,以至于它们可能是C语言中最大的缺陷之一。这些规则通常甚至不为普通的C程序员所知,因此会导致各种非常微妙的错误。
通常情况下,你会看到程序员说“只需将其转换为类型x,它就能工作”——但他们不知道为什么。或者这些错误表现为罕见的、间歇性的现象,从看似简单和直接的代码中产生。隐式提升在进行位操作的代码中尤其麻烦,因为C语言中的大多数位运算符在给定有符号操作数时具有定义不清的行为。
整数类型和转换等级
在C语言中,整数类型包括char、short、int、long、long long和enum。当涉及到类型提升时,_Bool/bool也被视为整数类型。
所有整数类型都有指定的转换等级。根据C11 6.3.1.1,以下是最重要的部分(重点标注为mine):
每个整数类型都有一个整数转换等级,定义如下:
- 即使具有相同的表示形式,两个有符号整数类型的等级也不能相同。
- 有符号整数类型的等级必须大于具有较低精度的任何有符号整数类型的等级。
- long long int 的等级必须大于 long int 的等级,long int 的等级必须大于 int 的等级,int 的等级必须大于 short int 的等级,short int 的等级必须大于 signed char 的等级。
- 任何无符号整数类型的等级必须等于相应的有符号整数类型的等级(如果有的话)。
- 任何标准整数类型的等级必须大于具有相同宽度的任何扩展整数类型的等级。
- char 的等级必须等于 signed char 和 unsigned char 的等级。
- _Bool 的等级必须小于所有其他标准整数类型的等级。
- 任何枚举类型的等级必须等于兼容的整数类型的等级(参见6.7.2.2)。
这里也包括了stdint.h
中的类型,它们与给定系统上对应的类型具有相同的等级。例如,在32位系统上,int32_t
与int
具有相同的等级。
此外,C11 6.3.1.1规定了哪些类型被视为小整数类型(不是正式术语):
以下类型可以在表达式中使用,无论何时都可以使用int
或unsigned int
:
— 具有整数类型(除了int
或unsigned int
)的对象或表达式,其整数转换等级小于或等于int
和unsigned int
的等级。
这段有些晦涩的文字在实践中的意思是,_Bool
、char
和short
(以及int8_t
、uint8_t
等)是"小整数类型"。它们以特殊方式处理,并受到隐式提升的影响,如下所述。
整数提升
每当在表达式中使用小整数类型时,它会被隐式转换为始终带有符号的int
类型。这被称为整数提升或整数提升规则。
正式地说,规则如下(C11 6.3.1.1):
如果int
类型可以表示原始类型的所有值(受位域的宽度限制),则将值转换为int
;否则,将其转换为unsigned int
。这被称为整数提升。
这意味着所有小整数类型,无论有无符号,当在大多数表达式中使用时,都会被隐式转换为(带符号的)int
。
这段文字经常被误解为:“所有小的有符号整数类型都被转换为有符号整数,所有小的无符号整数类型都被转换为无符号整数”。这是不正确的。这里的无符号部分只是意味着,如果我们有一个例如
unsigned short
的操作数,并且
int
恰好在给定系统上与
short
具有相同的大小,那么
unsigned short
操作数将被转换为
unsigned int
。也就是说,没有什么特别的事情发生。但是,如果
short
是比
int
更小的类型,它总是被转换为(有符号的)
int
,而不管
short
是有符号还是无符号!
整数提升带来的严酷现实意味着,在C语言中几乎不能对像
char
或
short
这样的小类型进行任何操作。操作总是在
int
或更大的类型上进行。
这可能听起来像胡言乱语,但幸运的是编译器可以对代码进行优化。例如,包含两个
unsigned char
操作数的表达式会将操作数提升为
int
,并进行
int
操作。但编译器可以优化表达式,使其实际上作为一个8位操作执行,这是可以预期的。然而,问题来了:编译器不允许优化由整数提升引起的隐式符号更改,因为编译器无法判断程序员是有意依赖隐式提升,还是无意之间发生了这种情况。
这就是为什么问题中的示例1失败的原因。两个无符号字符操作数都被提升为
int
类型,操作是在
int
类型上进行的,
x - y
的结果是
int
类型。这意味着我们得到的是
-1
而不是可能期望的
255
。编译器可能会生成使用8位指令执行代码的机器码,而不是
int
,但它可能不会优化掉符号的改变。这意味着我们最终得到一个负数的结果,当调用
printf("%u
时,会得到一个奇怪的数字。通过将操作的结果强制转换回
unsigned char
类型,可以修复示例1。
除了一些特殊情况,如
++
和
sizeof
运算符,整数提升适用于C中几乎所有的操作,无论是一元、二元(或三元)运算符。
通常的算术转换
在C语言中,每当进行二元操作(即涉及两个操作数的操作)时,操作符的两个操作数必须是相同的类型。因此,如果操作数的类型不同,C语言会强制将其中一个操作数隐式转换为另一个操作数的类型。这种转换的规则被称为通常的算术转换(有时非正式地称为"平衡")。这些规则在C11 6.3.18中进行了详细说明:
(将这个规则想象成一个长而嵌套的if-else if
语句,可能更容易理解 :) )
6.3.1.8 通常的算术转换
许多期望算术类型操作数的运算符会进行转换,并以类似的方式产生结果类型。其目的是确定操作数和结果的共同实际类型。对于指定的操作数,每个操作数都会被转换为一个类型,其对应的实际类型是共同的实际类型,而不改变类型域。除非另有明确说明,否则共同的实际类型也是结果的对应实际类型,其类型域与操作数相同,如果它们相同,则为复数。这种模式被称为“通常的算术转换”。
首先,如果任一操作数的对应实际类型为long double,则另一个操作数会被转换为一个类型,其对应的实际类型也是long double,而不改变类型域。
否则,如果任一操作数的相应实际类型是
double
,则另一个操作数将被转换为相应实际类型为
double
的类型,而不改变类型域。
否则,如果任一操作数的相应实际类型是
float
,则另一个操作数将被转换为相应实际类型为
float
的类型,而不改变类型域。
否则,对两个操作数执行整数提升。然后,对提升后的操作数应用以下规则:
- 如果两个操作数具有相同的类型,则不需要进一步转换。
否则,如果两个操作数都是有符号整数类型或都是无符号整数类型,则具有较低整数转换等级的操作数将转换为具有较高等级的操作数的类型。
否则,如果具有无符号整数类型的操作数的等级大于或等于其他操作数类型的等级,则具有有符号整数类型的操作数将转换为具有无符号整数类型的操作数的类型。
否则,如果具有有符号整数类型的操作数的类型能够表示无符号整数类型的所有值,则具有无符号整数类型的操作数将转换为具有有符号整数类型的操作数的类型。
否则,两个操作数将转换为与具有有符号整数类型的操作数的类型相对应的无符号整数类型。
值得注意的是,通常的算术转换适用于浮点数和整数变量。在整数的情况下,我们还可以注意到整数提升是从通常的算术转换中调用的。在此之后,当两个操作数至少具有int的等级时,运算符将平衡到相同的类型,并具有相同的符号。
这就是为什么在示例2中,
a + b
会得到一个奇怪的结果的原因。两个操作数都是整数,并且它们至少是
int
级别,因此不会进行整数提升。这两个操作数的类型不同 -
a
是
unsigned int
,而
b
是
signed int
。因此,操作数
b
会被临时转换为
unsigned int
类型。在这个转换过程中,它失去了符号信息,最终变成了一个很大的值。
在示例3中,将类型更改为
short
可以解决这个问题的原因是
short
是一个小的整数类型。这意味着两个操作数都会被整数提升为
int
类型,而
int
是有符号的。在整数提升之后,两个操作数具有相同的类型(
int
),不需要进一步的转换。然后,操作可以按预期在有符号类型上执行。
值得注意的是,C++应用的规则几乎完全相同。
printf("%u\n", x - y);
会导致未定义行为。 - M.M~((u8)(1 << 7))
添加到列表中,这是一个不错的例子。 - 0andriy