Skip to content

数据类型详解

数据类型详解 | Linux C编程一站式学习

Implicit conversions | cppreference

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

两边都是算术类型, 又要做出一个结果时, 先变成同一个类型再算. 适用于 + - * / %, 关系运算和 == !=, 以及位运算 & ^ |. 三目条件运算符 ?: 的两个分支若都是算术类型, 也走这套规则.

顺序是一条梯子, 碰到就停:

  1. 一边是 long double, 另一边转成 long double.

  2. 否则一边是 double, 另一边转成 double.

  3. 否则一边是 float, 另一边转成 float.

  4. 否则两边都是整数. 先做整数提升. 类型已经相同就结束. 否则看 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\).

同一行比较, 只改左边的宽度, 真假就会翻. 这就是不要混用有符号和无符号的原因.