何がLispを読みにくくするのか?
認知科学の視点から、Lispの括弧がC系の構文より読みにくい理由と、それが言語設計に示す示唆を分析する。
日本語
コピー
あるいは、次に言語を設計するなら括弧をどこに置くか
Lisp 系のプログラミング言語は、括弧だらけの構文で悪名高い。私自身を含め、Python や Java、C の書き方より読みにくいと感じる人は多い。Lisp は読みにくいという印象があまりに根強いので、Lisp を普及させようと新しい表記法を考案する試みが何度もなされてきた。しかし、そうした試みがあっても、Lisp が計算機科学そのものとほぼ同じくらい古くからあっても、Lisp が なぜ 読みにくいとされるのか、納得のいく説明に私はいまだに出会っていない。
Lisp 開発者はよく、これは単に慣れの問題だと言う。Lisp の書き方は C の書き方ほど一般的でないから、プログラマは自然と読む機会が少ない。Lisp でプログラミングするのに慣れてしまえば、括弧など気にならなくなると。私はそう単純な話ではないと考えている。掘り下げてみれば、心地よい構文をどう設計するかの手がかりが得られるかもしれない。本稿では認知科学のいくつかの知見を借りて、私の説を説明する。なぜ Lisp の書き方——具体的には括弧の置き方——は、より多くの解析コストを要求するのか。
Lisp の書き方に馴染みがない人向けに言えば、典型的な Lisp プログラムでは、文も式も関数呼び出しもすべて同じ構文で書かれる:(operation arguments...)。Lisp(Scheme)と JavaScript で再帰的な階乗関数を定義して比べてみよう。
(define (factorial n)
(if (= n 1)
n
(* n (factorial (- n 1)))))
Scheme 風の階乗定義
function factorial(n) {
if (n == 1) {
return n;
} else {
return (n * factorial(n - 1));
}
}
C 風の階乗定義(JavaScript)
気づくのは、Lisp の例には中置記法(n == 1)も、キーワード(else)も、異なる種類の区切り記号({})もないことだ。可読性の議論では、こうした違いがよく取り上げられる。可読性に一定の影響があるのは認めるが、その重要性は誇張されているのではないかと疑っている。慣れを別にすれば、(- n 1) が (n - 1) より明らかに劣っているようには思えない。
C 系言語は関数呼び出しなどで複数の書き方を持ち、この構文上の多様性は可読性にかなり効いている可能性が高い。この点は別の記事で論じたい。本稿は、ごく普通の関数呼び出しを比べたときに残る違い、つまり operation(arguments ...) が (operation arguments...) よりなぜ目に心地よいのかに焦点を当てる。
人間の知覚におけるハードウェアアクセラレーション
括弧の位置がなぜ重要かを理解するには、人間の知覚がすべての入力に等しく対応しているわけではないことを踏まえる必要がある。自分で確かめてみたいなら、次の単語の表示色をできるだけ速く声に出してみてほしい。
二行目——文字の色と語の意味が食い違っている行——のほうが遅く感じられるはずだ。これがストループ効果である。同種の効果はほかにも多数見つかっていて、視覚的な処理を妨げたり助けたりする。私はこうした効果を、知覚機構に組み込まれた一種のハードウェアアクセラレーションだと考えている。¹ プログラミングと同じで、ハードウェアの長所を活かすべきだ。
たとえば、同じデータを可視化した次の二つを見てほしい。データには外れ値を二つ仕込んである。一方の可視化は「ハードウェアアクセラレーション」の技法を使い、外れ値を見つけやすくしている。
二枚目の可視化のほうが、外れ値をずっと速く見つけられるはずだ。実際、データをそのまま提示した場合の探索はおよそ 𝑂(𝑛) かかるのに対し、ハードウェアアクセラレーションが効いた探索は驚くことに 𝑂(1) で終わる。優れた設計はこの効果を利用してインターフェースを検索しやすくする。形の異なるアイコンやアクセントカラーがその例だ。私がストループ効果とポップアウト効果を人間のハードウェアアクセラレーションの例に選んだのは、どちらも十分に顕著で再現しやすいからだ。残念ながら、認知科学の知見がすべてこれほど明白だったり、定義がはっきりしていたりするわけではない。こうした効果を応用するには、ある程度の創造性と主観的な判断が要る。だから、可読性が以下で論じる認知効果だけできれいに説明できると主張するつもりはない。² これらの効果は設計原則として、また私自身の観察を裏づけるものとして持ち出している。
引数の近接性
記法の話に戻ると、構文の中核的な目的は、トークンがどうまとまるべきかを示すことにある。ゲシュタルト心理学は、私たちが生得的に知覚しやすいまとまりのパターンをいくつか記述している。その中で最も具体的なのが近接の法則だ。この「法則」が言うのは、互いに近くにある物体は関連していると知覚されるということである。たとえば、これも同じデータを二通りに提示したものだ。データ点を二つのグループに分けてみてほしい。二枚目の可視化は、データが x 軸に沿って二つのグループに分かれることをはっきり示唆している。近接の法則がもたらす「ハードウェアアクセラレーション」のおかげで、この分離は生データよりも知覚しやすい。括弧とカンマの位置が間隔を生むため、C 風の記法は Lisp 風の記法よりも近接の法則にかなうことが多い。引数が一つだけの C 風の関数呼び出しには空白が要らない。そのため、そうした呼び出しは一つの視覚的なまとまりとして知覚される。これに対し、引数が複数の呼び出しでは、カンマと空白が各引数を区切る。Lisp 風の呼び出しには間隔の変化がない。関数も引数も同じ空白で区切られる。つまり間隔がまとまりの情報をほとんど伝えない。たとえば、同じ関数呼び出しを異なる記法で書いた次の二つを見てほしい。三番目と四番目のサンプルは、最初の二つをぼかしたもので、近接効果を際立たせるためにある。
(AAAAA (BBBBB (CCCCC)) (DDDDD) (FFFFF (GGGGG (HHHHH))))
Lisp
AAAAA(BBBBB(CCCCC()), DDDDD(), FFFFF(GGGGG(HHHHH())))
C
(AAAAA (BBBBB (CCCCC)) (DDDDD) (FFFFF (GGGGG (HHHHH))))
Lisp(ぼかし)
AAAAA(BBBBB(CCCCC()), DDDDD(), FFFFF(GGGGG(HHHHH())))
C(ぼかし) C の断片はぼかすと三つの塊になる。この塊はコードのおおよその構造を伝える。周辺視野でもそうだ。Lisp のサンプルでは、七つの識別子がそれぞれ独立した塊にぼける。周辺視野にまとまりの手がかりは何もない。
区切り記号の近接性
括弧によるまとまりに関わるゲシュタルトの法則がもう二つある。閉合の法則と対称の法則だ。閉合の法則によれば、図形の断片は、断片の間の隙間を心の中で埋めることで一つの実体として知覚される。つまり、二つの括弧は一つの視覚的なまとまりを作る。それらが楕円を成すからだ。対称の法則はもっと単純で、対称な構成要素(区切り記号など)は同じ視覚的なまとまりに属すると知覚される、というものである。 閉合の法則:構成要素の間の隙間を埋めることで、一つの図形(円)を知覚する。 対称の法則:対称な図形は同じグループに属すると知覚される(近接の法則も含む)。 こうしたまとまりの効果を得るには、対になる括弧の形がはっきり見分けられなければならない。だがほとんどの場合、そうなっていない。網膜で光受容体の解像度が最も高い部分は、視野にしておよそ二度しか覆わない。つまりモニターから 70 センチの距離では、直径およそ 2.5 センチの範囲だけが、快適に読めるだけの鮮明さを持つ。あなたの脳はこのぼやけた文字を隠しているが、目を動かさずに続くいくつかの語を読もうとすれば、それが見えるはずだ。
Lisp では関数名を開き括弧の後に置くため、括弧同士の距離が大きくなる。その結果、目はそれをひとつのまとまった形として捉えにくくなる。この距離はときにかなり開く。Lisp の関数名は長いことが多く、たとえば make-string-output-stream や call-with-current-continuation がそうだ。例として、同じ呼び出しを C と Lisp で書いたものを比べてみる。開き括弧の周囲に放射状ブラーをかけて、周辺視野での目の解像度低下を誇張して模した:
(long-function-name argument)
backdrop-filter: blur(0.11rem);"> Lisp スタイル
long-function-name(argument)
backdrop-filter: blur(0.11rem);">
C スタイル
C のコードでは、開き括弧から閉じ括弧まで一目で見渡せる。閉合の法則と近接の法則のおかげで、一度の走査で対応が取れる。Lisp のコードでは、閉じ括弧が視野に入るまで開き括弧を覚え続けなければならない。
条件分岐やループといった言語プリミティブの書き方を比べると、Lisp では括弧同士の距離がさらに開く:
(if condition consequent alternative)
Lisp スタイル
if (condition) {consequent} {alternative}
C スタイル
C スタイルの if は区切り記号が多いのに読みやすい。区切り記号同士がずっと近いからだ。
言語によっては、最後の引数の区切り記号を関数の括弧の後ろに置ける。この代替記法も、対になる区切り記号を近づける:
repeat(5, { body })
Kotlin の一般的な書き方
repeat(5) { body }
Kotlin の「末尾ラムダ」記法
text(fill: red, [hello world])
Typst の一般的な書き方
text(fill: red)[hello world]
Typst の末尾記法
私の経験では、区切り記号が近い記法のほうがずっとよく使われる。これは経験的な証拠として受け取っている。プログラマは実際、括弧が近くにあることを望んでいるのだ。
閉じ括弧
例として、閉じ括弧の2通りの組版を比べてみる。左はすべての閉じ括弧を同じ行に積み上げ、数えるのも難しい塊にしている。右は各括弧を1段ずつインデントする。この組版なら、数えなくても開き括弧と閉じ括弧の数が合っているか分かる。
(f (g (h (i (j)))))
Lisp の組版
(f
(g
(h
(i
(j)
)
)
)
)
Java の組版
もう少し現実に近いプログラムも見てみよう:
(define (factorial n)
(if (= n 1)
n
(* n (factorial (- n 1)))))
Lisp の組版
(define (factorial n)
(if (= n 1)
n
(* n (factorial (- n 1)))
)
)
Java の組版
心のスタック
こうした視覚的な要因のほかに、可読性は人間の記憶の性質にも左右される。作業記憶に保持できるのはせいぜい3〜5項目だ。電話番号を逆から暗唱してみれば、この上限が体感できる。ネストした式を読むとき、私たちは各階層の式の文脈を覚えておかなければならない。これはパーサや評価器のスタックと何ら変わらない。だから私は作業記憶を「心のスタック」と考えている。記法は心のスタックをできるだけ使わせないほうがよい。数項目しか入らないのだから。
視覚的なネスト
前述の視覚的な工夫を脇に置けば、一般に開き記号は閉じられるまで覚えておく必要がある。この記憶の負担こそが、深くネストした式を読みにくくしている。私たちが覚えていられる括弧はおよそ4つまでで、それを超えるネストは読むのに余計な労力がかかる。
全体として、これといった手立てはない。複雑な式はもともと読みにくい。ただ、特定の形のネストは視覚的なネストなしで表現できる。たとえば:
(((f a) b ) c)
疑似 Lisp
f(a)(b)(c)
疑似 C
f[a][b][c]
疑似 C、配列アクセス記法
C の構文では、ネストした呼び出しを、内側の呼び出しの直後に外側の呼び出しを続けて書ける。ネストの関係は呼び出し構文の左結合性が与えてくれる。この書き方なら、括弧を覚えて対応付ける必要はない。(さらに C の構文にはネストがなく、開き括弧と閉じ括弧が近いので視覚的にも対応を取りやすい。)
f(a)(b) の形のネストした呼び出しは C 系のプログラムではあまり見かけない(Scala は例外だ)が、ネストした配列アクセス f[a][b] はどこにでもある。この2つは本質的に同じもので、違いは角括弧が配列への呼び出しを専用に表す点だけだと考えている。Lisp に合わせて、以下では引き続き丸括弧で比較する。
誇張した左ネスト式の例を挙げれば、Lisp 版のほうが C の構文より括弧の対応付けが面倒だと分かる:
(((((((function aap) noot) mies) wim) zus) jet) teun)
疑似 Lisp
function(aap)(noot)(mies)(wim)(zus)(jet)(teun)
疑似 C
先ほどの Lisp の例が読みやすかったのは、見ているのが左ネスト式だと分かっていたからだ。ネストの種類を混ぜると、もっと注意を払う必要が出てくる。すると左ネスト式の略記が、左ネストでない式まで読みやすくしていることに気づく。
(aap noot ((mies wim) zus) (jet teun vuur))
疑似 Lisp
aap(noot, mies(wim)(zus), jet(teun, vuur))
疑似 C
ここでは、解析しやすい構文 mies(wim)(zus) が心のスタックを解放し、それを外側のジェネリクス式の解析に回せる。3
読む順序
逆の場合、つまり式が右にネストする場合を考えると、新たな問題が浮かぶ:
(h (g (f a)))
疑似 Lisp
最も内側の式 (f a) は最初に評価されるのに、最後にしか読まれない。コードの表層的な構文構造を超えて理解しようとすれば、頭の中でその一部を先に評価しなければならないかもしれない。この例のように読む順序と評価の順序が食い違うと、認知負荷が増す。外側の呼び出し h と g を、それが関係を持つまで覚えておくか——限られた心のスタックを消費する——あるいは (f a) から始めて意識的に式を逆順に読むかだ。
最初の読み方——外側の呼び出しを覚えておく——は、呼び出しの数が記憶容量を超えると成り立たなくなる。だから逆順に読むことが、深くネストした式を理解する唯一実行可能な戦略になる。つまり Lisp のコードを理解するには2回読む必要がありうる。1回目で式の形を把握し、2回目はその形に合った順序で読む。
同じプログラムを比べれば、逆順に読む問題は Lisp でも C の記法より深刻ではない。ただ、C 系言語でよくあるコーディングスタイルでは、逆順に読む必要のある深い右ネストはあまり現れない。
たとえば、次のプログラムはカンマ区切りファイル中の正数の総和を計算する。どちらの記法も字面どおりに読めば「『input.txt』をカンマで分割し、数値にマップし、正数を絞り込み、合計する」となる。
(sum
(filter
(split (read (open "input.txt"))
",")
(lambda (it) (> it 0))))
疑似 Lisp
sum(
filter(
split(
read(open("input.txt"))
","
),
lambda it: it > 0
)
)
疑似 C
C 風の記法も Lisp の記法と同じく内側から外側へ読む。ただ、こうした構造は C 風のプログラムではあまり見かけない。Lisp は関数型プログラミングを好み、C 風の言語は命令型プログラミングを好む。命令型では、プログラムはテキストの順序と実行順序が一致する形で自然に書かれる。次の二つのプログラムはどちらも「『input.csv』を読み込み、カンマで分割し、数値にマップし、正数を絞り込み、合計する」と読める。
x = read(open("input.txt"))
x = split(x, ",")
x = map(x, int)
x = filter(x, lambda it: it >= 0)
x = sum(x)
return x
疑似 C コード。x への再代入によって操作を評価順に並べている。
x = read_file("input.csv")
split!(x, ",")
map!(x, int)
filter!(x, ...)
return sum(x)
疑似 C コード。その場での書き換えによって操作を評価順に並べている。
C 系の言語にはたいていオブジェクトシステムと、それに対応するメソッド呼び出しの構文もある。典型的なメソッド呼び出し構文を使うと、読みの順序は評価順に強制的に縛られる。
open("input.txt")
.read()
.split(",")
.map(int)
.filter(lambda it: it >= 0)
.sum()
疑似 C コード
最近の主要言語のいくつかは、より多くの関数をメソッド呼び出し構文で書けるようにするための専用の言語機能を取り入れた。これはプログラマが評価順と一致する記法を好むことの表れだと私は見ている。
同じように、Clojure は threading マクロを普及させたが、これは Lisp におけるメソッドチェーン構文に相当すると考えている。
(~> (file->string "input.txt")
(string-split "\n")
(map string->number)
(filter (lambda (it) (> it 0)))
(apply +))
疑似 Lisp コード
その他の考慮点
すでに述べたように、ほかにも構文上の違いはあるが、可読性への影響は過大評価されていると私は思う。中置記法の欠如は、Lisp が読みにくい理由としてよく挙げられる。優先順位と結合性を覚えていれば、中置演算子に括弧は要らない。もっとも、私が自信を持って言えるのはごく一般的な演算子だけだ。+、-、*、/、^、&、|、==、``。Haskell プログラマなら証言できるだろうが、中置演算子が増えてもプログラムは読みやすくならない。つまり中置演算子が効いてくるのは、単純な算術が主体のプログラムだけだ。関数型のプログラム——Lisp が重きを置くのはまさにそういうプログラムだ——では、数値は比較的まれである。
言語プリミティブに専用の構文を与える効果も過大評価されていると思う。if 文の記法は Python のほうが Lisp より見栄えがよいかもしれないが、それは if が特別扱いされているからではなく、その特別扱いがたまたま見栄えのよい構文を使っているからにすぎない。
確かに重要だと思う点が一つある(本稿の以前の版で私が落としていた点でもある)。それは、Lisp 以外の言語では関数適用を表す記法がより多様だということだ。つまり、if 文に専用の記法があること自体は重要ではなく、異なる記法が複数存在することが重要なのだ。これがコードに変化をもたらし、その変化が視覚的な道しるべとして働く。この話題は別の記事で詳しく扱うつもりだ。
ほとんどの Lisp コードはブログのソフトウェアの中ではなく、専用のエディタで書かれる——少なくともそうであってほしい。エディタのシンタックスハイライトは、上述の問題のいくつかを和らげる。たとえば Visual Studio Code はデフォルトで、対応する括弧に互いに一致するハイライト色を割り当てるので、括弧の対応はずっと追いやすくなる。これは考慮に入れていない。こうしたハイライトは Lisp でも C 風の言語でも同じように効くからだ。
最後に、この記事で私が「可読性」というやや曖昧な語で指しているのは、両方の言語にすでに慣れているプログラマにとっての抽象的な解析しやすさだ。可読性には別の解釈もあり得るし、それらはより Lisp の記法に有利に働く。たとえば Lisp の構文は C よりずっと単純なので、学ぶのは容易だ。プログラミングの背景がない学生なら、Lisp のプログラムを解析するのに必要な時間は C のプログラムより短いだろう。また、関数型言語のほうがよく表現できるアルゴリズムもあり、そうしたものは Lisp のほうが可読だと言える。
Lisp の構文にはメタプログラミング上の利点もあり、これは本稿の範囲を超える。そうした利点と引き換えに可読性を一つ犠牲にするのは、私には割に合う取引だ。したがって本稿の分析は、Lisp という言語全体を批判することが目的ではなく、この取引のいくつかの側面を指摘することが目的である。
結論
まとめると、関数の構文について、Lisp が C 風の言語より読みにくく見える理由は四つあると私は考える。
- 前置記法は括弧同士の距離を遠ざける
- 整形の慣習が括弧の追跡に役立たない
- 前置記法は左への入れ子を増やす
- Lisp には、評価順と読みの順序を一致させる機能が欠けているか、それが不利になる
これらの観察を裏返せば、新しい記法を設計するための私の提案になる。
- 関連するトークン(たとえば区切り記号)ができるだけ近くに来るように記法を並べる
- コードの形でグルーピングを伝える
- 結合性を利用して区切り記号の入れ子を避ける
- 読みの順序と評価順が一致するように構文と意味を設計する
この分析は、Lisp 系の言語にいくつか手を加えれば使いやすくできることを示している。正直に言えば、私も新しい Lisp を作りたい誘惑に抗えなかった。数え切れないほどの Lisp と、それに対応する記法がすでにあるとはいえ、本稿のこれらの観察は、構文と意味をある組み合わせに導いた。それが独自のものであることにはかなり自信がある。これについては後の記事で述べる。
最終更新:2026-9-28