函数(三)
作用域与静态变量:可见性、生存期与状态管理

从社区慢病随访记录编号到内存存储区模型,理解“局部变量、全局变量、static 静态变量与隐藏状态陷阱”

本课核心问题

多个函数协同处理数据时,变量应该放在哪里?能被谁看见?能保留多久?

已有基础

已掌握自定义函数的定义、传参、返回值与 void 模块化分工架构。

新场景痛点

慢病管理系统需要为多次随访分配连续编号、计算居民历次随访动态累计平均指标,还要在模块间共享全局配置。

核心机制

作用域(空间可见范围)与存储期(时间存活周期),以及背后的内存三区分工。

第3课时学习目标

知识目标

  • 严格区分变量的作用域(Scope)与存储期(Lifetime)
  • 掌握局部变量、全局变量与 static 静态局部变量的声明与行为特征
  • 理解 C 程序的内存区域划分:程序区、静态存储区与动态存储区(栈区)

能力目标

  • 能准确预测同名局部遮蔽与 static 变量在多次调用中的演变
  • 能设计最小测试用例,揭示全局变量副作用与 static 带来的“调用顺序依赖”
  • 能借助 AI 协作自动化审计“看似正确却依赖调用次序”的隐蔽缺陷
5.3 / 变量的两个独立维度
“在哪些代码里能看见?”
与“在内存里能活多久?”
作用域决定空间可见范围,存储期决定时间存活长度,两者绝非同一概念。
作用域 Scope 存储期 Lifetime 同名遮蔽 内存三区

认知冲突:两个同名变量是同一个吗?

int count = 100;     /* Global variable: file scope */

void sample(void)
{
    int count = 0;   /* Local variable: block scope */
    printf("%d ", ++count);
}

int main(void)
{
    sample();
    printf("%d\n", count);
    return 0;
}

预测输出:打印 1 100 还是 1 1?

核心规则——同名局部遮蔽(Shadowing):
局部变量与全局变量同名时,在局部变量有效的作用域内,局部变量优先,全局变量被暂时“遮蔽屏蔽”。

作用域 vs 存储期:概念辨析表

核心维度 要回答的问题 主要分类 决定因素
作用域
(Scope)
空间概念:哪段代码、哪个函数能够访问该变量的名字? • 块作用域(局部):花括号 {} 内
• 文件作用域(全局):函数外定义
变量声明所在的代码物理位置
存储期
(Lifetime)
时间概念:该变量占用的内存单元何时创建、何时释放? • 自动存储期:进入块分配,退出块释放
• 静态存储期:程序启动分配,退出释放
变量的存储类型修饰符(如 static)
特别提醒:绝不能望文生义地认为“局部变量就必然在函数退出时消亡”——static 局部变量就是典型的反例!

C 程序的内存分工:静态存储区 vs 动态存储区

静态存储区(Static Data)

  • 存放全局变量与 static 变量
  • 程序启动时一次性分配固定的内存地址
  • 默认自动初始化为 0

动态存储区 / 栈区(Stack)

  • 存放普通局部变量与函数形参
  • 每次调用函数时动态分配栈帧
  • 函数返回时其内存被立即回收交还系统
C程序存储区结构

AI 协作互动:作用域与同名遮蔽陷阱审查

🤖 AI 协作互动:多层嵌套块作用域与同名变量遮蔽演练

我正在学习 C 语言的作用域规则。请构造一段包含【全局变量 x、main 局部变量 x、for 循环内部局部变量 x】的多层嵌套 C 程序。给出运行时的逐步单步推演表格,展示不同代码行打印的是哪一层内存空间中的 x,并分析为什么在工程中应尽量避免过度同名遮蔽。

💡 课堂互动:学生先不借助 AI,自己在草稿纸上圈出不同花括号对应的变量存活范围,再与 AI 的推演进行对齐验证。

