この記事は ひとりアドベントカレンダーRosettaCodeで楽しむプログラミング Advent Calendar 2025の15日めの記事です。
これ目次 1 (いち)じゃなくて O (オー)に入れるんだ。
説明
いわゆる「ライフゲーム」の1次元版。
タスク
明確に示されていはいないが、適当な感じの初期配置を乱数で作って、安定するまでの様子を表示する感じ。
Rosetta CodeにあるHaskellコード
微妙に長い気がするコードを参照用にコピー
import Data.List (unfoldr)
import System.Random (newStdGen, randomRs)
bnd :: String -> Char
bnd "_##" = '#'
bnd "#_#" = '#'
bnd "##_" = '#'
bnd _ = '_'
nxt :: String -> String
nxt = unfoldr go . ('_' :) . (<> "_")
where
go [_, _] = Nothing
go xs = Just (bnd $ take 3 xs, drop 1 xs)
lahmahgaan :: String -> [String]
lahmahgaan xs =
init
. until
((==) . last <*> last . init)
((<>) <*> pure . nxt . last)
$ [xs, nxt xs]
main :: IO ()
main =
newStdGen
>>= ( mapM_ putStrLn . lahmahgaan
. map ("_#" !!)
. take 36
. randomRs (0, 1)
)
bndは、結局#になるパターンが3つあってそれ以外は空白になるから、それのパターンマッチをしている。わかる。
名前は birth and death だろうか。
長さの割に nxt はごちゃごちゃしている。
与えられた文字列の末尾に空白追加、先頭にも追加、そしてunfoldr goしている。
unfoldrは、全ての1文字ずらしでbndをした結果をリストにしている。
今なら Data.List.Split.divvy でできることを手書きするのはいいけど、そこに bnd の呼び出しも入れてしまうと、機能分割が残念。一つの関数には一つの責務だけを持たせるべき。Single Responsibility Principle.
-
bndはそのままで3文字ごとに切り出す部分を専門家に任せる
import Data.List.Split
nxt :: String -> String
nxt xs = map bnd $ divvy 3 1 ('_' : xs ++ "_")
-
bndを直してしまえ
一つのリストの前後3文字を取り出そうとするから何かおかしくなるので、
between f xs = zipWith f xs $ tail xs の精神で、これが正解かな。
nxt xs = zipWith3 bnd3 ('_':xs) xs (tail xs ++ "_")
bnd3 '_' '#' '#' = '#'
bnd3 '#' '_' '#' = '#'
bnd3 '#' '#' '_' = '#'
bnd3 _ _ _ = '_'
次。lahmahgaan という語は、ググっても何もわからなかった。このページしか出てこなかった。
関数の名前は内容がわかるようなものを付けろ、の精神はどこいった。
宿題でこれをパクったらすぐわかるようにするトラップとかか?
コードもなんだかpointfreeでオシャレすぎる。
lahmahgaan :: String -> [String]
lahmahgaan xs =
init
. until
((==) . last <*> last . init)
((<>) <*> pure . nxt . last)
$ [xs, nxt xs]
until の終了判定 ((==) . last <*> last . init) は、普通(ポイントアリー)に書けば
\xss -> last xss == last (init xss)
リストの末尾二つが同じ、つまり、オートマトンの世界が安定したとき。
until の実行処理 ((<>) <*> pure . nxt . last) も直すと
\xss -> xss ++ nxt (last xss)
つまり、全ての世代のリストを、時系列順に作り出しているその [String] 全体が until の状態だと。
そして、リストの後ろに付け足していく処理をHaskellでこんなにダサく書く方法があるとは知らなかった。
そもそも初期値が [xs] でない時点で厳しい。
こちらもやはり、「終わるまで続ける」と「次に進む」がいっしょくたなのがSRP違反。
そこからメスを入れるとこう。
runall xs = stableEnd $ iterate nxt xs
stableEnd (x:y:_) | x == y = [x]
stableEnd (x:xs) = x : stableEnd xs
main もどうしてそう、右下にだらだらと垂れ下がるの。
- 乱数で初期状態を作る
- 全ての世代の答えを計算する(純粋計算、本体)
- 全ての結果を表示する
に分けて、途中結果に名前を付ける方が分かりやすくないかなぁ?
main :: IO ()
main = do
fld <- map ("_#" !!) . take 36 . randomRs (0, 1) <$> newStdGen
let result = runall fld
mapM_ putStrLn result
他の言語では。
J
いつものように J はワケわからん。何だこれ。
life1d=: '_#'{~ (2 = 3+/\ 0,],0:)^:a:
11l
聞いたことない言語だけど、名前のアルファベット順で、このサイトだといつも先頭にある 11l という変態言語のコードのすみっこに、気になることが書いてあるぞ?
.... map(m -> Int(sum(:gen[m .+ 3]) == 2))
sum の結果が == 2 だったら?
なるほどつまり、bnd でするべき計算は、注目している3マスの中に # がちょうど2つなら結果は # だ、とやればいいということね。
リトライ
足してちょうど2戦法で全部をまとめ直してみる。
import System.Random
main :: IO ()
main =
do
fld <- take 36 . randomRs (0, 1) <$> newStdGen
let result = stableEnd $ iterate step fld
mapM_ (putStrLn . map ("_#" !!)) result
where
step xs = zipWith3 bnd3 (0:xs) xs (tail xs ++ [0])
bnd3 a b c = if a + b + c == 2 then 1 else 0
stableEnd (x:y:_) | x == y = [x]
stableEnd (x:xs) = x : stableEnd xs
これは決まりましたね。
延長戦:Jをかじってみた
特定の人に熱狂的に支持されているカルト的言語、APLとその子孫のJが気になったので、このコードを読み解いてみよう。
(Jの用語はなじみがないのでHaskell寄りの用語で説明します。J派の人はご容赦ください。)
life1d=: '_#'{~ (2 = 3+/\ 0,],0:)^:a:
これで life1d という名前に '_#'{~ (2 = 3+/\ 0,],0:)^:a: を代入している。
実行は life1d ? 20 # 2 のようにして起動する。
この # は二項演算子で、Haskellに翻訳すると \xs ys -> concat $ zipWith replicate xs ys になる。
右のリストの各要素を、左のリストの対応する要素の回数だけ繰り返して繋いだリストを作る。
そしてJでは、数が1つだけ書いてあるように見えても、それは長さ1のリストと解釈されるので、結局 20 # 2 は replicate 20 2 に相当し、2を20個並べたリストができる。
Jでは演算子の優先順位がなく、すべて右から解釈を進めるため、次はこの 20 # 2 の結果が ? にかけられる。
この ? は単項演算子で、整数 $n$ が指定されたとき $[0,n)$ の範囲の整数で乱数生成する。
そして、単独要素を処理する演算子はリストに対して勝手に map されるので、0または1をとる乱数生成が20回行われ、結果もリストになる。ということで1次元セルオートマトンの初期状態が作成できた。
それが life1d に渡される。
life1d に入って最初に考えるのは (..略..)^:a: の部分。丸括弧は普通と同じ意味。
そして ^:a: はこれで「右の値を左の関数に繰り返し適用し、その全ての結果をリストにする。結果が収束したら(連続して同じ値になったら)終了する」という高階関数らしい。
関数適用を繰り返す、はHaskellでは iterate だし、収束したら終わる、は上で書いた stableEnd がそうなので、上の runall が完全に一致する。
すると (..略..) の部分には、1ステップの処理が書いてあるはずだ。
中に入る。(..略..) 全体は、0と1のリストで表される状態が右から与えられる無名の単項演算子である。
(ここから少し怪しい)
最初に ],0: が出迎える。
-
]は、単項演算子のとき、(右)引数をそのまま返すid相当の動作をする -
,は、両側を繋いだリストを作る(++)的な二項演算子 -
0:は、引数を無視して0を返すconst 0相当の単項演算子
そして、単項演算子、二項演算子、単項演算子と並ぶと fork というものになり、(f g h) y は (f y) g (h y) と計算される。
つまり、\xs -> (id xs) ++ (const [0] xs) という感じになって、結局 \xs -> xs ++ [0] をしているようだ。
さらにその結果に 0, が行われて 0 : xs ++ [0] ができる。
これは使わなかった方の nxt の ('_' : xs ++ "_") に完全一致している。
(少し怪しいのここまで)
次は3引数の高階関数 \ で、x f \ y は、「リスト y を長さ x ずつに切って、それぞれに f を適用する」とある。
\x f y -> concatMap f $ divvy x 1 y という感じか。
そして f に当たるのが +/ で、これは高階関数 / を二項演算子 + にかけたもので、+ の方は普通の意味、/ は foldl1 いわゆる reduce で、つまり +/ は Haskell の sum。
また x は普通に 3 が使われて、結局
3+/\
は
map sum . divvy 3 1
ということらしい。
最後に 2 = は普通に、二項演算子 = の左側が 2、右側が今作った和のリスト、である。
Jでは、二項演算子の左右のリストは同じ長さであることが求められるが、特例として、片方が長さ1のときは勝手に repeat する、までが基本動作である。その原則をそのまま解釈すると zipWith (==) (repeat 2) だが、map (2 ==) と考えた方がいいね。あと bool 0 1 もしないとだけど。
ともかく、3つずつ切り出して足して結果が2なら1、さもなくば0、上でいうところの map bnd3 . divvy 3 1 で、ほぼ nxt だこれ。
結局 (2 = 3+/\ 0,],0:) は、確かに1ステップ進める処理であることがわかった。
これで、状態が収束するまでステップ処理が繰り返された、状態のリストが得られた。
それが '_#'{~ に渡される。
高階の ~ は二項演算子の引数をひっくり返す、要は Haskell の flip である。
{ は、単独での機能は Haskell の (!!) で、リストを添え字アクセスして要素を取り出す。
普通は右にリスト、左に添え字を与える。
ここでは ~ でひっくり返されているので、左にある文字列 '_#' の0文字めか1文字めかが右引数で指定されて取り出される。
さらに、勝手にmapする性質によって、全ての世代の、全てのセルについて文字を選択する操作が適用されて、内部表現であった 0 と 1 のリストから、文字表現に変換する操作が全体に一気に行われる。
これで、Jプログラムの内容は完全に解明された。
感想
APLの、何と発音していいのかもわからない記号で演算を表すのも閉口したけど、ASCII記号列に替えたJも、それはそれで紛らわしくてつらい。HaskellでいうとLensに対する批判が似たような感じか。
同じ記号が、単項演算子として使われるとき、二項演算子として使われるとき、という文脈で解釈が変化するのも、それをいちいち判断しないと読めないので辛い。
上の内容では例えば、3 のない +/\ は、\ が右のリストを inits した結果に左の関数(+/ つまり sum)を map する高階関数なので map sum . inits になって、普通に書くと scanl1 (+) となるものになる。
あの時代に、ユーザ定義の関数も含めて、高階関数で挙動を制御できたのは確かに衝撃だっただろう。
基本データ型が多次元配列で、mapが基本動作の一部であるというのも、強大な力感がある。
ただ、基本動作の暗黙の了解が多すぎ、また独特すぎて、一般受けしないのは自明に思える。
こういう計算を日常的にする分野の人には、確かに刺さったのだろう。
(APLの記号を全部漢字一文字に置き換えたジョーク言語を作ったら受けるだろうか、むしろASCIIしか使わないJより便利になるのでは?という下心で調べ始めたが、思いのほか「暗黙の了解」が自分のメンタルモデルとソリが合わず、作っても自分が使いそうにないので企画はお蔵入りにして、調べた成果の供養としてここに書き残す。)