Chapter II. 新手上路
實作技巧
全域、區域變數
作者WiwiHo

本文介紹一些全域、區域變數的基本概念。

變數初始化


這個小節我們討論一些關於「變數會怎麼被初始化」的事情,如果你在宣告變數時,直接明確寫出變數的值,例如 int x = 123,那變數的初始值當然就是你一開始指定的值了,但是如果沒有指定的話,狀況就會複雜一些。C++ 的變數初始化規則非常複雜,這裡我們只提一些程式競賽中常見的狀況,有興趣暸解運作細節的讀者可以參考 cppreference 上的說明。為了方便理解,這裡的說明會簡化一些初始化時的細節步驟和原理。

雖然像是 int 這種數字型態,指定個初始值非常容易,但如果今天我們要用的變數是一個陣列、甚至是一個 class,就比較難在宣告的時候就把它的初始值整個打出來,因此我們還是得稍微暸解一下變數初始化是如何運作,寫程式時也可以省去一些不必要的麻煩以及避免 bug。

全域與區域變數的差異

變數初始化是全域與區域變數的一個很大的差別,先看看下面這段程式碼:

int global_x;
void f1() { 
    int x;
    cout << x << "\n"; // ???
    cout << global_x << "\n"; // 0
}

global_xx 分別是沒有被指定初始值的全域和區域變數,global_x 一定會是 0,但 x 卻不一定,也就是說全域變數會被自動初始化成 0,但區域變數不會,沒有指定初始值的區域變數,一開始的值是不確定的,在為它賦予一個明確的值之前也不應該讀取它。

要注意沒有初始化的區域變數,不幸地好像很容易在本機跑出 0 的結果,因此要是不小心用了沒初始化過的區域變數,自己測試的時候有可能不容易觀察到這個問題,在編譯時加上選項 -Wall 就可以在忘記初始化區域變數時獲得警告。

用全域和區域變數來區分這兩種不同的行為,實際上是不精準的分法,例如本文後面會介紹到的 static 區域變數,初始化的規則其實會和全域變數一樣。

陣列

int global_arr[5];
void f2() {
    int arr[5];
    cout << arr[1] << "\n"; // ???
    cout << global_arr[1] << "\n"; // 0
}

和剛剛的例子類似,全域的 global_arr 之中每一個元素都會自動被填成 0,但是區域的 arr 和剛剛一樣,不會被自動初始化。蛤我只是想要一個區域的全都是 0 的陣列怎麼那麼麻煩,有個簡單的方法是 int arr[5] = {},這樣就可以得到一個每個元素都是 0 的區域陣列了,大括號裡面也可以只打前幾個元素,後面會被自動填成 0,例如 int arr[5] = {1, 2},這樣一來 arr 的初始值會是 {1, 2, 0, 0, 0},反正有打東西就會全部初始化了。

static


有時候我們會希望在一個函式之中,某個變數最後的狀態可以保留到下一次執行這個函式時使用,也就是下一次函式執行時變數不會被重置,這個時候一種容易想到的解決方法就是直接把這個變數開成全域變數。舉例來說,你想要寫一個函式,每次執行時都會輸出這個函式是第幾次被執行,可能就會這樣寫:

int cnt = 0;
void f() {
    cnt++;
    cout << "f: " << cnt << "\n";
}

這麼做的缺點是,cnt 只被 f() 使用到,但它仍然是一個全域變數,會影響到整份程式碼,例如其他函式可能會不小心改到它,或是其他全域變數不能也叫 cnt。這個時候,就是 static 這個關鍵字出場的時候了!我們把 cnt 的宣告移到 f() 裡面,並且改成 static int cnt = 0,這樣一來 cnt 的作用區域就只在 f() 裡面,而且它的值會一直保留。

void f() {
    static int cnt = 0;
    cnt++;
    cout << "f: " << cnt << "\n";
}

