C++ 并不会自动消灭 C 的底层风险。真正的改进,是用带长度、边界和类型信息的抽象,让错误更早暴露。
1. 字符与字符串:结束符不是长度
三个容易混淆的值
写法 | 含义 | 常见用途 |
'0' | 字符 0,编码值通常为 48 | 文本数字 |
'\0' | 空字符,数值为 0 | C 字符串结束标记 |
0 | 整数零 | 数值、空指针常量(旧式代码) |
C 字符串依靠
'\0' 判断结尾,本身不记录长度:如果缓冲区不足,或者遗漏结束符,
strlen、strcpy 等函数就可能越界读取或写入。C++:优先使用 std::string
std::string 单独保存长度,因此可以容纳二进制零字节;但把它交给只认 C 字符串的接口时,仍可能在 '\0' 处被截断。工程案例:Redis SDS
Redis 没有直接把普通 C 字符串当作核心字符串结构,而是用 SDS 保存长度和容量:
这样做带来三个直接收益:读取长度是 O(1)、可安全保存二进制数据、扩容时能检查剩余空间。
2. 数组与指针:传参后,长度会消失
数组作为函数参数时通常会退化为指向首元素的指针:
因此,下面这种接口必须额外传入长度,调用者和函数还要共同维护约定:
C++:让容器携带边界
这里同时解决了四个问题:长度不丢失、只读参数不会被误改、空容器不会除零、求和使用更宽的整数类型降低溢出风险。
边界统一采用左闭右开
把条件写成
i < size,比 i <= size - 1 更直观,也能避开空数组时的无符号下溢。3. 类型转换:编译器“帮忙”,不代表结果正确
有符号数与无符号数比较
问题不在比较符,而在两边类型不同。更稳妥的做法是先判断负数,再进行同类型比较:
C++ 四种显式转换
转换 | 用途 | 原则 |
static_cast | 数值、相关类型之间的常规转换 | 最常用,但仍需验证范围 |
const_cast | 增删 const 属性 | 极少使用;修改真正的常量是未定义行为 |
dynamic_cast | 多态类型的运行时安全向下转换 | 失败时返回空指针或抛异常 |
reinterpret_cast | 按底层位模式重新解释 | 高风险,只用于明确的底层场景 |
显式转换的价值不是“让代码通过编译”,而是把危险点标出来,方便审查。转换越多,通常说明接口类型设计越可疑。
4. 位运算与整数:机器整数不是数学整数
移位的安全边界
有符号负数右移的行为容易依赖语言版本与实现;左移若超出可表示范围还可能触发未定义行为。做位掩码时,优先使用无符号类型,并确保移位量小于类型位数。
固定数量的标志位也可以用
std::bitset,让输出和按位操作更直观:整数溢出
关键是让第一次乘法就在更宽的类型中发生。先用
int 算完再赋给 long long,溢出已经发生,补票没用。如果数值可能超过 64 位整数,应使用
boost::multiprecision::cpp_int 等大整数类型;如果输入来自外部,还应在运算前做范围检查。5. 一张表记住选择原则
场景 | 容易踩坑的写法 | 优先选择 |
普通文本 | char* • 手工长度 | std::string |
动态数组 | 裸指针 + 单独维护长度 | std::vector |
固定长度数组 | 裸数组 | std::array |
只读连续数据视图 | 指针 + 长度 | C++20 std::span |
权限位/状态位 | 有符号整数移位 | 无符号定宽类型或 std::bitset |
类型转换 | C 风格强转 | 先修正类型设计,必要时使用命名转换 |
大整数 | 假设 int 足够 | 定宽整数、范围检查或多精度整数 |
结语
从 C 到 C++ 的核心变化,不是把
printf 换成 cout,而是把原本靠程序员记忆维护的信息交给类型和容器:选择
std::string、std::vector、std::array 或 std::span,不是为了“更现代”,而是为了减少那些编译器不报错、运行时却会咬人的隐含约定。
