浮点、字符、const 与类型转换
上一篇里我们把整数家族从里到外拆了一遍——整型层级、有符号无符号、固定宽度类型和 sizeof。但程序世界里不只有整数:商品价格需要小数,屏幕上的文字需要字符,变量声明后有时候需要保护它不被乱改,不同类型的数据混在一起运算时编译器到底怎么处理。这些就是我们今天要一块一块啃的内容。
说实话,这篇里有些东西——特别是隐式类型转换——初看会觉得很绕。但别担心,这些"坑"恰恰是 C++ 加强类型系统的动机。理解了 C 里面"什么容易出问题",后面学 C++ 的"怎么解决这些问题"就会顺理成章。
学习目标 完成本章后,你将能够:
- [ ] 理解浮点类型的精度特征,避免浮点比较的常见错误
- [ ] 认识字符类型的本质——它就是小整数
- [ ] 正确使用
const限定符保护数据- [ ] 理解隐式类型转换的规则,避免有符号/无符号混用的陷阱
环境说明
我们接下来的所有实验都在这个环境下进行:
- 平台:Linux x86_64(WSL2 也可以)
- 编译器:GCC 13+ 或 Clang 17+
- 编译选项:
-Wall -Wextra -std=c17
第一步——小数怎么存?浮点数的精度世界
浮点三兄弟
C 语言提供了三种浮点类型,按精度从小到大排列:
| 类型 | 典型位数 | 有效数字 | 字面量写法 |
|---|---|---|---|
float | 32 位(单精度) | 约 7 位 | 3.14f |
double | 64 位(双精度) | 约 15 位 | 3.14(默认) |
long double | 80 或 128 位 | 平台相关 | 3.14L |
double 是默认的浮点类型——你写 3.14 的时候,编译器就把它当作 double 处理。如果要用 float,记得加 f 后缀;要用 long double,加 L 后缀。
float f = 3.14f; // 后缀 f 表示 float
double d = 3.14159265359; // 默认就是 double
long double ld = 3.14L; // 后缀 L 表示 long double浮点数是不精确的——这不是 bug
理解浮点数,先认下一件事:它是近似值,不是精确值。这事反直觉——存进去的 0.1 凭什么就不等于 0.1?答案藏在浮点数内部的二进制结构里。
先用整数垫个底。一个 int 只有 32 个二进制位,能表示的数有上限也有下限,超过就溢出。可数学里的整数有无穷多个,计算机只取了其中一段。浮点数面对的是同一个问题:实数有无穷多个,不少还带无穷位小数,而 float 也只有 32 个位。它拿什么去盖住这么大的范围?靠的是和你纸上演算一样的办法——科学计数法。
十进制科学计数法把 0.0123 写成 1.23 × 10⁻²,一个尾数乘以 10 的幂。计算机干的事一样,只是底换成 2:小数 = (±1) × 尾数 × 2^指数。float 的 32 个位就照这三块切:
| 1 位符号 | 8 位指数 | 23 位尾数 |
|---|---|---|
| 正还是负 | 决定能表示多大、多小(范围) | 决定有多少位有效数字(精度) |
指数位管量级,尾数位管精细度,两个都有限。指数让你能写到 10³⁸ 那么大、也能小到 10⁻⁴⁵;可尾数只有 23 位,有效数字大概就 7 位十进制,再多存不下。
真正的麻烦出在尾数那 23 格。十进制的 0.1 换成二进制是 0.0001100110011...,一个无限循环小数。尾数位只有 23 格,放不下无限循环,只能截断——存进去的早不是 0.1 了。下面这段小程序把 0.1f 的 32 个二进制位原样打印出来,点"动手试一试"就能直接跑:
Compiler Explorer
0.1 在浮点数里到底长什么样
把 0.1f 的 32 个二进制位(符号|指数|尾数)打印出来,再看 double 下 0.1 + 0.2 为什么不等于 0.3。
关键几行输出长这样:
0.1f 存进 float 的 32 个二进制位 (符号 | 指数 | 尾数):
0 01111011 10011001100110011001101
用不同精度打印它,都不是 0.1:
9 位精度 : 0.100000001
20 位精度: 0.10000000149011611938
double: 0.1 + 0.2 == 0.3 ? no
a + b = 0.30000000000000004441
c = 0.29999999999999998890看第一行:符号位是 0(正数),指数 01111011,尾数 10011001100110011001101——那串 1001 1001 1001... 就是无限循环被截到 23 位的样子,末尾那个 1 是舍入进位留下的。所以 0.1f 打印出来是 0.10000000149011611938,不是 0.1。
double 那段更直观:0.1 + 0.2 算出来是 0.30000000000000004441,而 0.3 存进去是 0.29999999999999998890,两个本来就不等的数拿 == 比,自然不相等。原因不神秘:有限位尾数存不下无限循环小数,必然如此。
WARNING
永远不要用 == 比较浮点数。0.1 + 0.2 != 0.3 在浮点运算里是常态,不是 bug。用 epsilon 判断近似相等才是正解。
顺带一个坑:float 下 0.1f + 0.2f 恰好等于 0.3f。别因此觉得 float 更"准"——只是 23 位尾数精度太低,把误差一起舍没了。换成精度更高的 double,误差就藏不住。判等一律用下面这种近似比较:
#include <math.h>
/// @brief 判断两个 float 是否近似相等
/// @param a 第一个浮点数
/// @param b 第二个浮点数
/// @return 1 表示近似相等,0 表示不相等
int float_equal(float a, float b)
{
return fabsf(a - b) < 1e-6f;
}还有一个细节:写 float f = 0.1; 时,0.1 先按 double 处理再截成 float,会引入额外的精度差异。确定用 float 就养成加 f 后缀的习惯。
嵌入式里的浮点
在嵌入式系统上使用浮点运算要格外谨慎。很多微控制器没有硬件浮点单元(FPU),浮点运算靠软件模拟,性能会比整数运算差一个数量级。即使有 FPU,double 的运算速度通常也比 float 慢不少。所以嵌入式开发中,能用整数解决的问题就别用浮点。
第二步——字符就是小整数
char 的双重身份
C 语言里没有专门的"字符类型"。char 这个名字容易让人误解,实际上它就是"最小的可寻址存储单元",大小恰好是一个字节(1 字节)。只不过我们习惯上用它来存字符的 ASCII 码——而 ASCII 码本身就是 0~127 的整数。
char ch = 'A';
printf("%c\n", ch); // 作为字符打印:A
printf("%d\n", ch); // 作为整数打印:65'A' 的 ASCII 码是 65。所以 'A' + 1 的结果是 66,对应字符 'B'。这个"字符就是整数"的特性在做大小写转换的时候特别方便:
char lower = 'a';
char upper = lower - 32; // 'a' 的 ASCII 是 97,减 32 得 65 = 'A'
char upper2 = lower - ('a' - 'A'); // 更可读的写法来验证一下:
gcc -Wall -Wextra -std=c17 char_demo.c -o char_demo && ./char_demo运行结果:
A
65字符字面量的类型——C 和 C++ 不一样
这里有一个 C 和 C++ 之间微妙的不兼容区别:在 C 中,字符字面量 'A' 的类型是 int(占 4 字节),但在 C++ 中它的类型是 char(占 1 字节)。
printf("%zu\n", sizeof('A')); // C: 输出 4,C++: 输出 1这个区别在绝大多数情况下不影响你写代码,但如果你以后从 C 切换到 C++,记住有这回事,免得被 sizeof 的结果吓一跳。
编码的世界——ASCII 只是起点
ASCII 用 7 位(0~127)表示英文字母、数字和常用符号。但世界上不只有英文——中文、日文、emoji 都没法用 ASCII 表示。C 标准后来扩展了对多字节字符和宽字符的支持:
#include <wchar.h>
wchar_t wc = L'中'; // 宽字符,大小由实现定义
char* mb = "你好"; // 多字节字符(UTF-8 编码)wchar_t 的问题是它的大小不一致——Windows 上 2 字节,Linux 上 4 字节。这也是为什么很多现代项目直接用 UTF-8 编码的 char 数组来处理所有文本。编码是一个很大的话题,这里我们点到为止,知道有这么回事就行。
第三步——给变量加把锁:const
const 的基本用法
const 是一个类型限定符,告诉编译器"这个变量的值不应该被修改"。你可以把它理解为给变量加了一把锁——加了锁之后,任何试图修改这个变量的操作都会在编译期被拦下来。
const int kMaxSize = 256; // 常量,不能修改
const double kPi = 3.14159265;
// kMaxSize = 100; // 编译错误!不能修改 const 变量这里注意我的用词是"不应该"而不是"不能"——技术上你可以通过指针强制绕过 const 修改数据,但那是未定义行为,纯属自找麻烦。
const 在函数参数中的妙用
const 最常见的用途是在函数参数中声明"这个函数不会修改传入的数据":
/// @brief 计算字符串长度
/// @param str 不可修改的字符串
/// @return 字符串长度
size_t my_strlen(const char* str);
/// @brief 在缓冲区中写入数据
/// @param buf 可修改的缓冲区
/// @param len 缓冲区长度(函数不会修改 len)
void fill_buffer(char* buf, const size_t len);const char* str 意思是"str 指向的字符不可修改",但 str 本身可以指向别的地方。const size_t len 意思是"len 的值在函数内部不会被改变"。这些 const 不仅是给编译器看的,也是给读代码的人看的——函数签名本身就在传达意图。
⚠️ 踩坑预警
const int* p和int* const p是不同的东西。前者表示"指向的值不能改",后者表示"指针本身不能改"。这个区别在指针那一篇我们会展开讲,目前先知道有这么回事。
嵌入式中的 const
在嵌入式开发中,const 有一个很实际的好处——编译器可以把 const 数据放到 Flash/ROM 而不是 RAM 里。对于 RAM 寸土寸金的微控制器来说,这是很重要的优化。比如查表法中的正弦表:
const uint8_t sine_table[256] = {128, 131, 134, /* ... */};这个数组加了 const 之后,编译器就可以把它放进 Flash,不占用宝贵的 RAM。
第四步——当不同类型碰在一起:隐式转换
这一节是整篇里最容易让人困惑的部分。先别急,我们一步一步来。
整型提升——小类型自动"升级"
在任何算术运算中,char 和 short 都会先被自动提升为 int,然后再参与运算。这是历史遗留的设计——早期 CPU 的运算单元只支持 int 宽度的操作,所以编译器自动帮你做了这个转换。
uint8_t a = 200;
uint8_t b = 100;
uint8_t c = a + b; // 200 + 100 = 300,截断为 44
// 但 a + b 本身的类型是 int(300),不是 uint8_t这里 a + b 的结果是 int 类型的 300,然后赋值给 uint8_t 的时候被截断为 44。整型提升保证了小类型的运算不会在中间步骤溢出,但赋值回小类型的时候仍然可能截断。
常用算术转换——两个不同类型怎么办
当两个不同类型的操作数进行运算时,编译器会按一套规则把它们转换成"公共类型"。这套规则看起来挺复杂的,但我们只需要记住最容易踩坑的那一条:有符号数和无符号数一起运算时,有符号数会被转成无符号数。
int i = -1;
unsigned int u = 10;
if (i < u) {
// 你以为 -1 < 10 是 true?
// 错!i 被转成 unsigned int,变成 UINT_MAX(一个巨大的正数)
// 所以 UINT_MAX < 10 是 false
printf("这行不会打印\n");
}⚠️ 踩坑预警 有符号数和无符号数比较的时候,有符号数会被隐式转成无符号数。
-1 < 10u在 C 中的结果是 false。这种 bug 特别阴险,因为编译器可能根本不警告你。涉及size_t(无符号)和int(有符号)的混合比较中尤其常见。
我们的建议很简单:尽量避免有符号和无符号的混用。如果一定要混用,显式写类型转换,把意图表达清楚:
int count = -1;
size_t len = 5;
if (count < (int)len) { // 显式转换,意图清楚
// ...
}显式类型转换
C 语言的显式转换就是 C 风格的 cast:(type)value。它简单粗暴,什么都能转,而且不做任何检查:
double pi = 3.14159;
int i = (int)pi; // 截断为 3
unsigned int u = (unsigned int)-1; // 变成 UINT_MAXC 风格 cast 的问题在于太"万能"了——const 可以被 cast 掉、指针类型可以随意转换、数据布局的假设完全没有验证。这也是为什么 C++ 引入了命名的 cast 操作符(static_cast、const_cast、reinterpret_cast、dynamic_cast),让每一种转换的意图都一目了然。
C++ 衔接
C++ 在类型系统上做了大量的安全加固,很多改进直接瞄准了 C 的痛点:
{}初始化禁止窄化转换(上一篇已经提过)- 命名的 cast 操作符让类型转换的意图更明确
constexpr在const的基础上保证编译期求值char16_t、char32_t、char8_t解决了编码的类型安全问题std::numeric_limits<T>::epsilon()提供了比手写 epsilon 更精确的浮点比较工具
这些改进的动机全都来自我们今天讨论的这些"坑"。理解了 C 中"什么容易出问题",学 C++ 的"怎么解决这些问题"就会非常自然。
小结
我们来梳理一下这篇的核心要点。浮点数是近似值,0.1 + 0.2 != 0.3 是 IEEE 754 的固有特性,比较浮点数要用 epsilon 而不是 ==。char 本质上就是小整数,它的符号性取决于平台。const 给变量加了一把编译期保护的锁,在嵌入式场景中还能帮助编译器把数据放进 Flash。隐式类型转换——特别是有符号和无符号混用——是 bug 的高发地带,混用时务必显式写 cast。
到这里我们已经把 C 语言数据类型的基础打好了。接下来我们要进入运算符的世界,看看这些数据怎么进行各种运算。
练习
练习 1:浮点精度侦探
难度:基础 · 用 epsilon 判断浮点相等
预测以下代码的输出,然后编译运行验证你的预测:
展开代码 (共 25 行)收起代码
#include <stdio.h>
int main(void)
{
double a_double = 0.1;
double b_double = 0.2;
double c_double = 0.3;
float a_float = 0.1f;
float b_float = 0.2f;
float c_float = 0.3f;
float e_float = 0.3f;
float f_float = 0.4f;
float g_float = 0.7f;
printf("a_double + b_double == c_double? %s\n", (a_double + b_double == c_double) ? "yes" : "no");
printf("a_float + b_float == c_float? %s\n", (a_float + b_float == c_float) ? "yes" : "no");
printf("e_float + f_float == g_float? %s\n", (e_float + f_float == g_float) ? "yes" : "no");
printf("a_double + b_double = %.20f\n", 0.1 + 0.2);
printf("a_float + b_float = %.20f\n", 0.1f + 0.2f);
printf("e_float + f_float = %.20f\n", 0.3f + 0.4f);
printf("c_double = %.20f\n", c_double);
printf("c_float = %.20f\n", c_float);
printf("g_float = %.20f\n", g_float);
return 0;
}a_double + b_double == c_double? no
a_float + b_float == c_float? yes
e_float + f_float == g_float? no
a_double + b_double = 0.30000000000000004441 //0.1 + 0.2
a_float + b_float = 0.30000001192092895508 //0.1f + 0.2f
e_float + f_float = 0.70000004768371582031 //0.3f + 0.4f
c_double = 0.29999999999999998890 //0.3
c_float = 0.30000001192092895508 //0.3f
g_float = 0.69999998807907104492 //0.7f修改代码使用 epsilon 比较来得到正确的结果。
参考答案
把 == 换成「差的绝对值小于一个很小的阈值(epsilon)」来判断:
#include <math.h>
#include <float.h>
// double 用 DBL_EPSILON
int double_equal(double a, double b) {
return fabs(a - b) < DBL_EPSILON;
}
// float 用 FLT_EPSILON
int float_equal(float a, float b) {
return fabsf(a - b) < FLT_EPSILON;
}替换之后,0.1 + 0.2 与 0.3 的差约 5.5e-17,小于 DBL_EPSILON(约 2.2e-16),double_equal(0.1 + 0.2, 0.3) 就会返回真。要留意,绝对 epsilon 比较在数值数量级很大时会失效,工程里更稳妥的是相对误差比较,这里先用入门写法。
练习 2:隐式转换陷阱
难度:基础 · 有符号数与 size_t 混用的坑
下面这段代码有一个隐藏的 bug,找出它并解释原因:
int values[] = {1, 2, 3, 4, 5};
int target = -1;
// bug 就在下面这行
if (target < sizeof(values) / sizeof(values[0])) {
printf("target is in range\n");
}提示:sizeof 返回的是什么类型?
参考答案
int values[] = {1, 2, 3, 4, 5};
int target = -1;
// bug 就在下面这行
if (target < (int)sizeof(values) / (int)sizeof(values[0])) {
printf("target is in range\n");
}或者
int values[] = {1, 2, 3, 4, 5};
int target = -1;
// bug 就在下面这行
if (target < (int)(sizeof(values) / sizeof(values[0]))) {
printf("target is in range\n");
}练习 3:const 实战
难度:基础 · 辨析 const 保护了什么、参数该怎么传
读下面这段代码,回答四个问题:
// sum 承诺不改 data 指向的内容
int sum(const int* data, size_t n); // (1) 参数加 const 是什么契约?
void f(void) {
const int limit = 100; // (2) 局部变量加 const 有什么用?
int arr[3] = {1, 2, 3};
limit = 200; // (3) 这行能编译吗?
sum(arr, 3); // (4) int* 传给 const int* 参数,合法吗?
}再反向想:如果 sum 的参数是 int*(没有 const),而调用者传一个 const int carr[3],会发生什么?为什么?
参考答案
(1) const 是只读契约:告诉调用者和编译器,sum 不会通过 data 改数组内容。
(2) limit 加 const 后变成只读变量,不能再赋值,编译器也能据此做优化。
(3) 不能编译。limit 是 read-only,给它赋值会直接报错。
(4) 合法。把 int* 传给 const int* 参数属于"加严"(从可写到只读),是安全的隐式转换。
反向:把 const int* 传给 int* 参数会丢掉 const 保护,编译器会警告甚至报错;如果非要做,得显式强转,但这很危险,函数内部可能改了本不该改的数据。