この記事は一休.com Advent Calendar 2025の 5日目の記事です。
私は毎年この時期になると Haskell に関する記事を投稿していますが、今年もまた Haskell を題材にしつつ、今回は Haskell を使うことがプログラミング中の思考にどのような影響を与えるかについて考察してみようと思います。
LLM と「言葉が思考を形づくる」という直感
LLM (Large Language Models、大規模言語モデル) は次にくる言葉を予測しているだけなのに、それが知性のように見える。「言葉の推定」でプログラミングすらできてしまうという事実に誰もが驚いたところだと思います。
LLM を本当の知性とみなすかどうかは議論の分かれるところだと思いますが、LLM の原理をみるに、少なくとも「言語」が推論や思考の形式に深く影響するという直感は正しいのではないかと思います。
ところで「言語」といえば、我々はプログラミング言語を用います。「プログラミング言語」がその名の通り「言語」なら、プログラミング言語もまた思考に影響を与えるのではないか、そんなことを思います。
ChatGPT に「プログラミング言語が思考に影響を与えるなら、使うプログラミング言語を変えると自分の思考が変わるということはありそうですね」と尋ねてみたところ、以下のような返答が帰ってきました。

「Haskell は言語ではなく哲学」笑
いやあ、さすがにそれは大袈裟すぎるだろうとは思いつつ「思考が "操作" ではなく"意味" を軸に処理されるようになる」という点については、頷けるところがあります。
半年前の『関数型まつり』でも、競技プログラミングとアルゴリズムを題材にこの話を少し紹介しました。
Haskell でアルゴリズムを抽象化する / 関数型言語で競技プログラミング - Speaker Deck
今回はその中の一部、Haskell を使うとプログラムを見る目・・・メンタルモデルが変わるよ、という話をしてみたいと思います。
map と fold:再帰構造をどう“見る”かが思考を変える
一般の命令型言語の場合、値やデータ構造は基本的に書き換えが可能です。それを命令によって書き換えながら望む結果を得る、という考え方でプログラムを構成します。
例えば 1 から n までの和を取りたい場合、以下のように書くことができます。
int total = 0; for (int i = 1; i <= n; i++) { total += i; }
変数に値を代入するという操作を通じて、値を書き換えます。それを for ループで繰り返し操作します。操作が終わったところで total 変数の値は、目的の 1 から n までの和になっているはずです。
Haskell の場合、変数は基本的に書き換えることができません。値の書き換えのような「操作」で計算を構成するのではなく「値に関数を再帰的に適用する」ことで計算を構成します。
f acc [] = acc f acc (x : xs) = f (acc + x) xs -- f 関数を再帰 main :: IO () main = do ... print $ f 0 [1 .. n]
しかし何かプログラムを構成するたびに再帰をイチから書くのはプリミティブすぎます。Haskell には fold (畳み込み) や map (写像) のような、再帰構造を一般化した基本操作が用意されています。
先の和は foldl' を使うことで以下のように書けます。
print $ foldl' (+) 0 [1 .. n]
命令型言語で配列やリストの各要素を変換したいとき、やはり値を書き換えるという操作が中心になります。たとえば、1 から n までの整数それぞれに 1 を足した新しい配列を作る場合は以下のように書くことができます。(ChatGPT に書かせました)
vector<int> xs; xs.reserve(n); for (int i = 1; i <= n; i++) { xs.push_back(i + 1); // 値を書き換えて格納する }
Haskell では、やはり値の書き換えという操作ではなく、「各要素を変換する」という計算を map (写像) で表現します。
print $ map (+1) [1 .. n]
命令型言語では for 文や代入文、配列、if 文というプリミティブな操作で、多くのことができるのはみなさんご存知の通りです。 それと同じく Haskell では map や fold (と filter など) で、同様に、多くのことができます。
プログラミング言語におけるプリミティブな構文要素が異なる。これが、命令型言語と Haskell のような関数型言語の大きな違いです。
「動き」ではなく「意味」でプログラムを捉える
map や fold の「意味」はそれぞれ「写像」や「畳み込み」です。
慣れないうちは map や fold を、命令型プログラミングの for 文そのほか同様に動きで捉えてしまって、つい頭の中で値が再帰的に変換されていく様子をシミュレートしてしまうかしれません。
しかし、ある程度書き慣れてくると let xs' = map (+1) [1 ..n] という記述は「xs' は [1 .. n] というリストの写像だ」と、その意味そのままで解釈、記述できるようになっていきます。fold も同じです。この意味だけでコードを捉えても特に困らないので、動きについてはあまり考えなくなります。
ちなみにここで言っているのは「意図」ではなく「意味」です。「意味」はプログラム自身が持つ構造的・数学的な「何を表すか」のこと。
プログラマの「意図」とは無関係にプログラムが構造として「意味」を持つことがあります。そして Haskell のような抽象度の高い言語では、この「意味」が支配的になると思っています。
プログラムそのものが表す構造・関係というのは、たとえば
- 「fold はモノイドの結合である」
- 「関数
f :: A -> Bは A を B に写す写像である」 - 「map は関手(functor)の写像で、構造を保つ」
- 「IO は合成可能な計算のコンテナである」
みたいな解釈のこと。
あるコードが「何を表現しているか」「どんな数学的構造に対応するか」という、客観的な意味のことです。
Haskell の再帰的データ構造と map / fold
ところで Haskell で宣言するデータ構造は、再帰的データ構造です。
data List a = Nil | Cons a (List a)
再帰的データ構造は「全体が、同じ型の部分構造を含んで定義されているデータ構造」です。リストや木構造などが典型例ですが、Haskell のイミュータブルなデータ構造は概ねこの再帰的データ構造として定義されています。
詳細が気になる方は、昨年私が書いたこちらの記事も参照してください。
永続データプログラミングと永続データ構造 - 一休.com Developers Blog
さてリストの例でも分かるように、再帰的データ構造は「全体」を分解すると必ず「同じ型の部分構造」が出てきます。
- 全体が空か
- 要素と “残りの構造” からできている
この 「分解 → 要素への処理 → 部分構造の再帰」 という流れが、再帰的データ構造を扱うときの最も自然で基本的な操作です。そして、この 自然な操作を一般化したものが map と fold です。
map と fold は再帰的データ構造に適した最小の操作
繰り返しになりますが、リストを始め、Set や Map など、Haskell が提供する多くのデータ構造は再帰的データ構造で定義されています。この再帰的データ構造に対して何か処理をしたいとき、だいたい次の 2 つの行動 (両方、またはいずれか) が発生します。
- 各要素を何らかの関数で変換する
- 構造はそのまま
- 中身だけ変える
これはまさに map (写像) です。
- 構造全体を 1 つの値に畳み込む
- 各要素を読み取り
- 結合演算で集約する
これは fold (畳み込み) ですね。
たとえば、3×3 の格子点を集合 Set (Int, Int) で持ち、それを平行移動させたい場面を考えてみます。
Haskell では集合全体に対する写像 として、そのまま表現できます。
main :: IO () main = do -- n <- getInt let s = Set.fromList [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3) :: (Int, Int)] s' = Set.map (+ (5, 2)) s print s' -- fromList_[(6,3),(6,4),(6,5),(7,3),(7,4),(7,5),(8,3),(8,4),(8,5)]
集合の順序・構造は保たれたまま、要素だけが変換されます。これは再帰構造の「写像」という観点から見ても自然です。
同じ Set を使って、今度は「集合全体を 1 つの値に集約 (畳み込み)」することを考えてみます。 たとえば点集合の平均座標(重心)を求める場合です。Haskell なら fold でたたむだけです。
main :: IO () main = do let s = Set.fromList [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3) :: (Int, Int)] (sx, sy, cnt) = foldl' (\(ax, ay, c) (x, y) -> (ax + x, ay + y, c + 1)) (0, 0, 0) s center = ( fromIntegral sx / fromIntegral cnt, fromIntegral sy / fromIntegral cnt ) print center -- (2,0, 2.0)
このように、Haskell の再帰データ構造は map や fold で操作できて、map には写像、 fold には畳み込み (集約) という意味があり、いま見たような「平行移動」や「重心を求める」のよう計算も写像、集約の意味で捉えて記述することが可能になります。
Haskell で記述すると「意味」が自然と浮かび上がる
map と fold が強力なのは、 単に便利だからでも、抽象的だからでもありません。それらが再帰構造の本質的な二つの操作に完全に対応しており、 コードの表現がそのままデータ構造の意味に一致するからです。
- map → 「A を B へ写像した」
- fold → 「A を単位元と結合演算で畳んだ」
命令的な「どうやってやるか」ではなく、「何を表すか」「どんな変換なのか」という意味がそのままコードになります。ここが、Haskell が「意味で考える」言語だと私が考える重要なポイントです。
プログラミング言語は思考の外部化装置だとも考えられます。そしてコードというのは外部化された思考のまとまりです。
命令型プログラミングでは、for文、代入文という文、つまり計算機への命令が基本操作になっています。それをベースにコードを組み立てていったとき、そこで外部化されるのは「操作、動きのまとまり」でしょう。
一方、Haskell では map や fold などの式、つまり意味が基本操作になっている。それをベースにコードを組み立てていったとき、そこで外部化されるのはより抽象度の高い意味の構造に近づくのではないか、と考えています。
もちろん、map と fold だけが「操作ではなく意味で考える」ことに寄与している要素ではなく、そのほか関数合成、型、モナド、永続データ構造などなど Haskell を支える様々な概念が統合されて、コードを意味構造に導くのだと思います。
二分探索について考える
もう一つ別の例についても考えてみます。 二分探索のアルゴリズムは、プログラマであれば誰もが知るところです。
おそらく、初めて二分探索を学んだときは、多くの人がそれを 「配列の真ん中を見て」「条件を満たすかどうかで左か右に進んで」 といった操作の流れを頭の中に描いて理解したのではないでしょうか。
これはやはり「動き」を理解の中心に置く捉え方です。
意味的な捉え方:二分探索は「境界を見つける」アルゴリズム
一方で、特に競技プログラミングをやっている人などは、二分探索を「境界を見つけるアルゴリズム」 として意味的に捉えていることが多いのではないでしょうか。
ある領域の中に条件が true になる領域、条件が false になる領域があり、その境界(true → false に切り替わる点)を効率的に求めるのが本質だ、という解釈です。以下の文書などでも詳しく解説されています。
この境界を高速に見つけることこそが二分探索の意味であり、 動きの詳細(左右どちらを見る、など)はその「境界探索」を実現する手段にすぎません。
bisect2 という関数に抽象化する
二分探索は素で実装すると off-by-one なバグを埋め込みやすいので、私は以下のように bisect2 という名前で二分探索の関数をライブラリ化しています。
-- | 左が true / 右が false で境界を引く bisect2 :: (Integral a) => (a, a) -> (a -> Bool) -> (a, a) bisect2 (ok, ng) f | abs (ng - ok) == 1 = (ok, ng) | f m = bisect2 (m, ng) f | otherwise = bisect2 (ok, m) f where m = (ok + ng) `div` 2
この関数は「左側 ok が true の代表値」「右側 ng が false の代表値」であることを前提に、 true 域と false 域の境界を特定する計算に特化しています。
この二分探索の関数は以下のように使います。
let (ok, _) = bisect2 (0, 10 ^ 18) (\x -> countBy (>= x) as >= x) print ok
ここで引数として渡している高階関数 f :: a -> Bool は、 「x に対してその条件が成り立つかどうか」を返す写像であり「二分探索における境界の“意味そのものを表現する関数」と言えます。
改めて一歩引いてみてみると、高階関数 f によってパラメータ化された境界条件の存在が「二分探索は境界を見つけるアルゴリズム」だという意味構造をよりはっきりと表しているように見えてきます。こうやって、アルゴリズムを記述していてもそこに意味構造が自然と浮かび上がってくる。
そしてこの「二分探索の境界を引く、 f は境界条件だ」という意味構造を自然に捉えられるようになると、今度は思考が逆転して、二分探索をしようとするとき探索の動きで考えるのではなく、「境界条件をどのように写像として表現するか」という「意味の頭」で考えるようになります。
思考や発想そのものが、操作や動きを考えることから、意味から出発するように変わるのです。これこそ、プログラミング言語の特徴が思考に影響を与えた結果辿り着いた思考の癖だと私は思っています。
長年プログラミングをやってて思うこと
抽象度の高い Haskell のようなプログラミング言語を使うと思考が変わる、メンタルモデルがアップデートされるのではないかという仮説を、自分の実体験に基づき、紹介してきました。
以下、主観的な考察です。
プログラミングは一見すると知的作業のように見えます。でもその実は、反復作業によりプログラミング言語を反射的に操作できるよう身体化させることが必要だと思っています。プログラミング言語の本を読んだだけでスラスラとプログラムが書ける人は希で、多くの場合、繰り返し繰り返し記述して、考えなくても手癖でコードが記述できるようになって初めて、そのプログラミング言語を自分の道具にできたと実感するのではないでしょうか。
そして繰り返し繰り返し同じようなコードを書いて、同じような構造をみつけて、同じようなプログラムを構築する。その過程で同じような構造を何度も目にすることで、人はそこから抽象を見い出すことができるようになる。そしてより上手に、構造を描けるようになる。
これは言ってみれば、絵を描くとか、何か作品をつくるという行為によく似ているように思います。反復、繰り返しによる積み重ねが、より高い次元へとそれを導く。繰り返し繰り返しやっているうちに、気がつけばずいぶんと遠くに辿り着く。
この長年の積み重ねを、操作や動きを中心に据えたプログラミング言語でやっていくか、意味を中心に据えたプログラミング言語でやるかで辿り着く場所が大きく異なるのではないか、という実感があります。
何か新しいプログラミング言語に手を出すとき、もちろん実用性の面からそれを選ぶのも良いと思います。
でも別の視点として、プログラミング言語が思考に影響を与えるだろうという観点から、いつも使っている言語とは少しパラダイムが離れたものを使ってみるのも面白いと思います。今回みたとおり、新しいプログラミング言語を身体化する過程でメンタルモデルが更新されて、プログラミングに対する新たな視点が手に入るでしょう。
関数型プログラミングの実践として「不変な値で組み立てていくとプログラムが堅牢になるよ」とか「型安全にすると変更が楽だよとか」実用的なテクニックや旨みを中心に語ること自体は否定しません。でも、私としてはそういうことよりも、よりよいプログラミングの目を養うために関数型プログラミングや Haskell を学んでみたら? というのが本音としてあります。
そのとき、やっぱり反復や繰り返しが大事です。ちょっとやってみる、だけではもの足りない。
命令型プログラミングに慣れた人ほど、map や fold を最初から写像や畳み込み (集約) と直接意味で考えるのが難しい。頭のなかで操作を追ってしまう。でも、繰り返し繰り返しやっていると、やがて、操作を経由しなくても、写像、集約のような意味で脳が直接的に認知できるようになる。
よく、日本語ネイティブな人が英語を話すとき、慣れないうちは英語を一度日本語に頭の中で変換すると言います。でも、そのうち英語を英語のまま脳が処理できるようになるらしいです。それによく似ていて、最初は、動きに変換して考える癖が抜けない。でも、繰り返しやってるうちに、その癖が抜ける。それが一つの到達点だと思います。
私はできればプログラムを操作のまとまりではなく、意味の構造として捉えたいという欲求があります。それはたぶん、それを操作列としてではなく意味構造として捉えるほうが、情報としての圧縮率が高いからではないかと思っています。
自分の脳はさほど、操作的推論に強くない。だから、より低い認知負荷で対象を把握・理解する、記憶するためにはより圧縮率の高い表現の方が望ましかったんだと思います。Haskell ならプログラムを意味構造として組み立てていく、解釈するのが、命令型言語よりもやりやすい。それが今のところの自分の結論です。
ベクトルの和は (x + d1, y + d2) ではなくて v + d と書けたほうが嬉しいし、「ビット全探索」ではなく subsequences だし、直積を求めるなら for の二重ループではなく [ (x, y) | x <- xs, y <- ys] あるいは sequence [xs, ys] と書きたいし、理解したい。そんな気持ちです。
今年も長々とした駄文を最後まで読んでいただきありがとうございました。