void g() {
    static int cnt = 0;
    cnt++;
    cout << "g: " << cnt << "\n";
}

int main() {
    f(); // f: 1
    f(); // f: 2
    g(); // g: 1
    f(); // f: 3
    g(); // g: 2
}

f()g() 裡面各有一個宣告成 staticcnt 變數,它們兩個之間互不干擾,修改過後的數值也可以一直保留到函式下一次執行,完美達到了我們的需求。

const


很多時候我們會用到一個固定的數字,例如有些題目會要求把答案 mod $10^9+7$ 或 $998244353$ 之類的數字後輸出,這個時候就很適合把這些固定的數字宣告成變數,免得每次用到都得完整重打數字,還有可能會打錯。除了宣告成變數之外,也可以在型態前面加上 const,例如 const int MOD = 1000000007,全域或區域變數都可以用。const 的意思是這個變數宣告之後就不能改變了,所以如果你宣告之後寫了 MOD = 123123 之類的,就會直接獲得一個 compile error。這麼做除了可以避免不小心修改到以外,還有一個好處是能告訴編譯器這個變數永遠不會改變,編譯器就可以根據這點偷偷進行一些優化,讓程式碼執行得更快。

舉例來說:

#include <bits/stdc++.h>

using namespace std;

const int MOD = 1000000007;
int main() {
    int n;
    cin >> n;
    int total = 0;
    for (int i = 0; i < n; i++) {
        total = (total + i) % MOD;
    }
    cout << total << "\n";
}

把第 5 行的 const 拔掉的話,就會慢一些些,讀者可以自己試試看。

const 還有另一個好用的用法,是加在函式的參數型態前面,例如:

#include <bits/stdc++.h>

using namespace std;

void f(const vector<int> &a) {
    a[0] = 2; // compile error!   
}
int main() {
    vector<int> a = {1, 2, 3, 4, 5};
    f(a);
}

不管是不是 reference 都可以加上 const,這個參數傳入函式後就不能被修改,可以用來防止不小心改到參數。就像這個例子,這通常會和 reference 一起使用,在希望用 reference 避免參數傳入函式時還要複製一份浪費時間,但又不希望改到它,就可以這麼做。這樣還有一個好處是,如果參數宣告成 vector<int> &a 而沒有 const,那傳入的參數 a 一定得是一個可以被 reference 的東西(正式術語是一個 lvalue),像是如果直接寫 f({1, 2, 3, 4, 5}) 的話是不行的,但有加 const 的話就可以直接這樣做。

命名空間


基本常識中,有提過 C++ 把大多數的標準函式放在一個叫作 std 的命名空間(namespace)裡頭,這麼做的好處是防止名稱到處撞來撞去,而我們當然也可以自己這麼做,例如:

#include <bits/stdc++.h>

using namespace std;

namespace Test {
    int a = 456;
}
int a = 123;
int main() {
    cout << a << "\n"; // 123
    cout << Test::a << "\n"; // 456
}

Test 就是一個我們自己定義的命名空間,裡面可以有變數,也可以有函式、struct 等等,在那個命名空間之外使用裡面的東西時要加上 Test::

Stack Overflow


如果你在自己的電腦上執行這份程式碼:

#include <bits/stdc++.h>

using namespace std;

void f(int n) {
    if (n == 0) return;
    f(n - 1);
    cout << n << "\n";
}

int main() {
    f(10000000);
}

很有可能會得到 runtime error,這是因為這個程式碼會發生一個叫作「stack overflow」的錯誤。簡單來說,程式在執行的時候,不同東西會被分別放在記憶體的不同區域裡,而呼叫函式的資訊,包含目前哪些函式正在執行中、傳給函式的參數、函式裡面的區域變數等等,被統一放在一個稱作 stack 的區域裡面,而且大多數的作業系統預設都會限制程式使用的 stack 區域的大小,而且還很小(在 Ubuntu 上預設是 8192KB)。開一個區域的超大陣列也有可能遇到這個問題,這就是為什麼許多競賽選手都會把超大陣列放在全域。

