程式一大,「誰看得到這個變數?它活多久?」就成了核心問題。
本單元整理 C 的作用域規則與儲存類別(auto、static、extern、register),
並說明多檔案專案的組織方式——標頭檔、include guard、以及 static 作為「檔案私有」的封裝工具。
學習目標
- 分清楚作用域(scope,誰看得到)與生命週期(lifetime,活多久)
- 掌握區域變數、全域變數、參數的作用域規則與遮蔽(shadowing)
- 會用
extern在多檔案間共享全域變數 - 理解
static的兩種截然不同的用途:檔案私有(外部 static)與跨呼叫保值(內部 static) - 了解
register的歷史地位 - 掌握初始化規則:靜態物件自動清零、自動變數是垃圾值
- 會設計標頭檔(include guard)與組織多檔案專案
對照 K&R 章節
| 本單元主題 | K&R 2nd Edition |
|---|---|
| 外部變數 | §4.3 External Variables |
| 作用域規則 | §4.4 Scope Rules |
| 標頭檔 | §4.5 Header Files |
| static | §4.6 Static Variables |
| register | §4.7 Register Variables |
| 區塊結構 | §4.8 Block Structure |
| 初始化 | §4.9 Initialization |
觀念講解
1. 作用域 vs 生命週期
兩個獨立的概念,別混為一談:
| 變數種類 | 作用域(可見範圍) | 生命週期 |
|---|---|---|
| 區域(自動)變數 | 宣告點到區塊結尾 | 進入區塊到離開區塊 |
| 函式參數 | 整個函式 | 該次呼叫期間 |
static 區域變數 |
宣告點到區塊結尾 | 整個程式執行期間 |
| 全域(外部)變數 | 宣告點到檔案結尾(可用 extern 延伸到其他檔案) | 整個程式執行期間 |
static 全域變數 |
僅限本檔案 | 整個程式執行期間 |
2. 遮蔽(shadowing)與區塊結構
內層宣告會遮蔽外層同名變數——合法但易錯,-Wshadow 可以抓:
int x = 1; /* 全域 */
void f(void)
{
int x = 2; /* 遮蔽全域 x */
{
int x = 3; /* 再遮蔽一層 */
printf("%d\n", x); /* 3 */
}
printf("%d\n", x); /* 2 */
}
3. extern:宣告 vs 定義
全域變數在整個程式中只能定義一次(配置記憶體), 但可以宣告(告知型別,不配置)多次:
/* file1.c —— 定義(有記憶體) */
int shared_counter = 0;
/* file2.c —— 宣告(extern:記憶體在別的檔案) */
extern int shared_counter;
void bump(void) { shared_counter++; }
慣例:把 extern 宣告放進標頭檔,需要的 .c 都 include 它;定義只放在一個 .c。
4. static 的兩張面孔
(a)static 全域/函式:檔案私有——把名字對其他檔案隱藏,是 C 的封裝機制:
/* stack.c */
static double val[100]; /* 只有 stack.c 看得到 */
static int sp = 0; /* 外部檔案無法直接碰 sp */
void push(double v) { val[sp++] = v; } /* 公開介面 */
double pop(void) { return val[--sp]; }
(b)static 區域變數:跨呼叫保值——作用域不變,但生命週期變成整個程式:
int next_id(void)
{
static int id = 0; /* 只初始化一次,之後每次呼叫保留舊值 */
return ++id; /* 1, 2, 3, ... */
}
5. register
register 建議編譯器把變數放進 CPU 暫存器。現代編譯器的暫存器分配遠比人聰明,
這個關鍵字實務上已無效果(且對 register 變數取 & 是非法的)。認識即可,別使用。
6. 初始化規則
| 物件 | 未顯式初始化時 | 初始器限制 |
|---|---|---|
| 全域 / static | 保證清零(0、0.0、NULL) | 必須是常數運算式 |
| 自動(區域) | 垃圾值(使用前必須賦值) | 任意運算式皆可 |
int global_x; /* 保證是 0 */
void f(void) {
int local_y; /* 垃圾!printf("%d", local_y) 是未定義行為 */
static int s = 5; /* 常數初始化,只執行一次 */
}
7. 標頭檔與 include guard
標頭檔(.h)放「介面」:函式原型、extern 宣告、型別定義、巨集。
include guard 防止重複引入造成重定義錯誤:
/* stack.h */
#ifndef STACK_H /* 第一次引入:STACK_H 未定義,繼續 */
#define STACK_H /* 定義它,之後再引入就整段跳過 */
void push(double v);
double pop(void);
#endif /* STACK_H */
8. 多檔案專案的組織
一個典型的小專案(本單元的 .c 範例都是單檔可編譯的,
多檔結構以下面的程式碼區塊講解):
calc/
├── main.c # 程式進入點,只 include 標頭
├── stack.h # stack 模組的公開介面
├── stack.c # stack 模組的實作(內部狀態用 static 藏好)
└── Makefile
/* stack.h */
#ifndef STACK_H
#define STACK_H
void push(double v);
double pop(void);
#endif
/* stack.c */
#include "stack.h" /* 實作檔引入自己的介面:讓編譯器核對原型 */
#define MAXVAL 100
static int sp = 0; /* 檔案私有狀態 */
static double val[MAXVAL];
void push(double v) { if (sp < MAXVAL) val[sp++] = v; }
double pop(void) { return (sp > 0) ? val[--sp] : 0.0; }
/* main.c */
#include <stdio.h>
#include "stack.h" /* 只需要介面,不需要知道實作 */
int main(void)
{
push(1.0); push(2.0);
printf("%g %g\n", pop(), pop()); /* 2 1 */
return 0;
}
編譯與連結(多檔案要一起交給編譯器,或分開編譯再連結):
cc -std=c11 -Wall -Wextra -o calc main.c stack.c
# 或分開編譯:
cc -c main.c && cc -c stack.c && cc -o calc main.o stack.o
範例程式
以下範例每個都可獨立編譯(單檔內模擬多模組的觀念):
| 檔案 | 內容 |
|---|---|
01_scope_shadowing.c |
區域/全域、遮蔽、區塊結構、參數作用域、初始化規則(static 清零 vs auto 垃圾) |
02_static_demo.c |
static 區域變數(計數器/呼叫記憶)、static 函式與檔案私有狀態:單檔模擬 stack 模組 |
03_extern_and_lifetime.c |
同檔案內的 extern 宣告與定義、生命週期實驗、register 說明 |
編譯與執行(以第一個為例):
cc -std=c11 -Wall -Wextra -o /tmp/demo 01_scope_shadowing.c
/tmp/demo
常見錯誤與陷阱
1. 使用未初始化的區域變數
int sum; /* 垃圾值 */
for (int i = 0; i < n; i++)
sum += a[i]; /* 從垃圾開始累加 */
修正:int sum = 0;。只有 static/全域才保證清零。
2. 在標頭檔「定義」變數
/* util.h */
int counter = 0; /* 錯!每個 include 它的 .c 都定義一次 -> 連結錯誤 */
修正:標頭放 extern int counter;(宣告),定義放進唯一一個 .c。
3. 忘記 include guard
/* a.h 沒有 guard;b.h include a.h;main.c include a.h 和 b.h */
/* -> a.h 的型別定義出現兩次 -> 重定義錯誤 */
修正:每個標頭一律套上 #ifndef/#define/#endif 三件套。
4. 濫用全域變數
全域變數人人可改,bug 來源難以追蹤,還會讓函式無法重入。
原則:能用參數傳就用參數傳;真的需要跨檔案狀態,用 static + 存取函式包起來。
5. 誤以為 static 區域變數每次呼叫都重新初始化
int f(void) {
static int n = 10; /* 只在程式啟動時初始化一次! */
return n--;
}
/* 連續呼叫回傳 10, 9, 8, ...,不是每次都 10 */
練習題
- 寫一個
next_id()函式,用 static 區域變數回傳遞增的序號; 再解釋為什麼不能用普通區域變數。 - 寫一個偽亂數產生器:
static unsigned seed,rand_next()用線性同餘法更新並回傳(K&R §2.7 的 rand 範例)。 - 單檔模擬 K&R §4.3 的計算器:用 static 全域的堆疊陣列 + push/pop 函式,
在 main 中計算
(3 + 4) * 5(後序:3 4 + 5 *)。 - 故意寫一個有遮蔽 bug 的程式(內層
int sum遮蔽外層), 用cc -Wshadow編譯觀察警告,再修正。 - 把練習 3 拆成三個檔案(
stack.h、stack.c、main.c), 加上 include guard,用cc main.c stack.c編譯成功。 - (進階)在練習 5 中,嘗試從
main.c直接存取stack.c的 static 變數sp, 觀察連結錯誤訊息,體會 static 的封裝力。
延伸閱讀
- K&R 2nd Edition,§4.3–4.9(本單元主軸)
- K&R §4.5 的計算器完整多檔案版本(calc.h / main.c / stack.c / getop.c)
- 《C Interfaces and Implementations》:用 .h/.c 做模組化設計的進階經典
- 下一單元
10-preprocessor:巨集與條件編譯