2025/1/18 Haskellで抽象的なプログラムを書く練習をする @λ Kansai
2025/1/18 λ Kansai in Winter 2025の発表ネタ
/mrsekut-merry-firends/2025/1/18 Haskellで抽象的なプログラムを書く練習をする @λ Kansai
自己紹介
略
お断り
まだ十分に整理できておらず、感覚的な表現が多い内容になっています…。mrsekut.icon
終始よくわからなかったらすみません()
どんな些細な指摘や感想も大歓迎です!
言語化と整理のきっかけになります
よろしくお願いします!
良い抽象を発見するチカラを磨きたい!
我々は、日々複雑な問題に立ち向かっている
現実の複雑な概念を、どうにかプログラムに落とし込む
対象の問題を観察し、抽象を発見し、深く理解し、モデル化し、プログラムとして記述する
この一連の作業には、時にヒラメキも必要になる
とはいえ、なにかしらのコツがあるはずなので練習したい
抽象的なプログラムを書くとは?
(まだ上手く言語化できてません...。ツッコミ歓迎ですmrsekut.icon)
逆に、具体性が高いプログラムのイメージは
配列のインデックスアクセス
ワークアラウンドな条件分岐
その問題でしか使えないような関数を用意したり
「プログラミング」をいかに条件分岐を減らすように実装するかという営みと捉えている節があるmrsekut.icon
練習するための良い感じの題材を探す
https://gyazo.com/a078915fb45c32719e58e535b0ca1278
Exercismというプログラミング学習サイトを使うことにした
「えくささいずむ」?mrsekut.icon
練習問題が用意されている
Easy, Medium, Hardの3段階の難易度
たくさんの言語をサポートしている
今は76個あるらしい (2025/1/18現在)
いわゆる「関数型言語」をピックアップするとこんな感じ
Haskell
Clojure
Elixir
Erlang
F#
OCaml
PureScript
ReasonML
Standard ML
Unison
Unisonあるのアツいmrsekut.icon
Elm
Gleam
Common Lisp
Scheme
Racket
Scala
なんでExercismを使う?
この辺が好みmrsekut.icon
アルゴリズムの知識とか要求されない
点数とかつけて人と競ったりしない
(類似のサイトも色々あるので、別にExercismじゃなくてもいいと思う)
ExercismをHaskellで解いている
https://exercism.org/tracks/haskell
https://gyazo.com/2b08332bde784c962d75ae276389599e
なぜHaskellで解く?
Haskellはコミュニティの性質上、抽象化周りの知見がかなり溜まっている
Excerismは他の人の回答も見れるが、Haskellでは、その質も良くて参考になる
コードを書くことで、抽象化の評価のフィードバックが得られる
めちゃくちゃ具体的な関数を用意しないと書けない場合は、モデル化がミスってるな、と気付ける
Haskellは概念モデルのミスに気付くヒントが多い
例えば、何か簡単な問題を与えられた時に、
どういう実装にするか頭の中で考え、
その後、プログラムとして記述する
ここで、Haskellの標準ライブラリの関数を眺めたりするわけだが、
前段のモデル化がうまくいってると、それらの関数の組み合わせで自然に実装できる体感があるmrsekut.icon
実際の問題を2つ紹介します
問題 Diamond
レベルはEasy
アルファベット一つを入力として受け取り、ダイヤモンドの形状の文字列リストを返す関数を作る
code:hs
diamond :: Char -> Maybe String
(Maybeは気にしなくて良いです)
動作例を見るのがわかりやすい
code:diamond 'A'の出力
"A"
code:diamond 'B'の出力
[" A ",
"B B",
" A "]
code:diamond 'D'の出力
[" A ",
" B B ",
" C C ",
"D D",
" C C ",
" B B ",
" A "]
問題自体は簡単そうじゃないですか?mrsekut.icon
色々な方針が考えられる
こんな単純な問題でもぜんぜん異なる方針が考えられて面白いmrsekut.icon
code:diamond 'D'の出力の再掲
[" A ",
" B B ",
" C C ",
"D D",
" C C ",
" B B ",
" A "]
上から1行ずつ生成していく
Aは1番目で、Dは4番目なので、その差である3個のスペースを前後に入れて、...
Aの行は、1行目と、7行目で、
これを一般化すると、....
中央(D)から作って、先頭と末尾に追加していく
Dの行 → 前後にCの行を追加 → 更に前後にBの行を追加 → ...
"D D"の間のスペースは、5個で、...
この辺の解法が、「具体的になりそう」な感覚、何となく伝わるでしょうか...?mrsekut.icon
図形のパターンに着目した解法を考えてみた
https://gyazo.com/7555122c9374fab1ea673f6130439dbf
①まず、(どうにかして)左上の階段を生成し、
②次に、左右対称に鏡写しにし、
③最後に、上下対称に鏡写しにする
コードはこんな感じ
code:hs
import Data.Char (isAlpha, ord, toUpper)
diamond :: Char -> Maybe String
diamond c
| isAlpha c = Just . mirror . map mirror . stairs $ c -- ポイント1
| otherwise = Nothing
stairs :: Char -> String
stairs endChar = foldl go [] 'A'..endChar
where
go acc c = map (pad 1 ++) acc ++ c : pad (length acc)
pad n = replicate n ' '
mirror :: a -> a
mirror xs = init xs ++ reverse xs -- ポイント2
ポイントは2つ
Just . mirror . map mirror . stairs $ cの部分が、先程の①②③と対応して読みやすい
Just . ③ . ② . ①という関数を作ってる
抽象度を揃えて書く
mirrorは多相に定義されており、列にも行にも使えている
①では、mirror :: [Char] -> [Char]であり
②では、mirror :: [String] -> [String]である
作る過程としては
上下左右対称やん
鏡写しにできるやん
鏡写しにする関数は、縦にも横にも使いたいなあ
もし、縦向きと横向きを個別に書いていると「具体性が高い」感があるmrsekut.icon
Haskellでそれを自然にプログラムとして記述する
問題 Game of Life
レベルはmedium
ライフゲームの部分的な実装をする
ライフゲームとは
https://gyazo.com/906db63f4b2f9d404b6a5c894332fa66 https://ja.wikipedia.org/wiki/%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0
数学者のJohn Conwayが考案したセルオートマトンの一種
シンプルなルールに従って、セルが時間とともに変化する
セルは生きていたら1で、死んでいたら0
次の世代へのルール
誕生(0→1)
セルが0で、周囲に1のセルがちょうど3つある場合
生存(1→1)
セルが1で、周囲に1のセルが2つまたは3つある場合
それ以外のケース
0 → 0
1 → 0
問題
盤面が渡されるので、次の世代の盤面を返す関数を作る
code:hs
tick :: Int -> Int
動作の例
例1
code:_
-- 入力
[
1, 0, 1,
1, 0, 1,
1, 0, 1
]
-- 出力
[
0, 0, 0,
1, 0, 1,
0, 0, 0
]
例2
code:_
-- 入力
[
1, 1, 0, 1, 1, 0, 0, 0,
1, 0, 1, 1, 0, 0, 0, 0,
1, 1, 1, 0, 0, 1, 1, 1,
0, 0, 0, 0, 0, 1, 1, 0,
1, 0, 0, 0, 1, 1, 0, 0,
1, 1, 0, 0, 0, 1, 1, 1,
0, 0, 1, 0, 1, 0, 0, 1,
1, 0, 0, 0, 0, 0, 1, 1
]
-- 出力
[
1, 1, 0, 1, 1, 0, 0, 0,
0, 0, 0, 0, 0, 1, 1, 0,
1, 0, 1, 1, 1, 1, 0, 1,
1, 0, 0, 0, 0, 0, 0, 1,
1, 1, 0, 0, 1, 0, 0, 1,
1, 1, 0, 1, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 1, 1
]
ルールの適用は別に難しくない
ルールをそのまま書けばいいだけ
ルール (再掲)
誕生(0→1)
セルが0で、周囲に1のセルがちょうど3つある場合
生存(1→1)
セルが1で、周囲に1のセルが2つまたは3つある場合
それ以外のケース
0 → 0
1 → 0
code:hs
conway :: (Int,Int) -> Int
conway (0,n)
| n == 3 = 1
| otherwise = 0
conway (1,n)
| n == 2 = 1
| n == 3 = 1
| otherwise = 0
各セルの周囲の和を求めるところが難しい
人間の目から見れば、「周囲」は認識しやすいが、プログラムで記述するには工夫が必要
そこで、「自分自身も含めた周囲の和を求める関数を作ること」を目的にする
code:hs
neighborhoodSum :: Int -> Int
code:入力
[
1, 0, 1,
1, 0, 1,
1, 0, 1
]
code:出力
[
2, 4, 2,
3, 6, 3,
2, 4, 2
]
色々な方針が考えられる
愚直にやるなら、indexを使って周囲の要素を取得するとか...
code:イメージ(hs)
getNeighborhood grid index numCols = undefined
where
topLeft = grid !? (index - numCols - 1)
topCenter = grid !? (index - numCols)
topRight = grid !? (index - numCols + 1)
midLeft = grid !? (index - 1)
self = grid !? index
midRight = grid !? (index + 1)
botLeft = grid !? (index + numCols - 1)
botCenter = grid !? (index + numCols)
botRight = grid !? (index + numCols + 1)
周囲を取得する関数をそれぞれ定義する
(!?) :: [a] -> Int -> Maybe aはindex accessする関数
今回の問題のポイントは
「周囲の和」が必要なだけであって、「周囲の個別の値」は重要でない点、というのはありそうmrsekut.icon
「周囲の和」が3なのか4なのかは重要だが、どこが0でどこが1かは重要ではない
座標で周囲の値を取得するのは具体的すぎ?
要素の位置で分類することもできそう
code:入力
[
1, 0, 1,
1, 0, 1,
1, 0, 1
]
隅
隣接するものが3つ
辺
隣接するものが5つ
中心
隣接するものが8つ
これら、3つのパターン用にロジックを書く必要がある...?
処理前に全体を0で囲めば、全てを中心として扱える
条件分岐を減らせる!mrsekut.icon
code:入力
[
0, 0, 0, 0, 0,
0, 1, 0, 1, 0,
0, 1, 0, 1, 0,
0, 1, 0, 1, 0
0, 0, 0, 0, 0,
]
次元を減らして考えられないか?
例えば、1次元のリストで考えてみる
code:_
1, 0, 1
「上」「下」がなくなり、「左」「右」だけになる
後で、これを良い感じに2次元にも使えるように一般化できると嬉しい
これでやってみる
今回はサイズ3のウィンドウをスライドすることにしようmrsekut.icon
1次元のリストで周囲の和を求める関数
入力を
code:_
1, 0, 1
① 0で囲んで
code:_
0, 1, 0, 1, 0
②サイズ3のウィンドウをスライドする
code:_
0, 1, 0], 1, 0, 1, [0, 1, 0
③それぞれの和を求める
code:_
1, 2, 1
コードにするとこんな感じ
細かいところは理解しなくて大丈夫ですmrsekut.icon
smoothColsが上記の①~③に対応している
code:hs
-- >>> smoothCols 1, 0, 1
-- 1,2,1
smoothCols :: Int -> Int
smoothCols = map (\a,b,c -> add3 a b c) . windows3 . padding 0
-- >>> padding 0 1,2,3
-- 0,1,2,3,0
padding :: Int -> Int -> Int
padding border xs = border : xs ++ border
-- >>> windows3 1,2,3,4,5
-- 1,2,3],2,3,4,[3,4,5
windows3 :: Int -> Int
windows3 xs = [ x, y, z | (x:y:z:_) <- tails xs ]
add3 :: Int -> Int -> Int -> Int
add3 a b c = a + b + c
上記と全く同じことを列方向にも適用したい
https://gyazo.com/b3d2a3bf0effc69c8f4133688684e9a5
さっきの関数を使えないだろうか..?
列方向用の関数を用意したくないmrsekut.icon
paddingとかやってることは同じはず
さっきの関数を一般化する
code:元のやつ(hs)
padding :: Int -> Int -> Int
code:一般化した(hs)
padding :: a -> a -> a
型引数をIntからaにしただけ
これで、aに、Intも[Int]も入れられるようになる
試しにpaddingを列方向に適用してみる
code:format
padding 0,0 1,2], [3,4
-- 入力
[
1,2,
3,4
]
-- 出力
[
0,0,
1,2,
3,4,
0,0
]
型引数を変えるだけで列方向にも使えるようになったmrsekut.icon
windows3も同じようにする
code:元のやつ(hs)
windows3 :: Int -> Int
code:一般化した(hs)
windows3 :: a -> a
code:format
windows3 1,2],3,4,5,6,[7,8
-- 入力
[
1,2,
3,4,
5,6,
7,8
]
-- 出力
[
[
1,2,
3,4,
5,6
],
[
3,4,
5,6,
7,8
]
]
列方向の周囲の和を求める関数を完成させる
code:hs
-- >>> smoothRows 1, 0, 1],1, 0, 1,[1, 0, 1
-- 2,0,2],3,0,3,[2,0,2
smoothRows :: Int -> Int
smoothRows = map (\a,b,c -> zipWith3 add3 a b c) . windows3 . padding 0,0..
① 0で囲んで
②サイズ3のウィンドウをスライドする
③それぞれの和を求める
行と列を並べてみる
code:行(hs)
smoothCols :: Int -> Int
smoothCols = map (\a,b,c -> add3 a b c) . windows3 . padding 0
code:列(hs)
smoothRows :: Int -> Int
smoothRows = map (\a,b,c -> zipWith3 add3 a b c) . windows3 . padding 0,0..
かなり対応が取れているのがわかる
列と行の両方を適用すれば、周囲の和が得られる
code:hs
neighborhoodSum :: Int -> Int
neighborhoodSum = map smoothCols . smoothRows
code:_
-- 入力
[
1, 0, 1,
1, 0, 1,
1, 0, 1
]
-- 出力
[
2, 4, 2,
3, 6, 3,
2, 4, 2
]
いい感じに整理して完成
(元の問題はライフゲームの次の世代の盤面を返す関数の作成でしたね)
code:hs
import Data.List (tails)
tick :: Int -> Int
tick xss = map (map conway) $ zipWith zip xss neighbors
where
-- 周囲9つの合計から自身を引いたもの
neighbors = zipWith (zipWith (-)) (neighborhoodSum xss) xss
conway :: (Int,Int) -> Int
conway (0,n)
| n == 3 = 1
| otherwise = 0
conway (1,n)
| n == 2 = 1
| n == 3 = 1
| otherwise = 0
neighborhoodSum :: Int -> Int
neighborhoodSum = map smoothCols . smoothRows
where
smoothCols = map (\a,b,c -> add3 a b c) . windows3 . padding 0
smoothRows = map (\a,b,c -> zipWith3 add3 a b c) . windows3 . padding 0,0..
padding :: a -> a -> a
padding border xs = border : xs ++ border
windows3 :: a -> a
windows3 xs = [ x, y, z | (x:y:z:_) <- tails xs ]
add3 :: Int -> Int -> Int -> Int
add3 a b c = a + b + c
その他のアイディア
列方向は、単純に2回transposeしてもいい
code:hs
import Data.List (transpose)
smoothRows = transpose . map smoothCols . transpose
transposeは転置する関数mrsekut.icon
https://gyazo.com/d35cc70853a0b86b4877f5abecdd072a
3 * 3版のwindows3を作るとか
code:hs
windows33 :: a -> a
windows33 = map (map concat . transpose . map windows3) . windows3
https://gyazo.com/6690f53b0530fa27addab00006edd441
2次元リストの全要素の周囲の和を求める#676c07e51982700000855249に書いたmrsekut.icon
入力と回答は全く同じサイズのリストなので、頑張ればFunctorを定義したりできそう
しらんけど
何となく伝わりましたでしょうか?
「このプログラムは具体性が高いな」という感覚
こんな感じで「抽象的なプログラム」を書くことの練習をしてるmrsekut.icon
取り組む時に意識してること
全く異なるアプローチの代替案を考える
抽象の発見というのは、ヒラメキ要素も多い
数日かけて考える
今日思いつかなくても、明日思いつくかも
AIに頼らない
AIに頼るのではなく、自分で発見する
業務でプログラム書いてる人なら、問題を解くこと自体は簡単にできるはずmrsekut.icon
計算量は意識しすぎなくていい
「抽象の発見の練習」が目的なので。
パターンを収集する
0から発想するのは難しいが、既に知っているパターンは応用が効くかもしれない
まとめ
抽象的なプログラムを書くことは、概念のパターンを発見すること
「具体性が高い」ということに感づくのがまず先
Haskellはこの練習に適している
コミュニティ、標準ライブラリに知見が蓄積している
Excerismは難易度やサイズが練習に丁度良い
一つの問題もいろんな方向性で解いてみよう
おまけ
Haskellの環境構築は、Devboxを使うのが圧倒的に楽だったので共有
DevboxでHaskellプロジェクトを作る