函式式設計的核心偏好#

第 9 章說過:函式式程式的設計更像水管系統而非流程。它對「資料流動」有明確的偏好——常常使用 mapfilterreduce 把整個串列轉換成另一個串列,而不是「一個個元素地迭代」。

我們在前幾章已多次看過這種偏好:保齡球計分、閒聊巴士司機、薪資系統都是例子。本章再用一個實際題目把這份偏好說清楚。

範例: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 += ".";
  }
}

注意:所有狀態(xpixelsextraCyclescycleicinstructions)都是可變的。

為了處理 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 把整個流程串成一條管線

  1. execute-file:把指令檔轉成「每個 cycle 結束時的 x 值」清單
  2. render-cycles:把 x 清單轉成像素字串、再 partition 為 40 字一行
  3. print-screen:印出來

沒有可變變數。stateexecute-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 完全一樣的演算法。但「函式式思維」自然偏向資料流——這是設計品味的差異,不是語言本身的限制。