C语言类型转换详解

C语言的类型及其转换一直是一个基础但又容易出错的场景,本文以C11标准为基础,为读者提供C语言类型转换的重要知识和最佳实践。

C是目前活跃的编程语言中历史最久的,自然积攒了相当多的历史遗留问题,这篇文章中尽可能指出C中常见的不明确点,避免读者踩坑。

C标准历史

在正式学习之前,我需要简短地介绍C语言的历史,这并非我教条,而是了解这段历史有助于理解为什么如今的C语言是这样的。

在计算机发展的田园时代,“标准”是一个几乎不存在的概念。一个比C还要古老的语言,Lisp,就很好地体现了这种现象:直到如今,仍然存在着大量Lisp方言和定制解释器,这让这种语言的学习、开发和维护都受到了不小的限制。最早期的C也是一样的,不同厂家很可能会基于它们的需求而定制C编译器和语法。随着C使用越来越广泛,1980年代,美国国家标准协会制定了第一套C语言的标准,并与1989年发布,该标准被称为C89。

C89和现在大家所见的C语言仍然有不小的区别,所以本文不会使用C89标准。一个显著的区别是C89的变量必须在函数体一开始就声明完,无法做到最基本的使用时声明。而C99就现在大家见到的C一致了。

由于早期存在众多独立实现,且C追求极限性能,所以产生了许多未定义行为与实现定义行为。

未定义行为与实现定义行为

未定义行为

未定义行为(UB)就是标准没有定义的行为。但是标准未定义并非标准没有说明,相反,C标准会明确提及一些操作是未定义的,标准中出现了一百多处 behavior is undefined,用于指导编译器应如何实现。

