除了第 12 章介紹的基本資料型別,某些語言還提供一些較為特殊的資料型別。 本章涵蓋三個主題:結構體(Structures)指標(Pointers),以及全域資料(Global Data)。 在現代物件導向語言中,這些概念或許不常直接出現,但理解它們有助於掌握語言底層的運作原理。

13.1 結構體#

結構體(Structure) 是由其他型別組合而成的使用者自訂資料,例如 C/C++ 的 struct、Visual Basic 的 Structure。 一般來說,應優先使用類別(Class)以獲得封裝與功能的好處,但在某些場景下,直接操作結構化的資料區塊仍然有其價值。

使用結構體的理由#

理由說明
釐清資料關係將相關資料群組在一起,讓程式碼更清楚地表達哪些資料彼此關聯。employee.namesupervisor.title 這樣的寫法一目了然
簡化區塊操作複製、交換等操作只需一行指令,而非逐一對每個欄位賦值。新增欄位時也只需修改結構宣告
簡化參數列表呼叫端只需傳遞一個參數,而非一長串個別變數。但若子程式只需一兩個欄位,應直接傳遞該欄位
降低維護成本修改結構宣告後,使用整個結構的程式碼段無需逐一更新,變更次數減少,錯誤也隨之減少

每當你考慮使用結構體時,都值得進一步思考:是否更適合建立一個類別?類別提供了結構體所沒有的封裝性與行為定義。

13.2 指標#

指標(Pointer)的使用是現代程式設計中最容易出錯的領域之一。Java、C#、Visual Basic 等語言因此不提供指標型別。許多常見的安全漏洞(如緩衝區溢出)都可追溯到指標的錯誤使用。

理解指標的概念模型#

每個指標在概念上由兩部分組成:

  1. 記憶體位置(Location):一個位址,通常以十六進位表示(例如 0x0001EA40)。
  2. 如何解讀該位置的內容(Interpretation):由指標的**基底型別(Base Type)**決定。同一段記憶體位元,可以被解讀為整數、字串、浮點數——端看指標的型別而定。
flowchart LR
    P["指標變數\n(Pointer)"] -->|"儲存"| A["記憶體位址\n(如 0x0001EA40)"]
    A -->|"指向"| C["記憶體內容\n(位元資料)"]
    T["基底型別\n(Base Type)"] -.->|"決定如何解讀"| C

記憶體本身沒有內建的解讀方式。只有透過特定型別的指標,某個位置的位元才會被解讀為有意義的資料。

一般性指標技巧#

指標錯誤的症狀往往與成因毫無關聯——可能是系統崩潰、計算結果錯誤,也可能暫時毫無症狀卻像定時炸彈。策略是預防為主、早期偵測為輔

點擊展開:指標操作的完整建議
  • 將指標操作隔離到子程式或類別中:例如為鏈結串列撰寫 NextLink()InsertLink() 等存取子程式,減少散布各處的指標操作。
  • 宣告與定義指標時同時初始化:不要在宣告指標後隔了大量程式碼才配置記憶體;在需要使用時才宣告並立即初始化。
  • 在同一作用域層級配置與釋放指標:配置(new)與釋放(delete)應保持對稱,例如同一子程式內、或建構子與解構子配對。
  • 使用前檢查指標:在程式的關鍵段落,先驗證指標指向的記憶體位置是否合理。
  • 使用前檢查指標所參照的變數:對指標所指向的值進行合理性檢查,例如預期為 0-1000 的整數,超過此範圍則值得懷疑。
  • 使用標記欄位(Dog-Tag Fields)偵測記憶體損壞:在配置的記憶體區塊前後加入特殊標記值,釋放前檢查標記是否完整,以偵測覆寫或重複釋放。
  • 加入明確的冗餘欄位:將某些欄位重複儲存,若冗餘欄位不一致,代表記憶體已被破壞。
  • 使用額外的指標變數增進可讀性:避免 pointer->next->last->next 這類巢狀表達式,改用具名義的中間變數。
  • 簡化複雜的指標運算式:將 rates->discounts->factors->net 之類的鏈式存取指派給有意義的變數名稱,同時可能改善效能。
  • 畫圖輔助理解:指標操作的文字描述容易令人混淆,畫出節點與箭頭的關係圖通常更有效。
  • 以正確順序刪除鏈結串列中的指標:先取得下一個元素的指標,再釋放當前元素。
  • 配置一塊預留記憶體(Reserve Parachute):程式啟動時預先配置足夠的記憶體,以便在記憶體耗盡時能優雅地儲存工作並關閉。
  • 釋放前將記憶體填入垃圾值:讓使用已釋放指標的錯誤更容易被發現。
  • 釋放指標後將其設為 null:確保對懸空指標(Dangling Pointer)的寫入操作會產生明確的錯誤。
  • 刪除前先檢查指標:驗證指標是否為 null、是否在已配置的指標清單中,避免重複釋放。
  • 追蹤指標配置清單:維護一份已配置指標的列表,釋放前可據此驗證。
  • 撰寫包裝子程式集中管理指標策略:例如 SAFE_NEW(配置 + 加入追蹤清單)與 SAFE_DELETE(驗證 + 填垃圾值 + 從清單移除 + 釋放 + 設為 null),並可根據開發環境與正式環境切換行為。
  • 若有其他替代方案,就不要用指標:指標難以理解、容易出錯,又常導致不可攜帶的程式碼。

