ハクソク

世界を動かす技術を、日本語で。

なぜ私はHaskellではなくLispやSchemeを選ぶのか

概要

  • ソフトウェア開発における理想的な純粋性現実的な実用性の葛藤
  • Haskellの革新的な型システム学術的な美しさの評価
  • Scheme(Lisp)の柔軟性と実用性の優位性
  • DSL・メタプログラミングにおける両者の違い
  • REPLによる開発体験の圧倒的な快適さと生産性

ソフトウェア開発における理想と現実

  • ソフトウェア工学では、数理的に美しい理想と、現場での現実的な実装との間に永続的な緊張関係が存在
  • Haskellのような純粋関数型言語は、理論的な美しさと高い抽象度を持つ一方、実用面でのハードルも高い
  • 実用的な開発では、素早くプロトタイピングし、問題を手早く解決する柔軟性が求められる
  • 著者はHaskell、Go、JVM系(Java, Scala, Kotlin)、Lisp(Emacs, Common, Scheme)などを経験し、関数型プログラミングの価値を深く理解
  • 理論と実践のバランスを探求し、自分なりの「ハッキングの最適点」を模索

Haskellの美しさと課題

  • Haskellは型システムの革新性代数的データ型パターンマッチングファンクター・モナド・モノイドなどの概念で有名
  • 数学的なアイデアをプログラミングに持ち込み、学術界や研究者からも高評価
  • ただし、実用的な開発では「素早くコードを書く」際にHaskellが抵抗を示す
  • モナドファンクターなど、高度な抽象概念の習得が必要
  • 依存関係やモナディックAPIに悩まされ、プロトタイピング時に生産性が低下しやすい
  • 副作用の制御純粋性の強制が、ちょっとしたデバッグや出力にも大きな手間をもたらす

Scheme(Lisp)の実用性と柔軟性

  • SchemeやLispは最小主義的な設計柔軟性を重視し、人間にとって扱いやすい関数型言語
  • 複雑なシステムや問題領域も、他言語よりシンプルに表現可能
  • 副作用も簡単に扱え、(write ...) で即座に結果を確認できる
  • プロトタイピングやデバッグが圧倒的に楽で、開発の「喜び」を感じやすい
  • 学術的な純粋性よりも現場での使い勝手を優先
  • Lispマクロによる言語拡張・メタプログラミングが強力

DSLとメタプログラミングの違い

  • Haskellは**DSL(Domain Specific Language)**の構築が得意で、Parsecをはじめ多くの分野特化DSLが存在
  • しかし、各DSLの文法や使い方がバラバラで、学習コストが高い
  • JavaやKotlinなどのJVM系は、一貫したAPI設計で学習負担が少ない
  • Schemeはシンプルな構文強力なマクロシステムにより、柔軟なメタプログラミングが容易
  • Haskellで同等の柔軟性を得るには、多数の言語拡張やTemplate Haskell等の学習が必要

REPLによる開発体験と生産性

  • **REPL(Read-Eval-Print Loop)**はインタラクティブな開発環境であり、Lisp系(特にGuile Scheme)はそのサポートが非常に優秀
  • Emacs + Guix + REPLの組み合わせで、従来のIDEを凌駕する開発体験を実現
  • インクリメンタル開発:関数や1行単位で即時評価、即時フィードバック
  • 強力なデバッグ:プログラム実行中に値の変更や関数の再定義が可能
  • 高速なプロトタイピング:新しいライブラリやAPIも即座に実験
  • 開発サイクルの短縮で、創造的かつ効率的な開発体験を提供

言語選択と開発体験の結論

  • 言語は道具であり、プロジェクトや目的に応じて最適なものを選択
  • Haskellは理想的な純粋性革新的なアイデアで道を照らす存在
  • Scheme(Lisp)は実用性と開発体験を重視する現場向けの「スイートスポット」
  • Lisp系言語は、開発者に「創造的な力」と「美しいシステム構築能力」を与える

Hackerたちの意見