5.3 / 静态局部变量 static
只在函数内部看得见,
却能在调用结束后活下来
static 局部变量:兼具局部安全的作用域与全程持久的静态存储期。
static 局部变量 一次性初始化 状态保留 调用顺序依赖

普通局部变量 vs static 静态局部变量

版本 A:普通局部变量(auto)

void show(void)
{
    int count = 0;
    count++;
    printf("%d ", count);
}

show(); show(); show();
输出结果:1 1 1
每次调用重新分配栈帧并初始化为 0。

版本 B:静态局部变量(static)

void show(void)
{
    static int count = 0;
    count++;
    printf("%d ", count);
}

show(); show(); show();
输出结果:1 2 3
只初始化一次,后续调用保留上一次的值!

贯穿案例应用 1:慢病随访记录自动编号器

int recordId(void)
{
    static int next = 0;   /* In static storage, initialized once */
    next++;
    return next;
}

int main(void)
{
    printf("Record ID: #%d\n", recordId());   /* Output #1 */
    printf("Record ID: #%d\n", recordId());   /* Output #2 */
    printf("Record ID: #%d\n", recordId());   /* Output #3 */
    return 0;
}

优点:信息隐藏

next 只能在 recordId 内部访问,外部任何函数都无法擅自篡改编号。

缺点:缺乏重置机制

如果新慢病居民建档要求重新从 #1 开始编号,函数接口无法从外部复位清零。

贯穿案例应用 2:动态累计平均函数

double runningAverage(double bmi)
{
    static double sum = 0.0;
    static int n = 0;
    if (bmi <= 0) return -1.0;   /* Guard clause: avoid polluting history */
    sum += bmi;
    n++;
    return sum / n;
}

/* Simulate 3 follow-up measurements for one resident */
printf("%.2f\n", runningAverage(24.0));   /* 24.0 / 1 = 24.00 */
printf("%.2f\n", runningAverage(23.6));   /* 47.6 / 2 = 23.80 */
printf("%.2f\n", runningAverage(23.8));   /* 71.4 / 3 = 23.80 */
看似入参只有一个 bmi,实际计算却暗中依赖了过去所有历史随访数据!

暗礁:隐藏状态带来“调用顺序依赖”

同一个输入,函数一定返回相同结果吗?在有 static 的世界里并非如此!

测试场景 调用序列 每次返回的结果 为什么会这样?
场景 1:完整历史 24.0 → 23.6 → 23.8 24.00 → 23.80 → 23.80 累加了 3 个随访测量值
场景 2:新居民直接测 直接调用 23.8 23.80 只累加了 1 个值(\(23.8 / 1 = 23.8\))
场景 3:未重启重复测试 再次传入 24.0 23.85(因为 \(95.4 / 4 = 23.85\)) 上一位居民的历史残留污染了新居民!
软件工程警示:隐藏状态会导致无法独立进行单元测试,测试结果受前面跑了什么测试用例的严重干扰!

AI 协作互动:消除隐藏状态的纯函数重构

🤖 AI 协作互动:从 static 隐藏状态重构为无副作用纯函数

我写了一个利用 static double sum 和 static int n 维护累计平均的函数 double runningAverage(double rate)。但测试时发现它无法在同一个程序内为第二个患者清零重置。请帮我将其重构为不包含 static 变量、依赖显式参数传递(如传入当前总分与总人数)的纯函数设计,并对比两者的单元测试友好度。

💡 课堂互动:同桌讨论:为什么工业界现代代码规范提倡“尽量显式传递状态,慎用不可控的持久局部状态”。

5.3 / 全局变量的权衡
共享虽方便,
副作用与隐蔽修改代价高
任何函数都能读写全局变量,任何函数也都可能在不知不觉中改坏它。
全局变量 副作用 Side Effect 模块紧耦合 只读 const 防御

教材例5-8:现金收支记账程序

double cash = 0.0;   /* Global balance variable */

void income(double amount)
{
    cash += amount;
}

