数据类型详解¶
Abstract
类型决定一块内存按什么规则解释, 以及运算前要不要先改解释方式.
C 故意不把每种整型的宽度和 char 的符号性写死, 好让编译器迁就具体 CPU. 代价是同一段源码换平台后, 常量的类型, 比较的结果, 溢出的行为都可能变.
约定¶
-
要可移植的宽度时用
<stdint.h>的int32_t/uint64_t, 不要赌long是 32 位还是 64 位. -
char拿来存 8 位整数时写明signed或unsigned. 只存 ASCII 时可以不写. -
有符号和无符号不要混在同一个运算或比较里. 混用时负数会按模 \(2^N\) 变成很大的正数.
-
字面量的类型在参加运算之前就定了.
long long x = a * b救不了两个int先乘爆. -
不带后缀的浮点字面量是
double. 要float写3.14f. -
getchar的返回值用int接. 它不是char.
整型¶
标准¶
sizeof(char) == 1. 这个 "1" 的单位是字节, 一个字节至少 8 bit, 常见平台就是 8 bit.
char, signed char, unsigned char 是三种类型. 后两种的符号性是确定的. 不写 signed / unsigned 的 char 是有符号还是无符号, 由实现决定, 必须写进编译器文档. x86 上的 gcc 和 clang 把 char 当成有符号, 范围 \(-128 \sim 127\). 换一个把 char 当成无符号的编译器, 同一份源码里 char 的取值范围就变成 \(0 \sim 255\).
ASCII 落在 \(0 \sim 127\), 两种实现都能存. 所以字符用 char 即可. 把 char 当 8 位整数做算术或比较, 就必须写明符号性.
short, int, long, long long 不写符号关键字时一定是有符号的, 这一点不是实现自选. 它们都可以再加 signed 或 unsigned.
标准只保证最小宽度和一条大小关系:
-
short至少 16 位,int至少 16 位,long至少 32 位,long long至少 64 位 -
sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)
有符号数在机器里用原码, 反码还是补码, C23 之前也是实现自选. 主流平台一直用补码. C23 起标准要求补码. 下面的范围都按补码算.
位域 (bit-field) 也是整型的一种, 出现在结构体里, 这里不展开.
三种常见数据模型¶
默认平台是 x86 / Linux / gcc, 32 位, 数据模型 ILP32, 并且 char 有符号. 现在的 64 位 Linux 和 macOS 是 LP64. 64 位 Windows 是 LLP64, long 仍然是 32 位.
| 类型 | ILP32 | LP64 (Linux / macOS 64 位) | LLP64 (Windows 64 位) |
|---|---|---|---|
char | 8 | 8 | 8 |
short | 16 | 16 | 16 |
int | 32 | 32 | 32 |
long | 32 | 64 | 32 |
long long | 64 | 64 | 64 |
| 指针 | 32 | 64 | 64 |
名字的含义: ILP32 表示 int, long, 指针都是 32 位. LP64 表示 long 和指针是 64 位. 指针宽度跟地址空间走, 所以 64 位系统上指针是 64 位.
int 在这三套模型里都是 32 位, 所以多数算术用 int 没有移植问题. 真正容易踩坑的是 long: 同一份代码在 Linux 64 位上是 64 位, 在 Windows 64 位上是 32 位. 要固定宽度就用 stdint.h, 见 C Basics.
三种行为¶
| 用语 | 编译器必须怎么做 | 出错时通常发生什么 |
|---|---|---|
| Implementation-defined | 必须选定一种行为, 并写进文档 | 换编译器结果可能变, 但在同一套文档里是确定的 |
| Unspecified | 可以在几种合法行为里自选, 无需写进文档 | 同一编译器的不同版本也可能变 |
| Undefined | 标准不要求任何行为 | 可能看起来能跑, 也可能被优化成完全另一段程序 |
char 的符号性是 implementation-defined. 函数实参从左到右还是从右到左求值是 unspecified. 有符号整数溢出, 数组越界, 是 undefined. 写可移植代码就是避开后两类, 并且对第一类显式表态 (signed char 而不是裸 char).
整数常量¶
进制:
-
十进制:
1234. 不能以0开头, 否则变成八进制. -
八进制:
022, 数字只能是0~7. -
十六进制:
0x7fff或0X7fff. -
二进制:
0b1010. 长期只是 gcc 扩展. C23 起进入标准. C17 及更早的代码如果要可移植, 仍用十六进制.
后缀 (大小写均可): u 无符号, l 表示 long, ll 表示 long long. 可以组合, 如 0x1234U, 98765ULL.
后缀并不等于"这个常量就是该关键字的类型". 编译器拿着一张候选表, 从上到下选第一个装得下这个值的类型:
| 后缀 | 十进制, 按顺序尝试 | 八进制或十六进制, 按顺序尝试 |
|---|---|---|
| 无 | int, long, long long | int, unsigned int, long, unsigned long, long long, unsigned long long |
u | unsigned int, unsigned long, unsigned long long | 同左 |
l | long, long long | long, unsigned long, long long, unsigned long long |
ul | unsigned long, unsigned long long | 同左 |
ll | long long | long long, unsigned long long |
ull | unsigned long long | 同左 |
十进制和十六进制的候选表不一样, 十进制不会去选无符号类型, 八进制和十六进制会.
int 为 32 位时, 0x7fffffff 装得进 int, 类型是 int. 0xffff0000 装不进 int (最高位是 1, 按有符号解释会变成负数, 而这个字面量要表示的是正数), 下一候选 unsigned int 装得下, 所以类型是 unsigned int. 这一点在 LP64 上同样成立: 候选表里 unsigned int 排在 long 前面, 不会因为 long 有 64 位就改选 long.
字符常量和枚举常量的类型是 int, 不是 char.
-2147483648 往往不是 int 常量
int 的最小值在 32 位补码下是 \(-2147483648\), 但源码里的 -2147483648 是 "常量 2147483648" 再取负, 不是一个整数常量.
2147483647 才是 32 位 int 的最大值, 所以 2147483648 装不进 int.
-
LP64:
long是 64 位, 装得下, 整个表达式的类型是long.printf("%d", -2147483648)会警告, 因为%d要的是int. -
ILP32:
long也是 32 位, 装不下, 类型继续落到long long.
要得到 int 的最小值, 写 (-2147483647 - 1), 或用 <limits.h> 里的 INT_MIN.
乘法乘积的类型在赋值之前就定了
long long i = 1234567890 * 1234567890; // 两个 int 先乘, 乘积超出 int
有符号溢出是 undefined behavior. 右边先按 int 算, 赋给 long long 时已经晚了.
若改成 1234567890LL * 1234567890, 其中一个操作数是 long long, 另一个会先被转换再相乘.
另一个常见后果: 在 LP64 上 long x = 0xffffffff; 得到的不是 \(-1\). 0xffffffff 的类型是 unsigned int, 值是 \(4294967295\). 再转成 64 位 long 时这个值装得下, 所以 x 是 \(4294967295\), 高 32 位是 0. 想要全 1 的 long, 应该写 -1L 或 ~0L.
浮点型¶
标准规定了 float, double, long double, 但没规定宽度, 也没规定必须用 IEEE 754.
带浮点单元的实现叫硬浮点, 用整数指令模拟浮点的叫软浮点.
主流平台上 float 是 IEEE 754 binary32 (32 位), double 是 binary64 (64 位).
long double 各平台相差较大:
| 平台 | 常见实现 |
|---|---|
| x86, 32 位 System V, gcc | 80 位扩展精度, sizeof 为 12 (对齐到 4 字节) |
| x86-64 System V (Linux / macOS) | 仍是 80 位精度, sizeof 为 16 (对齐到 16 字节) |
| MSVC | 与 double 相同, 64 位 |
| 一些 PowerPC / RISC-V 工具链 | 128 位四精度, 或直接等于 double |
所以"比 double 更精确"不能当成可移植承诺. 可移植代码里把 long double 当成 "至少和 double 一样宽".
字面量:
-
3.14,3.14e-1,.987都是double. 科学计数法的底是 10. -
3.14f/3.14F是float. -
3.14L是long double. -
C99 还有十六进制浮点, 底是 2:
0x1.8p1表示 \(1.5 \times 2^1 = 3\). 指数用p/P, 不用e.
float 的尾数只有 23 位, 大约 7 位十进制有效数字, 比如 20000001 转成 float 会变成 20000000.
很多十进制小数 (比如 0.1) 在二进制里是无限循环, 存进去的是近似值. 因此不要用 == 判断两个浮点数是否"相等". 表示和舍入的细节见Floating Point.
类型转换¶
转换分两类. 编译器按规则自动做的是隐式转换, 程序员写 (type) value 的是强制转换.
隐式转换出现在四个时机: 整数提升 (Integer Promotion), 寻常算术转换 (Usual Arithmetic Conversion), 赋值 (含传参和 return), 以及可变参数的默认实参提升.
Integer Promotion¶
int 能表示某类型的全部取值时, 这个值先提升为 int. 否则提升为 unsigned int, 值不变, 包括符号.
受影响的是 rank 不高于 int 的整数: bool, char, signed char, unsigned char, short, unsigned short, 以及对应宽度的位域. int 及以上不变. unsigned short 的范围是 \(0 \sim 65535\), 32 位 int 装得下, 所以它提升成 有符号 int, 不是 unsigned int.
整数转换等级
整数转换等级 (integer conversion rank) 是标准给每种整数类型规定的高低顺序, 只在整数提升和寻常算术转换里用来决定谁迁就谁.
bool < char < short < int < long < long long
char 与 signed char, unsigned char 等级相同. short 与 unsigned short 相同, int 与 unsigned int 也相同. 加上 unsigned 不会升一档.
"rank 不高于 int" 表示该类型不高过 int, 运算前要先提升. int 及以上不再提升. 等级只决定有没有资格被提升. 提升成 int 还是 unsigned int, 看 int 能否装下原类型的全部取值.
unsigned char c1 = 255, c2 = 2;
int n = c1 + c2; // 257, 不是 1
相加之前两边都提升为 int. 若没有这一步, 8 位加法会丢掉进位得到 1. 有符号溢出还是 undefined behavior, 不能把"丢掉进位"当成可依赖的结果.
整数提升用在:
-
寻常算术转换的整数分支里, 两边先提升再比 rank
-
默认实参提升: 旧式函数声明, 以及
printf这类带...的参数. 另外float会提升为double -
单目
+,-,~的操作数 -
<<和>>的两个操作数各自提升. 移位不要求两边类型相同, 所以不做下面的寻常算术转换
因此 printf("%c", ch) 里的 char 会先变成 int, 而 %c 要的本来就是 int. printf("%f", some_float) 能工作, 是因为 float 先提升成了 double.
Usual Arithmetic Conversion¶
两边都是算术类型, 又要做出一个结果时, 先变成同一个类型再算. 适用于 + - * / %, 关系运算和 == !=, 以及位运算 & ^ |. 三目条件运算符 ?: 的两个分支若都是算术类型, 也走这套规则.
顺序是一条梯子, 碰到就停:
-
一边是
long double, 另一边转成long double. -
否则一边是
double, 另一边转成double. -
否则一边是
float, 另一边转成float. -
否则两边都是整数. 先做整数提升. 类型已经相同就结束. 否则看 rank 和符号:
-
rank:
char<short<int<long<long long. 同一宽度的有符号和无符号 rank 相同.bool低于以上全部. -
符号相同: 低 rank 转成高 rank.
-
符号不同, 且无符号那边的 rank 不低于 有符号那边: 有符号转成对方的无符号类型.
-
符号不同, 无符号的 rank 更低, 且有符号类型能覆盖这个无符号类型的全部取值: 无符号转成对方的有符号类型.
-
否则两边都转成 "有符号那边那个 rank 的无符号类型".
负数和
unsigned比较unsigned int u = 1; int i = -1; if (i < u) { /* not reached */ }int和unsigned int的 rank 相同, 符号不同, 所以i转成unsigned int. \(-1\) 变成 \(2^{32}-1\). 这个数不小于1.同理,
sizeof(int) > -1为假.sizeof的结果是无符号的size_t, \(-1\) 被转成SIZE_MAX. -
LP64 和 ILP32 在这条规则上会分叉, 比如 unsigned int 和 long 运算时:
-
LP64:
long是 64 位, 装得下全部unsigned int, 两边都转成long. -
ILP32:
long也是 32 位, 装不下 \(0 \sim 2^{32}-1\), 两边都转成unsigned long.
这套规则不需要背下来拿去写代码. 需要记住的是混用之后结果依赖宽度, 而宽度依赖数据模型. 比较和循环下标优先让两边类型一致, 例如计数用 size_t 就不要和 int 的 -1 比.
赋值, 传参和返回¶
初始化, 赋值, 带原型的函数参数, return, 都是把右边的值转成左边的类型.
int c = 3.14; // c == 3, 小数部分丢掉
short s = 32768; // 超出 short, 见下一节
foo(3.1, 4.2); // 原型若是 foo(int, int), 实参先转成 int
return 1.2; 在返回类型为 int 的函数里同样会截成 1.
getchar 返回 int
int ch;
while ((ch = getchar()) != EOF) { /* 使用 (char)ch */ }
EOF 是值为 \(-1\) 的 int, 用来表示 "读完了", 和任何一个真实字节都要能区分. 用 char 接返回值时: char 若无符号, 它永远不等于 \(-1\), 循环不会停. char 若有符号, 字节 0xFF 会变成 \(-1\), 被误当成文件结束.
强制转换¶
(double)3 + i 里, (double) 是单目运算符, 先把 3 变成 3.0. 然后寻常算术转换再把 i 也变成 double, 结果类型是 double.
操作数和目标类型都要是标量 (整数, 浮点, 指针). 目标也可以是 void, 用来显式丢掉一个值.
强制转换能改变这一处表达式的类型, 但改不了溢出已经发生的事实, 这和上面在整数常量中提到的warning类似. (long long)(a * b) 仍然是先按 a 和 b 的类型相乘, 再转换. 要写成 ((long long)a) * b 才行.
编译器如何处理类型转换¶
上面几节只确定了会转成哪种类型, 这一节介绍转换之后值变成什么.
设原类型有 M 位, 值为 X, 目标类型有 N 位:
| 转换 | 变窄 (M > N) | 等宽 (M = N) | 变宽 (M < N) |
|---|---|---|---|
| 有符号整数 → 有符号整数 | 装得下则 X 不变, 否则 implementation-defined | X 不变 | X 不变, 高位做符号扩展 |
| 无符号整数 → 有符号整数 | 装得下则 X 不变, 否则 implementation-defined | 装得下则 X 不变, 否则 implementation-defined | X 不变 |
| 有符号整数 → 无符号整数 | 加上或减去 \(2^N\) 的整数倍, 使结果落在 \([0, 2^N-1]\) | 同左 | 同左 |
| 无符号整数 → 无符号整数 | 同上, 等价于保留低 N 位 | X 不变 | X 不变 |
| 浮点 → 整数 | 向零截断. 截完装不下则 undefined | 同左 | 同左 |
| 整数 → 浮点 | 装得下则保留数值, 但不保证每个整数都有精确的浮点表示. 装不下则 undefined | 同左 | 同左 |
| 浮点 → 浮点 | 装得下则保留数值, 可能损失精度. 装不下则 undefined | X 不变 | X 不变 |
后三行里标"同左"的格子不按位宽再分三种情况, 规则与变窄列相同. 转到无符号时"加上或减去 \(2^N\) 的整数倍"也不是 C 的 %: % 向零截断, 负数余数可以为负, 而这里的结果必须落在 \([0, 2^N-1]\).
写代码时不要依赖 implementation-defined 和 undefined 那几格. 查错时对照:
-
有符号整数变宽:
short的 \(-1\) 是0xffff, 变成 32 位int时高位补符号位, 得到0xffffffff, 不是0x0000ffff. -
有符号整数转无符号, 且变窄:
(unsigned short)-1落进 \([0, 2^{16}-1]\), 结果是65535. -
有符号整数变窄, 且装不下:
short s = 32768;是 implementation-defined, 实现也可以选择发信号. gcc 和 clang 通常按补码截断, 并给出警告. -
浮点转整数:
int接收-3.9得到-3.int x = 1e10;在 32 位int上装不下, 是 undefined. -
整数转浮点:
float f = 20000001;落在float的范围内, 但 23 位尾数表示不了这个整数, 结果是20000000. -
浮点变窄:
double收窄成float再转回来, 不保证还是原来的double.
LP64 上, unsigned int u = 1; long L = -1; if (L < u) 中的 L < u 会被执行吗?
进得去. long 的 rank 更高, 而且 64 位 long 装得下全部 unsigned int, 所以 u 转成 long 的 1. -1 < 1 为真.
把 L 换成 int i = -1 就进不去: int 和 unsigned int rank 相同, i 被转成无符号, 变成 \(2^{32}-1\).
同一行比较, 只改左边的宽度, 真假就会翻. 这就是不要混用有符号和无符号的原因.