在大部分的 Online Judge 上,都會把 stack 區域的大小限制調成無限制,所以傳上 Online Judge 後通常是不會有問題的。只不過在本機測試的話,有 stack 限制會有點麻煩,這時可以自己調 stack 限制,在 Windows 的話是編譯時加上一個選項 -Wl,--stack,268435456、Mac 是 -Wl,-stack_size -Wl,268435456268435456 是新的 stack 大小限制,單位是 Byte,268435456 是 256MB;Linux 的話是打一個指令 ulimit -s 262144,這個指令會把 stack 大小上限直接改掉,單位是 KB。

常見比賽用的 Online Judge,例如 OI 比賽常用的 CMS、ICPC 比賽常用的 DOMjudge,stack 的大小都沒有限制,但遇到比較特殊的 judge 的話,最好還是稍微注意一下會不會有 stack 大小限制很小的問題。

在 Linux 中,ulimit 只在目前的 terminal session 裡有用,所以如果重開 terminal 或開另一個,是需要重打這個指令的,可以把它放進 .bashrc 之類的檔案裡,就不需要每次重打。不過這個指令的效果會套用在所有你執行的指令上,畢竟這個限制是一種防止程式使用過多資源的防護,這可能會衍伸一些安全性問題。

補充


這裡是一些進階一點的概念。

class type 變數的初始化

這裡的 class type 包含用 classstruct 宣告的型態。non-class type 包含所有的數字型態,含整數(charint、……)也含浮點數(floatdouble、……),還有對於任何東西的指標都是 non-class type。一些 class type 的例子有 STL 中的各種容器(vector、set 等等),還有你自己寫的 struct 也是。

嗯嗯,全域會被初始化但區域不會,好像也沒有很難……不,事情遇到了 class type 就會真的複雜許多。舉例來說:

struct Test {
    int x = 5;
};

Test global_test;
void f3() {
    Test test;
    cout << test.x << "\n"; // 5
    cout << global_test.x << "\n"; // 5
}

明明 test 就是區域變數,它卻自己被初始化了!當變數是 class type 時,就一定會自動初始化,陣列裡的也會,如果型態有定義預設建構子(default constructor,如 Test())的話,就會用預設建構子初始化。

新的問題又來了,那些在 struct 裡的成員變數是區域還是全域呢?

struct Item {
    int x;
    int a[5];
    Test test;
    Test arr[5];
};

Item global_item;
void f4() {
    Item item;
    // ??? ??? 5 5
    cout << item.x << " " << item.a[1] << " " << item.test.x << " " << item.arr[3].x << "\n";
    // 0 0 5 5
    cout << global_item.x << " " << global_item.a[1] << " " << global_item.test.x << " " << global_item.arr[3].x << "\n";
}

當 struct 出現在區域或全域時會有不一樣的結果,總之大致的規則是,如果你宣告了一個 class type 的全域變數,有預設建構子的話一定會自動執行預設建構子,至於它的成員變數,如果既沒有在宣告時指定初始值,也沒有用預設建構子賦值,那 non-class type 的變數會初始化成 0,class type 的變數會用相同的規則遞迴地初始化。至於 class type 的區域變數,也是類似的原理,但是 non-class type 的成員變數就會如同直接在區域裡的 non-class 變數一樣,沒有被初始化。陣列的部分,取決於它裡面的型態是什麼,適用一樣的規則。

最好的作法是確定 struct 裡的所有 non-class 成員變數都有指定初始值,打個 = 0= {} 應該不是什麼困難的事情,這樣你的 struct 就無論出現在區域還是全域,你都不用多費心思想是不是需要另外初始化它裡面的變數們,也不用怕忘記。至於 class type 的所有變數,都可以放心地相信它們會好好被初始化,只要顧好裡面的 non-class type 變數就好了。