void expend(double amount)
{
    cash -= amount;
}
教材代码细节找错:
教材原例在从键盘读入 double 变量时错写成 scanf("%f", &value),必须纠正为 %lf!

利弊深度剖析

便利之处:
income 与 expend 都不需要通过指针或复杂结构传参,直接读写公共余额 cash。

致命风险:
如果系统中扩展出 50 个函数,只要任何一个实习生不小心写了 cash = 0;,整个账户余额瞬间蒸发,几乎无法追踪是哪一行代码下的黑手!

全局变量的本质:破坏函数独立性

规范做法:显式参数与返回值传递

balance = income(balance, 100.0);
balance = expend(balance, 30.0);

✓ 输入什么、输出什么一清二楚;函数无副作用,极其易测。

滥用全局:隐式黑盒交互

income(100.0);  /* Modifies global cash silently */
expend(30.0);

✗ 模块间产生看不见的“强耦合纽带”,牵一发而动全身。

工程守则:不要把全局变量当成“省力不用写参数列表”的偷懒工具!若全局配置无需修改,必须加上 const 修饰!

AI 协作互动:全局变量的污染排查

🤖 AI 协作互动:全局变量并发与调用副作用审计

我正在维护一个包含了 double total=0.0 全局变量的 C 程序。多个函数 addRate(r)、clearTotal()、printReport() 都在读写它。请帮我分析:如果有两个任务同时调用该函数,或者在调用某个子函数时它内部偷偷执行了 clearTotal(),会导致什么隐藏 Bug?请提供一个防御性封装方案。

💡 课堂互动:观察 AI 如何解释“全局变量导致的多线程数据竞态(Race Condition)”与“隐蔽副作用”,加深对局部封装的敬畏。

教材例5-7 · 例5-9
经典教材代码审查:
找错、证伪与缺陷修复
复数运算全局变量公式错误与 static 阶乘函数的致命假象。
例5-7 复数乘法 例5-9 static 阶乘 缺陷证伪 最小测试用例

教材例5-7 题面描述与复数加乘

📋 教材例5-7 题目要求

使用全局变量在 main 与自定义函数之间共享数据,计算两个复数的和与积。输入两复数的实部与虚部,输出它们的和与积。

复数加法法则

\((a + bi) + (c + di) = (a + c) + (b + d)i\)

result_real = real1 + real2;
result_imag = imag1 + imag2;

复数乘法法则(需特别小心!)

\((a + bi)(c + di) = (ac - bd) + (ad + bc)i\)

因为 \(i^2 = -1\),所以实部必须减去两虚部乘积!

例5-7 深度找错:抓出教材原版经典 Bug

🚨 原教材课件的公式错误

/* Buggy formula: mixed real and imaginary parts */
result_real = real2 * imag1 - real1 * imag2;

测试打假:对于 \((1 + 2i) \times (3 + 4i)\):
正确实部为 \(1 \times 3 - 2 \times 4 = -5\)。
原课件错码算出的实部却是 \(3 \times 2 - 1 \times 4 = 2\),差了十万八千里!

✓ 修复后的正确公式

/* Correct math formula: (a+bi)(c+di) = (ac-bd) + (ad+bc)i */
result_real = real1 * real2 - imag1 * imag2;
result_imag = real1 * imag2 + imag1 * real2;

教训:全局变量名字长且相似,极易在编码时手滑敲错变量名而浑然不知!

教材例5-9:用 static 变量计算阶乘

📋 教材例5-9 题目要求

输出 1 到 \(n\) 的阶乘表。要求定义和调用函数 fact_s(n) 计算 \(n!\),其内部利用静态局部变量保留上一次计算的结果。

double fact_s(int n)
{
    static double f = 1.0;
    f = f * n;
    return f;
}
仔细端详这段 5 行代码:它真的能随时求任意正整数 \(n\) 的阶乘吗?

例5-9 致命陷阱:它根本不是真正的阶乘函数!

