函式式設計的核心偏好#
第 9 章說過:函式式程式的設計更像水管系統而非流程。它對「資料流動」有明確的偏好——常常使用 map、filter、reduce 把整個串列轉換成另一個串列,而不是「一個個元素地迭代」。
我們在前幾章已多次看過這種偏好:保齡球計分、閒聊巴士司機、薪資系統都是例子。本章再用一個實際題目把這份偏好說清楚。
範例:Advent of Code 2022 Day 10#
Advent of Code 2022 Day 10 ↗ 的題目要求模擬一台陽極射線管(cathode ray tube, CRT)顯示器:
- 螢幕大小為 6×40 個像素
- 像素從左到右、由上而下、一次一個地繪出
- 計時從 cycle 0 開始
題目規則#
- 處理器有兩個指令:
noop:吃 1 個 cycle、不改變狀態addx n:吃 2 個 cycle,結束時把n加進x暫存器
- 螢幕在當前 cycle 點亮像素,當且僅當
x暫存器與 cycle 數匹配 - 匹配的判斷拓寬成「3 個像素的視窗」:若
x ∈ {clock−1, clock, clock+1}就點亮 - 因為螢幕寬 40,所以 cycle 與
x的比較是mod 40
任務:執行一段指令,輸出 6 行各 40 字元的字串——# 為亮、. 為不亮。
這也很像舊式 CRT 顯示器的工作方式:電子束依時鐘訊號掃過螢幕,當 bitmap 對應位元為 1 時就把電子束打開。
Java 解法:以可變狀態驅動的迴圈#
如果用 Java/C/C++/Go 等語言寫,自然會寫成「逐 cycle 迭代、累積像素」的迴圈:
public class Crt {
private int x;
private String pixels = "";
private int extraCycles = 0;
private int cycle = 0;
private int ic;
private String[] instructions;
public Crt(int x) { this.x = x; }
public void doCycles(int n, String instructionsLines) {
instructions = instructionsLines.split("\n");
ic = 0;
for (cycle = 0; cycle < n; cycle++) {
setPixel();
execute();
}
}
private void execute() {
if (instructions[ic].equals("noop")) ic++;
else if (instructions[ic].startsWith("addx ") && extraCycles == 0)
extraCycles = 1;
else if (instructions[ic].startsWith("addx ") && extraCycles == 1) {
extraCycles = 0;
x += Integer.parseInt(instructions[ic].substring(5));
ic++;
} else
System.out.println("TILT");
}
private void setPixel() {
int pos = cycle % 40;
int offset = pos - x;
if (offset >= -1 && 1 >= offset) pixels += "#";
else pixels += ".";
}
}注意:所有狀態(
x、pixels、extraCycles、cycle、ic、instructions)都是可變的。為了處理
addx吃 2 cycle 這件事,多了一個extraCycles標記,邏輯需要分支。雖然方法切得不算大,所有方法都被可變狀態互相耦合——這是可變物件最常見的耦合形態。
Clojure 解法:管線式資料流#
(ns day10-cathode-ray-tube.core
(:require [clojure.string :as string]))
(defn noop [state]
(update state :cycles conj (:x state)))
(defn addx [n state]
(let [{:keys [x cycles]} state]
(assoc state :x (+ x n)
:cycles (vec (concat cycles [x x])))))
(defn execute [state lines]
(if (empty? lines)
state
(let [line (first lines)
state (if (re-matches #"noop" line)
(noop state)
(if-let [[_ n] (re-matches #"addx (-?\d+)" line)]
(addx (Integer/parseInt n) state)
"TILT"))]
(recur state (rest lines)))))
(defn execute-file [file-name]
(let [lines (string/split-lines (slurp file-name))
starting-state {:x 1 :cycles []}
ending-state (execute starting-state lines)]
(:cycles ending-state)))
(defn render-cycles [cycles]
(loop [cycles cycles
screen ""
t 0]
(if (empty? cycles)
(map #(apply str %) (partition 40 40 "" screen))
(let [x (first cycles)
offset (- t x)
pixel? (<= -1 offset 1)
screen (str screen (if pixel? "#" "."))
t (mod (inc t) 40)]
(recur (rest cycles) screen t)))))
(defn print-screen [lines]
(doseq [line lines] (println line))
true)
(defn -main []
(-> "input"
execute-file
render-cycles
print-screen))Clojure 程式從下往上看比較好懂:先看
-main、再追每個函式做了什麼。
TILT是作者最愛的錯誤訊息——早年彈珠台機台被搖晃時會印出這個字串並終止遊戲。
三段管線#
-main 用 threading macro 把整個流程串成一條管線:
execute-file:把指令檔轉成「每個 cycle 結束時的x值」清單render-cycles:把x清單轉成像素字串、再partition為 40 字一行print-screen:印出來
沒有可變變數。
state從execute-file出發,流經execute,反覆進出noop/addx,再回到execute-file。每一站都用舊狀態產生新狀態,從不修改舊狀態。
與 Unix pipe 的比擬#
這個感覺與我們在 shell 用慣的 | 完全相同:
ls -lh private/messages | cut -c 32-37,57-64- 資料從
ls流出 - 經 pipe 進入
cut - 各自做自己的事,互不打擾
因為採取了管線式風格,Clojure 解法被切成一群小函式,彼此不被可變狀態耦合——僅剩的耦合來自「函式之間流通的資料格式」。
雙 cycle 不再是特例#
Java 版本必須引入 extraCycles 來處理 addx 吃兩個 cycle 的特殊性。Clojure 版本則用一行 (vec (concat cycles [x x])) 把 x 連續加兩次到 :cycles——根本不需要特殊邏輯。
一句話差異#
在可變語言中,行為流經物件;在函式式語言中,物件流經行為。
新一些的 Java 與 C# 也提供類似的資料流操作(Stream、LINQ),但語法常顯得冗長、不夠自然。在沒有強烈推力時,這些語言的程式設計師仍傾向於迭代而非「鋪管線」。
當然,作者也可以用 Clojure 寫成跟 Java 完全一樣的演算法。但「函式式思維」自然偏向資料流——這是設計品味的差異,不是語言本身的限制。