在寫程式時, 常常需要宣告變數, 不過這個經常出現的動作, 其實有許多細節在裡面, 不瞭解這些細節, 可能會寫出出乎意料的程式, 或者搞不懂別人寫的程式。
宣告 (declaration) 與定義 (definition)
讓我們舉一個例子來說明宣告的語法:
#include<stdio.h>
int i = 10;
int main(void) {
printf("%d\n", i);
}
其中 int
稱為指示詞 (specifier), 它指出變數的型別, i
稱為宣告器 (declarator), 它指出變數的名稱, 後面的 = 10
稱為初值設定器 (initializer), 它會配置變數的記憶體, 並且設定該變數的初值。
理論上, 宣告時不一定要有初值設定器, 具有初值設定器的宣告稱為定義 (difinition), 你可以宣告同一個變數多次, 但是只能定義它一次, 因為同一個變數不能配置不同位置的記憶體, 否則就會有兩份同名的變數了。例如:
#include<stdio.h>
int i;
int i = 10;
int i;
int main(void) {
printf("%d\n", i);
}
這裡總共出現了 3 次變數 i
的宣告, 但是只有第 2 次的宣告有初值設定器, 完成了變數 i
的定義。沒有初值設定器的單純宣告只是讓編譯器知道會有一個指定型別、名稱的變數, 但是不會配置記憶體, 只有加上初值設定器完成定義才會為該變數配置記憶體。如果同一個變數有多次定義, 就會出現重複定義變數的錯誤, 例如:
#include<stdio.h>
int i;
int i = 10;
int i = 10;
int main(void) {
printf("%d\n", i);
}
編譯時就會看到如下的錯誤:
main.c:5:5: error: redefinition of 'i'
5 | int i = 10;
| ^
main.c:4:5: note: previous definition of 'i' was here
4 | int i = 10;
| ^
注意錯誤訊息的用詞是 'redifinition' 而不是 'redeclaration', 是因為重複定義才錯誤, 而不是重複宣告出錯。
變數的有效範圍 (scope) 與可視範圍 (visibility)
宣告變數的位置會影響在原始檔內的哪個區域可以使用變數的名稱, 以及在哪個區域可以透過這個名稱存取變數的內容, 前者稱為有效範圍 (scope), 後者稱為可視範圍 (visibility)。
檔案範圍 (file scope)
宣告在任何函式外的變數在檔案範圍 (file scope) 內有效, 也就是從宣告的位置開始, 到原始檔結尾處都可以使用, 像是這樣:
#include<stdio.h>
int i = 10;
void print_i(void) {
printf("%d\n", i);
}
int main(void) {
printf("%d\n", i);
print_i();
}
不論是在 print_i
或是 main
中, 都可以使用前面宣告的變數 i
。
檔案範圍有效的宣告也稱為外部宣告 (external declaration), 因為是在所有函式的外面。
先宣告再使用
要特別注意的是要先宣告才能使用, 如果改成以下順序, 就會出錯:
#include<stdio.h>
void print_i(void) {
printf("%d\n", i);
}
int i = 10;
int main(void) {
printf("%d\n", i);
print_i();
}
編譯時的錯誤訊息如下:
main.c: In function 'print_i':
main.c:4:20: error: 'i' undeclared (first use in this function)
4 | printf("%d\n", i);
| ^
main.c:4:20: note: each undeclared identifier is reported only once for each function it appears in
這就是因為 i
的宣告是在 print_i
之後, 所以在 print_i
中 i
就是尚未宣告的變數。如果變數 i
有特定理由一定要在 print_i
之後才完成定義, 那也可以將宣告與定義分離, 像是這樣:
#include<stdio.h>
int i;
void print_i(void) {
printf("%d\n", i);
}
int i = 10;
int main(void) {
printf("%d\n", i);
print_i();
}
就不會出錯了。
區塊範圍 (block scope)
如果是在區塊內宣告變數, 有效範圍就僅止於區塊內, 在區塊外並無法使用該變數, 因此稱為區塊範圍 (block scope), 例如:
#include<stdio.h>
int main(void) {
int i = 10;
printf("%d\n", i);
}
由於變數 i
是宣告在 main
函式的區塊內, 所以只在該區塊內有效。如果我們故意加一層區塊, 並且在其中宣告 i
, 像是這樣:
#include<stdio.h>
int main(void) {
{
int i = 10;
}
printf("%d\n", i);
}
編譯時就會出現如下的錯誤:
main.c: In function 'main':
main.c:8:20: error: 'i' undeclared (first use in this function)
8 | printf("%d\n", i);
| ^
main.c:8:20: note: each undeclared identifier is reported only once for each function it appears in
由於是在宣告 i
的區塊外叫用 printf
時引用 i
, 超出了 i
的有效範圍, 所以編譯器會認為 i
並未宣告。如果在內層區塊內引用 i
就沒有問題:
#include<stdio.h>
int main(void) {
{
int i = 10;
printf("%d\n", i);
}
}
可視範圍
一般情況下, 可視範圍和有效範圍是一樣的, 不過如果區塊內宣告了與區塊外同名的變數, 會產生遮蔽效應, 也就是在區塊內只能用該名稱引用到區塊內的變數, 無法引用區塊外的同名變數, 這稱為巢狀範圍 (nested scope) 例如:
#include<stdio.h>
int i = 300;
int main(void) {
int i = 100;
{
int i = 10;
printf("%d\n", i);
}
printf("%d\n", i);
}
執行結果如下:
10
100
你可以看到在最內層區塊使用 i
引用到的是宣告在最內層的變數, 而在 main
中同樣的名稱 i
引用到的是宣告在 main
中的變數, 也就是在區塊內看不到宣告在區塊外的同名變數, 最外層以及 main
內的變數 i
他們的可視範圍都比有效範圍小。
變數的儲存類型 (storage class)
前面提到只有完整定義變數時才會配置記憶體, 那麼到底是什麼時候配置呢?這就要提到變數的儲存類型 (storage class)了。
變數的存續期 (storage duration)
在宣告變數時, 可以在最前面的指示詞加上以下四種儲存類型的其中一種:
- auto
- register
- static
- extern
不同的儲存類型會影響變數的存續期 (storage duration), 主要分成以下幾種:
-
auto 存續期:
auto
與register
儲存類型的變數都屬於這種存續期, 他們只能用在區塊內的變數,auto
也是區塊內的變數在宣告時未指定儲存類型時的預設值。這種存續期的變數會在每次進入變數所在區塊時配置記憶體並設定初值, 在離開該區塊時釋放記憶體。register
基本上與auto
一樣, 不過編譯器會盡可能將這種變數放置在處理器內的暫存器, 實際上因為處理器內的暫存器有限, 真的會放到暫存器的情況很少, 因此極少使用。 -
static 存續期:
static
與extern
儲存類型的變數都屬於這種存續期, 會在程式執行後, 叫用main
函式前配置記憶體並設定初值, 在程式結束時釋放記憶體。不管該變數是在哪裡定義, 此種存續期的變數都只會配置記憶體及設定初值一次。檔案範圍有效的宣告如果沒有指定儲存類型, 會被視為extern
。
由於 auto
儲存類型只能用在區塊內的變數, 就跟該變數的有效範圍一致, 很容易理解, 因此底下主要以 static
儲存類型做說明, 請先看這個例子:
#include<stdio.h>
void inc(void) {
static int j = 10;
printf("%d\n", j++);
}
int main(void) {
inc();
inc();
inc();
}
執行結果如下:
10
11
12
你可以看到雖然 j
是在 inc
函式的區塊內定義, 但因為是 static
儲存類型, 所以只會在叫用 main
之前配置記憶體並設定初值 1 次, 然後持續使用, 而不是每次叫用 inc
都重新配置記憶體設定初值。叫用 inc
時變數 j
都會延續之前的值遞增。如果把 static
拿掉改用區塊內預設的 auto
儲存類型, 或是明確標示 auto
, 像是這樣:
#include<stdio.h>
void inc(void) {
int j = 10; // 預設是 auto 儲存類型
printf("%d\n", j++);
}
int main(void) {
inc();
inc();
inc();
}
執行結果就會是一成不變的 10 了:
10
10
10
這是因為每次進入 inc
的區塊就會重新配置記憶體並設定初值給 j
, 所以每次叫用 inc
時 j
的值都是 10。
要注意的是, 對於檔案範圍的宣告, 若未指定儲存類型, 預設為 extern
。
初值設定器的限制
對於 static
存續期的變數, 在宣告時只能使用常數構成的運算式當成初值設定器, 不能使用到其他變數。例如:
#include<stdio.h>
int i = 22;
int j = i + 1;
int main(void) {
}
在編譯時就會出現以下錯誤訊息:
main.c:4:9: error: initializer element is not constant
4 | int j = i + 1;
| ^
若是 auto
存續期的變數, 就沒有這樣的限制, 可以使用其他變數來構成初值設定器, 例如:
#include<stdio.h>
int main(void) {
int i = 22;
int j = i + 1;
}
就可以正常編譯執行。
register 儲存類型的變數不能取址
由於 register
儲存類型的變數有可能會把值放在暫存器中, 而暫存器並沒有記憶體位址, 所以這種變數不能用在取位址的運算中, 例如:
#include<stdio.h>
int main(void) {
register int i;
printf("%p\n", &i);
}
編譯時就會出現以下的錯誤:
main.c: In function 'main':
main.c:6:5: error: address of register variable 'i' requested
6 | printf("%p\n", &i);
| ^~~~~~
告知你要取址的對象是 register
儲存類型的變數 , 不能取址。
連結性 (linkage)
你可能會覺得奇怪, extern
和 static
變數的存續期是一樣的, 那麼為什麼要有兩種儲存類型呢?這是因為儲存類型除了區分變數的存續期以外, 還會定義變數的連結性 (linkage)。
在說明連結性之前, 我們要先介紹一個特別的術語--轉譯單元 (tranlation unit), 它指的是單一原始檔經過前置處理器處理完的結果, 亦即原始檔加上表頭檔 (header files), 但去除被條件式前置處理器指令略過的部分, 也就是最後真正交付給編譯器編譯的內容。
連結性指的是能不能把其他同名的宣告整合成同一個的能力, 依據儲存類型分為以下 3 種連結性:
- 無連結性 (no linkage)。
- 內部連結性 (internel linkage)。
- 外部連結性 (externel linage)。
以下分別說明。
無連結性 (no linkage)
所有在區塊內非 extern
儲存類型的宣告都屬於無連結性, 這表示無法與其他同名宣告整合成一個, 也就是在相同有效範圍內不能有其他同名的宣告, 像是以下這樣在區塊內宣告及定義變數就會在編譯時出錯:
#include<stdio.h>
int main(void) {
int i; // 區塊內預設是 `auto`
int i = 10 // 區塊內預設是 `auto`
printf("%d\n", i);
}
錯誤訊息如下
main.c: In function 'main':
main.c:5:9: error: redeclaration of 'i' with no linkage
5 | int i = 10
| ^
main.c:4:9: note: previous declaration of 'i' was here
4 | int i;
| ^
main.c:7:5: error: expected ',' or ';' before 'printf'
7 | printf("%d\n", i);
| ^~~~~~
編譯器認為重複宣告了無連結性的變數 i
。如果你把程式改成這樣, 就可以編譯執行:
#include<stdio.h>
int main(void) {
int i;
printf("%d\n", i);
}
你可能會想這裡並沒有完成變數 i
的定義, 那程式執行時變數 i
的內容到底是什麼?實際上編譯器會幫你自動完成 auto
宣告的定義, 並以所配置記憶體當時的內容為初值, 如果執行程式, 就會看到變數內容是奇怪的數值 (你看到的不一定會是這個數值):
32551
由於是記憶體當時的內容, 所以每次執行都不一定一樣, 例如我再執行的結果為:
32710
為了確保執行結果的一致性, 請記得為 auto
儲存類型的變數設定初值。
由於只要是在區塊內沒有加上 extern
的宣告都屬於無連結性, 即使是存續期超過區塊範圍的 static
宣告也一樣, 例如:
#include<stdio.h>
int main(void) {
static int i;
static int i = 20;
printf("%d\n", i);
}
也一樣因為是無連結性而會在編譯時出現錯誤:
main.c: In function 'main':
main.c:3:16: error: redeclaration of 'i' with no linkage
3 | static int i = 20;
| ^
main.c:4:16: note: previous declaration of 'i' was here
4 | static int i;
| ^
不過和 auto
不同的是, 編譯器會幫區塊內沒有完成定義的 static
宣告定義初值為 0, 所以如果把程式修改如下:
#include<stdio.h>
int main(void) {
static int i;
printf("%d\n", i);
}
執行結果就會是:
0
內部連結性 (internel linkage)
所有在檔案範圍有效的 static
宣告都具有這種連結性, 這表示在檔案範圍內可以有同名的宣告, 但是這些同名的宣告最後會被整合為 1 個, 因此我們可以把定義變數和單純宣告分離, 甚至像是這樣一個在頭、一個在尾:
#include<stdio.h>
static int i;
int main(void) {
printf("%d\n", i);
}
static int i = 10;
內部連結性也代表這個變數不會開放給其他轉譯單元使用, 如果在不同的轉譯單元中有同名的 static
宣告, 就會是各自獨立的變數。
外部連結性 (externel linage)
所有 extern
的宣告, 以及在檔案範圍有效的非 static
宣告都具備這種連結性。這表示在構成完整程式的不同轉譯單元中, 可以有同名的宣告, 而且這些同名宣告最後會被整合成 1 個宣告, 例如:
-
main.c
#include<stdio.h> extern int i; int main(void) { printf("%d\n", i); }
-
foo.c
extern int i = 10;
也可以正常編譯執行。
這對於撰寫程式庫非常有用, 這樣就可以把 extern
宣告寫在一個表頭檔, 讓其他原始檔匯入, 並在連結時找到在程式庫中的定義, 像是這樣:
-
main.c
#include<stdio.h> #include "foo.h" int main(void) { printf("%d\n", i); }
-
foo.h
extern int i;
-
foo.c
#include "foo.h" int i = 10;
所有需要使用 foo
程式庫的原始檔都只要匯入 foo.h
就可以使用定義在 foo.c
中的變數 i
了。
extern
指示詞並不限於只能在函式外使用, 例如若把剛剛的 main.c
改成這樣:
#include<stdio.h>
int main(void) {
extern int i;
printf("%d\n", i);
}
也可以引用定義在 foo.c
中的變數 i
。
要特別說明的是 GCC 對於標示 extern
的變數定義會提出警告, 例如:
#include<stdio.h>
extern int i = 10; // 定義變數 i
int main(void) {
printf("%d\n", i);
}
就會看到這樣的警告訊息:
main.c:3:12: warning: 'i' initialized and declared 'extern'
3 | extern int i = 10;
| ^
10
不過程式仍然可以執行。
未決定義 (tentative difinitions)
對於沒有初值設定器, 且是 static
儲存類型或是沒有指定儲存類型的外部宣告, 稱為未決定義 (tentative definition), 如果在轉譯單元中沒有找到同名變數的定義, 編譯器會自動完成定義, 並且設定初值為 0, 例如:
#include<stdio.h>
static int i;
int j;
int main(void) {
static int k;
printf("%d\n", i);
printf("%d\n", j);
printf("%d\n", k);
}
其中 i
、j
都是未決定義, 且在原始檔中都沒有找到同名變數的定義, 所以會被編譯器自動定義設定初值為 0, 執行結果如下:
0
0
0
要注意的是, 沒有加上儲存類型的外部宣告, 會被視為 extern
儲存類型, 如果與既有的同名變數宣告牴觸, 例如:
#include<stdio.h>
static int i = 10;
int i;
int main(void) {
static int k;
printf("%d\n", i);
}
就會在編譯時出錯:
main.c:4:5: error: non-static declaration of 'i' follows static declaration
4 | int i;
| ^
main.c:3:12: note: previous definition of 'i' was here
3 | static int i = 10;
| ^
由於 i
先以 static
儲存類型宣告, 再以未加上儲存類型時預設的 extern
宣告, 兩者並不一致, 所以會出現錯誤。如果宣告一致, 就不會有問題:
#include<stdio.h>
int i = 10;
extern int i;
int main(void) {
static int k;
printf("%d\n", i);
}
不過良好的習慣還是在宣告時就明確完成定義, 不要依賴編譯器暗中進行的預設行為。
GCC 的注意事項
GCC 9 以及之前的版本, 會把同名的未決定義變數整合變成單一個變數, 例如以下 3 個檔案:
-
main.c
#include<stdio.h> int i; int main(void) { printf("%d\n", i); }
-
foo.c
int i;
-
foo1.c
int i = 10;
實際執行結果如下:
10
這 3 個檔案中的 i
實際上是同一個變數。這樣做看起來好像很方便, 但是可能會有潛在的問題, 舉例來說, 如果 foo1.c
並不是你寫的, 所以並不知道其中有定義變數 i
, 而且這個 i
是控制 foo1.c
中特定功能的關鍵變數, 如果在 main.c
中修改了 i
的值, 就可能會讓程式出錯。
為了避免這樣的問題, 從 GCC 10 開始, 預設是把未決定義的變數視為獨立的變數, 若其它轉譯單元內有同名的變數, 就會在連結階段顯示錯誤, 像是剛剛的程式改在 GCC 10 下就會看到這樣的錯誤訊息:
/usr/bin/ld: /tmp/cc3PEcNo.o:(.bss+0x0): multiple definition of `i'; /tmp/ccEpD2Pm.o:(.bss+0x0): first defined here
/usr/bin/ld: /tmp/ccWlu8wn.o:(.data+0x0): multiple definition of `i'; /tmp/ccEpD2Pm.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status
它會抱怨 i
重複定義了。注意到這是由連結器 (ld) 發出的錯誤, 因為個別檔案編譯時都可以編譯成功, 但是要到連結階段才會發現有多個 i
。
如果你有舊的專案需要維護, 並且高度倚賴 GCC 9 幫你把未決定義的同名變數整合成 1 個變數, 在 GCC 10 中可以加上 -fcommon
旗標採用 GCC 9 中的方式編譯連結程式;反之, 如果你使用 GCC 9, 但不希望 GCC 9 預設的方式讓你意外中招, 可以加上 -fno-common
旗標, 取消預設的作法。
函式的宣告與定義
相同的概念也可以運用在函式上, 函式也可以將單純的宣告與定義分離, 例如:
#include<stdio.h>
int fact(int);
int fact(int n) {
return (n < 2) ? 1 : n * fact(n - 1);
}
int main(void) {
printf("%d\n", fact(5));
}
第 3 行就是函式的宣告, 它指出了函式的傳回值型別以及參數的型別, 注意在單純宣告函式時結尾處要加上分號;第 5~7 行則是包含有區塊的函式定義。這樣的形式也可以方便我們製作程式庫, 例如我們可以把剛剛的程式拆分成以下:
-
fact.c
int fact(int n) { return (n < 2) ? 1 : n * fact(n - 1); }
-
fact.h
int fact(int);
-
main.c
#include<stdio.h> #include"fact.h" int main(void) { printf("%d\n", fact(5)); }
所有需要使用 fact
函式的原始檔只要匯入 fact.h
, 再跟 fact.c
一起編譯連結就可以了。
要注意的是, 未標示存取類型的函式預設就是 extern
, 如果沒有注意的話, 就會造成重複定義函式, 例如在上述的 main.c
中增加了另一個版本的 fact
函式, 像是這樣:
#include<stdio.h>
#include"fact.h"
int fact(int n) {
if(n < 2) return 1;
return n * fact(n - 1);
}
int main(void) {
printf("%d\n", fact(5));
}
連結時就會發現有兩個 fact
函式, 發生重複定義函式的錯誤:
/usr/lib/gcc/x86_64-alpine-linux-musl/9.3.0/../../../../x86_64-alpine-linux-musl/bin/ld: /tmp/
ccgamBCl.o: in function `fact':
main.c:(.text+0x0): multiple definition of `fact'; /tmp/ccGKjPhP.o:fact.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
為了避免這種狀況, 沒有要公開給其他原始檔使用的函式都應該明確加上 static
指示詞讓他僅具有內部連結性:
#include<stdio.h>
#include"fact.h"
static int fact(int n) {
if(n < 2) return 1;
return n * fact(n - 1);
}
int main(void) {
printf("%d\n", fact(5));
}
不過只是這樣編譯連結還是會出錯:
main.c:4:12: error: static declaration of 'fact' follows non-static declaration
4 | static int fact(int n) {
| ^~~~
In file included from main.c:2:
fact.h:1:5: note: previous declaration of 'fact' was here
1 | int fact(int);
| ^~~~
這是因為我們匯入了其他版本同名函式的表頭檔, 造成兩個宣告之間存取類型的衝突, 也要記得不要匯入表頭檔:
#include<stdio.h>
// #include"fact.h"
static int fact(int n) {
if(n < 2) return 1;
return n * fact(n - 1);
}
int main(void) {
printf("%d\n", fact(5));
}
就可以正確編譯使用同一檔案內的函式版本了。
小結
本文針對 C 程式最基本的動作作了有系統、詳細的說明, 希望能對初學者有幫助, 只要依循基本規則, 遇到各式各樣的程式就可以迎刃而解, 不再會有似是而非的困擾。
Top comments (0)