2025/1/18 Haskellで抽象的なプログラムを書く練習をする @λ Kansai
自己紹介
mrsekut.icon
mrsekut (まる)
Haskellが好きです!
コメント、感想、指摘、補足など、(いつでも)このページに直接書き込んでもらえると嬉しいですmrsekut.icon
お断り
まだ十分に整理できておらず、感覚的な表現が多い内容になっています…。mrsekut.icon
終始よくわからなかったらすみません()
どんな些細な指摘や感想も大歓迎です!
言語化と整理のきっかけになります
よろしくお願いします!
良い抽象を発見するチカラを磨きたい!
我々は、日々複雑な問題に立ち向かっている
現実の複雑な概念を、どうにかプログラムに落とし込む
対象の問題を観察し、深く理解し、抽象を発見し、モデル化し、プログラムとして記述する
この一連の作業には、時にヒラメキも必要になる
とはいえ、なにかしらのコツがあるはずなので練習したい
抽象的なプログラムを書くとは?
(まだ上手く言語化できてません...。ツッコミ歓迎ですmrsekut.icon)
逆に、具体性が高いプログラムのイメージは
配列のインデックスアクセス
ワークアラウンドな条件分岐
その問題でしか使えないような関数を用意したり
/icons/hr.icon
~3:30
練習するための良い感じの題材を探す
https://gyazo.com/a078915fb45c32719e58e535b0ca1278
「えくささいずむ」?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://gyazo.com/2b08332bde784c962d75ae276389599e
なぜHaskellで解く?
Haskellはコミュニティの性質上、抽象化周りの知見がかなり溜まっている
Excerismは他の人の回答も見れるが、Haskellでは、その質も良くて参考になる
コードを書くことで、抽象化の評価のフィードバックが得られる
めちゃくちゃ具体的な関数を用意しないと書けない場合は、モデル化がミスってるな、と気付ける
例えば、何か簡単な問題を与えられた時に、
どういう実装にするか頭の中で考え、
その後、プログラムとして記述する
ここで、Haskellの標準ライブラリの関数を眺めたりするわけだが、
前段のモデル化がうまくいってると、それらの関数の組み合わせで自然に実装できる体感があるmrsekut.icon
実際の問題を2つ紹介します
/icons/hr.icon
~8:00
レベルはEasy
アルファベット一つを入力として受け取り、ダイヤモンドの形状の文字列リストを返す関数を作る
code:hs
diamond :: Char -> Maybe String (Maybeは気にしなくて良いです)
動作例を見るのがわかりやすい
code:diamond '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
where
pad n = replicate n ' '
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でそれを自然にプログラムとして記述する
レベルはmedium
ライフゲームの部分的な実装をする
/icons/hr.icon
~14::00
ライフゲームとは
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:_
-- 入力
[
]
-- 出力
[
]
例2
code:_
-- 入力
[
]
-- 出力
[
]
ルールの適用は別に難しくない
ルールをそのまま書けばいいだけ
ルール (再掲)
誕生(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:入力
[
]
code:出力
[
]
色々な方針が考えられる
愚直にやるなら、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:入力
[
]
隅
隣接するものが3つ
辺
隣接するものが5つ
中心
隣接するものが8つ
これら、3つのパターン用にロジックを書く必要がある...?
処理前に全体を0で囲めば、全てを中心として扱える
条件分岐を減らせる!mrsekut.icon
code:入力
[
]
次元を減らして考えられないか?
例えば、1次元のリストで考えてみる
code:_
「上」「下」がなくなり、「左」「右」だけになる
後で、これを良い感じに2次元にも使えるように一般化できると嬉しい
これでやってみる
今回はサイズ3のウィンドウをスライドすることにしようmrsekut.icon
/icons/hr.icon
~21::30
1次元のリストで周囲の和を求める関数
入力を
code:_
① 0で囲んで
code:_
②サイズ3のウィンドウをスライドする
code:_
③それぞれの和を求める
code:_
コードにするとこんな感じ
細かいところは理解しなくて大丈夫ですmrsekut.icon
smoothColsが上記の①~③に対応している
code:hs
smoothCols = map (\a,b,c -> add3 a b c) . windows3 . padding 0 padding border xs = border : xs ++ border 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)
code:一般化した(hs)
型引数をIntからaにしただけ
これで、aに、Intも[Int]も入れられるようになる
試しにpaddingを列方向に適用してみる
code:format
-- 入力
[
]
-- 出力
[
]
型引数を変えるだけで列方向にも使えるようになったmrsekut.icon
windows3も同じようにする
code:元のやつ(hs)
code:一般化した(hs)
code:format
-- 入力
[
]
-- 出力
[
[
],
[
]
]
列方向の周囲の和を求める関数を完成させる
code:hs
-- >>> smoothRows 1, 0, 1],1, 0, 1,[1, 0, 1 smoothRows :: Int -> Int
smoothRows = map (\a,b,c -> zipWith3 add3 a b c) . windows3 . padding 0,0.. ① 0で囲んで
②サイズ3のウィンドウをスライドする
③それぞれの和を求める
行と列を並べてみる
code:行(hs)
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:_
-- 入力
[
]
-- 出力
[
]
いい感じに整理して完成
(元の問題はライフゲームの次の世代の盤面を返す関数の作成でしたね)
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 border xs = border : xs ++ border windows3 xs = [ x, y, z | (x:y:z:_) <- tails xs ] add3 :: Int -> Int -> Int -> Int
add3 a b c = a + b + c
その他のアイディア
code:hs
import Data.List (transpose)
smoothRows = transpose . map smoothCols . transpose
transposeは転置する関数mrsekut.icon
https://gyazo.com/d35cc70853a0b86b4877f5abecdd072a
3 * 3版のwindows3を作るとか
code:hs
windows33 = map (map concat . transpose . map windows3) . windows3
https://gyazo.com/6690f53b0530fa27addab00006edd441
入力と回答は全く同じサイズのリストなので、頑張ればFunctorを定義したりできそう
しらんけど
何となく伝わりましたでしょうか?
「このプログラムは具体性が高いな」という感覚
こんな感じで「抽象的なプログラム」を書くことの練習をしてるmrsekut.icon
/icons/hr.icon
~27::00
取り組む時に意識してること
抽象の発見というのは、ヒラメキ要素も多い
数日かけて考える
今日思いつかなくても、明日思いつくかも
AIに頼らない
AIに頼るのではなく、自分で発見する
業務でプログラム書いてる人なら、問題を解くこと自体は簡単にできるはずmrsekut.icon
計算量は意識しすぎなくていい
「抽象の発見の練習」が目的なので。
パターンを収集する
0から発想するのは難しいが、既に知っているパターンは応用が効くかもしれない
まとめ
抽象的なプログラムを書くことは、概念のパターンを発見すること
「具体性が高い」ということに感づくのがまず先
Haskellはこの練習に適している
コミュニティ、標準ライブラリに知見が蓄積している
Excerismは難易度やサイズが練習に丁度良い
一つの問題もいろんな方向性で解いてみよう
おまけ
Haskellの環境構築は、Devboxを使うのが圧倒的に楽だったので共有
発表の感想
13人ぐらい?の前で話した
終わった時に31分ぐらいだったので、地味に1分ぐらいオーバーしてた
発表しているときはウケは分かりづらかったけど、Twitterではみんなコメントしてくれてて嬉しかった
自分ももっと人の発表の感想を投げるようにしようmrsekut.icon
数名、頷きながら聞いてくれる人がいたので、その人達を見るようにしてた
ただ、発表の内容上、画面を凝視する必要がある部分も多かったのが難しかった
マウスじゃなくてスクリーンを指すようにすればよかったのかも?
他の人の発表で、発表者の熱量が会場に伝わって、全体が盛り上がってたの良かった
ああいう発表をできると楽しそう
ライフゲームの説明のところ
愚直の実装で9パターンを個別に書いて、
隅、辺、中心の3パターンに減らせて、
0paddingすることで、1パターンにできる
という風に、具体的に数字を提示したほうが
インパクトがありつつ、
抽象していってる雰囲気がわかりやすかったのでは、
と帰って風呂に入っている時に思ったmrsekut.icon
発表後の質疑で、「Haskellは抽象的に書けるが、抽象的に書けすぎる問題はどう考えているか」という質問をもらった
その場では、良い感じに命名する、といったことを答えた
これまた風呂に入っている時に思ったが、
自分がプログラムで表現する上で真に目指しているものって、
「頭の中にあるモデル」と「プログラム」をどれだけ一致させられるか、
というのが大きい
頭の中のモデルにはfor文のような機構はないし、
①②③と考えたモデルをmirror . map mirror . stairsで表現できているのは良い
(ノイズが.ぐらいしかない)
だから、抽象化しすぎて良くわからない、という状態は、汚いコードと同等にダメと言える
結局、コードというのは、未来の自分や他のメンバーとのコミュニケーションのツールなので、対象者に伝わらなければ意味がない