為什麼不要使用全域變數?

這篇文章並不完全是我的原創,文章的原始主體來自於 程式設計俱樂部上相關主題討論的對話節錄 , 重新編排修飾後又加入了一些我自己的觀點整理而成。

什麼是全域變數?

全域變數(Global Variable)是一種在較老的程式語言中廣泛被支援的一種特別的變數類型, 這種變數可以在任何時候被整個程式的任何一段程式碼引用、修改, 而它的生命週期也橫跨了整個程式的生命期。 在記憶體資源極度缺乏的時代,函式參數傳遞機制和自訂資料結構使用受限, 全域變數可能是整個程式內部傳遞資料所必須且高效的手段。 因此全域變數常見於那些由比較年長的程式語言,如 Fortran、C、C++ 等,所寫作的程式碼。

到了現代,隨著程式開發愈發複雜,全域變數的缺點更顯突出; 加上硬體資源的擴展,以及新的軟體設計架構如物件導向程式設計等被提出, 全域變數的使用必要性不再。 因此許多新一代的程式語言如 Java 等,甚至就直接移除對全域變數的語法支援!

全域變數的缺點

  1. 變數值追蹤困難

    全域變數的值可以在整個程式的任何地方被更改掉。 任何一個只要有使用到這個全域變數的程式碼都有可能對其值進行修改, 並且通常很難整理出全域變數在大程式裡面的工作流程與變動關係,造成開發除錯上的困難!

  2. 全域變數不易接受開發上的修改變動

    如果你更改了全域變數的使用方式,你必須去檢查每一個用到全域變數的函式,做出適當的修改; 況且通常你還得注意這些全域數值被修改的時機和順序! 這些增加了程式維護上的難度,也阻礙了程式在日後擴充的道路。

  3. 程式碼不易閱讀理解

    使用函數參數傳遞變數的好處是, 你可以一眼看出一個函式被呼叫執行時需要參考什麼資料、又可預期什麼資料可能會被改變, 因為這些資料都展示在函式的傳入、傳出參數或傳回值上。

    相反的,一眼看向函式的呼叫時,無法知道這函式將會改變哪些全域變數? 實際上你得實際查閱函式的內容,並且層層深入更下層的函式, 才有可能明白會受到這個函式影響的全域變數們; 或者你也可以查閱函式的使用說明文件,如果這個函式有文件而且還沒與現實脫節的話…… 如果函式多呼叫幾層,而每一層可能都會參考或改變一些全域變數的話, 可想而知整個程式的行為會非常隱晦不明,使得難以閱讀、難以理解!

  4. 函式或模組將不能被獨立使用

    如果你的函式用了全域變數,那這的函式就不能獨立使用, 你無法簡單的把函式(或整個模組)抽出來用在其它的地方。

    因為這個模組依賴了這個可能實體被藏在其他模組的全域變數, 導致你必須要連同其他模組一起使用,而不能單獨的將一個模組抽出來另做他用。 你不一定能夠透過連結一個代位的全域變數來解決模組之間基於全域變數的連結, 除非能夠搞清楚並模擬出這個全域變數在執行期應該要有的行為。

    這些條件降低了程式碼的重用性、組合性、移植性、和可測試性, 同時還提高了模組間的偶合性!

  5. 全域變數的設計與物件導向概念格格不入

    一個模組一旦設計了全域變數,則這個模組將不能夠產生兩個以上的實體, 立刻限制了模組複用的可能性。

    物件導向的重要精神之一就是每個物件擁有屬於自己的獨立狀態,而全域變數打破了這點! 撇開比較高深的名詞術語,我用較為通俗的例子來問這個問題: 「你能夠試著儘量全部使用全域變數來設計製做一個像 std::string 這樣的功能模組嗎?」 如果你真的嘗試去做的話,請再試著在你的測試程式中建立 5 個 string 實體, 然後同時、或交互的使用它們並觀察結果。

    全域變數具有的獨有性和唯一性, 使得一個模組一旦使用了全域變數做為狀態儲存,則十之八九可肯定它將與物件無緣。

合理使用全域變數的時機

