关于C语言类型转换的整理笔记。

概述

在C语言中,对于A->B的类型转换过程中,无论是C语言风格的显式转换 (type)value(强制类型转换),还是隐式转换(自动类型转换,编译器自动推断目标类型),只要转换前后的类型一致,它们都遵循相同的转换规则,因此无损转换、截断、取模、UB 等可能的表现都完全一致。

如果A->B的转换过程是非法的,那么无论是显式还是隐式转换,都无法成功编译。 如果A->B的转换过程是合法的,那么都可以顺利编译,但是存在如下细微区别:

  • 如果转换过程较为自然且安全,那么使用隐式转换和显式转换没有任何意义的区别;
  • 如果转换过程较为危险,转换前后可能丢失数据,那么:
    • 使用隐式转换时,编译器或静态代码检查可能会发出警告;
    • 使用显式转换可以抑制这些警告,这表明我们手动确认这个转换是安全的。

注:

  • 隐式转换通常发生在赋值(包括初始化和函数传参)和算术运算中;
  • gcc对于涉及隐式转换的警告比较保守,很多情况下的警告需要使用-Wconversion手动开启。

由于本文就是探究类型转换的细节,因此在示例中更倾向于使用显式转换,以增强代码的可读性,当然在很多时候都可以使用更简单的隐式转换。

内建数值类型转换语义

整数转换为整数:

  • 如果原始整数值可以在目标整数类型下 精确表达,则转换是无损的。
  • 如果原始值超出目标类型的表示范围,则转换是有损的:
    • 转换为无符号整数类型时,结果等于该值对目标类型的取值范围取模。
    • 转换为有符号整数类型时,如果值超出目标类型的表示范围,行为是实现定义的(可能导致值截断、溢出、甚至不同平台表现不同)。
1
2
3
4
5
6
7
8
9
10
11
12
13
void test_int() {
int i1 = 100;
short s = (short)i1; // 范围内,无损转换
printf("%d -> %d\n", i1, s); // 100 -> 100

int i2 = -1;
unsigned int u1 = (unsigned int)i2; // 超出范围,溢出取模
printf("%d -> %u\n", i2, u1); // -1 -> 4294967295 (假定 32 位 int)

unsigned int u2 = 300;
char c = (char)u2; // 超出范围,实现定义
printf("%u -> %d\n", u2, c); // 300 -> 44
}

浮点数转换为浮点数:

  • 如果原始浮点数值可以在目标浮点数类型下 精确表达,则转换是无损的。
  • 否则转换是有损的,结果是目标类型可表示范围内 向 0 舍入 的最接近浮点数。
1
2
3
4
5
6
7
8
9
10
11
12
13
void test_float() {
double d1 = 3.14;
float f1 = (float)d1; // float不可以精确表示,无损转换
printf("%.12f -> %.12f\n", d1, f1); // 3.140000000000 -> 3.140000104904

double d2 = 3.25;
float f2 = (float)d2; // float可以精确表示,无损转换
printf("%.12f -> %.12f\n", d2, f2); // 3.250000000000 -> 3.250000000000

double d3 = 1.234567890123456; // 16 位有效数字
float f3 = (float)d3; // float无法精确表示,近似转换
printf("%.12f -> %.12f\n", d3, f3); // 1.234567890123 -> 1.234567880630
}

整数转换为浮点数:

  • 结果是数学意义下最接近原始整数值的浮点数:
    • 在大多数情况下是无损的。
    • 一旦超过尾数部分的有效位数,转换就是有损的。
  • 如果整数值的精度超出了目标浮点数类型的有效位数,则转换可能是有损的(例如 long long 转 float 可能会丢失精度)。
1
2
3
4
5
6
7
8
9
10
void test_int_float() {
int i1 = 42;
float f1 = (float)i1; // 42 可被 float 精确表示
printf("%d -> %.12f\n", i1, f1); // 42 -> 42.000000000000

long long l1 = 1234567890123456789LL; // 超过 float 精度
float f2 = (float)l1; // float无法精确表示,近似转换
printf("%lld -> %f\n", l1,
f2); // 1234567890123456789 -> 1234567939550609408.000000
}

