🎯 什麼情境該想到我

先看純函式做不到什麼:

無論調用一個函式多少次,只要使用相同的參數,總會得到相同的結果。即使是帶累積器的函式,只要累積器相同,返回值也將相同。函式只是不能記住過去的調用結果。(p.295)

可是很多程式必須記住以往呼叫中的某些資料。書上一連舉了三個例子:

  • 通訊錄(p.295-296)。標準的通訊錄軟體至少提供兩種服務:查找某人的電話號碼、把某人和號碼加進通訊錄。加進去之後,「與以前完全一樣的 lookup 呼叫現在返回了所需的電話號碼」。過去要產生這種效果,唯一的方法就是編輯定義——但「我們並不希望用戶來編輯程式。事實上,它們應該沒有權力訪問我們的程式」(p.296)。
  • 交通號誌(p.296-297)。原本使用者得輸入 (next (next (next (next 'red))));改成按鈕之後,回呼函式沒有參數,「該回呼函式要以某種方式得知當前信號燈的狀態,並改變之」。要做到這件事,「next 的當前呼叫必須能影響將來的呼叫」(p.297)。
  • 劊子手遊戲(p.297)。check 讀入一個字母回傳 true/false,「hangman 和 check 必定有一定的記憶,記住『Check』按鈕被使用了多少次,還要記住讀入的猜測有多少次是錯誤的」。

書把「何時需要記憶」收斂成兩種情況(p.305),而且說「要辨認出何時程式需要記憶相對來說比較簡單」:

  1. 程式向使用者提供不止一種服務,每種服務對應一個函式。 通訊錄是經典例子——「『添加服務』的使用影響了『查找服務』的使用,所以程式需要記憶」(p.305)。倉庫管理員的帳簿也是:輸入物品、搜尋帳簿、刪除物品三種服務,「我們不能從倉庫中取出一個不存在的東西,所以程式必須保證兩種服務能正確地互相影響」(p.306)。
  2. 程式只提供單一服務,但同樣的參數可能返回不同答案。 交通號誌是經典例子:「因為連續兩次計算 (next) 會產生不同的效果,所以這個函式需要記憶」(p.306)。random 也是:「兩次計算 (random 10),返回值可能相同,也可能不同。因此 random 的實現需要配備有記憶的函式」(p.306)。

判準其實在程式的邊界上:

簡而言之,程式與世界的其餘部分之間的界面決定了該程式是否需要記憶,以及它需要何種類型的記憶。(p.305)

不只是 GUI。書說即使要求使用者從互動視窗使用程式,也必須把程式組織成「每一種服務對應一個函式,而函式通過記憶相互合作」;「即使是在物理設備(例如電梯、錄影機等)中運行的函式,也必須以某種方式與設備相結合」(p.305)。

⚙️ 怎麼用

第 0 步:三個重要的步驟

定義記憶函式需要三個重要的步驟:

  1. 確認確實需要記憶,
  2. 確定要記憶的資料,
  3. 理解哪項服務需要修改記憶,那項服務需要使用記憶。(p.305)

書對這三步的說明是:第一步是必須的;一旦知道程式需要記憶,「就必須對記憶進行資料分析,也就是說,必須理解記憶的資料型別是什麼」;最後「必須仔細設計那些改變記憶的函式」——而只使用、不修改記憶的函式,照原本的設計訣竅做就好(p.305)。

第 1 步:畫組織圖

一般說來,在分析問題說明時應該畫出組織圖。(p.306)

圖 36.1「記憶程式的組織圖」給了兩個例子:通訊錄管理程式和交通號誌程式。畫法(p.306):

圖上的東西意思
矩形方框程式所提供的每一個服務
指向方框的箭頭該服務所需的資料型別
從方框向外的箭頭服務的輸出
圓圈記憶
從圓圈到方框的箭頭該服務使用記憶作為一個參數
從方框到圓圈的箭頭該服務改變記憶

這兩張圖表明服務通常要使用記憶,並且要修改記憶。(p.306)

第 2 步:把記憶變成狀態變數,並替它寫合約與用途說明

  • 記憶是用變數定義實現的。「原則上,只要一個變數就足夠實現所有需要的記憶了,但是這通常是不方便的。一般來說,從記憶分析可以知道我們需要多少變數,以及哪項服務需要哪個變數。」(p.306)

  • 記憶改變時,「相應的變數就變成了一個新的值,或者換一種說法,變數聲明的狀態改變了,這反映了記憶隨時間變化。因此,我們把實現記憶的變數稱為狀態變數。」(p.306)

  • 「改變程式的記憶的服務由對某個(或某些)狀態變數使用 set! 的函式實現。」(p.306)

    set! 是 Scheme 的賦值表達式(p.298);換到別的語言,就是「改變一個外部變數的值」的那個動作。

  • 關鍵動作:

    就像設計函式定義的合約與用途說明一樣,我們必須設計狀態變數的合約與用途說明。(p.306)

    通訊錄的狀態變數 address-book 合約是「(listof (list symbol number)),保存人名和電話號碼的對」(p.306);交通號誌的 current-color 合約是 TL-color(‘red / ‘green / ‘yellow 三者之一),用途是「保存交通號誌當前的顏色」(p.307)。

  • 合約立刻拿來當檢查工具。address-book 賦值成 5「是沒有意義的」,因為 5 不是一個表,「這個表達式違背了狀態變數的合約」;把它賦值成空表是正確的,因為那是初始值;把新條目 cons 上去也是正確的,因為它「構造了一個更長的、型別正確的表」——「set! 表達式正好改變了狀態變數的值,使它代表 (listof (list symbol number)) 型別中的另一個值」(p.307)。

  • 賦值的右邊不一定要是現成的值:「在許多情況下,使用一個能夠計算出值的函式也是合理的」——交通號誌就寫一個純函式 next-color,把當前顏色算成下一個顏色,再一次賦值回去(p.307)。

第 3 步:寫初始化函式

在完成了程式中狀態變數的合約與用途說明的設計之後,我們應立即定義一個函式,把這些狀態變數設置為合適的初始值。我們把這樣的函式稱為初始化函式。在程式運行時,初始化函式應當是第一個被執行的函式;程式也可以提供其他的方法來調用初始化函式。(p.307)

兩個細節:

  • 初值不是隨便挑的。交通號誌初始化成 ‘red,因為「遵從工程上的傳統規則,在啟動設備時將狀態設為最安全」(p.308)。
  • 初始化函式通常不只是設值。「很容易看出初始化函式還應當做另外一些有用的工作」——通訊錄的初始化函式應該建立並顯示 GUI,交通號誌的應該建立並顯示畫布(p.308)。

第 4 步:修改過的設計訣竅(本卡的核心)

現在,我們來看看最基本的設計訣竅的各個階段應該如何適應狀態變數:(p.308)

① 資料分析 —— 沒變。「即使是影響變數的狀態的函式,也可以(或可能)讀入並返回資料。因此仍然需要分析如何表示資訊,如果必要的話,還要引入結構和資料的定義。」交通號誌就得益於 TL-color 這個資料定義(p.308)。

② 合約、用途和效果 —— 第一個主要的改變。

除了要說明函式讀入和返回的東西,還必須寫明它影響了那些變數,以及它是怎樣影響這些變數的。函式對狀態變數的效果必須與變數的用途說明一致。(p.308)

所以規格多了一欄「效果」。交通號誌的 next 寫成「效果:改變當前的顏色,從 ‘green 變為 ‘yellow,從 ‘yellow 變為 ‘red,從 ‘red 變為 ‘green」,它不讀入資料也不回傳可見的值。書特別註明:「在傳統意義上,這個函式是沒有意義的,所以它只有一個效果的說明(而沒有其他的說明)」(p.308)。通訊錄那個則是「效果:把 (list name phone) 添加到 address-book 的前部」——「從效果說明中可以看出,address-book 的定義是按照它的用途說明與合約被修改的」(p.308)。

③ 程式例子 —— 例子一樣重要,但變難寫了。

以前,我們必須開發例子,舉例說明輸入和輸出的關係,但是,因為現在函式有了效果,所以我們還需要用例子來說明效果。(p.308)

寫法是「如果狀態變數是 A 時我們執行這個函式,那麼其後狀態變數就是 B」。狀態空間小就窮舉——交通號誌「只能代表三種符號中的一個,所以實際上我們可以用例子來表示其所有可能的效果」;狀態空間無限就挑代表——通訊錄「可以代表無限個值,所以不可能舉出所有的例子。但是,舉出一些例子還是非常重要的,因為例子可以使得以後開發函式的主體更簡單」,書挑的三個是:空表、一個元素的表、以及形如 (list E-1 … E-2) 的一般情形(p.309-310)。

在例子中,我們用到了表示時間的文字,其後,這並奇怪,畢竟,賦值就是要強調時間的概念。(p.310)

警告: 狀態變數永遠不會是某個函式的參數。(p.310)

④ 模板 ——

改變狀態的函式的模板與普通函式的模板很相似,只是其主體中應包含 set! 表達式,用來修改狀態變數(p.310)

也就是「函式主體 = 一次對狀態變數的賦值」,賦值右邊先留空。書補了兩點:

  • 「計算狀態變數下一個值的任務可以被交給一個讀入 x、y 和 z 的輔助函式處理。我們的兩個例子就是這樣的。」(p.310)
  • 「有時候,按照函式輸入的定義,我們還需要使用選擇器和 cond 表達式。」交通號誌的輸入資料定義(三種顏色)就暗示要用一個三分支的條件式,每個分支各放一個賦值(p.310)。

⑤ 主體 —— 這一階段的重點只有一個:

對於有效果的函式來說,最需要注意的步驟就是 set! 表達式的執行。(p.310)

書給了兩種寫法,並各配一個例子(p.310):

  • 賦值右邊直接算出來:「在某些情況下,賦值的右部只是原始操作、函式參數和狀態變數(或者是幾個狀態變數)。」add-to-address-book 屬於這種——右邊只由 address-book 加上兩個基本的表建構操作組成。
  • 抽一個沒有效果的輔助函式:「對於其他情況,最好設計一個(沒有效果的)輔助函式,讀入狀態變數當前的值以及函式參數,返回新的狀態變數的值。」交通號誌是兩種方法都可以選用的例子:可以照模板寫成三分支各自賦值,也可以寫成一行「把 current-color 賦值成 next-color 算出來的顏色」(p.310)。

⑥ 測試 ——

對於有效果的函式,我們可使用相同的方法,但是證實函式對於某種狀態變數有著預期的效果是一個複雜的任務。(p.310)

書給了兩種方法(p.310-311):

  • 方法一:設好狀態 → 呼叫 → 檢查。「可以把狀態變數設值為所需的狀態,再調用函式,然後檢查函式的結果和效果是不是預期的值。」交通號誌「就很適合使用這種方法」,三個例子直接各轉成一條測試。
  • 方法二:先存舊值 → 呼叫 → 比對新舊關係。「我們可以在測試前保存某個狀態變數的值,再調用改變記憶的函式,然後進行合適的測試。」通訊錄的測試就是:計算開始時把舊的 address-book 存起來,計算結束時「檢查特定的條目是否被添加到了狀態變數的前部,而狀態變數的其餘部分保持不變」(p.311)。
  • 而且要把測試本身抽成函式:「要對有效果的函式進行測試,特別是進行第二種測試,把測試表達式抽象成一個函式是很效的手段」;抽出來之後就能連跑多次,「並確保每一次測試它的效果都是正確的」。注意連跑時要用一個能保證順序的組合子——書用 and:「其中 and 表達式保證測試表達式按照順序計算,並且全部返回 true」(p.311)。

⑦ 將來的重用 ——

一旦得到了完整的、經過測試的函式,我們應當記住它們的存在,記住它們計算了什麼,記住它們的效果是什麼。不過,我們並不需要記住它們是怎樣計算的。(p.311)

完整範本

圖 36.2「狀態變數的設計訣竅:一個完整的例子」(交通號誌,p.309)與圖 36.3「狀態變數的設計訣竅:第二個例子」(電話簿,p.312)把整份文件的欄位列了出來,順序是:

資料定義 → 狀態變數(合約 + 用途)→ 合約 → 用途 → 效果 → 頭部 → 例子 → 模板 → 定義 → 測試

兩個例子的「用途」欄都寫「該函式總是返回 (void)」——全部的資訊都在「效果」那一欄。交通號誌那份的「頭部」被省略了,書說明原因是「就這個特定的例子來說,有用途和效果的說明就足夠了」(p.311)。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 只使用、不修改記憶的函式不需要這一套。「只使用而不修改記憶的函式只需按照前面介紹過的設計訣竅進行設計就可以了。」(p.305)

  • 狀態變數永遠不會是某個函式的參數。 書把這句獨立標成「警告」(p.310)。

  • 賦值會帶進「時間」,而且會毀掉舊的值。

    手工計算還說明,set! 表達式的計算過程帶來了額外的時間約束或時間區間。更具體地說,計算是由兩個部分組成的:賦值前的部分和賦值後的部分,而賦值會影響定義的狀態。在我們介紹賦值語句之前,只要願意,隨時可以把變數替換成它的值,或者把一個函式調用替換成它的定義。現在,我們必須等到真正需要某個變數的值的時候才能執行這樣的替換。(p.300)

    賦值操作「消滅」了當前的值,除非程式設計者能詳細安排變數的賦值順序,使用 set! 可能會帶來災難性的後果(p.300)

    書的習題 35.2.2 就是這個坑的實例:連著兩次賦值想交換兩個變數,會失敗;要先用一個 local 定義把舊值存下來才對(p.300)。

  • 回傳值不再是全部的資訊。「使用 set! 的函式既有返回值,又有效果,不過返回值有可能是不可見的。」(p.302)賦值本身的值是 (void),一個不可見的值——「所有 set! 表達式的值都是相同的,也是不可見的,因此和計算不相關。與計算相關的是 set! 表達式的效果。」(p.298、p.299)所以規格書裡真正承載意義的是「效果」欄,不是「用途」欄。

  • 測試變成複雜的任務(p.310);而且要驗的不只是「效果對」,還有「沒有多餘的效果」——書上那個抽出來的測試函式,用途說明寫的是「判斷 add-to-address-book 是否對 address-book 產生了正確的效果,而且沒有多餘的效果」(p.311)。

  • 重用變得比以前困難。

    警告:在效果出現了之後。要重用一個函式比在代數程式的世界中要困難得多。(p.311)

  • 狀態變數的合約可能被違反。 把型別不對的值賦給狀態變數「是沒有意義的」,因為它違背了狀態變數的合約(p.307)——合約在這裡是你自己要守的紀律,不是語言幫你擋的。

🔗 相關工具

  • 工具-設計訣竅 —— 本卡是它的「有狀態版」。書的原話是「現在,我們來看看最基本的設計訣竅的各個階段應該如何適應狀態變數」(p.308):資料分析照舊,合約那一步多出「效果」,例子要改寫成「執行前的狀態 → 執行後的狀態」,模板主體多一個賦值,主體階段的重點變成賦值表達式的執行,測試要先擺好狀態或先存舊值。
  • 工具-資料驅動的模板推導 —— 狀態變數也要先做資料分析:交通號誌先有 TL-color 的資料定義,模板的三個分支才長得出來(p.308、p.310)。
  • 工具-累積器與累積器不變式 —— 同樣是「把資訊往下傳」,但差別在傳的地方不同。累積器是多加一個參數,資訊沿著遞迴呼叫往下走,外面的變數完全沒有被動過——所以書才說「即使是帶累積器的函式,只要累積器相同,返回值也將相同」(p.295)。狀態變數則是改動一個外部的變數定義,資訊留在函式之外、跨越呼叫存活,而且函式因此有了「效果」;書還特別警告狀態變數不可以當參數(p.310)。要記的東西只在這一串遞迴裡有效 → 用累積器;要記的東西要活過整個程式、被不同的服務共用 → 用狀態變數。
  • 回到 程式設計方法