调用序列 实际返回值序列 真正的阶乘 \(n!\) 诊断结论
按严格顺序调用
fact_s(1) → (2) → (3)
1.0 → 2.0 → 6.0 1 → 2 → 6 表面巧合匹配(每步恰好乘上了 \(n\))
新程序直接算 3!
只调用 fact_s(3)
3.0 6 彻底算错!(\(1.0 \times 3 = 3\))
同一个程序中算两次 3!
fact_s(3) → fact_s(3)
3.0 → 9.0 6 → 6 不可复现!(第二次直接乘上去了)
本质揭秘:fact_s(n) 根本不是求 \(n!\),它仅仅是“把传入的数乘到内部静态累乘器上”。一旦调用次序颠倒,满盘皆输!

AI 协作互动:为伪算法设计证伪测试用例

🤖 AI 协作互动:设计最小测试序列刺破 static 伪算法

教材例5-9 的函数定义为:double fact_s(int n) { static double f=1; f*=n; return f; }。请帮我设计一个仅有 2 行调用的 main 函数,证明它无法作为通用的阶乘函数复用,并用数学归纳法推演如果用户先调 fact_s(4) 再调 fact_s(2) 会产生何种荒谬的输出结果。

💡 课堂互动:学生体会黑盒测试的威力——用事实说话,用最小测试用例推翻伪逻辑。

第3课时后测与全章总结
掌控变量,驾驭状态:
让程序行为可预测、可复现
全章知识图谱大闭环:从接口封装、模块化解耦到内存生命周期管理。
判定矩阵 最小测试 第五章全景图 第六章先导

课堂后测:两道出口核心诊断题

出口题一:变量属性判定矩阵

声明语句作用域存储期与跨调用保留
int a = 0;(函数内)函数/块内部自动存储期,不保留
static int b = 0;(函数内)函数/块内部静态存储期,跨调用保留
int c = 0;(函数外)整个文件向后可见静态存储期,全程保留

追问:静态局部变量 b 属于全局变量吗?绝不是!它的作用域仅限函数内部!

出口题二:最小测试序列证伪

如何用最简单的两行代码证明 fact_s(n) 不是通用阶乘?

/* Test: call directly with n = 3 */
double ans = fact_s(3);
printf("%f\n", ans);   /* Prints 3.0 instead of 6.0! */
结论:通用函数必须是幂等、无害、调用独立的。

第五章全景图谱:结构化程序设计三部曲

5.1 封装计算
接口契约 / 值传递 / 边界防错
→
5.2 拆解架构
void 动作 / 单一职责 / 菜单集成
→
5.3 驾驭状态
作用域 / 内存三区 / 消除隐蔽依赖
设计维度 优秀实践(Best Practice) 反面教材(Bad Smell)
接口设计 输入输出明明白白,按值传递各司其职 依靠全局变量暗渡陈仓,形参列表形同虚设
职责分配 单一职责,计算不掺杂打印,高内聚低耦合 一个 300 行函数从输入到计算、打印一手包办
状态管理 状态显式传递,函数可独立单测、结果可复现 滥用 static 隐藏累加状态,调用顺序稍变即崩溃

课后挑战练习与第六章先导

课后挑战实战

  • 纠正教材例5-7 复数乘法公式,并改为不用全局变量的结构化接口
  • 将例5-9 的 fact_s 重构为不带任何隐藏静态变量的独立通用阶乘函数
  • 为社区慢病健康助手实现一个带有 reset() 重置接口的测试安全的编号生成器

第六章先导:批量数据的组织

如果我们的社区慢病管理系统不仅记录 1 次随访,而是要保存全社区 100 名慢病居民的一整年随访数据并排序:
难道我们要声明 bmi1, bmi2, ... bmi100 吗?
下周见——第六章:数组(批量同类数据的内存管理)!

接口清晰,状态可测;函数有界,程序常青。