程式一大,「誰看得到這個變數?它活多久?」就成了核心問題。 本單元整理 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 */

練習題

  1. 寫一個 next_id() 函式,用 static 區域變數回傳遞增的序號; 再解釋為什麼不能用普通區域變數。
  2. 寫一個偽亂數產生器:static unsigned seed, rand_next() 用線性同餘法更新並回傳(K&R §2.7 的 rand 範例)。
  3. 單檔模擬 K&R §4.3 的計算器:用 static 全域的堆疊陣列 + push/pop 函式, 在 main 中計算 (3 + 4) * 5(後序:3 4 + 5 *)。
  4. 故意寫一個有遮蔽 bug 的程式(內層 int sum 遮蔽外層), 用 cc -Wshadow 編譯觀察警告,再修正。
  5. 把練習 3 拆成三個檔案(stack.h、stack.c、main.c), 加上 include guard,用 cc main.c stack.c 編譯成功。
  6. (進階)在練習 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:巨集與條件編譯