13. 【C语言】变量的生存空间:作用域与生命周期
上一篇文章我们学会了把代码封装成函数还注意到一个奇怪的现象函数里定义的变量出了函数就“消失”了函数参数在里面不管怎么改都影响不到外面。这背后其实是 C 语言的两条看不见的规则在起作用作用域和生命周期。理解它们你才能回答为什么在main里不能直接用另一个函数的局部变量为什么有的变量每次调用函数都重新来一遍有的却能记住上一次的值为什么全局变量这么“方便”却总被前辈劝说要少用这篇文章我们就来揭开这些谜底把变量的“生存空间”搞清楚。这也是学习指针之前必须打好的一层地基。一、作用域你能在哪里访问这个变量作用域Scope规定了变量名在代码的哪些区域是可见的、可访问的。你可以把它理解成“这个名字的有效势力范围”。C 语言的作用域主要有三类1. 块作用域Block Scope块是指用一对花括号{}包裹的区域。函数体是一个块if后面的大括号里也是块甚至你可以凭空写一对花括号来创建一个独立的块。在块内定义的变量作用域就限制在这个块内从定义处到块的右花括号}结束。#includestdio.hintmain(void){inta10;// a 的作用域开始if(a5){intb20;// b 的作用域只在这个 if 块里printf(a%d, b%d\n,a,b);// OK}printf(a%d\n,a);// OK// printf(b%d\n, b); // 错误b 不在作用域里return0;}b只能在它所在的if块内使用出了这个块编译器就不认识它了。函数的形参虽然写在花括号外面但它们也被视为属于函数体的块作用域。也就是说形参只在函数内部有效。2. 文件作用域File Scope在所有函数外面定义的变量具有文件作用域从定义处到整个文件结束都可见。我们常把这样的变量叫全局变量。#includestdio.hintglobal_counter0;// 文件作用域从这行往下全文件可见voidincrement(void){global_counter;}intmain(void){increment();printf(%d\n,global_counter);// 1return0;}如果多个.c文件分别定义了同名的全局变量会怎样那就要用到extern和static来协调这是下一篇“多文件编译”的核心话题。3. 函数原型作用域了解即可你在函数原型里写的参数名作用域仅限于那个括号内。写不写名字都无所谓它只是个占位符intmax(inta,intb);// a 和 b 的范围仅限于这个括号内二、生命周期这个变量能活多久如果说作用域回答了“你在哪里能访问我”**生命周期Storage Duration存储期**则回答了“我在内存里存活多长时间”。C 语言有三种主要的存储期1. 自动存储期Automatic Storage Duration我们在函数内定义的普通局部变量包括形参都具有自动存储期。它们的内存空间在函数被调用时自动分配在函数返回时自动释放。这就是为什么局部变量不能跨函数使用——不是“看不见”而是它本身已经消失了。栈帧被销毁变量所在的地址重归系统。int*bad_pointer(void){intx42;returnx;// 危险x 在函数返回后消失}这个经典的错误正是因为返回值指向了一个自动存储期的变量。2. 静态存储期Static Storage Duration具有静态存储期的变量在整个程序运行期间一直存在——从程序启动时创建到程序结束时销毁。它们的空间分配在静态数据区不在栈上。哪些变量有静态存储期所有全局变量不管是否加static用static关键字修饰的局部变量字符串字面量如Hello也可以视为具有静态存储期全局变量的生命周期从main执行之前就开始到main执行结束后才结束持续整个程序。3. 动态存储期Dynamic Storage Duration这种变量不由编译器自动管理而是由程序员用malloc等函数手动申请用free手动释放。它的空间在堆Heap上生命周期由你决定。我们会在后面的动态内存分配专题里详细讲。三、static关键字让局部变量拥有“记忆力”在函数内部用static修饰一个局部变量它就会被从“自动存储期”升级到“静态存储期”。也就是说虽然作用域还是函数内部外部不可见但它的生命周期变成了整个程序运行期间。这意味着什么它能在函数调用之间“记住”上一次的值。看这个经典的计数器例子#includestdio.hvoidcount_calls(void){staticintcounter0;// 静态局部变量只初始化一次counter;printf(这个函数被调用了 %d 次\n,counter);}intmain(void){for(inti0;i5;i){count_calls();}return0;}输出这个函数被调用了 1 次 这个函数被调用了 2 次 这个函数被调用了 3 次 这个函数被调用了 4 次 这个函数被调用了 5 次注意static int counter 0;这行只在第一次调用时执行初始化之后每次进入函数counter保留上次的值。这就像一个“私有的永久储物柜”——别人碰不到但它一直在那儿。如果不用static把static去掉counter就变成普通自动变量每次调用都重新初始化为 0永远只能输出 1。试试看体会一下差别。四、块作用域的嵌套名字遮蔽块作用域可以嵌套。当内外两个块定义了同名的变量时内层变量会遮蔽外层的同名变量——在内层作用域里你访问的是内层的那个外层的暂时不可见。#includestdio.hintmain(void){intvalue10;printf(外层 value %d\n,value);{intvalue20;// 这个 value 遮蔽了外层的 valueprintf(内层 value %d\n,value);}// 内层 value 生命结束printf(外层 value %d\n,value);// 仍然 10return0;}输出外层 value 10 内层 value 20 外层 value 10同一个名字出现在不同层级时编译器按“最近优先”的规则查找。这种遮蔽有时候方便但也容易造成迷惑所以尽量避免在嵌套块中定义同名变量。五、全局变量力量与代价全局变量文件作用域变量看起来很方便——到处都能访问不用传参了。但这条捷径往往通向“混乱”。随着程序增大任何一个函数都可能偷偷修改全局变量导致 bug 难以追踪。一条经验法则能不用全局变量就不用。优先把数据在函数之间传递而不是放在外面共享。如果确实需要用一个全局变量比如整个程序配置用static限制它的作用域范围下一章会讲并且给它起一个清楚、不易冲突的名字。六、作用域与生命周期速查表变量位置作用域生命周期存储位置初始化情况函数内局部变量无static块作用域函数调用期间栈不自动初始化垃圾值函数内局部变量static块作用域整个程序运行期静态数据区自动初始化为 0全局变量无static文件作用域可被其他文件引用整个程序运行期静态数据区自动初始化为 0全局变量static文件作用域仅本文件可见整个程序运行期静态数据区自动初始化为 0注意全局变量和static局部变量如果你不手动初始化它们会被自动初始化为0或空字符、空指针这和普通局部变量垃圾值不同。七、常见错误与陷阱1. 返回局部变量的地址再强调一遍int*create_int(void){intn100;returnn;// n 在函数结束后消失返回的指针是悬空指针}要返回指针可以返回static局部变量的地址因为它的生命周期是全程的或返回动态分配的内存地址或返回传入的参数地址。这个“大坑”在后面指针专题还会填。2. 滥用全局变量导致逻辑混乱intcount;// 全局voidfuncA(void){count2;}voidfuncB(void){count*3;}intmain(void){count1;funcA();funcB();printf(%d\n,count);// 输出 9 还是什么你要在心里跟踪return0;}当程序变大任何一个函数都可能悄悄改变全局变量你完全搞不清它怎么变成了这个值。用参数传递和返回值来代替全局状态是更安全的设计。3. 误解静态局部变量的初始化static int x 0;在函数中只执行一次。但如果你写成static int x; x 0;就是另一回事了——赋值语句每次函数调用都会执行。保持用初始化语法。4. 在嵌套块中无意遮蔽当你写int i 5;在一个块里而外层已经有个i你可能不小心改了逻辑。给变量起有意义的名字尽量避免重用。八、小结与预告今天我们搞清楚了变量的“地盘”和“寿命”。作用域决定了在哪里能访问生命周期决定了能活多久。static让局部变量有了跨调用的记忆全局变量让全文件共享但要谨慎使用。你还看到了局部变量在栈上分配、函数返回即销毁的根本原因。理解了这些你就不会再困惑“为什么这个变量这里不可见”“为什么这个值没有保留下来”。更重要的是你已经为下一篇文章做好了铺垫——当多个.c文件需要共享变量和函数时extern和static是怎么协作的这就是多文件编译与头文件要讲的东西。再下一步当我们进入指针的深处时你才会真正体会到透彻地理解变量住在哪里、活多久是安全使用指针的前提。课后小练习写一个函数next_id(void)每次调用返回一个递增的整数第一次返回 1第二次返回 2……要求在函数内使用static变量实现。下面的代码有错误找出并解释int*trouble(void){inttemp10;returntemp;}intmain(void){int*ptrouble();printf(%d\n,*p);return0;}定义一个全局变量int mode 0;再写两个函数一个把mode设为 1一个设为 2。在main里交替调用它们并打印mode观察结果。然后思考这种依赖全局状态的写法有什么不方便的地方思考写一段代码在内层块中定义一个和外部同名的变量并在内外分别打印。验证名字遮蔽的效果。我们下期见获取本系列示例代码请访问 GitCode 仓库。