C++ 指標注意事項#

  • 理解指標與參照的區別:參照(&)必須指向一個物件且初始化後不可更改;指標(*)可以為 null 且可重新指向。
  • 以指標傳遞「需修改」的參數,以 const 參照傳遞「唯讀」的參數:這同時達成效能(避免複製大型物件)與語意清晰的目的。在函式內部,object->member 代表可修改,object.member 代表不可修改。
  • 善用智慧指標(Smart Pointers)auto_ptr 在離開作用域時自動釋放記憶體,能有效防止記憶體洩漏。更進階的智慧指標可控制資源管理、複製與指派行為。

C 語言指標注意事項#

注意事項說明
使用明確的指標型別避免使用 charvoid 指標代替具體型別,讓編譯器能偵測型別不匹配
避免型別轉換型別轉換會關閉編譯器的型別檢查,若需要大量轉換,應重新檢視架構設計
遵循星號規則在 C 中,賦值語句前必須加上 * 才能將值傳回呼叫端
使用 sizeof() 決定配置大小比手動查詢更可靠、可攜,且編譯期計算無效能代價

13.3 全域資料#

全域變數(Global Variables) 可在程式中的任何地方存取。大多數經驗豐富的程式設計師已得出結論:使用全域資料比使用區域資料風險更高,但跨子程式共享資料的需求確實存在。

全域資料的常見問題#

問題說明
意外修改(Side Effects)在某處修改了全域變數的值,卻在另一處誤以為它未被改變,導致計算結果錯誤
別名問題(Aliasing)當全域變數同時作為參數傳入子程式時,同一變數擁有兩個名稱,修改參數竟同時改變了全域變數
重入性問題(Re-entrant Code)多執行緒環境下,全域資料會被多個執行緒甚至同一程式的多個副本共享,帶來嚴重的同步問題
阻礙程式碼重用若類別依賴全域資料,移植到另一個程式時就必須連帶處理那些全域變數
初始化順序不確定在 C++ 等語言中,不同翻譯單元間全域變數的初始化順序未定義,可能引發隱微的錯誤
破壞模組化與可管理性全域資料在模組之間打了洞——無法只專注於一個子程式,必須同時考慮所有存取同一全域變數的子程式

使用全域資料的合理情境#

情境說明
保存全域值例如反映程式狀態(互動模式 vs. 命令列模式)或全程共用的資料表
模擬具名常數在不支援具名常數的語言(如 Python、Perl)中,以全域變數代替字面值
模擬列舉型別在不直接支援列舉的語言中以全域變數模擬
消除流浪資料當某資料只是被中間層傳遞卻未使用時,全域變數可省去逐層傳遞的麻煩

即使有上述合理情境,全域資料也應作為最後手段。先嘗試區域變數,再嘗試類別變數,最後才考慮全域變數。

以存取子程式取代全域資料#

存取子程式(Access Routines)是處理全域資料問題的核心技巧。將資料隱藏在類別中,外部程式碼透過 Get() / Set() 存取,而非直接讀寫全域變數。

存取子程式的優勢:

優勢說明
集中控制日後變更資料結構時,只需修改存取子程式,不影響其他程式碼
自動化防護例如在 PushStack() 中內建溢位檢查,呼叫端無需自行處理
資訊隱藏可自由更換內部實作,外部介面不變
易於轉換為抽象資料型別例如將 if lineCount > MAX_LINES 改寫為 if PageFull(),在問題領域層次溝通
flowchart TB
    subgraph Before["Before:直接存取全域變數"]
        direction TB
        G1["全域變數"]
        MA1["模組 A"] --> G1
        MB1["模組 B"] --> G1
        MC1["模組 C"] --> G1
    end

    subgraph After["After:透過存取子程式間接存取"]
        direction TB
        MA2["模組 A"] --> AR["存取子程式\nGet() / Set()"]
        MB2["模組 B"] --> AR
        MC2["模組 C"] --> AR
        AR --> G2["被封裝的資料"]
    end

    style G1 fill:#f9d5d5,stroke:#c0392b
    style G2 fill:#d5f9d5,stroke:#27ae60
    style AR fill:#d5e8f9,stroke:#2980b9

存取子程式的抽象層級應對齊問題領域而非實作細節。比較 node = node.nextaccount = NextAccount(account)——後者明確表達了意圖。同時,對同一資料結構的所有操作都應維持在相同的抽象層級。

降低全域資料風險的做法#

做法說明
制定命名慣例讓全域變數一眼可辨(例如 g_ 前綴),並區分不同用途
建立有註解的全域變數清單這是維護者最實用的工具之一
不要用全域變數存放中間結果只在計算完成後才將最終值指派給全域變數
不要將所有資料塞入一個巨大物件這只是換了形式的全域資料,無法獲得真正封裝的好處

更多資源#

  • Steve Maguire,《Writing Solid Code》:第 3 章深入討論指標使用的風險與具體防範技巧。
  • Scott Meyers,《Effective C++》第 2 版與《More Effective C++》:大量關於 C++ 安全使用指標的建議,以及記憶體管理的完整討論。

要點#

  • 結構體能讓程式更簡潔、更易理解與維護;但每次使用前都該思考類別是否更適合。
  • 指標極易出錯——透過存取子程式或類別來隔離操作,並搭配防禦性程式設計技巧。
  • 應盡量避免全域變數,不只因為它們危險,更因為你能用更好的方式取代它們。
  • 若無法避免全域變數,就透過存取子程式來操作——它能提供全域變數的所有好處,而且更多。