> 実際、私の意見では、Scheme(とLisp)は、他のどの言語よりも複雑なシステムや問題領域をシンプルに表現できるんだよね。短い記事だけど、読む価値あるよ。でも、私が飲み込んだのはこの一文だけ。構文がね。セミコロンが好きなら、それがPascal系の言語が好きな理由だよ。
実際的な観点から見ると、Lispの構文は単なる見た目の選択肢じゃないんだよね。
Lispを知ってるなら、Haskellの代わりにCoaltonを手に取ってみて。
Coaltonはまだ進化の余地があるけど、十分良くて柔軟だよ。
> もちろん、私のツールキットについて完全に公平に言うと、標準のSchemeはJVMと比べると、大規模なエンタープライズ生産に必要な「バッテリー付き」エコシステムが欠けていることがあるんだ。ずっと考えてたのは、「この人はClojureが大好きだろうな」ってこと。
KawaはJVM上で動くSchemeで、かなり素晴らしいよ。https://www.gnu.org/software/kawa/index.html 私は`syntax-case`がないLispには耐えられないタイプの人間なんだ。
パートタイムのScheme使いとして、最近はSchemeよりもClojureを使うことが多いんだ。
> Lispハッカーたちは、強力なマクロシステムを使って、何十年も言語を自在に形作ってきたんだ。私は少しRacketのコードを書いたことがあるけど(https://github.com/evdubs?tab=repositories&q=&type=&language...)、まだマクロを書いたことがない。マクロが役立つと思ったのは一度だけで、クラスメンバーの定義を同じ行に型とデフォルト値を含めてマージすることだった。Racketは、はるかに大きな標準ライブラリと多くの素晴らしいユーザー提供ライブラリを持つSchemeなのに、「マクロで低レベルのツールを作れる」というScheme/Lispのマーケティングに悩まされるのはちょっと残念だよね。Racketの開発者は、すでに書かれていて標準ライブラリの一部になっているから、マクロを書く必要がないことが多いし。> でも、Parsecの成功はHackageに何百もの特注DSLをもたらしたんだ。解析用、XML用、PDF生成用のものがある。それぞれ全く異なっていて、それぞれ独自の学習曲線が必要なんだ。XMLを解析して、ウェブAPIからのJSONに基づいて変形させて、PDFに書き出すことを考えてみて。Lispの別の福音を伝えるチャンスを逃してるよね:s式。XMLとJSONは、使っているプログラミング言語にはネイティブではないデータ形式なんだ(例外はJavaScriptのJSON)。XMLやJSONよりも良いものは何か?s式だよ。Lispの開発者たちはXMLやJSONをどう扱ってるかって?s式に変換するんだ。データを定義する場合はどうなる?s式があるから、XMLやJSONに制限されず、データにソートされたマップを使ったり、適切な日付を使ったりできる。JSONのようにすべてを配列、ハッシュ、文字列、浮動小数点のバケツに収める必要はないんだ。Lispについて聞いたことがあって、「DSLを作ってより良いマクロを使える」っていうマーケティングに引いてしまったなら、RacketはJavaやC#のような大きな標準ライブラリを持つ言語に慣れた開発者にはずっと快適な環境だよ。
15年くらい前に、キャリアや自分がやっている仕事についてちょっとした存在の危機に直面したことがある。自分がやっていた特定の技術が「問題の一部」だと思った。.NETやC#に縛られて、いつも企業のCRUDコンサルタントになってしまう気がしていたから。だから、もっと良いものを探しに出かけた。違うプログラミング言語、違う環境。単に、ホテルの向こう側での障害について人を怒鳴るのが普通だと思っているクソクライアントのために働くのは嫌だった。長い話になるけど、完全に自分のコントロール外のことで家族との休日を逃してしまい、それでも自分が責められた。問題は技術ではなく、自分が働いていた会社だったけど、その時の自分にはその違いが理解できなかった。Racketはその時の命綱だった。説明するのは本当に難しいけど、実際にはRacketでフルタイムで働くことはなかったし、10年くらい触れてもいない。でも、それでも自分のソフトウェア開発者としてのアイデンティティに影響を与えた。Racketを学んで、グラブプログラマーから、宇宙の根底にあるストリングを見える人になった。S式や構文形式、コードはデータであることの美しさ。それはこの仕事が何であるべきかという私の見方に永続的な影響を与えた。今でも主に.NETで働いている。 .NET Frameworkに関する技術的な問題のほとんどは、最初の.NET Coreや今の.NETで解決されたから、もう道具に足を引っ張られている気はしない。Racket(そしてコミュニティ!当時のRacketのリストサーブは素晴らしかった。今もそうかもしれないけど、もう関わっていない)には感謝している。 編集: 実はHaskellもその時に探求した言語の一つで、OcamlやRuby、Python(ああ!Pythonの話はやめて!)などと一緒だった。どれもそれぞれの「クールさ」があったけど、Racketのようには感じなかった。どれも自分を支配されているような変なルールがあった。Racketはアートのように感じた。Racketは自分のために存在しているように感じた、逆ではなくて。
マクロを書くときは、必要だからというより、単に楽しいと思うから書くことが多い :)
Lispの開発者はXMLやJSONをどう扱うの?S式に変換するんだよ。私がCommon Lispの開発者としては、これは非常に曖昧な真実だ。jsonLispのために私が好むマッピングはこうだね:true: t false: nil null: :null [] #() {} (make-hash-table :test #'equal) これは、マッピングが双方向であることを望むから出てきた。 - 明確にマッピング型である組み込み型はハッシュテーブルだけ。 - nilはCLで唯一の偽の値。 - ()はnilと同じだから、空のリストとして使えないし、ベクターが明らかな代替。 - 「null」に使う明確な値が残っていないから、キーワードに逃げる。
モナドが「重い抽象」だとは思わないし、それが人々がHaskellでプロトタイピングするのを妨げているとは感じない。実際にHaskellで合理的なスピードで書くのを妨げているのは、言語設計の悪さだと思う。プログラミング言語は、構造を強調することで読みやすさを助けるべきなんだ。特定の「言葉」のグループが関数呼び出しや変数定義、型定義を構成していることを強調するのが大事だよね。Haskellは言葉のサラダみたいで、読むたびに何度も読み返さないといけないし、バラバラな略語から構造を推測しようとするのが大変。まるで「バッファローがバッファローをバッファローする」みたいなギミックに属してる。これはプロトタイピングや、コードを素早く読む能力が必要な他の活動にとって大きな障害だよ。それに、男たちが考えた奇妙なインデントルールが加わってるから、余計に厄介。例えばSMLやErlangにはこんな問題は全くないのに、同じカテゴリの言語なのにね。Haskellは、文法をもっと体系的にして、ユーザーが作った中置演算子の導入やリテラルのオーバーロード(なんでそんなことを?)を禁止して、関数の引数の定義や適用にカッコを必須にしなければ、もっと良い言語になってたと思う。実行モデルは素晴らしいし、型システムも素晴らしいけど、言語の表面、つまりこれらの素晴らしい機能への入り口は、ただのアマチュアレベルのナンセンスだよ。 * * * Lisp系の言語を実用的な問題に使う利点についてだけど、私は「syntax-rules ...」にはあまりワクワクしない。これはCommon Lispのマクロの自由を制約しようとした試みだと思うけど、うまくいってないと思う。扱うのが不器用で面倒だし。初めて使おうとした時、制限にぶつかって、それが完全に不当だと感じた。プロトタイピングには自由な動きが必要で、何かが邪魔をしてきて、それを回避する方法を考えなきゃいけないのは嫌だよね。でも、絶対的なセールスポイントはSWANKだね。ソースコードを編集する代わりに、プログラム自体を編集して、好きなポイントでインタラクションできる。こんな体験を提供する現代の言語は知らないな。80年代でも、プログラマーがコンピュータとインタラクトするこのアプローチは一般的だったと思う。学校では、いろんなBasicの端末があって、プログラムを打つとすぐに変更の効果が見えた。Forthも似たような感じで、コンピュータと非常に整理された構造的な方法で「話している」ように感じたけど、リアルタイムでね。今の主流の言語は、プログラマーがプログラムが実行されるときにキーボードの前にいないバッチジョブのアイデアから生まれたものだと思う。彼らは、インタラクティブなセッションで簡単に検出して修正できた小さなミスからプログラマーを守る必要性を伴ってきた。CやRust、Haskellを書くことを考えるたびに、目隠しをして食料品店に行くような気分になる。ステップ数や曲がり角を覚え、交通を予測し、ジャガイモがセールになったときのための戦略を考えておかなきゃいけない... プログラミングがこんな進化の道を辿ったことを深く後悔してるし、プログラミングが何を意味するのかという私たちの考えは、ほとんどが予測不可能な未来を推測するスキルになってしまっていると思う。イベントが展開するにつれて反応することを学ぶのではなく。
あなたの最後の段落から、どの言語やパラダイムを推奨しているのか気になる。SWANKが好きだということは分かったけど、私はそれに詳しくないからごめん。
あなたのHaskellに対する批判は完全に主観的だね。Haskellの構文が好きで好んで使ってる人はたくさんいるし、僕もその一人だよ。
> Haskellは言葉のサラダだ。読むたびに何度も読み返さなきゃいけないし、バラバラな略語から構造を推測しようとするのが大変だ。これはプロトタイピングや、コードを素早く読む能力が求められる他の活動にとって大きな障害だ。全く同意できないね。確かにHaskellのコードを理解するには最初に手間がかかるけど、すごく密度が高いんだ。一度パターンを理解すれば、もっと早く読めるようになるよ。map/filter/foldはforループより理解しづらいけど、一度理解すればどんな繰り返しが行われているかすぐにわかる。forループは変なインデックス操作をたくさんできるけど、それを毎回最初から消化しなきゃいけない。 > それに、男たちが考えた最も奇妙なインデントルールが加わる。これには驚いたよ。このルールはすごくシンプルで、内側の式はもっとインデントしなきゃいけない。どれくらいインデントするかは自由だよ。だからいろんな「スタイル」があるんだろうね。もしかしたらそれが奇妙だと思う理由かもしれないけど、言語が変な制約を強いているわけじゃない。むしろ制約が緩すぎるくらいだよ。他の言語でも任意のインデントが許されているし、一般的にもっと多くの言語が必須のインデントを採用しない理由がわからない。全てのif/else/while/...文を一行で書きたいなら、波括弧やセミコロンが必要だけど、誰もそんなことしないよ。
> それに、最も奇妙なインデントルールが加わる。タブとスペースを混ぜてるの?ここで例を出すと助かるかも。 > リテラルのオーバーロード(なんで???)これは重要で、デフォルトの文字列がしょぼいものである必要はないからね。C++もこれに乗っかってるし。 > 関数の引数の定義や適用時に括弧が必要だって???? これも例があると助かるね。Haskellに対する一般的な不満は、みんなが括弧を使いすぎないことなんだ。 > 実行モデルは素晴らしい…遅延実行がHaskellの最悪な部分だと広く合意されていると思ってたんだけど。
CIDER/nREPLを使ってClojureを試してみて。SLIME/SWANKに似た感じだよ。
> 特定の「単語」のグループが関数呼び出しや変数定義、型定義を構成することを強調するのは重要だよ。文法ハイライト? https://play.haskell.org/ このコメントには完全に困惑してるんだけど、もしかして括弧付きの関数呼び出しを見逃してるのかな?そうなら、ちょっと共感できるかも。
> (syntax-rules ...) 初めて使おうとしたとき、その制限にぶつかったんだ。syntax-caseが一般的な構文だよ。syntax-rulesは制限された、簡単なことは簡単にするための構文なんだ。 https://www.scheme.com/tspl2d/syntax.html
だって、エレガントだから。Haskellは概念的にも構文的にも混乱してる。
Lispと比べると?まあ、いいよ。構文はLispよりシンプルにはならない。でも、JavaScriptやC++、C#と比べると?Haskellは構文的にも概念的にもエレガンスのトップクラスだと思う。一番の問題はツールだと言えるかな。
Haskellはとてもエレガントで美しい。プログラミング言語における「美しさ」を説明するのは難しいけど、個人的にはgolangは醜い、rustは良い、Haskellが一番だと思う。
Haskellの一番の魅力は、文法のシンプルさとクリーンさだと思う。
「HaskellよりSchemeが好きな理由」にちょっと似てる気がする(https://news.ycombinator.com/item?id=3816385、2012年)。ちょっと盗作っぽいけど、偶然かもしれないね。
> オブジェクトを一時停止して、値を確認したり、壊れた関数をその場で再定義して修正をテストしたりできるんだ(はい、実行中の本番環境でも)。これがよく言われるけど、すごく便利そうだね(特に本番環境での修正の部分が!)。でも、実行中のプログラムに接続してデバッグやホットフィックスができるLispの方言はどれくらい普及してるの?Common Lispにはあるのは知ってるけど、Racketでどうやるかはうまくわからなかった。正直、僕はあまり経験のないLispプログラマーだから、正しい場所や言葉を探してなかったのかもしれない。どのLispの方言が実行中のプログラムを検査・編集するこの極端な機能をサポートしてるの?
PythonはLispじゃないけど、途中で動いているプログラムのPython REPLに飛び込んで内部をいじるのは、デバッグツールとしてすごく便利だよ。特に複雑なプログラムの答えをすぐに得られるからね。理論的にはこれができる他のスクリプト言語がやらないのは残念だ(nodeを見てるよ!Chromeの開発ツールはいいけど、`import pdb; pdb.set_trace()`や「ただ」stdinを使うのに比べると、すごく面倒だし)。Emacsも使ってるけど、Emacs Lispの`trace-function`を使えば、デバッガを引っ張り出さなくても実行中のインスタンスでコールトレースをすぐに取得できるよ。もちろん`gdb`で関数をトレースできないわけじゃないけど、プロセス内で動的にデバッグできることで、最初からより豊かなデバッグツールにアクセスできるんだ。
大抵の人が「Lispはこれやあれをする」と言うとき、彼らが言いたいのは「Common Lispはこれやあれをする」ってことだと思う。しばしば「SLIMEを使って」っていう暗黙の前提もあるよね。
Lispじゃないけど、運用中のプログラムを編集したい人には:Erlangの記事でホットスワッピングは実際には運用中にはあまり役に立たないって理由があって、代わりにブルーグリーンデプロイメントが好まれるって読んだことがある。今はリンクが見つからないけど。これに近いのがこれだよ:https://learnyousomeerlang.com/relups このコメントと比較してみて:https://news.ycombinator.com/item?id=42405168 小さなパッチやバグ修正にはホットスワップ、データ構造やスーパーバイザーツリーを変更するにはハードリスタートが必要だね。
Clojureや他のLispでもよくあることだよ。今週の初めに、実際に稼働中のプログラムを修正して、デバッグ情報を集めるためにprintコールを追加したり、バグを直すためにコードを修正したりして、そのまま本番環境に反映させて、正しい動作を確認したばかりなんだ。
そういうホットフィックスのワークフローは、RacketやSchemeではあまり見られないかな。関数の定義を変えても、その関数を呼び出している他の部分は更新されないから、CLとは違うんだ。Emacs Lispはそういう感じなのかな?
一人でやるプロジェクトにはすごく使ってるよ。その環境では本当に素晴らしい。SBCLを専ら使ってるんだけど、すごく速くて堅牢で、イメージベースの開発ができるんだ。自分のバージョン管理ツールキットも持ってるから、頭がおかしくなることはないよ。チームで使われない理由は明らかだし、2人でもほとんどうまくいかないことが多い。でも、バグをリアルタイムで修正して、新しい.exeをクライアントに出すのは、現代の代替手段よりずっと速いし、危険度も高いね。
生産環境の MtG カード管理アプリでこれをやったことがあるけど、うまくいったよ。オーナーは MtG カードのお金を守れたし、Lisp が助けてくれた。
Clojure では、開発環境でこれをいつもやってるよ。技術的には顧客環境でもできるけど、もちろんちょっとカウボーイ的なことになるね。
一般的なワークフローは、REPL で関数をテストして、準備ができたらテストに昇格させるって感じで、特に Lisp ではこのプロセスがスムーズなんだ。必要なら自分のテストハーネスも作れるし。面白いことに、AI REPL を使うとエラー率がかなり下がって、トークンや時間を半分以上節約できることもあるよ。
そうだけど、Jetbrains IDE に慣れてる人が「その機能は Lisp だけ」と言うのは理解できないな。Common Lisp と SLIME が大好きだけど、できることのほとんどは Java の IDE でもできるよ。実行中にメソッド定義を変更して、メソッドを再起動する?問題ない。実行中のメソッドのコンテキストでコードを実行する?もちろん、Java でもできる。メソッドの途中でローカル変数の値を変更する?簡単だよ!Lisp の REPL は、DECOMPILE や INSPECT みたいな、ランタイムでもコンパイラとしての特性があるからこそできる機能があるから優れてるけど、Java では IDE を使えばそれらのことができるから、Lisp と Java や Kotlin のような良い IDE サポートを持つ言語との距離は、今やほとんど気にならないと思う。
(Haskellで) > どこかにシンプルなprintを追加するだけじゃうまくいかないんだ。面白いね。実際にはみんなどうやって対処してるの?log()文をデバッグに使えないってこと?
いつも `trace` があるよね。 https://wiki.haskell.org/Debugging#Printf_and_friends
`()` を返すように unsafeIO 関数でラップすればいいよ。でも、デバッグのためにプリントすることはあんまり使わなかったな。Haskell では、小さめで純粋な関数を書いて、プロパティベースのテストで徹底的にテストできるからね。型もかなり助けてくれるし。だから、予期しない入力に対処するのは、アプリの通信境界だけで、すでに何らかの IO の中にいるから、プリントするのも簡単なんだ。
`trace` を追加するだけだよ。難しくない。 https://wiki.haskell.org/Debugging
LLM を使うときは Haskell が神のようだね。