C++基础入门教程(七):一些比较特别的基础语法总结

发布时间:2026/10/20 19:50:37
C++基础入门教程(七):一些比较特别的基础语法总结
前言入门教程写到第七篇语法点看起来都学过了但真正写代码时出错的地方往往不是类和多态而是那些以为自己懂了的基础规则逗号表达式到底返回什么、Widget w();为什么不是定义对象、-1 1u为什么可能为假、switch忘了break会怎样。这些问题单个拎出来都很小凑在一起却足以让一段逻辑完全没有按预期运行。本文挑选初学者最容易踩的一批语法点做集中梳理重点不在罗列语法而在说明标准规定了什么、哪些是未定义行为、哪些依赖实现。文中代码以 C17 为基准示例都尽量短小便于逐行推演。需要先说明一个前提本文不讨论编译器优化后的结果也不给出任何运行输出。凡涉及求值结果的地方只按标准语义描述。一、表达式中的特殊规则逗号运算符逗号在 C 里有两个身份。作为运算符时它先求值左操作数丢弃结果再求值右操作数整个表达式的结果与类型取自右操作数。作为分隔符时函数实参、变量声明列表它没有运算含义。// 适用于 C17int a (1, 2, 3); // a 等于 3前两个值被丢弃int b 1, c 2; // 这里是分隔符不是逗号运算符逗号运算符的求值顺序是从左到右且左操作数的副作用在右操作数求值前完成这是少数有严格顺序保证的运算符之一。但不要因此写i (i, i)这类代码可读性极差且容易与序列点规则纠缠。对类类型重载operator,之后行为完全不同标准库中极少这么做。逻辑运算符的短路和||保证短路求值左边为假时右边不求值||左边为真时右边不求值。这不仅是优化也是语义的一部分常被用来做空指针检查// 适用于 C17if (ptr ! nullptr ptr-ready()) {// 只有 ptr 非空才会调用 ready()}如果把两个条件调换顺序先调用ptr-ready()空指针解引用就是未定义行为。短路规则是这类写法的合法性来源不是编译器碰巧不执行。三目运算符cond ? a : b要求两个分支能转换为一个公共类型。如果两个分支都是同一类型的左值结果也是左值否则通常是右值。混用有符号与无符号时会发生隐式转换结果类型可能出乎意料。// 适用于 C17int x 1;unsigned int y 2;auto z (x 0) ? x : y; // z 的类型是 unsigned int这里int与unsigned int的公共类型是unsigned int所以z是无符号的。若x为负数转换后的值会按模运算回绕而不是保持负号。求值顺序C17 之前函数实参的求值顺序是未指定的f(i, i)既可能先算左边也可能先算右边甚至可能交错。C17 起改为不确定顺序indeterminately sequenced各实参的求值不会交错但先后次序仍不确定。所以f(i, i)在 C17 中不再是未定义行为但具体哪个先执行仍不可依赖结果依旧不可移植地看起来合理。赋值运算符在 C17 起规定右操作数先于左操作数求值但像i i i这样对同一标量既有前置自增又有后置自增的表达式标准仍然明确规定为未定义行为。最稳妥的原则是一个表达式里不要对同一个标量做两次修改。二、声明与初始化的陷阱最令人头疼的解析// 适用于 C17struct Widget {Widget() default;};Widget w1; // 定义一个 Widget 对象Widget w2(); // 声明一个返回 Widget 的函数不是对象Widget w3{}; // C11 起定义一个 Widget 对象Widget w2();之所以是函数声明是因为语法上返回类型 名字 空参数列表正好构成函数声明编译器优先按声明解析。这就是最令人头疼的解析most vexing parse。用花括号初始化可以消除歧义也是现代 C 推荐的写法。花括号初始化与窄化花括号初始化列表初始化会拒绝窄化转换这是它比圆括号更安全的地方// 适用于 C17int a 3.9; // 允许a 为 3截断// int b{3.9}; // 编译错误double 到 int 是窄化转换int c{3}; // 正确double d{3}; // 正确int 到 double 不是窄化窄化包括浮点转整型、高精度浮点转低精度浮点、整型转无法表示原值的整型、整型转浮点除非是常量且能精确表示。编译器必须拒绝这些转换这让很多隐式截断在编译期暴露。但花括号也有坑如果一个类同时有接受std::initializer_list的构造函数{1, 2}会优先匹配它而不是你以为的两个参数的构造函数。这是标准规定的重载决议顺序。const 的位置const修饰它左边的内容如果左边没有内容就修饰右边。// 适用于 C17int x 0;const int* p1 nullptr; // 指向 const int 的指针可以改指向不能改 *p1int const* p2 nullptr; // 同上int* const p3 x; // const 指针不能改指向可以改 *p3const int* const p4 x; // 两者都不能改读法是从右往左int* const p3读作p3 是一个 const 指针指向 int。引用不是指针引用必须在定义时绑定之后不能改绑不存在空引用对引用取地址得到的是被引用对象的地址。函数返回局部变量的引用是悬垂引用使用它是未定义行为。// 适用于 C17int bad() {int local 42;return local; // 悬垂引用调用方使用即 UB}引用成员还会让类失去默认的拷贝赋值能力引用不可重新绑定这是设计类时要考虑的。三、控制流里的小陷阱switch 的贯穿switch的case只是跳转标签不会自动在分支末尾停下。没有break就会继续执行下一个分支这称为贯穿fallthrough。// 适用于 C17void handle(int code) {auto handle_one [] {};auto handle_two_or_three [] {};auto handle_other [] {};switch (code) {case 1:handle_one();break;case 2:case 3:handle_two_or_three(); // 有意合并两个标签共用一个分支break;default:handle_other();break;}}如果确实需要贯穿C17 提供了[[fallthrough]]属性明确告诉编译器这是故意的避免静态检查工具报警// 适用于 C17void configure(int level) {auto prepare [] {};auto run [] {};switch (level) {case 1:prepare();[[fallthrough]];case 2:run();break;default:break;}}还要注意在case标签之间定义变量时如果变量带有非真空初始化例如int x 1;或需要调用构造函数的类类型跨过它的初始化跳转到后面的标签是非法的。需要时用花括号把分支包成独立作用域。悬垂 elseelse总是与最近的、尚未配对的if结合// 适用于 C17void check(bool a, bool b) {auto do_something [] {};auto do_other [] {};if (a)if (b)do_something();elsedo_other(); // 这个 else 属于内层 if不是外层}缩进不改变语义。需要外层分支时必须加花括号。这类错误在代码格式化之后尤其隐蔽。goto 与初始化goto可以跳出多层循环但不能跳过带初始化的变量声明进入其作用域// 适用于 C17void jump_demo(bool skip) {if (skip) {// goto label; // 非法跳过了 x 的初始化编译失败}int x 1;label:(void)x;}如果变量没有初始化或初始化是平凡的跳转可能被允许但这类代码极难维护。现代 C 中goto的主要合理用途是从深层嵌套中统一跳出到清理代码其余场景应改用函数、break或异常。循环变量的作用域for (int i 0; ...)中的i作用域限于循环体循环结束后不可见。范围for同理且范围for中对元素的修改是否影响原容器取决于绑定的是值还是引用// 适用于 C17#include vectorvoid scale(std::vectorint v) {for (int x : v) { x * 2; } // 修改的是副本v 不变for (int x : v) { x * 2; } // 修改的是元素本身}范围for在遍历过程中修改容器大小增删元素可能导致迭代器失效属于未定义行为应当避免。四、类型转换与整型规则有符号与无符号混用这是初学者最容易掉进去的坑。当有符号与无符号整数参与同一运算时有符号数会被转换为无符号数在通常的算术转换规则下负数会回绕成很大的正数。// 适用于 C17#include cstddef#include vectorvoid compare_demo() {int i -1;unsigned int u 1;if (i u) {// 这个分支不会进入i 先被转换为 UINT_MAX}std::vectorint v{1, 2, 3};// for (int k 0; k v.size(); k) // 有符号/无符号比较编译器会警告for (std::size_t k 0; k v.size(); k) { // 正确类型一致// ...}}v.size()返回std::size_t是无符号类型。用int与之比较会触发隐式转换开启警告时编译器通常会提示。习惯上索引用std::size_t或者用范围for避开下标。整型提升小于int的整型char、short、bool在算术运算前会被提升为int。这意味着两个char相加得到的是int赋值回char时可能发生截断。// 适用于 C17char a 100;char b 100;int sum a b; // 200提升为 int安全char c a b; // 200 超出 signed char 范围结果依赖实现signed char的范围通常是 -128 到 127但标准只保证至少 -127 到 127char是否有符号由实现定义implementation-defined在 x86 上常见为有符号在 ARM 上常见为无符号。需要明确符号时应写signed char或unsigned char不要依赖char。有符号溢出是 UB无符号整数的溢出按模运算回绕是良定义的有符号整数溢出是未定义行为编译器可以假设它不发生并据此做优化。这不是结果变成一个负数这么温和而是整个程序的行为都不再有保证。// 适用于 C17#include climitsvoid overflow_demo() {int x INT_MAX;// x 1; // UB不要依赖回绕unsigned int y UINT_MAX;unsigned int z y 1; // 良定义结果为 0(void)x;(void)z;}需要可回绕的语义时用无符号类型需要检测溢出时用更宽的类型或编译器内建函数并在文档中说明。枚举与强类型枚举传统enum的枚举值会隐式转换为整数也会污染外围作用域enum class不会隐式转换且枚举值位于枚举类型的作用域内。// 适用于 C17enum class Color { Red, Green, Blue };Color c Color::Red;// int n c; // 错误不能隐式转换int n static_castint(c); // 正确显式转换enum Legacy { A, B };int m A; // 允许传统 enum 隐式转换新代码应优先使用enum class避免名字冲突和意外的隐式转换。常见坑点❌Widget w();以为定义了一个对象✅ 这是函数声明应写Widget w;或Widget w{};。❌int x{3.9};以为会截断成 3✅ 花括号初始化禁止窄化这是编译错误需要截断时显式static_castint(3.9)。❌ 用int与std::vector::size()比较✅ 用std::size_t或范围for避免有符号/无符号转换。❌switch分支末尾漏写break以为会自动结束✅ 每个分支显式break有意贯穿时写[[fallthrough]]。❌if (a) if (b) x(); else y();以为else属于外层✅ 给外层if加花括号消除悬垂else歧义。❌ 依赖char的符号性做数值判断✅ 明确写signed char或unsigned charchar的符号性由实现定义。❌ 认为有符号溢出会变成负数✅ 有符号溢出是未定义行为无符号溢出才按模回绕。❌ 返回局部变量的引用或指针✅ 按值返回或返回动态分配/由调用方持有的对象。总结语法点标准规定常见误解逗号运算符结果为右操作数从左到右求值以为返回第一个值/ 三目运算符两分支转换为公共类型忽略有符号/无符号转换花括号初始化禁止窄化转换以为和圆括号等价switchcase不自动跳出以为每个分支独立有符号溢出未定义行为以为会回绕char符号性实现定义以为一定是 signedenum class不隐式转换为整数以为和传统 enum 一样这些规则单独看都不复杂难的是在写代码时保持敏感看到有符号与无符号混用就停下来确认类型看到switch就检查break看到花括号就想到窄化。把这几条变成条件反射能挡掉入门阶段相当一部分难以定位的 bug。