除了第 12 章介紹的基本資料型別,某些語言還提供一些較為特殊的資料型別。 本章涵蓋三個主題:結構體(Structures)、指標(Pointers),以及全域資料(Global Data)。 在現代物件導向語言中,這些概念或許不常直接出現,但理解它們有助於掌握語言底層的運作原理。
13.1 結構體#
結構體(Structure) 是由其他型別組合而成的使用者自訂資料,例如 C/C++ 的 struct、Visual Basic 的 Structure。
一般來說,應優先使用類別(Class)以獲得封裝與功能的好處,但在某些場景下,直接操作結構化的資料區塊仍然有其價值。
使用結構體的理由#
| 理由 | 說明 |
|---|---|
| 釐清資料關係 | 將相關資料群組在一起,讓程式碼更清楚地表達哪些資料彼此關聯。employee.name 與 supervisor.title 這樣的寫法一目了然 |
| 簡化區塊操作 | 複製、交換等操作只需一行指令,而非逐一對每個欄位賦值。新增欄位時也只需修改結構宣告 |
| 簡化參數列表 | 呼叫端只需傳遞一個參數,而非一長串個別變數。但若子程式只需一兩個欄位,應直接傳遞該欄位 |
| 降低維護成本 | 修改結構宣告後,使用整個結構的程式碼段無需逐一更新,變更次數減少,錯誤也隨之減少 |
每當你考慮使用結構體時,都值得進一步思考:是否更適合建立一個類別?類別提供了結構體所沒有的封裝性與行為定義。
13.2 指標#
指標(Pointer)的使用是現代程式設計中最容易出錯的領域之一。Java、C#、Visual Basic 等語言因此不提供指標型別。許多常見的安全漏洞(如緩衝區溢出)都可追溯到指標的錯誤使用。
理解指標的概念模型#
每個指標在概念上由兩部分組成:
- 記憶體位置(Location):一個位址,通常以十六進位表示(例如
0x0001EA40)。 - 如何解讀該位置的內容(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 語言指標注意事項#
| 注意事項 | 說明 |
|---|---|
| 使用明確的指標型別 | 避免使用 char 或 void 指標代替具體型別,讓編譯器能偵測型別不匹配 |
| 避免型別轉換 | 型別轉換會關閉編譯器的型別檢查,若需要大量轉換,應重新檢視架構設計 |
| 遵循星號規則 | 在 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.next與account = NextAccount(account)——後者明確表達了意圖。同時,對同一資料結構的所有操作都應維持在相同的抽象層級。
降低全域資料風險的做法#
| 做法 | 說明 |
|---|---|
| 制定命名慣例 | 讓全域變數一眼可辨(例如 g_ 前綴),並區分不同用途 |
| 建立有註解的全域變數清單 | 這是維護者最實用的工具之一 |
| 不要用全域變數存放中間結果 | 只在計算完成後才將最終值指派給全域變數 |
| 不要將所有資料塞入一個巨大物件 | 這只是換了形式的全域資料,無法獲得真正封裝的好處 |
更多資源#
- Steve Maguire,《Writing Solid Code》:第 3 章深入討論指標使用的風險與具體防範技巧。
- Scott Meyers,《Effective C++》第 2 版與《More Effective C++》:大量關於 C++ 安全使用指標的建議,以及記憶體管理的完整討論。
要點#
- 結構體能讓程式更簡潔、更易理解與維護;但每次使用前都該思考類別是否更適合。
- 指標極易出錯——透過存取子程式或類別來隔離操作,並搭配防禦性程式設計技巧。
- 應盡量避免全域變數,不只因為它們危險,更因為你能用更好的方式取代它們。
- 若無法避免全域變數,就透過存取子程式來操作——它能提供全域變數的所有好處,而且更多。