资讯详情

资讯详情

C语言基础复盘:宏、变量、常量与运算符的常见陷阱

最近在复盘C语言基础起因是想给团队里某位新人讲清楚一段看起来很“正常”却总出问题的代码讲完之后我自己也被那些基础细节卡住了。写了不少年代码回头再看宏、变量、常量、运算符这四个主题才发现很多坑不是“知道不知道”的问题而是“有没有真正理解规则背后的原因”。所以有了这个C语言基础复习系列第一篇就把这四个最容易被忽略的主题一次性梳理清楚。不管你是刚学C语言的学生、准备面试的开发者还是已经工作但想补基础的同行这篇都值得花半小时慢慢看。很多内容我会直接给出结论、示例和避坑建议也会解释“为什么”而不是单纯罗列语法点。读完之后你会发现看似基础的东西反而是排查工程问题时最该优先怀疑的方向。1. 为什么还要回头复习C语言基础1.1 基础不牢排查问题的时候只能靠猜很多人觉得C语言基础不就是那几个语法点嘛宏定义替换一下、变量声明一下、常量写死、运算符算一下能有什么难度但实际开发里我见过太多“奇奇怪怪”的bug最后定位到根源全都是基础概念没吃透。举个例子某段代码里写了#define MAX 100 int arr[MAX];看起来很正常。但如果某天有人不小心在别的文件里也定义了一个同名宏或者宏名和变量名冲突行为就会变得很难查。再比如带参宏#define SQUARE(x) x * x int result SQUARE(2 3);你以为结果是25实际结果是11。这种问题不真正运行起来根本意识不到。很多面试题也是基于这些细节设计的因为出题人知道绝大多数人在日常开发里根本不会把“23”直接塞进带参宏里但一旦遇到就会浪费大量时间。基础复习的核心价值不是让你背语法而是让你在面对问题时能把“嫌疑范围”锁定在编译器、预处理、内存模型、运算规则这些底层机制上。做到这一点排查效率会高很多。1.2 这篇复习文怎么安排这篇博文围绕标题里的四个关键词展开宏、变量、常量、运算符。每一部分我不会铺开全部语法只挑最有工程价值、最容易被忽略的点深入讲宏的部分重点看作用域、带参宏的陷阱、以及 #undef 的用途。变量的部分重点看存储类型、作用域、生命周期和内存分区之间的关系。常量的部分重点看 const 的真正含义尤其是 const 指针的读法。运算符的部分重点看优先级、结合性、副作用、短路求值、位运算和 sizeof 的冷知识。每个主题后面都会配上实际代码示例和“避坑”提示顺手再放一张常用运算优先级表方便你之后查阅。2. 宏预处理器替你做的事比想象中多2.1 宏的作用域与取消宏由预处理器处理本质上就是“文本替换”。这句话讲了无数遍但很多人的理解只停留在“定义之后遇到这个名字就替换成后面的内容”这一层很少去想作用域的问题。宏的作用域从#define出现的位置开始一直到文件结束。请注意没有“块级作用域”的说法。你不能在一个函数内部定义一个宏然后期望它只在函数内生效。它不受花括号限制也不管什么局部还是全局就是“定义之后往下一直有效”。这一点和变量完全不一样容易踩坑。想提前结束宏的作用域用#undef#define DEBUG_LEVEL 2 void func(void) { printf(debug level %d\n, DEBUG_LEVEL); } #undef DEBUG_LEVEL void another_func(void) { // 这里不能再使用 DEBUG_LEVEL }在大型项目中配合#ifdef做条件编译时#undef能帮助避免宏定义污染。比如某个库的头文件里定义了通用名字的宏你只想在特定片段内使用用完立即取消避免影响其他代码。另一个实用场景你怀疑某个宏可能被外部文件定义过想在工程里强制覆盖可以这样#ifdef XXX #undef XXX #endif #define XXX 100这在跨平台适配里很常见。因为不同平台、不同编译器可能预定义了一些宏名字相同但含义不同。先#undef再重新定义能确保最终使用的一定是你指定的值。2.2 带参宏和函数看起来像用起来不像带参宏长得很像函数但机制完全不同。函数调用有参数传递、类型检查、栈帧分配而宏只是把你写的实参文本原封不动地替换进宏体。这个差异直接导致两类经典问题。第一类问题优先级错误。#define SQUARE(x) x * x int a SQUARE(3 2);替换之后变成3 2 * 3 2乘法的优先级高于加法结果是3 6 2 11而不是 25。解决办法是给宏体的每一个参数都加括号给整个宏体也加括号#define SQUARE(x) ((x) * (x))这个写法看起来繁琐但这是必须的。没有括号宏就没法保证表达式层级正确。第二类问题副作用多次求值。#define MAX(a, b) ((a) (b) ? (a) : (b)) int i 3; int j 5; int k MAX(i, j);展开后i可能被求值两次因为条件里的a被替换成i于是i先自增、进入比较、然后根据结果可能再一次求值。最终 i 的值变成 5 而不是 4k 的值也可能不符合预期。这个行为在 C 里是定义良好的只是产生了两次副作用但结果通常不是你想要的效果。带参宏和函数怎么选我的建议是能用函数就用函数除非你明确需要避免函数调用开销、或者要做类型无关的泛型操作后面会说到_Generic但那是另外的话题。现代编译器对普通函数的优化已经很强static inline函数往往比宏更好读、更好调试。如果确实要用宏请牢牢记住参数不要传有副作用的表达式宏体不要漏括号。2.3 宏的高危操作清单我整理了一份平时 review 代码时最常看到的宏问题清单写在这里当作速查表问题场景错误示意正确/安全做法宏体末尾加分号#define ADD(a,b) ab;不要在宏体末写分号让调用方自己加分号宏里用if不带花括号#define DO(x) if (x) foo(); bar();宏体不要写成语句块多用do { } while(0)包裹参数不加括号#define SQR(x) x*x#define SQR(x) ((x)*(x))依赖局部变量名#define SWAP() { int t a; a b; b t; }不要这样写a 和 b 必须作为参数传入宏名过于通用#define SIZE 100加项目前缀如MYPROJ_SIZE特别说下do { } while(0)这个惯用法。它能保证宏像“单条语句”一样被调用不会干扰外层控制结构#define CHECK_AND_EXIT(ptr) \ do { \ if (!(ptr)) { \ return -1; \ } \ } while(0)在 if 分支里调用这个宏时如果没有do { } while(0)包裹展开后可能多出分号导致 else 无法匹配、或者编译报错。加了之后宏整体成为一条复合语句行为可控。还有一个调试困境宏展开出错时编译器报错信息往往指向宏定义行而不是调用点。如果你看到错误定位在一个#define行第一反应不是去改调用处而是先看宏定义本身的括号、分号、类型是否有问题。这是经验也是套路。3. 变量声明只是一行代码背后是整个内存模型3.1 全局变量、局部变量与块级作用域C语言里变量按作用域可以分成三种全局变量、局部变量、块级变量。全局变量定义在所有函数之外从定义点开始到文件末尾可见。局部变量定义在函数内部仅在函数内可见。块级变量则定义在花括号内比如 if、for、while 的块里作用域仅限于该块。实战中容易犯错的地方有三个。第一同名变量遮蔽shadowingint x 10; void func(void) { int x 20; printf(%d\n, x); // 20局部变量遮蔽了全局变量 }如果你在函数里想访问被遮蔽的全局变量得用extern声明或者换个变量名。但更推荐的做法是工程上避免全局变量和局部变量使用相同的名字尤其是简单项目里非常常见的i、x、count这类名字。代码被遮住的时候你以为在改全局变量其实改的是局部变量这是经典的隐蔽bug来源之一。第二全局变量的初始化。C标准规定静态存储期的变量如果没显式初始化会被自动置为0。但局部变量不会局部变量的初始值是“不确定的”。很多人在这里踩坑int count; // 全局默认 0 void func(void) { int sum; // 局部值不确定 printf(%d\n, sum); }我在实际代码评审中多次看到局部变量忘记初始化、导致在 release 模式下输出随机值的例子。Debug 模式下内存可能是 0看起来正常一发布就出问题。第三数组长度必须用常量表达式。C99 之后虽然支持变长数组但在普通场景下数组维度最好还是编译期常量。如果你写int n 10; int arr[n]; // 某些编译器支持但这是变长数组不是标准常量表达式在移植性要求高的代码里不建议这样用。想用运行时的值分配内存直接用malloc更明确。3.2 static和extern改变可见性与生命周期的钥匙static和extern是修饰变量以及函数的两个重要关键字很多人对它们的理解只停留在“static 变量只初始化一次”这个层面这个理解没错但太浅了。static修饰局部变量改变的是变量的生命周期从自动存储期变成静态存储期。也就是说局部变量不再是函数调用时创建、结束时销毁而是程序启动时就分配好第一次执行到声明处时初始化之后一直保留。void counter(void) { static int count 0; count; printf(%d\n, count); }这个函数每调用一次count 就加一。因为它存储在静态区不是栈上。注意“第一次执行到声明处时初始化”这话如果用static int count 0在之后调用中不会重新赋值为 0只初始化一次。static修饰全局变量改变的是链接属性。全局变量默认是外部链接多个源文件可以通过extern互相引用。加上static后变成内部链接只能在本源文件内访问。这实际上是在文件层面实现了“私密性”。extern的作用是声明一个已经在别的文件里定义的外部变量。注意extern是声明不是定义不分配内存extern int global_count;这里有个常见误区int global_count;在文件作用域写一句是定义如果就想声明外部变量必须加extern。两个源文件里都写int global_count;链接器会直接报重复定义错误。很多初学者遇到“重复定义”报错就是这个原因。3.3 存储期与内存分区变量到底住在哪C程序运行时的内存可以粗略分成几个区域区域存放内容生命周期栈区局部变量、函数参数、返回地址函数调用时分配返回时释放堆区程序员手动 malloc/free 的内存手动控制全局/静态区全局变量、static 变量程序启动到结束常量区字符串常量、const 全局变量等程序启动到结束代码区编译后的机器指令程序启动到结束理解这个划分有助于解释很多基础现象。比如函数里定义一个大数组为什么可能栈溢出就是因为栈区空间有限默认在几 MB 到几十 MB 级别。而全局变量数组放在全局区可以开很大。register关键字也顺便提一下。它曾经想提示编译器把变量放进寄存器里加速访问但现代编译器优化能力极强你写了register也没啥用而且 C17 已经移除了它的语义C语言里还保留。复习时知道它是“存储类说明符”之一即可写代码时不推荐用。局部变量默认存储期是自动的用auto关键字显式声明也可以但C语言里基本没人写 auto因为裸写变量就是 auto。在C11 之后 auto 的语义完全变了这一点建议C和C分开记不要混。4. 常量使代码稳定的几种姿势4.1 字面常量与符号常量的选择常量分两种字面常量和符号常量。42、3.14、A、hello都是字面常量直接写在代码里的值。符号常量是用名字代表一个固定值常见实现方式有两种宏定义和枚举。#define BUFFER_SIZE 1024 enum { STATUS_OK 0, STATUS_ERROR -1 };宏定义的符号常量在预处理阶段完成替换不会占用变量空间。枚举则是一个真正的类型枚举常量是 int 类型。如果你的常量是若干相关的选项值用enum更合适因为会有一定类型检查也方便调试器显示名称。字符串字面量的存储需要单独注意。它储存在只读区理论上不可修改。如果你写char *p hello; p[0] H; // 未定义行为很可能会段错误而写成数组就不同char p[] hello; // 数组在栈上初始内容是 hello可修改 p[0] H;两者的本质差异是指针版本指向常量区的字符串数组版本把字符串复制到栈区。面试里经常考这个差别工程中也值得牢记。4.2 const修饰变量从“只读”到“权限缩小”const含义是“只读”不是“不可变”。这个区分很关键。比如const int a 10; int *p (int *)a; *p 20; // 未定义行为编译器不一定拦你运行可能崩溃const int a的意思是通过名字a不能给 a 赋值但通过其他途径比如强转指针仍然可能改到它只是这属于未定义行为不要这么写。对于普通变量const 的意义在于告诉编译器和读代码的人“这个变量不应该被修改”。函数参数里用 const 更是一种权限缩小的声明int string_length(const char *s) { // s 指向的内容不能通过 s 修改 }从实践角度讲这样写有两个好处。第一编译期能检查出误改写操作第二调用方看到 const 就知道函数不会改动传入的字符串接口行为更清晰。还有一个和 C 语言标准有关的坑C 语言里const int N 10;不能直接拿来当数组长度const int N 10; int arr[N]; // 某些编译器可以但严格来说 N 不是常量表达式如果需要在编译期确定数组大小用#define或enum不要用const int。这一点和 C 不一样C 里 const int 局部常量可以用于数组维度C 里不行。4.3 const与指针从右往左读的诀窍const 修饰指针是最容易记混的部分。三个形式const int *p; // p 指向一个 const int int *const p; // p 本身是 const 指针 const int *const p; // 两者都 const读法技巧是“从右往左读遇到 p 就翻译成 p is pointer to”。拿const int *p举例先看到变量名p从右往左是*所以 p 是一个指针再往左看到const int所以 p 是一个指向 const int 的指针合起来p 是指向常量整数的指针。那么p本身可以改*p不能改const int a 10; const int b 20; const int *p a; p b; // 可以p 本身可改 *p 30; // 不行*p 是 const另一种形式int *const pp是 const 的指针指向 intp不能改*p可以改。理解这个读法之后不会再看错。函数参数const char *s就是前一种表示不能通过 s 修改指向的字符串。至于数组类型的参数退化成指针后const 依然作用于它指向的首元素这一点写void f(const char arr[])和void f(const char *arr)等价。5. 运算符排除优先级和副作用这两个大坑5.1 优先级与结合性用括号给编译器按头优先级表我在网上看过 N 个版本但记不住其实是正常的。真正稳的做法是复杂表达式一律加括号不依赖优先级“默认正确”。不过常用优先级还是要有个基本概念。下面这份精简表基本覆盖了日常 95% 的场景从高到低排序优先级运算符说明1() [] - .括号、下标、成员访问2! ~ -- - * sizeof单目运算符3* / %乘除取余4 -加减5 移位6 关系比较7 !相等比较8位与9^位异或10|位或11逻辑与12||逻辑或13?:条件运算符右结合14 - ...赋值右结合15,逗号这里面有一个典型偷懒误区把位运算的、|当成逻辑运算来写。a b和a b完全是两码事。尤其是判等if (flags FLAG_MASK 1) // 按优先级 先于 结果完全不对正确写法是if ((flags FLAG_MASK) 1)。所以说位运算表达式最好加括号。右结合性也值得注意。a b c是从右往左赋值的所以 b 得到 c 的值a 再得到 b 的值。条件表达式a ? b : c ? d : e从右往左结合但大多数人不会写这么复杂的嵌套条件遇到时直接用括号理清结构更省心。5.2 自增自减与未定义行为和--是 C 语言里“看起来很简单但水最深”的运算符。前缀i先加一再用新值后缀i先用旧值再加一。这个解释本身没问题但一旦出现下面这种写法就是未定义行为int i 1; int j i i; // 未定义行为 printf(%d %d\n, i, i); // 未定义行为C 标准里有一条规则在两个顺序点之间一个对象的值最多只能被修改一次。如果同一表达式里同一个变量被修改多次编译器如何求值完全不可预测。可能这里算、那里算不同编译器、不同优化级别、甚至同一个程序跑两次结果都可能不同。工程上怎么避坑就一句话不要把自增/自减和读取混在同一个复杂表达式里。要修就单独一行写。循环里用for (i 0; i n; i) { ... }这是最干净、语义最清晰的用法。不要在写arr[i] i这种偷懒代码这句话到底先取 i 还是先自增标准也没有完全定义纯粹是给自己埋雷。顺带提一下很多人面试时会遇到sizeof相关的陷阱int i 0; printf(%zu\n, sizeof(i)); // i 不会自增sizeof是运算符不是函数。它求的是类型或表达式的类型大小在编译期就确定不会真的执行表达式求值。所以sizeof(i)只是算出 int 的大小i 的值保持不变。如果你在sizeof里写了一个带副作用的表达式它不会执行副作用。这一点经常被拿来出题也值得留个印象。5.3 短路求值、位运算与sizeof的冷知识逻辑与、逻辑或||都有短路特性只要能确定整个表达式的结果后面的部分就不再求值。int a 0; if (a foo()) { // foo 不会执行 }这个特性在写代码时可以优化分支比如先检查指针是否为 NULL 再解引用if (ptr ! NULL *ptr A) { // 安全 }相反的坑也常见。有人写if (ptr NULL || *ptr A)短路特性保证 ptr 为 NULL 时不会解引用这是安全的写法。了解短路特性之后很多“奇怪”的流程问题比如明明逻辑上说某个函数不会被调用但 debug 发现它被调了就能解释清楚。位运算在嵌入式、协议解析、状态压缩里面都是高频操作。几个常用的技巧// 判断奇偶 if (x 1) { /* 奇数 */ } // 取低 n 位 int low x ((1 n) - 1); // 检查第 n 位是否为 1 if ((x n) 1) { ... } // 置位/清位 x | (1 n); x ~(1 n); // 交换两个数不推荐日常用但思路值得了解 a ^ b; b ^ a; a ^ b;交换两个数的异或写法曾经是很多书里的“炫技”内容但在现代 CPU 和编译器下普通变量交换直接引入临时变量更清晰、更快。异或交换主要在寄存器约束严格或硬件寄存器操作的场景中才会考虑日常代码别为了炫技牺牲可读性。再补充一个和sizeof相关的长实战坑数组作为函数参数时会退化成指针导致sizeof测量错误。void print_array(int arr[]) { printf(%zu\n, sizeof(arr)); // 不是数组大小是指针大小 }你以为是数组实际上函数参数arr已经是指针了。在函数内算出数组元素个数几乎不可能用 sizeof 完成。正确做法是传入数组长度或者在调用处计算后传进去。这个问题非常基础但在 C 工程中出现的频率高得吓人几乎每个维护过 C 项目的人都见过类似 bug。6. 综合复习小测试与易错点速查6.1 一道综合题的逐行拆解把上面讲到的知识点揉进一个例子只看代码、不运行你能准确说出每一行输出吗#include stdio.h #define SQUARE(x) ((x) * (x)) #define PRINT_SQUARE(a) printf(%d\n, SQUARE(a)) static int times 0; int add_one(int n) { static int last 0; int temp 0; temp last; last n 1; return temp 1; } int main(void) { const int base 4; int i 2; int a 10; int b 20; int result 0; result a 5 b 30; printf(result1 %d\n, result); result a 5 || (b 100); printf(a %d, b %d\n, a, b); result (a ^ b) 0xFF; printf(result2 %d\n, result); PRINT_SQUARE(i 1); printf(add_one: %d\n, add_one(1)); printf(add_one: %d\n, add_one(2)); printf(base %d\n, base); return 0; }逐块分析第一段是宏定义SQUARE(x) ((x) * (x))和PRINT_SQUARE(a)。PRINT_SQUARE(i 1)展开后是printf(%d\n, ((i 1) * (i 1)))所以输出 9。这个例子提醒你带参宏里面传表达式如果没有括号保护结果会和你预想的完全不同。第二段 static 全局变量times 0虽然定义了但没在 main 中使用暂时忽略。重点在add_one函数里的static int last 0。第一次调用add_one(1)temp lastlast 是 0所以 temp 0last n 1last 2返回 temp 1 1。第二次调用add_one(2)temp lastlast 现在是 2所以 temp 2last n 1last 3返回 temp 1 3。输出结果是 1 和 3。这里就体现了 static 局部变量的“记忆”能力它不会因为函数结束而消亡。再回到 main 里result a 5 b 30a10、b20两个条件都真短线求值也不会省掉第二个所以 result 为 1。result a 5 || (b 100)由于 a 5 为真||短路(b 100)不执行b 保持 20。输出 a10, b20。如果你以为 b 被改成 100就踩了短路求值的坑。result (a ^ b) 0xFFa100x0A、b200x14异或得 300x1E与 0xFF 还是 30输出 30。最后一个const int base 4在这里只是打印没有修改操作安全通过。但如果有人试图直接改 base编译器会报错这正是 const 的作用。这种综合测试的好处是把所有知识点串起来看宏替换、static 静态变量、短路求值、位运算、const 修饰全在一个小函数里体现。自己动手跑一遍和纸面推导对比就能发现自己理解上的漏洞。6.2 常见问题速查表最后放一张我在实际开发和面试中总结出来的易错点速查表建议截图保存。现象根本原因解决方案宏展开后公式算错参数或宏体缺括号带参宏全括号嵌套宏展开后 if/else 异常宏体是复合语句、末尾带分号用do { } while(0)包裹i 放入带参宏后值变来变去宏参数被多次求值调用宏时不要传带副作用的表达式函数里想改全局变量却被局部变量遮住变量遮蔽避免同名变量局部变量输出乱值未初始化局部变量显式初始化两个源文件定义同一全局变量报链接错定义和声明没区分一个源文件定义其他源文件 extern 声明const int 不能当数组长度C语言中 const 非编译期常量用#define或enump[0]H段错误字符串字面量指向只读区用char p[] helloi i结果随机修改多次未定义行为一个表达式内不重复修改同一变量sizeof(arr)在函数内算出的是指针大小数组参数退化为指针传长度参数位运算判断逻辑条件结果不对、优先级混乱位运算表达式加括号||后面的函数明明被“调用了”却没执行短路求值理解短路规则不写依赖副作用的表达式这十二个问题基本覆盖了宏、变量、常量、运算符四个主题里最高频的坑。如果你能把这十二个现象背后的原理都讲清楚面试和实际开发都会更稳。我个人在实际复盘里体会最深的一点是语法规则记得再熟不如亲自把代码跑一遍、观察一次 undefined behavior 的“随机性”到底有多随机。在 IDE 里写好示例、单步跟踪比死记硬背优先级表有效得多。基础复习这件事慢下来反而更快。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →