ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言语句分类与注释:分号、复合语句与工程实践

C语言语句分类与注释:分号、复合语句与工程实践 记得有次我给一个大二学生改代码他盯着编译器的报错信息看了半天问我error: expected ; before } 到底差在哪我扫了一眼他的代码发现他在 do-while 循环结尾丢了分号。这种问题几乎每个学C语言的人都会遇到但奇怪的是教材里明明写得很清楚大家还是会反复踩。后来我意识到大多数人学C语言的时候根本没把语句分类这个概念当回事。C语言程序本质上由一条条语句构成而语句一共就那么几类每一类的语法形态、边界、分号要求都不一样。把语句分类吃透编译报错至少能少一半读别人代码的速度也会快很多。至于注释虽然不参与编译但在工程协作、代码维护和复习排查里都离不开它注释写得好不好直接影响一段代码能被多少人轻松接手。这篇文章我就把C语言的语句分类和注释这两件事从头到尾讲透适合刚学完基础语法、准备开始刷题的初学者也适合那些写了很久代码、但对语法细节还说不太清楚的复习者。我会按语句的执行形态逐个拆解再把注释的使用边界和工程规范讲明白。1. 语句和表达式很多C语言困惑的根源都在这条边界上1.1 表达式在计算语句在做事在C语言里表达式expression是运算符和操作数的组合它描述了一次计算。比如a b * 2、x 5、printf(hi)都是表达式。这里容易误解的是表达式计算完之后会产生一个值但产生值不等于做了一件事。a b * 2算完就完了没有人关心结果存到哪里。x 5之所以算做事是因为它有副作用——把5写进变量x。printf(hi)也是计算过程中把字符串输出到终端了。C标准中表达式后面加一个分号就构成表达式语句expression statement。一个表达式语句才真正是一个可执行单元。可以这样类比表达式是算一笔账语句是做了个决定或动作。账算完可以不算数动作一旦产生后果程序流程就真正变化了。看个非常典型的例子int a 3, b 5; a b; // 合法但啥也没干编译器通常警告 expression result unused a b; // 合法且把b的值5赋给了a printf(%d, a); // 合法产生输出分号就是那个把算式变成指令的句号。一个语句的边界在哪说到底是看分号和花括号而不是看换行。这个基础认知一旦建立后面很多语法问题都能迎刃而解。1.2 分号的角色把表达式变成语句的句号C语言里分号非常容易让人崩溃有些地方要分号有些地方不要没有统一直觉。其实你如果站在分号是语句终结符的角度去看规律会清晰很多。表达式语句结尾要分号return 语句结尾要分号do-while 的while(...)之后要分号空语句就是一个分号但 if/while/for 后面的括号之后不要分号除非你想执行空语句函数定义的花括号之后不要分号结构体定义的}之后却要分号。这些规则说到底是语句边界问题。在语法分析器眼里分号是它判断这一条语句完没完的关键依据。你写while(n--);是一个空语句的while循环但如果你写if(x);然后下面跟了一行语句问题就来了——if条件判断完空语句是它的执行体下面的语句变成无条件执行。这类问题理解了语句分类之后基本都能自己看出来。2. C语言里的六类语句我按执行形态逐个拆给你看C标准把语句分为六类带标号语句、复合语句、表达式语句、选择语句、循环语句和跳转语句。有些教材会把空语句也算作一类严格说它属于表达式语句的特殊形式后面我单独提。下面按大家最容易理解的顺序一个个过。2.1 表达式语句与空语句最朴实也最容易看走眼表达式语句的形态就是表达式;。日常写代码用得最多的就是它int i 0; i; // 自增表达式构成语句 i i 1; // 赋值表达式构成语句 printf(i%d\n, i); // 函数调用表达式构成语句很多人以为一条语句就是一行代码其实不是。语句的边界是分号不是换行。你可以把两条语句写在同一行也可以把一条语句拆成多行只要不在标识符中间换行就行int a 1; int b 2; printf(%d, a b);这完全是合法的。理解这个能帮你更好地读懂压缩排版过的代码也能理解为什么编译器有时候定位错误会指向上一行末尾——因为它把换行当成空白字符真正的分隔符只有分号。空语句就是光一个分号;。它不执行任何操作但语法上确实是一条语句。什么时候用它比如你有一个循环所有工作都在循环头部表达式里完成了for (p head; p ! NULL; p p-next) ;循环体不需要做事时那里必须得有一条语句空语句是合法的占位符。这种写法容易被新手误会一般建议加个注释说明这里的空语句是有意为之。2.2 复合语句一对花括号就是一条语句复合语句也叫块。它是用一对花括号包起来的若干条语句还可以包含声明在语法上整体视作一条语句。为什么需要它因为C语言规定 if、while、for 这些控制结构后面的一条语句只能是一条如果你想让它们同时控制多条语句就得用花括号把它们打包成一条复合语句。if (score 60) printf(pass\n); // 想多执行一条必须写成 if (score 60) { printf(pass\n); passedCount; }复合语句还有一个非常重要的副作用是它形成作用域。花括号内部声明的变量外部访问不到。看这个例子int x 1; { int x 2; printf(%d\n, x); // 输出2 } printf(%d\n, x); // 输出1这叫遮蔽shadowing在很多编码规范里是要避免的行为但理解它是复合语句自带的属性。另外在C99之前C语言要求复合语句内的所有声明必须放在块的最前面之后才能出现语句C99起放宽了这个限制你可以随时声明变量这让代码的局部性好了很多。2.3 选择语句if-else和switch的语法配对关系选择语句有两种if 和 switch。if 的完整语法是if (表达式) 语句if (表达式) 语句 else 语句表达式的值被判断为真非0时执行后面的语句为假0时跳过或走else分支。需要特别注意的是每个 if 只能管到一条语句如果你想控制多条必须用复合语句。else 会与最近的尚未配对的if结合这就是著名的悬挂else问题后面我会单独展开。switch 的语法是switch (表达式) 语句很多人以为 switch 后面必须跟花括号其实标准语法里 switch 的语句可以是任意语句只不过实际使用时几乎总是复合语句。真正有意思的是case 和 default 并不是 switch 专属的、特殊的条款它们在语法上属于带标号语句是放在 switch 控制的那个语句内部的入口标签。这种视角能解释很多switch的怪癖比如case的位置可以很随意甚至可以写在某个 if 里面虽然没人建议这么干以及为什么case之间需要break来紧急刹车。switch (x) { case 1: puts(one); break; case 2: puts(two); break; default: puts(other); break; }这里的case 1:就是一条带标号语句的标号部分puts(one); break;是它后续的语句。2.4 循环语句while、do-while、for三兄弟循环语句有三种对应不同的使用习惯。while 是先判断后执行条件不满足一次都不会跑while (n 0) { sum n; n--; }do-while 是先执行后判断循环体至少执行一次do { scanf(%d, input); total input; } while (input ! 0);注意 do-while 末尾的while(...)后面必须带分号这是它和普通 while 最大的区别之一。for 是最浓缩的一种把初始化、条件、步进三个表达式放在一对括号里for (i 0; i n; i) { result arr[i]; }标准语法是for (表达式opt; 表达式opt; 表达式opt) 语句三个表达式都可以省略但两个分号不能省。for(;;)就是无限循环等价于while(1)。我见过不少初学者把 for 的三个部分理解成必须写全其实 for 的初始化表达式和步进表达式都可以是逗号表达式比如for (i 0, j n - 1; i j; i, j--) swap(a[i], a[j]);这种写法在操作数组两端的算法里很常见读起来也紧凑。2.5 跳转语句break、continue、goto、return跳转语句改变程序的执行流向共四种break、continue、goto、return。break 可以跳出 switch或者跳出最近的一层循环。注意最近一层它不能一口气跳出多层循环也不能跳出 if。很多新手在 if 里写 break 想结束整个函数结果编译报错或者行为诡异。continue 在循环里使用作用是跳过循环体中 continue 之后的语句立刻进入下一轮循环。在 for 循环里continue 之后会先执行括号内第三个表达式步进再判断条件在 while 里则直接判断条件。这个差别会在某些边界场景带来bug。goto 和带标号语句配合使用是无条件跳转。goto 只能跳到同一个函数内部的标号处。C语言不推荐用 goto 主要是为了结构化编程考虑但其实在内核级别的错误处理、从嵌套循环中快速跳出等场景goto 是很好的工具。C99以后goto 不能跳入某些含变长数组VLA声明的作用域这是标准层面的限制。return 结束当前函数可以返回一个值非void函数也可以什么都不带void函数。void process(int *p) { if (p NULL) return; // 提前退出 // ... } int find(int *arr, int n, int key) { for (int i 0; i n; i) { if (arr[i] key) return i; // 找到直接返回 } return -1; // 没找到 }2.6 带标号语句case和default其实长这样带标号语句的语法有三种形式标识符: 语句case 常量表达式: 语句default: 语句第一种是给 goto 用的目标。写个例子体会一下int i 0; retry: if (i 3) { i; goto retry; }标号可以放在任何语句前面甚至可以悬空紧跟在花括号前只要它能标识一个位置。case 和 default 形式上也是标号但它们只能在 switch 内部出现且 case 后面的常量表达式必须是整数常量或者能转换成整数的常量不能是变量。这点很多资料直接说case后面不能接变量本质就是因为标号需要的是编译期就能确定的常量值。把 case 看成入口标签有一个好处你会理解为什么 switch 的 case 可以没有自己的语句就落到下一个case也会理解我们常说的case穿透。具体到代码里switch (ch) { case a: case e: case i: puts(元音或者i); break; default: break; }case a 下面没有任何语句它和下一个case共享同一个逻辑块。这是合法的、而且常有用的写法。3. 语句分类直接决定的三个经典语法陷阱3.1 悬挂elseelse到底跟谁配对悬挂elsedangling else是C语言新手最经典的编译通过但行为不对的坑。规则是else 总与最近的、尚未配对的 if 结合。看代码if (a) if (b) printf(a and b\n); else printf(a is false?\n);缩进想表达的是else 配外部 if但按照语言的配对规则else 其实配对的是里面的if (b)所以这段代码的结构是if (a) { if (b) { printf(a and b\n); } else { printf(a is false?\n); } }也就是说只有当a为真且b为假时才会输出a is false?这多半不是写代码的人的本意。解决方式没有悬念用花括号把内部 if 包起来。if (a) { if (b) printf(a and b\n); } else { printf(a is false?\n); }这个问题解释了为什么很多编码规范强制要求if后必须带花括号——不是花括号爱好者病而是为了避免这类歧义。3.2 switch穿透为什么每个case都要break在switch里一旦某个case的标号被匹配执行会从那个位置一直向下贯通到switch结束或者遇到break。case标号并不自动产生跳转边界它只是指向这块代码的一个入口点。这也就是为什么很多人习惯在case末尾写break。看这个例子int level 2; switch (level) { case 1: printf(level 1\n); case 2: printf(level 2\n); case 3: printf(level 3\n); }level 为 2 时会连续输出 level 2 和 level 3。如果这不是你的本意就是穿透bug如果你确实想让多个case共享逻辑就可以故意不写break但建议加注释说明是有意为之。实际项目中switch穿透是有用的但也很危险。一个通用建议是每个case都以break、return、continue或goto结束例外情况写清楚。GCC配合-Wimplicit-fallthrough能帮你在一定程度上查出遗漏它会针对缺少break却未注释的情况提醒你。3.3 do-while的结尾分号一个标点引发的编译错误文章开头那个学生丢的分号就是do-while结尾的分号。很多人在 do-while 的 while 之后漏掉分号编译器就会出现类似 expected ; before } 的报错信息而且报错位置看起来总是指向下一行甚至花括号附近初学者很难定位。为什么会这样因为 do-while 的语法是do 语句 while (表达式);注意整个do 语句 while(表达式);才是一条完整的语句而while(表达式)之后的分号是这条语句的终结符。普通 while 循环则是while (表达式) 语句它不需要在结尾额外加分号因为循环体语句本身如果是表达式语句自带分号如果是复合语句闭花括号就表示结束。所以while (n) {} // 对不需要分号 do {} while (n); // 对必须有分号记住这个差别以后看do-while相关的报错会快很多。4. 注释的三种形态和使用边界4.1 单行注释与多行注释从C99开始才齐备的两种标准写法C语言注释有两种标准形式// 单行注释从//到行尾结束。严格来说C99才把它纳入标准C89没有很多C89编译器是作为扩展支持的。/* 多行注释 */从/*到最近的*/可以跨多行。关于多行注释有个最经典的说法它是不能嵌套的。这意味着/* 外层注释 /* 内层注释 */ 外层还想着长 */这段在编译器眼里内层的*/就已经结束了整个注释后面那个外层还想着长 */ 变成了普通代码实际上会引发编译错误。因为注释处理是从/*开始一路找最近的*/作为注释终点它不理会注释内部还有没有/*。需要临时注释掉一段含有多行注释的代码时这个特性会坑人。我的做法是临时屏蔽代码优先删成大段后加/*...*/如果里面本来就有/*...*/宁可先把内层注释改掉或者用条件编译#if 0 ... #endif来替代。4.2 关于注释绕不开的三条硬规则第一注释在预处理阶段会被替换成一个空格而不是直接删除。这个区别在单词拼接场景下会产生诡异的差异int a /* 注释 */ b;因为注释被替换为空格这行实际上被理解为int a b;而不是int ab;。虽然没人会故意在标识符中间插注释但理解这个机制对解坑有用。第二字符串字面量里的//和/*不是注释。比如printf(http://example.com/path\n);这里的//在字符串内部是普通字符不会引起注释行为。想要在字符串里输出/*同理也没问题。如果哪天你在字符串拆分时不小心把换行写进了字符串字面量编译器会报错而不是把它当注释注意别把注释手段用于字符串内部。第三注释不能出现在字符字面量内部构成特殊转义的位置。例如char c /;是合法的/是普通字符不构成注释。这一点可能有点绕但关于字符、字符串和注释的关系你只需要记住注释识别只看词法不看语义就够。4.3 注释代码不是代码是包袱注释代码指的是用注释把一段暂时不用的代码包起来比如/* if (old_version) { run_old(); } */这种做法的初衷是我可能还会用回来但现实是版本管理工具git、svn早就承担了这个职能。而且被注释的大段代码没人敢随便删时间一长就成了代码库里的垃圾。我从实际项目里得到的经验是需要保留的代码进版本库历史不需要的直接删除要恢复用 git 查历史即可。这样代码库保持清爽review 的人也不会因为大段注释而分心。另外在多人协作时注释里不要写情绪和废话。我之前见过// 不要改这里谁改谁负责这样的注释不但不解决问题还制造冲突。注释应该写事实写意图不写威胁。5. 把注释写进工程规范函数头、文件头与doxygen风格5.1 注释到底该写什么很多初学者的问题不是注释太少而是注释没写到底层意图。比如// 将a赋值给b b a;这种注释没有意义因为代码本身已经说清楚了。有价值的注释是你在代码里看不出来的信息为什么选这个实现而不选另一个这个边界条件为什么存在某个看起来很奇怪的写法背后有什么坑调用的前置条件和后置条件。举个例子// 这里用位运算而不是除法因为原始数据量大性能敏感 // 且len始终是2的幂 int idx (pos (len - 1));没有这两行注释后来的人大概率会把它优化掉丢掉性能设计意图。我见过太多类似案例注释的价值恰恰体现在代码自身解释不了的地方。5.2 用doxygen风格整理文档注释当项目变大配合 Doxygen 这类工具可以把注释转换成文档注释就变成API文档的一部分。函数头常见的doxygen风格是/** * brief 计算两个整数的最大公约数 * param a 第一个整数 * param b 第二个整数 * return a和b的最大公约数若a或b为0则返回0 * note 使用辗转相除法复杂度O(log(min(a,b))) */ int gcd(int a, int b);常用的标签包括brief简短的函数说明、param参数、return返回值、note注意事项、warning警告、see相关参考、author作者、version版本号。文件头注释也可以有约定/** * file utils.h * brief 常用工具函数声明 * author XXX * date 2024-06-01 * version 1.2 */配合版本历史可以这样* version 1.2 2024-06-01 增加gcd函数 * version 1.1 2024-03-10 修正注释格式有了这个习惯之后团队里查函数用法、排查代码改动就方便多了。不过也不要走极端每个函数都写满标签而内容空洞同样很烦人。提示Doxygen并不是唯一选择如果你用 Visual Studio或使用带文档注释功能的IDE比如给VSCode配置注释模板也可以达到类似效果。关键是注释格式一致而不是格式本身有多高级。5.3 中文注释乱码和行尾对齐这些实际麻烦我在实际教学和项目里碰到过两个很常见的注释问题。一是中文注释乱码。根源基本是文件编码不一致Windows平台有的编辑器默认GBK/GB2312而Linux和macOS默认UTF-8。解决方式很直接编辑器统一设置成UTF-8无BOM编译器也要保证源码文件按UTF-8读取跨平台项目还要注意字符串字面量里的中文字符编码。因为注释乱码而导致的编译报错多数是文件编码被破坏后某个汉字的字节序列吞掉了引号或分号处理办法是先用十六进制模式去看文件头或者把乱码段直接删掉重新输入。二是行尾注释对齐问题。实际编码规范中很多人喜欢在代码右侧写注释但不同人编辑器里的等宽字体宽度不同对齐得很死换个环境就会崩。我的建议是行尾注释只用于简短提醒一两行内容多行说明和逻辑叙述放代码块上方。这样既不容易在缩进和空格上较劲也让注释的层次更清楚。最后再说一句。很多C语言学习者把重心放在背语法、刷题上却很少花一下午时间把语句分类这张语法地图铺开看一眼。我个人的体会是当你把每条语句的边界分号、花括号、标号都想明白了编译报错就是一群讲道理的朋友每一个提示都指向具体位置注释则是写给同行和自己看的说明书值得认真对待。希望这篇拆解能成为你按图索骥的那张图。
返回列表