C语言类型转换
关于C语言类型转换的整理笔记。
概述
在C语言中,对于A->B
的类型转换过程中,无论是C语言风格的显式转换
(type)value
(强制类型转换),还是隐式转换(自动类型转换,编译器自动推断目标类型),只要转换前后的类型一致,它们都遵循相同的转换规则,因此无损转换、截断、取模、UB
等可能的表现都完全一致。
如果A->B
的转换过程是非法的,那么无论是显式还是隐式转换,都无法成功编译。
如果A->B
的转换过程是合法的,那么都可以顺利编译,但是存在如下细微区别:
- 如果转换过程较为自然且安全,那么使用隐式转换和显式转换没有任何意义的区别;
- 如果转换过程较为危险,转换前后可能丢失数据,那么:
- 使用隐式转换时,编译器或静态代码检查可能会发出警告;
- 使用显式转换可以抑制这些警告,这表明我们手动确认这个转换是安全的。
注:
- 隐式转换通常发生在赋值(包括初始化和函数传参)和算术运算中;
- gcc对于涉及隐式转换的警告比较保守,很多情况下的警告需要使用
-Wconversion
手动开启。
由于本文就是探究类型转换的细节,因此在示例中更倾向于使用显式转换,以增强代码的可读性,当然在很多时候都可以使用更简单的隐式转换。
内建数值类型转换语义
整数转换为整数:
- 如果原始整数值可以在目标整数类型下 精确表达,则转换是无损的。
- 如果原始值超出目标类型的表示范围,则转换是有损的:
- 转换为无符号整数类型时,结果等于该值对目标类型的取值范围取模。
- 转换为有符号整数类型时,如果值超出目标类型的表示范围,行为是实现定义的(可能导致值截断、溢出、甚至不同平台表现不同)。
1 | void test_int() { |
浮点数转换为浮点数:
- 如果原始浮点数值可以在目标浮点数类型下 精确表达,则转换是无损的。
- 否则转换是有损的,结果是目标类型可表示范围内 向 0 舍入 的最接近浮点数。
1 | void test_float() { |
整数转换为浮点数:
- 结果是数学意义下最接近原始整数值的浮点数:
- 在大多数情况下是无损的。
- 一旦超过尾数部分的有效位数,转换就是有损的。
- 如果整数值的精度超出了目标浮点数类型的有效位数,则转换可能是有损的(例如 long long 转 float 可能会丢失精度)。
1 | void test_int_float() { |
浮点数转换为整数:
- 结果是向 0 舍入的整数:
- 在大多数情况下是有损的。
- 如果浮点数恰好是整数,且处于整数类型的范围内,则转换是无损的。
- 如果转换后的整数值超出了目标整数类型的表示范围,行为是未定义的。
1 | void test_float_int() { |
小结一下:如果值在目标类型中可以精确表示,那么转换过程就是无损的,如果值只能近似表示,那么就会近似,如果值超出目标类型合法范围,就会出现未定义等其它问题。
赋值中的隐式转换
例如在使用不同基本类型的数据进行初始化或赋值时,会自动尝试隐式类型转换
1
float a = 100; // int -> float
赋值对应的类型转换可能会损失精度,例如 1
2int a = 1.1; // double -> int
// a = 1
在使用不同类型的数据进行算术运算并用于初始化时,也要注意类型转换问题,例如
1
2double a = 2.0/3; // 0.6666667
double b = 2/3; // 0
这里2.0/3
会将分母自动转换为浮点数进行运算,而2/3
只会执行整数除法,这导致两者结果不同。
算术中的隐式转换
对于涉及浮点数的二元算术运算,满足“精度提升”规则:自动将低精度类型提升为高精度类型进行运算,具体而言就是
- 如果其中一个数为
double
,会试图将另一个数(无论是整数类型还是float
)转换为double
- 否则,如果一个数为
float
,会试图将另一个数(整数类型)转换为float
例如 1
2
3float f = 3.5f;
double d = 2.7;
f + d; // double
对于只涉及整数的二元算术运算,满足如下规则:
- 首先,对于那些范围小于
int
的整数类型(例如char
,bool
),将其自动提升为int
类型,提升的过程保持值不变(包括符号)。(CPU更适合int
级别的整数运算) - 然后比较两者的类型:
- 如果两个类型一样,即为公共类型,无须继续转换
- 否则,两个类型不同:
- 如果两个类型均为有符号或无符号类型,则自动将小类型转换为大类型
- 否则,一个为有符号类型
T1
,一个为无符号类型unsigned T2
,继续判断:- 如果
T1
的等级比unsigned T2
不低,则T1
转换为unsigned T2
- 否则,
T1
的等级比unsigned T2
高- 如果
T1
可以包括unsigned T2
,则unsigned T2
转换为T1
, - 否则,将
T1
和unsigned T2
均转换为unsigned T1
,
- 如果
- 如果
例如 1
2
3
4
5
6
7
8
9
10
11char a1 = 5;
int b1 = 10;
a1 + b1; // int
int a2 = -5;
unsigned int b2 = 10;
a2 + b2; // unsigned int
int a3 = -1;
unsigned int b3 = 1;
a3 + b3; // unsigned int
结构体类型转换
C
语言不允许我们直接对两个结构体类型进行相互转换,即使它们具有完全相同的尺寸和内存布局,下面的代码无法通过编译。
1
2
3
4
5
6
7
8
9
10
11
12
13
14struct A {
int m;
};
struct B {
int n;
};
int main() {
struct A a;
struct B b = a; // compile error
return 0;
}
但是我们可以通过两种做法绕过这个限制:
- 直接
memcpy
拷贝内存 - 通过指针类型转换(见下文)
基于memcpy
的做法例如 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct A {
int m;
};
struct B {
int n;
};
int main() {
struct A a;
a.m = 10;
struct B b;
memcpy(&b, &a, sizeof(struct A));
return 0;
}
指针类型转换语义
所有的指针其实都只是记录了指向的目标地址,不同类型的指针大小都是一致的(32位系统下为4字节,64位系统下为8字节),
不同指针类型之间可以进行转换,转换过程既不会改变指针本身,也不会改变目标地址中的数据,只是新指针会将目标地址的数据按照新类型重新解释,
以此进行的读取操作可能得到无意义的值,例如 1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
int a = -1;
unsigned int *p1 = (unsigned int *)&a;
printf("%u\n", *p1); // 4294967295
int *p2 = (int*)p1;
printf("%d\n", *p2); // -1
return 0;
}
这里指针类型转换并没有导致a
所在的4个字节数据发生更改,只是按照无符号整数的规则进行了重新解释,读取的结果就发生了变化,
重新转换回int *
后,按照有符号整数的规则进行重新解释,仍然可以得到原本的值。
更危险的是写入操作:在转换前后类型大小不一致时,写操作可能破坏存储在附近的数据,例如
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
int main() {
int a = 0;
int b = 0;
double *p = (double *)&a;
*p = 1.24;
printf("[&a = %p, &b = %p] a = %d, b = %d\n", &a, &b, a, b);
printf("[p = %p] *p = %.12f\n", p, *p);
*p = 0.62;
printf("[p = %p] *p = %.12f\n", p, *p);
printf("[&a = %p, &b = %p] a = %d, b = %d\n", &a, &b, a, b);
*p = 0;
printf("[p = %p] *p = %.12f\n", p, *p);
printf("[&a = %p, &b = %p] a = %d, b = %d\n", &a, &b, a, b);
return 0;
}
运行结果如下 1
2
3
4
5
6[&a = 0x7ffcd90e2f48, &b = 0x7ffcd90e2f4c] a = 1030792151, b = 1072944906
[p = 0x7ffcd90e2f48] *p = 1.240000000000
[p = 0x7ffcd90e2f48] *p = 0.620000000000
[&a = 0x7ffcd90e2f48, &b = 0x7ffcd90e2f4c] a = 1030792151, b = 1071896330
[p = 0x7ffcd90e2f48] *p = 0.000000000000
[&a = 0x7ffcd90e2f48, &b = 0x7ffcd90e2f4c] a = 0, b = 0
在这个例子中,我们将两个连续的int
数据强行解释为一个double
数据,并按照double
的规则进行多次读写,这完全破坏了原来的两个int
数据。
可以通过指针的类型转换实现不同结构体之间的转换,即按照转换后的结构体的规则对原始数据进行重新解释,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct A {
int m;
};
struct B {
int n;
};
int main() {
struct A a;
a.m = 10;
struct B *p = (struct B *)&a;
struct B b = *p;
printf("a.m = %d\n", a.m); // 10
printf("b.n = %d\n", b.n); // 10
return 0;
}
和前面的例子一样,读写操作仍然是非常危险的。除此之外,结构体还存在内存对齐的问题,这会直接影响结构体转换后的重新解释。
一个实际的例子是socket编程中,socket
API所使用的结构体是通用的sockaddr
1
2
3
4struct sockaddr {
sa_family_t sa_family; // 地址族(2 字节)
char sa_data[14]; // 具体协议的地址信息(14 字节)
};
例如bind
接收的参数类型就是const struct sockaddr *
1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
对于具体的IPv4,我们通常使用更具体的sockaddr_in
1
2
3
4
5
6
7
8
9
10
11struct sockaddr_in {
sa_family_t sin_family; // 地址族(2 字节)
in_port_t sin_port; // 端口号(2 字节)
struct in_addr sin_addr; // IPv4 地址(4 字节)
char sin_zero[8]; // 填充字节,以保证和 sockaddr 大小一致
};
// IPv4 地址结构体(4 字节)
struct in_addr {
uint32_t s_addr; // IPv4 地址(4 字节)
};
两个结构体具有相同的大小,在传参时使用类型转换,例如
1
bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr));
void * 通用指针类型
void*
是 C
语言中的通用指针类型,可以指向任何类型的数据,void*
与其他指针类型之间的转换非常常见。
但是需要注意的是,由于void *
指针没有体现任何类型信息,无法直接解引用进行读写操作,必须转换为其他指针类型后才能对指向的地址进行读写(否则编译器报错)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
int i = 42;
double d = 3.14;
void *ptr1 = (void *)&i;
printf("%d\n", *(int *)ptr1); // 42
void *ptr2 = (void *)&d;
printf("%f\n", *(double *)ptr2); // 3.140000
return 0;
}
由于指针的转换本身并没有修改数据,因此可以保证:任何指针被转换成void *
再转换回来,得到的结果与原指针比较相等。
很多通用函数的参数指针和返回值指针都会使用void *
类型,例如
1
2
3void sort(void* array, size_t size, size_t elem_size, int (*cmp)(const void*, const void*));
void* memcpy(void* dest, const void* src, size_t n);
malloc
的返回值通常需要进行指针类型转换
1
2
3
4
5int *arr = (int *)malloc(5 * sizeof(int));
if (arr == NULL) {
printf("Memory allocation failed!\n");
return 1;
}
指针和整数类型转换语义
指针类型还可以直接和整数类型进行转换,此时的语义:
- 指针转换为整数:获取指针指向的内存地址
- 整数转换为指针:将整数值视作一个内存地址,转换后的指针可以被用来访问对应内存地址中的对象。
不同整数类型存在有无符号和范围的差异,但是显然内存地址是无符号的,并且位数和平台有关,
在不同平台上最适合存储指针地址的整数类型是uintptr_t
,使用这个类型可以避免不同平台之间的差异(例如unsigned long
的位数不同)。
例如 1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
uintptr_t addr = 0x7ffee3b2b0a0;
int *ptr1 = (int *)addr;
int a = 42;
int *ptr2 = &a;
uintptr_t addr2 = (uintptr_t)ptr2;
return 0;
}
总体来说,这种做法都不推荐,因为可能破坏代码的可移植性,两种转换在隐式进行时,编译器都会发出警告。
涉及指针的隐式转换
指针类型之间的转换通常都允许隐式进行,没有必要使用显式转换。
C++对于涉及指针的类型转换,语法上的要求更加严格,可能会比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
void print_array(int *arr, int size) {
for (int i = 0; i < size; i++) { printf("%d ", arr[i]); }
printf("\n");
}
void say_hello() {
printf("Hello\n");
}
void call_function(void (*func)()) {
func(); // 调用函数指针
}
int main() {
int arr[] = {1, 2, 3, 4, 5};
print_array(arr, 5);
call_function(say_hello);
return 0;
}
涉及const修饰的类型转换
对于普通的值类型,在赋值时加上或移除const
限制都是非常自然的操作,不存在任何问题,完全不需要使用显式转换
1
2
3int a = 10;
const int b = a;
int c = b;
给指针加上或移除顶层const
,以及volatile
修饰符也是可以的,例如
1
2
3
4int a = 10;
int *p = (int *)&a;
const int *p2 = (const int *)p;
int *p3 = (int *)p2; // 移除const的隐式转换会触发警告
其中:
- 添加
const
只是给加上了指向的目标加上可读限制,编译器可以隐式进行; - 移除
const
可能存在风险,编译器会警告,可以通过显式转换来消除警告。
小结
C语言风格的类型转换语法实在是过于灵活宽松了,而且它将下面两类完全不同的转换语义混杂在了一起:
- 二进制数据转换(例如数值类型的转换)
- 二进制数据不变,但是按照新类型重新解释
这还导致在编程中都难以对类型转换进行排查。