网络上有人说:“UB时编译器可以生成任何代码,所以不能假定任何行为。” 但是使用C时,遇到UB是很常见的事,不会有哪个编译器生成的代码检测到数组越界访问就调用rm -rf /*。所以了解一些常见的UB特点会有助于程序崩溃时的分析。

下面是一些典型的UB,部分UB会很常见,一些行为是可以预测的。

  • 解引用空指针、野指针。
  • 数组越界访问。
  • 使用未初始化的变量。
  • 一个语句中同时修改和使用变量。例:i = i++ + ++i

实现定义行为

由于C语言这种先存在实现后制定标准的特性,所以标准制定时必须考虑已有的实现,导致出现了大量很容易见到的“实现定义行为”。与未定义行为不同,虽然实现定义行为在C标准中没有说明,但C标准要求编译器的文档必须严格规定该行为。与未定义行为类似,C标准会明确指出哪些行为是实现定义的。

下面是几种常见的实现定义行为。

  • char类型的大小和符号,int类型的大小。
  • 对象(整型、结构体等)在内存中的表示方法。
  • 位域字段是否有符号。
  • 枚举值的具体类型。

C语言的类型系统

C语言的类型系统并不复杂,大致可以分为以下五类。

  1. 数字类型,包括整数、浮点数、复数、布尔值和枚举。
  2. 指针和数组。
  3. 结构体。
  4. 联合体。
  5. 函数。
  6. void类型。

类型的转换

显式转换与隐式转换

显式转换专指使用转换运算符(也就是平时所谓的强制类型转换)进行的转换。例如(char *) malloc(100)(size_t)lenght

除了显式类型转换,所有的类型转换均为隐式类型转换。隐式类型转换在赋值、传参、算数运算时都可能会发生。

数字间的类型转换

C语言中最常见的转换就是数字间的转换,数字间转换最常见的就是不同整数间的转换。

整数间的类型转换

C语言定义了五种标准整型和对应的无符号整型,每种有符号整型都对应着一个整型转换级别(rank)。对于五种标准有符号整数,即使类型实际上表示相同(例如64位Linux下的longint),其转换级别也不同。

简单地说,有符号整型的rank如此排序:long long int > long int > int > short int > signed char。除此之外,位宽(也可以说精度)高的整数要比位宽低的整数rank高。

每个有符号整型都有一个与之对应的无符号整型,无符号整数的rank则与其对应的有符号整型相同,即long long int = unsigned long long int > long int

char类型的特殊性

在这之中,char类型非常特殊。char类型具体是有符号还是无符号,是实现定义的。这也就导致了charsigned charunsigned char类型并不一样。作为参考,intsigned int是完全相同的两个类型。

C标准定义char的rank低于signed char,所以还有signed char = unsigned char > char

扩展整数类型

C标准还规定了扩展整数类型,这些类型是由编译器或扩展库额外提供的整型,例如gcc中的int128_t,或stdint.h中提供的int32_tuint64_t等。这些扩展整型的rank要比同位宽的标准整型低。若int为32位,则int = unsigned int > int32_t = uint32_t

整型提升

关于char类型的使用,有两个巨大的坑。一是char类型是否有符号是由实现定义的,二是char类型存在“整型提升”问题。让我们来看下面的C代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdint.h>
int main() {
signed char sc = -1;
unsigned char uc = 1;
printf("uc(1) > sc(-1) = %d\n", uc > sc);
signed long sl = -1;
unsigned long ul = 1;
printf("ul(1) > sl(-1) = %d\n", ul > sl);
int8_t i8 = -1;
uint8_t u8 = 1;
printf("u8(1) > i8(-1) = %d\n", u8 > i8);
int32_t i32 = -1;
uint32_t u32 = 1;
printf("u32(1) > i32(-1) = %d\n", u32 > i32);
unsigned char a = 0x80;
unsigned char b = 0x80;
int c = a+b;
printf("c = %d\n", c);
unsigned int a2 = 0x80000000;
unsigned int b2 = 0x80000000;
long long c2 = a2 + b2;
printf("c2 = %lld\n", c2);
return 0;
}

上面的代码会输出什么呢?对于没有仔细了解过的人来说,结果会很让人吃惊。

对于char,C标准规定可以存储任何基本执行字符集的成员(通常就是ASCII)。而对于int,则规定足以包含头文件limits.hINT_MININT_MAX的最大值(听君一席话,如听一席话)。所以不排除两者位宽相等的可能,但由于rank的约束,绝不可能char的位宽大于int

事实上,整型通常至少2个字节(也就是$[-32768, 32767]$),而char则一定是一个字节。

1
2
3
4
5
6
uc(1) > sc(-1) = 1
ul(1) > sl(-1) = 0
u8(1) > i8(-1) = 1
u32(1) > i32(-1) = 0
c = 256
c2 = 0

出现这种现象的原因就是因为存在“整型提升”。当一个整型的rank比int低时,若int可以表示该类型的所有值,那么该类型就会转换到int,若int不能表示该类型的所有值,就转换到unsigned int

可能有读者会有疑问:要是连unsigned int也不能表示该类型的所有值呢?但这其实不会发生。因为一个整型的rank比另一个低的必要条件是该整型的位宽小于或等于另一个,所以unsigned int必然能表示rank低于它的整型的所有值。

对于两种char类型,因为它们的rank比int低,且在64位Linux下位宽比int低。所以在进行算数运算时,会先提升到int。此时1和-1比较,自然是1比较大。类似的,由于ab都被提升到int,所以128+128并不会发生溢出,可以被类型为intc完美接收。

相反,long slunsigned long ul就不会发生类型提升。此时有符号被转换为无符号,变成了unsigned long的最大值,所以ul(1) > sl(-1)为假。而int32_t i32uint32_t u32虽然rank比int低,但其位宽与int相同,所以提升时被转为了unsigned int。类似的,两个无符号整型无法再提升,所以0x80000000 + 0x80000000会直接发生溢出,即使c2的类型为long long也无法得到正确的计算结果。

整型提升很大程度上是为了照顾硬件的实现。主流CPU的寄存器多为32位或64位,并不存在8位寄存器。所以即使是8位的char类型执行算数操作,也只能在32位的寄存器内实现。如果没有提升机制,会导致每次计算后都需要额外的指令来清除寄存器中多余的部分,拖慢整体运行速度。这也是C语言为了性能考虑。当然,如果操作数和结果的类型严格一致,无论是否发生提升,在主流平台上,结果都应该是一致的。

例如下面的ARM64汇编,就展示了ARM64架构中只能使用32位的寄存器w0w1来进行char类型的相加操作。X86虽有%sil寄存器别名,但也并非所有指令都支持该别名。

1
2
3
4
5
6
extern char a;
extern char b;
int main() {
char c = a + b;
printf("c = %d\n", c);
}
1
2
3
4
ldrb    w1, [x0, #:lo12:a]
adrp x0, b
ldrb w0, [x0, #:lo12:b]
add w1, w1, w0

整数的二进制表示

C语言标准并未严格规定整数一定要使用补码(2’s Complement Code),也可以使用原码(True form)或反码(1’s Complement Code)。但当前主流平台及编译器(Windows, Linux, Mac on X86, ARM, MIPS…)均使用补码表示。

整数、浮点数、复数间的类型转换

看完了地狱般的整数间的类型转换,浮点数和复数间的转换就显得轻松得多。C标准定义了三种浮点数:floatdoublelong double,其长度分别为32、64、128位。

整数与浮点数转换

当有限的浮点数转整数时,该浮点数首先会舍弃小数部分进行取整,若整型可以表示取整后的值,则保留该值。若无法表示,则行为未定义。

也就是所谓的向0取整。例:(int)7.8 == 7(int)(-9.5) == -9

当整数转浮点数时,若浮点数可以精确表示该整数,则保留整数的值。若无法精确表示该整数,则由实现定义取最接近的较高或较低的可表示值。若整数超出了浮点数的表示范围,则行为未定义。

即使是UINT64_MAXfloat也不会超出其表示范围,所以通常使用不必担心。对于自定义类型,请参考对应的文档。

浮点数间的转换

与整数转浮点数类似。若新类型可以精确表示原类型,则保留原类型的值。若无法精确表示,则由实现定义取最接近的较高或较低的可表示值。若超出了新类型的表示范围,则行为未定义。

复数间转换

C语言提供了可选的复数类型_Complex支持,但在实际编程中大多数程序员不会使用,这里只简单一提。

复数的类型与浮点数相同,包括float _Complexdouble _Complexlong double _Complex,只是复数还有一个虚部。复数间转换实部与虚部都遵循浮点数间转换的规律。若复数转浮点数,则虚部被丢弃;若浮点数转复数,则虚部为0。

布尔类型

布尔类型是C99新增的一种类型,此前C中并没有独立表示真假的布尔类型,通常是程序员根据需要,采用intchar等整数类型代替。C语言会将任何0值认为是false值。

没有独立的布尔值带来了一些局限性,最大的问题就是逻辑算符与算术算符结果的不统一。例如1 & 2 == 0,但是1 && 2 == 1。为此C99新增了独立的布尔类型_Bool,在#include <stdbool.h>后可以直接使用bool作为类型名。布尔类型只有0和1两种可能的值,统一了逻辑算符和数字算符的结果,允许编译器进行更激进的优化。

同样作为抽象的整数类型,_Bool类型的rank比其他所有整数类型的rank都要低。即signed char = unsigned char > char > _Bool

其他类型与布尔类型间的转换

数字和指针类型到布尔类型的转换非常简单,任何非0值都为true,而0值则为false。布尔值转换到数字,false会转为0,而true则会转为1。

例如,空指针NULL00.00 + 0j均会被转为falseinfnan0 + 10j都会转为true

复数转实数类型仅保留实部,但是转布尔类型时,当且仅当实部和虚部都为0时,结果才是false

通用算数转换

通用算数转换是最常见的隐式类型转换的模式,用于匹配算数符号两侧操作数的类型,以及确定结果的类型。

注意:不仅当算符两侧操作数类型不一致时会发生通用算数转换,但由于整型提升的存在,即使两侧操作数类型一致,也会发生通用算数转换。

通用算数类型转换先看是否有浮点数。若算符两侧任意一侧有浮点数,则另一侧也转为对应的浮点数。若两侧都为浮点数,则精度低的会转为精度高的。

精度排名: long double > double > float

例如long double + float => long double + long doubledouble + int => double + double

若不存在浮点数,则先进行整型提升。这里非常重要,即使两侧类型一致,也要先进行整型提升

然后根据以下规则转换。

如果两个操作数都为有符号或都为无符号,那么rank低的会转向rank高的;如果符号不一致,则位宽低的向位宽高的转换(就不提rank这个坑货了);如果位宽一致,则有符号会转为无符号。

C标准在这里写了很多晦涩难懂的Otherwise,我就总结成了上面一句话。

通用算数转换涵盖了所有二元算数运算符(不包含逻辑与或非)及C中唯一的三元条件表达式。对于三元条件表达式,很显然条件部分不参与转换。需要注意的是,由于整型提升的存在,即使是一元运算符也会发生这种转换。但赋值操作不在通用算数转换的范围内。

另外,无参数原型函数及可变参数函数的变参部分也会发生整型提升。

指针与数组的类型转换

指针与整数的转换

指针和整数间可以互相转换。但结果是实现定义的。在三种实现(gcc, clang, msvc)中,整数转为指针是没有问题的,当然,指向地址的有效性需要程序员来保证。而指针转整数,则可能会遇到不在整型范围内的情况。当然,为避免指针转整数出现上述问题,C标准中的标准库部分提供了可选的(但应该都有)intptr_tuintptr_t来保证指针转到该整数类型再转回,一定与原指针相同。

数组与指针的转换

数组到指针的隐式转换一直是C中的一个常见问题。早年间各种“数组就是指针”、“数组是由指针实现的”等等毫无理解的错误发言层出不穷。不过如今正确观念相比以前普及了很多,逐渐正确的声音开始占领舆论场,这让人欣慰不少。

首先必须要明确的一点是,数组与指针是完全不同的两种类型,它们不仅在C中的抽象语义不同,在实现上也是不同的,绝不能把数组和指针混为一谈。

C中很多数组的迷惑行为都是隐式类型转换导致的。而数组很容易发生隐式类型转换,从包含n个类型为T的元素的数组转换为指向T的指针,该指针指向数组首元素,只有以下三种情况才不会发生上述转换。

  1. 使用sizeof运算符获取数组大小。
  2. 使用一元&运算符取数组引用。
  3. 数组是用于初始化字符串数组的字符串字面量。(没错,字符串字面量是一个数组)

所以,很多问题都可以通过隐式类型转换来解释。

  • 为什么函数不能返回数组局部变量,但是却可以返回包含数组成员的结构体的局部变量?
    因为数组局部变量会隐式转换为指向数组首元素的指针,返回局部变量的指针自然有问题。但包含数组的结构体则不会发生隐式转换,会将整个结构体拷贝给返回值。注:事实上C标准直接规定了函数不能返回数组。
  • 为什么函数参数不能是数组?
    因为数组作为参数也会隐式转换为指向数组首元素的指针,根本无法直接传递数组。
  • 为什么数组不能赋值?
    因为数组会转换为指针,让一个数组=指针肯定是无法编译的。
  • 为什么数组不能自增、自减?
    因为自增自减也相当于重新给数组赋值,所以原因同上。

吐槽一下,既然都是放到通用寄存器里,都能拿来寻址,那是不是整数也是指针,整数也是指针实现的?

指针间的类型转换

C语言中void *类型的指针可以隐式转为任何类型的指针,反之,任何类型的指针也都可以隐式转为void *类型的指针。

这是一种很常见的转换,例如下面的代码就是C中常用的一些情况。

1
2
3
4
5
#define BUFLEN 1024UL
// 函数签名: void *malloc(size_t sz);
char *buf = malloc(BUFLEN);
// 函数签名: size_t fread(void *buf, size_t size, size_t nmemb, FILE *stream)
fread(buf, sizeof(*buf), BUFLEN, fd);

通过将指针类型声明为void *,可以有效存储各种类型的指针。特殊地, ((void *)0)在C中被定义为空指针NULL。任何类型的空指针都是相等的。

在C++中,NULL不再被定义为((void *)0),而是直接被定义为0L。因为C++为了支持函数重载及基于构造函数的隐式类型转换,禁止了void *与其他指针类型间的隐式转换,为了兼容C,只能让数字隐式转换为指针。

除了void *类型的指针外,其他类型的指针间只能进行强制类型转换。C不保证转换后的指针是否可以对齐,若没有正确对齐,会产生未定义行为。

C中需要进行不同指针类型转换的情况并不多,一个常见的场景是Linux内核链表,会通过宏频繁在链表头和数据之间发生强制类型转换。

还有一种需求是以另一种类型重新解释内存。例如整型1000转为浮点数,结果是1000.0,但如果就想知道1000这个整数的这些比特原封不动地解释为浮点数对应的值是什么,就可以通过指针的强制类型转换。例如大名鼎鼎的wtf快速平方根倒数算法就利用了这个能力。把传入的浮点数当作整数看待,进行了整数运算后再重新当浮点数看待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float Q_rsqrt( float number )
{
long i;
float x2, y;
const float threehalfs = 1.5F;

x2 = number * 0.5F;
y = number;
i = * ( long * ) &y; // evil floating point bit level hacking(邪恶的浮点数位运算黑科技)
i = 0x5f3759df - ( i >> 1 ); // what the fuck?(这是什么鬼?)
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration (第一次迭代)
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed(第二次迭代,可以删除)
return y;
}

函数到函数指针的隐式类型转换

在C中,函数是一个很特殊的存在。与很多现代语言的“一等公民函数”不同,C语言的函数不能进行任何运算,只能进行调用。和数组类似,除非是作为sizeof操作符或&操作符的操作数,否则函数都会隐式转换为“指向该函数类型的指针”。

但是C标准还规定,函数不能作为sizeof的操作数。所以函数的唯二作用就是被调用和隐式转换为指针。

1
2
3
void * (*allocator1)(size_t sz) = malloc;
// 等价于下面的写法
void * (*allocator2)(size_t sz) = &malloc;
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2019-2025 Ytyan

请我喝杯咖啡吧~