浮点数转换为整数:

  • 结果是向 0 舍入的整数:
    • 在大多数情况下是有损的。
    • 如果浮点数恰好是整数,且处于整数类型的范围内,则转换是无损的。
  • 如果转换后的整数值超出了目标整数类型的表示范围,行为是未定义的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void test_float_int() {
double d1 = 9.99;
int i1 = (int)d1; // 向 0 舍入取整
printf("%f -> %d\n", d1, i1); // 9.990000 -> 9

double d2 = -3.75;
int i2 = (int)d2; // 向 0 舍入取整
printf("%f ->%d\n", d2, i2); // -3.750000 ->-3

double d3 = 1e20; // 超 int 范围
int i3 = (int)d3; // 未定义行为
printf("%f -> %d\n", d3,
i3); // 100000000000000000000.000000 -> -2147483648
}

小结一下:如果值在目标类型中可以精确表示,那么转换过程就是无损的,如果值只能近似表示,那么就会近似,如果值超出目标类型合法范围,就会出现未定义等其它问题。

赋值中的隐式转换

例如在使用不同基本类型的数据进行初始化或赋值时,会自动尝试隐式类型转换

1
float a = 100; // int -> float

赋值对应的类型转换可能会损失精度,例如

1
2
int a = 1.1; // double -> int
// a = 1

在使用不同类型的数据进行算术运算并用于初始化时,也要注意类型转换问题,例如

1
2
double a = 2.0/3; // 0.6666667
double b = 2/3; // 0

这里2.0/3会将分母自动转换为浮点数进行运算,而2/3只会执行整数除法,这导致两者结果不同。

算术中的隐式转换

对于涉及浮点数的二元算术运算,满足“精度提升”规则:自动将低精度类型提升为高精度类型进行运算,具体而言就是

  • 如果其中一个数为double,会试图将另一个数(无论是整数类型还是float)转换为double
  • 否则,如果一个数为float,会试图将另一个数(整数类型)转换为float

例如

1
2
3
float f = 3.5f;
double d = 2.7;
f + d; // double

对于只涉及整数的二元算术运算,满足如下规则:

  • 首先,对于那些范围小于int的整数类型(例如charbool),将其自动提升为int类型,提升的过程保持值不变(包括符号)。(CPU更适合int级别的整数运算)
  • 然后比较两者的类型:
    • 如果两个类型一样,即为公共类型,无须继续转换
    • 否则,两个类型不同:
      • 如果两个类型均为有符号或无符号类型,则自动将小类型转换为大类型
      • 否则,一个为有符号类型T1,一个为无符号类型unsigned T2,继续判断:
        • 如果T1的等级比unsigned T2不低,则T1转换为unsigned T2
        • 否则,T1的等级比unsigned T2
          • 如果T1可以包括unsigned T2,则unsigned T2转换为T1
          • 否则,将T1unsigned T2均转换为unsigned T1

例如

1
2
3
4
5
6
7
8
9
10
11
char 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
14
struct 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
#include <string.h>

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
#include <stdio.h>

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
#include <stdio.h>

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
#include <stdio.h>

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
4
struct 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
11
struct 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
#include <stdio.h>

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
3
void 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
5
int *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
#include <stdint.h>

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
#include <stdio.h>

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
3
int a = 10;
const int b = a;
int c = b;

给指针加上或移除顶层const,以及volatile修饰符也是可以的,例如

1
2
3
4
int a = 10;
int *p = (int *)&a;
const int *p2 = (const int *)p;
int *p3 = (int *)p2; // 移除const的隐式转换会触发警告

其中:

  • 添加const只是给加上了指向的目标加上可读限制,编译器可以隐式进行;
  • 移除const可能存在风险,编译器会警告,可以通过显式转换来消除警告。

小结

C语言风格的类型转换语法实在是过于灵活宽松了,而且它将下面两类完全不同的转换语义混杂在了一起:

  • 二进制数据转换(例如数值类型的转换)
  • 二进制数据不变,但是按照新类型重新解释

这还导致在编程中都难以对类型转换进行排查。