本章探討如何表達程式的行為(behavior)。John Von Neumann 為計算建立了一個核心隱喻——一連串依序執行的指令。這個隱喻滲透了大多數程式語言,Java 也不例外。
本章涵蓋的模式:
| 模式 | 說明 |
|---|---|
| Control Flow | 以步驟序列表達運算 |
| Main Flow | 清楚表達主要控制流程 |
| Message | 透過發送訊息表達控制流程 |
| Choosing Message | 變換訊息的實作者來表達選擇 |
| Double Dispatch | 沿兩個軸變換訊息實作者,表達層疊式選擇 |
| Decomposing Message | 將複雜計算拆解為內聚的區塊 |
| Reversing Message | 透過對同一接收者發送一系列訊息,使控制流程對稱 |
| Inviting Message | 發送可被不同方式實作的訊息,邀請未來變化 |
| Explaining Message | 發送訊息來解釋一段邏輯的目的 |
| Exceptional Flow | 在不干擾主流程表達的前提下,盡可能清楚地表達異常流程 |
| Guard Clause | 以提前回傳表達局部的例外情況 |
| Exception | 以例外機制表達非局部的異常流程 |
| Checked Exception | 透過明確宣告,確保例外被捕捉 |
| Exception Propagation | 傳播例外,並視需要轉換其攜帶的資訊以適合捕捉者 |
Control Flow#
為什麼程式需要控制流程?有些語言(如 Prolog)沒有明確的控制流程概念——邏輯片段漂浮在「湯」裡,等待適當條件才被啟動。
Java 屬於以控制序列為基本組織原則的語言家族:
| 機制 | 說明 |
|---|---|
| 相鄰語句 | 依序執行 |
| 條件式 | 讓程式碼只在特定情況下執行 |
| 迴圈 | 重複執行程式碼 |
| 訊息 | 被發送以啟動多個子程式之一 |
| 例外 | 使控制跳到呼叫堆疊的上層 |
mindmap
root((控制流程))
順序執行
相鄰語句
選擇
條件式
訊息(多型)
重複
迴圈
非局部跳躍
例外這些機制加總起來,構成了豐富的運算表達媒介。身為程式作者,你需要決定:
- 將流程表達為一條主流程加上例外
- 或是多條同等重要的替代流程
- 或是以上的某種組合
你將控制流程分組,讓讀者能先以抽象方式理解,再視需要深入細節。有些分組是類別中的方法,有些則是將控制委派給其他物件。
Main Flow#
程式設計師通常心中有一條程式的主要流程:處理從這裡開始、在那裡結束。途中可能有決策和例外,但運算有一條路徑可循。
- 某些專為在敵對環境下可靠運作的程式,不太有可見的主流程,但這類程式是少數
- 把程式語言的表達力用來清楚表達很少執行、很少變更的事實,會遮蔽程式中更具槓桿效應的部分——經常被閱讀、理解和修改的部分
- 例外條件並非不重要,而是聚焦於清楚表達主流程更有價值
清楚表達程式的主流程。使用 exception 和 guard clause 來表達不尋常或錯誤的條件。
Message#
訊息(message)是 Java 中表達邏輯的主要手段之一。程序式語言使用 procedure call 作為資訊隱藏機制:
compute() {
input();
process();
output();
}這段程式碼說的是:「要理解這個運算,你只需要知道它由這三個步驟組成,細節目前不重要。」
物件導向程式設計更進一步——同一個 procedure 還表達了更豐富的意涵:
- 對每個方法,潛在地存在一整組結構相似但細節不同的運算
- 而且你不必在撰寫不變部分時,就釘死所有未來變化的細節
以訊息作為基本控制流程機制,承認了變化是程式的常態:
- 每個訊息都是一個潛在的變化點——可以在不改變發送者的情況下更換接收者
- 訊息版本不是說「那邊有某個東西,細節不重要」,而是說「故事進行到這裡,有件和 input 概念相關的有趣事情會發生,細節可能會變」
明智地運用這種彈性——在可能之處做出清楚直接的邏輯表達,並適當地延遲細節——是撰寫有效溝通程式碼的重要技能。
Choosing Message#
有時候,發送訊息是為了選擇實作,就像程序式語言中的 case 語句。例如,若要以數種方式之一顯示圖形,可發送一個多型訊息來表達執行時期的選擇:
public void displayShape(Shape subject, Brush brush) {
brush.display(subject);
}display() 訊息會根據 brush 的執行時期型別選擇實作。這讓你可以自由實作多種 brush:ScreenBrush、PostscriptBrush 等。
大量使用 choosing message 的效果:
- 程式碼中幾乎沒有明確的條件式
- 每個 choosing message 都是對未來擴充的邀請
- 每個明確的條件式則是程式中另一個需要手動修改才能改變整體行為的點
閱讀大量使用 choosing message 的程式碼需要學習技巧:
- 讀者可能需要查看多個類別才能理解特定路徑的細節
- 身為作者,你可以透過意圖揭示的方法名稱(intention-revealing name)幫助讀者導航
- 也要注意 choosing message 何時是多餘的——如果某個計算不可能有變化,就不要為了提供變化的可能性而引入方法
Double Dispatch#
Choosing message 擅長表達單一維度的可變性。如果你需要表達兩個獨立維度的可變性,可以串聯兩個 choosing message。
例如,假設 Postscript 橢圓的計算方式不同於螢幕矩形。首先決定計算邏輯的歸屬——基礎計算似乎屬於 Brush,所以先對 Shape 發送 choosing message,再對 Brush 發送:
displayShape(Shape subject, Brush brush) {
shape.displayWith(brush);
}每個 Shape 都有機會以不同方式實作 displayWith()。但它們不做任何細節工作,而是將自己的型別附加到訊息上,轉交給 Brush:
Oval.displayWith(Brush brush) {
brush.displayOval(this);
}
Rectangle.displayWith(Brush brush) {
brush.displayRectangle(this);
}現在不同種類的 brush 擁有做好工作所需的資訊:
PostscriptBrush.displayRectangle(Rectangle subject) {
writer.print(subject.left() + " " + ... + " rect");
}sequenceDiagram
participant Client
participant Shape as Shape(Oval)
participant Brush as Brush(PostscriptBrush)
Client->>Shape: displayWith(brush)
Note over Shape: 將自身型別<br/>附加到訊息上
Shape->>Brush: displayOval(this)
Note over Brush: 根據具體 Shape<br/>執行對應繪製邏輯Double dispatch 的取捨:
- 引入了一些重複,伴隨著相應的彈性損失
- 第一個 choosing message 接收者的型別名稱會散布在第二個 choosing message 接收者的方法中
- 以此例來說,要新增一個
Shape,就必須在所有 Brush 中新增方法 - 如果某一維度比另一維度更可能變化,讓它成為第二個 choosing message 的接收者
作者曾嘗試過 triple dispatch,但最終都找到了更清楚的方式來表達多維度邏輯。
Decomposing (Sequencing) Message#
當你有一個由許多步驟組成的複雜演算法時,可以將相關步驟分組並發送訊息來調用它們。這種訊息的目的不是提供特化的掛鉤或其他複雜用途——它就是老式的功能分解(functional decomposition)。訊息只是用來調用子程式中的步驟子序列。
Decomposing message 的要點:
- 需要描述性的名稱——大多數讀者應該只從名稱就能理解子序列的目的
- 只有對實作細節感興趣的讀者才需要閱讀被調用的程式碼
- 難以命名是一個警示信號,表示這可能不是正確的模式
- 長參數列表也是另一個警示信號
遇到這些症狀時,可以 inline 被 decomposing message 調用的方法,再嘗試其他模式(如 Method Object)來幫助表達程式結構。
Reversing Message#
對稱性可以提升程式碼的可讀性。考慮以下程式碼:
void compute() {
input();
helper.process(this);
output();
}這個方法雖然由三個子方法組成,但缺乏對稱性。引入一個 helper method 可以揭示潛在的對稱性:
void process(Helper helper) {
helper.process(this);
}
void compute() {
input();
process(helper);
output();
}現在讀 compute() 時,不需要追蹤訊息發送給誰——它們都發送給 this。讀者只需閱讀單一類別就能理解 compute() 的結構。
使用 reversing message 的注意事項:
- 有時 helper method 會自行變得重要
- 過度使用可能遮蔽搬移功能的需要
例如,如果出現這樣的情況:
void input(Helper helper) {
helper.input(this);
}
void output(Helper helper) {
helper.output(this);
}可能更好的結構是將整個 compute() 搬到 Helper 類別:
compute() {
new Helper(this).compute();
}
Helper.compute() {
input();
process();
output();
}不要因為引入方法「只是」為了滿足對稱性這種「美感」需求而覺得愚蠢。程式碼的美感比純粹的線性邏輯思維更深層——它動用了你大腦更多的部分,是對程式碼品質有價值的回饋。
Inviting Message#
有時在撰寫程式碼時,你預期別人會想在子類別中變化部分計算。發送一個適當命名的訊息,來溝通日後精煉的可能性。這個訊息邀請程式設計師稍後為自己的目的精煉計算。
- 如果邏輯有預設實作,就讓它成為該訊息的實作
- 如果沒有,就將方法宣告為 abstract,使邀請變得明確
Explaining Message#
意圖(intention)和實作(implementation)之間的區別在軟體開發中一直很重要。它讓你能先以本質理解一個運算,之後在必要時再深入細節。你可以用訊息來做出這種區分——發送一個以你要解決的問題命名的訊息,而該訊息再發送一個以如何解決問題命名的訊息。
一個經典的 Smalltalk 範例(轉譯為 Java):
highlight(Rectangle area) {
reverse(area);
}初看之下會想:「為什麼不直接呼叫 reverse()?」但 highlight() 雖然沒有計算上的用途,卻傳達了意圖。呼叫端的程式碼可以用它要解決的問題(高亮螢幕的一個區域)來撰寫。
另一個應用場景——當你想對單行程式碼加註解時,考慮引入 explaining message:
// 與其寫:
flags |= LOADED_BIT; // Set the loaded bit
// 不如寫:
setLoadedFlag();即使 setLoadedFlag() 的實作很簡單,這個單行方法的存在是為了溝通:
void setLoadedFlag() {
flags |= LOADED_BIT;
}有時 explaining message 調用的 helper method 會成為進一步擴充的有價值掛鉤。但引入 explaining message 的主要目的是更清楚地傳達意圖。
Exceptional Flow#
就像程式有主流程,也可以有一個或多個例外流程(exceptional flow)。這些是較不重要的計算路徑——因為它們較少被執行、較少被變更,或概念上不如主流程重要。
- 清楚表達主流程,並在不遮蔽主流程的前提下,盡可能清楚地表達這些例外路徑
- Guard clause 和 exception 是表達例外流程的兩種方式
程式在語句依序執行時最容易閱讀。但有時程式有多條路徑:
- 如果等同地表達所有路徑,會產生一團混亂——flag 在這裡設定、在那裡使用,回傳值有特殊含義
- 回答「哪些語句被執行了?」變成了一場考古學和邏輯學的結合練習
選定主流程,清楚地表達它。使用 exception 來表達其他路徑。
Guard Clause#
Guard clause 是一種以提前回傳表達簡單且局部的例外情況的方式。比較以下兩個版本:
void initialize() {
if (!isInitialized()) {
...
}
}與:
void initialize() {
if (isInitialized())
return;
...
}flowchart LR
subgraph if-then-else["if-then-else 風格"]
A1["進入方法"] --> A2{"已初始化?"}
A2 -->|"否"| A3["執行初始化邏輯"]
A2 -->|"是"| A4["什麼都不做"]
A3 --> A5["結束"]
A4 --> A5
end
subgraph guard["Guard Clause 風格"]
B1["進入方法"] --> B2{"已初始化?"}
B2 -->|"是"| B3["return ↩"]
B2 -->|"否"| B4["執行初始化邏輯"]
B4 --> B5["結束"]
end- 讀第一個版本時,你必須在閱讀 then 子句期間記住去找 else 子句——這在閱讀主體時是一種干擾
- 第二個版本的前兩行簡單地告訴你一個事實:接收者尚未初始化
if-then-else 表達的是同等重要的替代控制流程。Guard clause 適用於表達不同的情境——其中一條控制流程比另一條更重要。
古老的程式設計戒律「每個程式應有單一入口和單一出口」在 FORTRAN 或組合語言時代有道理。但在 Java 中——方法小且資料大多是局部的——這條規則過於保守,且會阻止 guard clause 的使用。
Guard clause 在有多個條件時特別有用。巢狀條件容易滋生缺陷:
void compute() {
Server server = getServer();
if (server != null) {
Client client = server.getClient();
if (client != null) {
Request current = client.getRequest();
if (current != null)
processRequest(current);
}
}
}Guard clause 版本在沒有複雜控制結構的情況下,註記了處理請求的前置條件:
void compute() {
Server server = getServer();
if (server == null)
return;
Client client = server.getClient();
if (client == null)
return;
Request current = client.getRequest();
if (current == null)
return;
processRequest(current);
}Guard clause 的一個變體是迴圈中的 continue 語句,表達「跳過這個元素,繼續下一個」:
while (line = reader.readline()) {
if (line.startsWith('#') || line.isEmpty())
continue;
// Normal processing here
}Exception#
Exception 適用於表達跨越多層函式調用的程式流程跳躍。當你在呼叫堆疊的很上層才發現問題(磁碟滿了、網路連線斷了),可能只能在堆疊的很下層才能合理地處理。在發現點拋出例外、在可處理的點捕捉——遠比在所有中間層程式碼中加入明確檢查好得多。
Exception 的代價:
- 它是一種設計洩漏(design leakage)——被呼叫方法拋出的例外會影響所有可能的呼叫方法的設計和實作
- 使追蹤控制流程變得困難,因為相鄰語句可能在不同的方法、物件或套件中
- 可以用條件式和訊息撰寫但卻用 exception 實作的程式碼,非常難以閱讀
盡可能以序列、訊息、迭代、條件式(按此順序)表達控制流程。只有在不這麼做會混淆主流程的清楚表達時,才使用 exception。
Checked Exception#
Exception 的一個危險是:拋出了卻沒人捕捉——程式就終止了。你希望控制程式何時非預期終止,印出診斷所需的資訊並告知使用者發生了什麼。
- 當不同人撰寫拋出 exception 的程式碼和捕捉 exception 的程式碼時,風險更大
- 任何溝通遺漏都會導致突然且不禮貌的程式終止
Java 的 checked exception 由程式設計師明確宣告、由編譯器檢查。受到 checked exception 影響的程式碼必須捕捉它或傳遞它。
Checked exception 的代價:
- 宣告本身的成本——可以輕易增加 50% 的方法宣告長度
- 在拋出者和捕捉者之間的每一層增加了需要閱讀和理解的東西
- 使修改程式碼更困難——重構帶有 checked exception 的程式碼比沒有的更困難和繁瑣
Exception Propagation#
Exception 發生在不同的抽象層次。捕捉並報告低層次的 exception 可能讓人困惑。
- 當 web server 顯示一個帶有
NullPointerException堆疊追蹤的錯誤頁面時,使用者不知道該怎麼處理 - 使用者更希望看到「程式設計師未考慮到你呈現的情境」這樣的訊息
- 同時附上可以發送給程式設計師的進一步資訊連結,但呈現未翻譯的細節是無益的
處理方式:
- 低層次 exception 通常包含有助於診斷缺陷的有價值資訊
- 將低層次 exception 包裝(wrap)在高層次 exception 中
- 當 exception 被印出(例如在日誌中)時,確保寫入足夠的資訊以幫助找到缺陷
結語#
控制流程在物件構成的程式方法之間流動。下一章將描述如何使用方法(method)來表達計算中的概念。