雖然全域變數的缺點一籮筐,甚至導致比 C++ 還新的程式語言紛紛拋棄全域變數這種大怪獸! 但以 C/C++ 程式設計來說,還是有些時候必須使用全域變數,甚至使用全域變數會更好。 可是相信我,在絕大部分的情況下你不應該設計全域變數, 而且絕大部份產業界程式碼裡見到的全域變數其實都有不使用全域變數的、更優雅的寫法!

少數可以合理使用全域變數的時機列舉如下:

  1. 「常數」,也就是在程式的整個生命週期內不會進行任何更動的資料。

    這些資料常見如數學上的 PI、或是某種資料常數表格如加密對照表、影像調色盤顏色表等等, 在需要使用它們的時候才建立物件、然後填寫資料的做法不僅囉唆,而且效率較低。 既然這些資料是固定不動的,就不存在「是誰什麼時候在哪裡改了資料」這樣的變數追蹤困難, 這時讓大家在程式各處參照使用同一個物件就是一種聰明的設計!

  2. 「獨體」設計模式裡的引用計數器。

    在獨體設計模式裡,至少必須存在一個唯一的物件引用計數器, 因此無法避免的必須使用生命週期足夠長、而且所有物件都可以參照使用的全域變數。 事實上在這種狀況下,要討論的可能是獨體設計在該環境下是否合理? 是否該物件所使用的資源真的為系統中的唯一、並且具有獨占性? 至於若採用了獨體的設計,則全域變數的使用就成為必然!

全域變數的補救措施(以 C/C++ 為例)

有時候程式裡就是會有全域變數,不論是因為前述的合理使用、還是因為老程式碼既成的結果, 尤其在後者的狀況下如果要試圖消滅程式碼中的全域變數,往往會帶來翻天覆地的程式翻修, 以及連帶的程式不穩定、和測試工作量的暴增! 其實不管是要改善既成程式中全域變數、還是在上述合理範圍內即將加入的全域變數, 在擺脫不了全域變數的前提下,還是有一些小技巧可以稍稍減輕全域變數對整份程式碼造成的破壞力的!

  1. 縮減全域變數的可視範圍

    縮小全域變數的可見範圍也就縮小了它的影響範圍、就減小了複雜度。 所以:

    1. 只有一個函式需要參照的全域變數就應該在函式裡宣告為 static 變數;
    2. 只有一個程式碼檔(*.c*.cpp)內部需要使用的全域變數 就不要定義在標頭檔(*.h*.hpp);
    3. 沒有要給其他模組參照使用的全域變數就加上 static 修飾;
    4. 在獨體設計中,若使用的是 C++ 的話, 請儘量使用 class static member variable 定義引用計數, 而不要使用定義在 class 外面的全域變數。
  2. 使用嚴謹的命名方案

    對於暴露在外的全域變數,特別是整份程式碼都可以參照的全域變數, 我們應使用比較嚴謹複雜的命名方案來解決一些問題,寧可讓全域變數的名稱變得比較長、比較難寫, 誰叫它是全域變數呢!

    因缺乏上下文關係,而導致變數意含難明,所以我們應該把名稱定的更能清楚表達其作用與意義, 像是 student_count 的名稱就比 stdcnt 來得好; 至於名為 aa 這樣的全域變數根本就是故意搞亂其他工程師,讓程式更容易產生 bug 用的!

    因為在上層使用環境中可能還存在其它的程式庫、甚至是上層使用者自己寫的程式碼, 為了避免名稱衝突,暴露在模組外的全域變數應加上模組名稱做為前綴, MYMODULE_TANK_MAX_VOLUME 的名稱就比 TANK_MAX_VOLUME 來得妥當; 至於 MAX_VOLUME 的名稱應被禁止,因為它太可能和其他的程式庫或上層使用者產生名稱衝突, 比如說一般記錄音量最大值的變數通常也可以叫作 MAX_VOLUME!而反過來看亦同。

以上改善全域變數的做法很有限、效果也很有限,沒辦法,全域變數就是這樣討厭! 所以正途還是要避免全域變數的使用才對!