第13章 プログラムを組み立てる — ケーススタディ
部品はすべて揃いました。この章では3つの小さなプログラムを、設計から完成まで通しで書きます。新しい構文は出てきません。代わりに、これまで別々に学んだ道具がどう噛み合うのかを見ます。
どれも同じ手順で進めます。型を決める → 骨組みを書く → コンパイラの witness に導かれて埋める → fmt と --check で仕上げる。第11章のエラー駆動開発を、実物で3回繰り返すわけです。
13.1 テキスト統計 — fold とレコードで1パス集計
K&R の入門書は文字を数えるプログラムから始まります。敬意を表して、単語の統計を作りましょう。
まず断りを一つ。v0.1 の文字列には length と +(連結)しかなく、文字列を単語に分割する道具がありません。そこで入力は「すでに分割された単語のリスト」List[String] とします。分割はプログラムの外の仕事、という設計です。
型を決める。欲しい結果は「単語数・総文字数・最長の単語」の3つ組。名前を付けて運びたいので、レコードです(第6章)。
record Stats:
count: Int
totalChars: Int
longest: String
骨組みを書く。リストを1つの値に畳むのだから fold(第8章)。ということは「途中の Stats と次の単語から、新しい Stats を作る」関数があればいい。stats.lune:
module stats
record Stats:
count: Int
totalChars: Int
longest: String
let empty = Stats(count = 0, totalChars = 0, longest = "")
def step(s: Stats, w: String): Stats =
let n = length(w)
let longer = if n > length(s.longest) then w else s.longest
Stats(count = s.count + 1, totalChars = s.totalChars + n, longest = longer)
def summarize(words: List[String]): Stats =
fold(words, empty, step)
def averageLength(s: Stats): Double? =
if s.count == 0 then null else s.totalChars / s.count
let words = ["the", "quick", "brown", "fox", "jumps"]
let summary = summarize(words)
let average = averageLength(summary)
let emptyAverage = averageLength(summarize([]))
$ lune --eval summary stats.lune
{ count = 5, totalChars = 21, longest = "quick" }
$ lune --eval average stats.lune
4.2
$ lune --eval emptyAverage stats.lune
null
設計上の判断が3つ入っています。
リストを1回しか歩かない。3つの統計を別々に計算すれば fold を3回書くことになりますが、レコードをアキュムレータにすれば1回で済みます。fold の「途中結果」は数値でなくてよい — これが fold の本当の使い方です。
平均を Double? にした。単語が0個のときの平均は存在しません。0 を返すのは嘘なので、null を返します(第7章)。呼び出し側は ?? 0.0 で着地するか match で場合分けするかを選べます。
step を独立した関数にした。fn で書けばラムダに埋め込めますが、名前を付けておくと REPL で単体で試せます(step(empty, "hi"))。テストしたいものには名前を付ける、が実務の勘所です。
13.2 家計簿 — ADT・Result・モジュール分割
次はもう少し「業務っぽい」お題です。収入と支出を記録して、残高を出します。
型を決める。項目は収入か支出のどちらか — 直和型の出番です(第5章)。データとその操作はモジュールにまとめます(第10章)。ledger/entry.lune:
module ledger.entry
type Entry =
| Income(label: String, amount: Int)
| Expense(label: String, amount: Int)
def signedAmount(e: Entry): Int =
match e:
| Income(_, amount) -> amount
| Expense(_, amount) -> 0 - amount
def labelOf(e: Entry): String =
match e:
| Income(label, _) -> label
| Expense(label, _) -> label
def validate(e: Entry): Result[Entry, String] =
if signedAmount(e) == 0 then Err("amount must not be zero: " + labelOf(e)) else Ok(e)
ここが設計の心臓部です。符号を型で持たないという判断をしました。Expense の amount は正の数で持ち、符号は signedAmount が付けます。こうすると「支出をマイナスで記録するのか?」という混乱が起きません — 型と関数のどちらに意味を持たせるかの、小さいけれど本質的な選択です。
validate は失敗を値として返します(第5章)。例外を投げないので、呼び出し側は失敗を無視できません。ledger_main.lune:
module ledger_main
import ledger.entry
let entries = [Income("salary", 3000), Expense("rent", 1200), Expense("food", 400)]
let balance = fold(map(entries, signedAmount), 0, fn a x -> a + x)
let expenses = filter(entries, fn e: Entry -> signedAmount(e) < 0)
let rejected = validate(Expense("zero", 0))
let accepted = validate(Income("bonus", 500))
$ lune --eval balance ledger_main.lune
1400
$ lune --eval expenses ledger_main.lune
(Expense("rent", 1200) Expense("food", 400))
$ lune --eval rejected ledger_main.lune
Err("amount must not be zero: zero")
$ lune --eval accepted ledger_main.lune
Ok(Income("bonus", 500))
「map で符号付き金額に変換して fold で畳む」— 第6章の集計パターンがそのまま効いています。filter で支出だけ取り出すのも同じ発想です。
ここで witness の恩恵を体験してください。Entry に3つ目のケース(たとえば Transfer)を足してみると、signedAmount と labelOf の両方で TYP0007 が出ます。「振替の符号をどうするか決めていない」ことを、コンパイラが漏れなく指摘してくれる — 第11章で学んだとおりです。仕様変更に強い設計とは、こういうことです。
v0.1 の注意 — ADT のコンストラクタは位置引数で呼びます(
Income("salary", 3000))。Income(label = "salary", amount = 3000)のように名前付きで書くとTYP0012で弾かれます。コンストラクタは部分適用できる(第3章)ので、Income(amount = 3000)は「第1引数が未充填の部分適用」を意味することになり、名前を対応させる先がないためです。名前付きが必須なのはレコードで、そちらは逆に位置引数がREC0006になります(第6章)— この2つはちょうど裏返しの関係です。
13.3 数列の実験室 — 無限リストと評価回数の観察
最後は遅延評価の総合演習です。コラッツ数列(偶数なら半分、奇数なら3倍して1を足す。1に到達するまで続く)を無限リストで作ります。
「偶数なら半分」を書くときは割り算の種類に注意します。/ は常に Double を返すので、n / 2 では Int になりません。整数のまま割るには床除算 // です(2.2 節)。
lune> 7 // 2
3 : Int
lune> 7 / 2
3.5 : Double
collatz.lune:
module collatz
# `//` は整数の床除算。`/` は常に Double を返すので、Int のままにするには `//`。
def next(n: Int): Int =
if n % 2 == 0 then n // 2 else 3 * n + 1
let fromSix = take(iterate(next, 6), 9)
let fromSeven = takeWhile(iterate(next, 7), fn n: Int -> n != 1)
$ lune --eval fromSix collatz.lune
(6 3 10 5 16 8 4 2 1)
$ lune --eval fromSeven collatz.lune
(7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2)
iterate(next, 6) は「6 から始まる無限のコラッツ列」です(第8章)。何個見るかは使う側が決める — take で9個、あるいは takeWhile で「1 になる直前まで」。数列の定義と、どこまで使うかの分離がここでも効いています。
そして最後に、遅延評価が本当に働いていることを数値で確かめます。tick()(第4章)を仕込んだ「重い」計算を用意し、5要素のリストから2個だけ取り出したとき、計算が何回走るかを測ります。counted.lune:
module counted
# tick() を仕込んだ「重い」計算。呼ばれた回数がカウンタに残る。
def costly(n: Int): Int =
seq tick() (n * 2)
let doubled = map([1, 2, 3, 4, 5], costly)
# 先頭 2 個だけを取り、中身まで評価する。
let firstTwo = deepForce take(doubled, 2)
# そのとき costly が何回走ったか。
let cost = seq firstTwo tickCount()
$ lune --eval firstTwo counted.lune
(2 4)
$ lune --eval cost counted.lune
2
5要素に map したのに、costly は2回しか走りませんでした。正格な言語なら5回走ってから2個を捨てるところです。第4章から追いかけてきた遅延評価の御利益が、ついに数字で出ました。
deepForce が必要な理由も味わっておいてください。take(doubled, 2) だけでは背骨(Cons の連なり)しか実体化せず、要素はサンクのままです — :trace で見ると Cons(<thunk>, <thunk>) で止まっているのが分かります(第8章の :thunks の観察と同じ景色)。「どこまで評価するか」を選べること自体が、Lune の設計思想の現れです。
13.4 3つのケースから学ぶこと
3本を並べると、共通の型紙が見えてきます。
- 形を型にする(レコードか ADT か — 直積か直和か)
- 不変な変換で書く(
map/filter/fold。命令的な部品が要るならブロックに閉じ込める) - 「ない」「失敗した」を値で表す(
T?/Result/ 単相の結果型) - コンパイラに漏れを見張らせる(match の網羅性、witness に導かれて埋める)
- 確かめる道具を自分で作る(
costlyとtick()のように。「速いはず」ではなく数字で確かめる)
そして、どのプログラムも lune fmt で整形し、lune --check を通してから完成としています(第12章の CI レシピ)。
演習問題
演習 13-1(★★) Stats に「最短の単語」を足してください。空のリストに対しても壊れないように。
解答
初期値を "" にすると「最短」が常に空文字列になってしまうので、「まだ無い」を null で表します(第7章)。
record Stats:
count: Int
totalChars: Int
longest: String
shortest: String?
def pickShortest(current: String?, w: String): String =
if current == null:
w
elif length(w) < length(current):
w
else:
current
$ lune --eval summary ex13-1.lune
{ count = 5, totalChars = 21, longest = "quick", shortest = "the" }
pickShortest の1行目で current == null を確かめた後は current が String に絞り込まれるので、length(current) が書けます(第7章の narrowing)。なお 1行形式の if ... then ... elif は書けません — elif はブロック形式専用です。
演習 13-2(★★) Entry に Transfer(label: String, amount: Int)(口座間の振替で、残高に影響しない)を足してください。コンパイラが何を要求してくるか観察すること。
解答
type に1行足すだけで、signedAmount と labelOf の両方に TYP0007(網羅的でない match)が出ます。signedAmount では | Transfer(_, _) -> 0(残高に影響しない)、labelOf では | Transfer(label, _) -> label を足せば通ります。
型を1箇所変えると、影響を受ける全箇所をコンパイラが挙げてくれる — これが _ -> ... のワイルドカードを避ける理由です(演習 5-1)。ワイルドカードで書いていたら、Transfer は黙って支出扱いになっていたかもしれません。
演習 13-3(★★) counted.lune の take(doubled, 2) を take(doubled, 5) に変えると cost はいくつになるでしょう。deepForce を外したらどうなるでしょう。予想してから確かめてください。
解答
5 になります(5要素すべてを評価するので)。deepForce を外すと cost は 0 — take は背骨だけ実体化し、要素のサンクには触らないからです。「リストがある」ことと「中身が計算済みである」ことは別、という第8章の教訓が数字で確認できます。
演習 13-4(★★★) -7 // 2 は -4 になります。-3 ではありません。なぜそうなるのかを、-7 % 2 の値と合わせて説明してください。まず予想し、REPL で確かめてから答えること。
解答
まず両方の値を見ます。
lune> -7 // 2
-4 : Int
lune> -7 % 2
1 : Int
// の丸めは負の無限大方向への切り下げ(floor)で、ゼロ方向への切り捨て(truncation)ではありません。-7 / 2 は -3.5 なので、floor すると -4 になります。
なぜ -3 ではないのか。// と % は無関係な2つの演算ではなく、組で辻褄が合っていなければならないからです。商と余りの関係は、どんな符号でもこの等式で結ばれています。
a == (a // b) * b + (a % b)
確かめてみます。
lune> (-7 // 2) * 2 + (-7 % 2)
-7 : Int
もし // がゼロ方向切り捨てで -3 を返したとすると、% の側(1)はそのままなので:
lune> (-3) * 2 + (-7 % 2)
-5 : Int
-7 に戻りません。つまり -3 を選ぶと // と % が互いに矛盾し、「商と余り」と呼べなくなってしまいます。% が除数の符号に合わせた余りを返す(-7 % 2 が -1 ではなく 1)と決めた時点で、// は floor でなければならないのです。片方だけを見て決められる話ではない、というのがこの演習の眼目です。
コラッツ数列には正の数しか出てこないので、next を書く限りこの違いは表に出ません。負の数を割るときに初めて効いてきます。丸め方向は documents/LANGUAGE_SPEC.md の 9.1 節に規定があります。
演習 13-5(★★★・総合) 3つのケーススタディのどれかを選んで拡張してください。例: テキスト統計に「n 文字以上の単語だけ」の絞り込みを足す / 家計簿に月ごとの集計を足す(record Month と filter の組み合わせ)/ コラッツ列の「1 に到達するまでの歩数」を求める関数を書く。
解答
例として3つ目(歩数)は、takeWhile の結果の length に1を足せば求められます。
lune> length(takeWhile(iterate(next, 7), fn n: Int -> n != 1)) + 1
17 : Int
無限リストに length を使っていますが、takeWhile が先に有限化しているので止まります(第8章 8.5節)。「無限のまま加工して、有限化してから消費する」— この順序を守れているかが、遅延評価を使いこなせているかの試金石です。
より正確には — この章は新しい機能を使っていません。各節が依拠する仕様は、レコードが documents/RECORD_FIELD_SPEC.md、ADT と match が documents/MATCH_EXHAUSTIVENESS_SPEC.md、リストと遅延コンビネータが documents/STANDARD_LIBRARY_SPEC.md §6、seq/deepForce が documents/LAZY_EVALUATION_SPEC.md §9–10。この章のコード例は books/examples/ch13/ にあり、すべて実際の CLI で検証されています。