ジョン・カーマックが語る!可変変数の落とし穴 不変性がもたらすコードの明瞭さとは?
引用元:https://news.ycombinator.com/item?id=45767725
Clojureを2年間やってみて、可変で副作用を起こすのに慣れたプログラマーに不変性がもたらす明瞭さを説明するのは本当に難しいんだ。見て理解するしかないことの一つなのかもね。
変数を書き換えると暗黙の順序依存性が生まれるんだ。後の処理が前の変更に依存するけど、言語はそれをモデル化しないから、順序を変えてもエラーにならない。
例えばx=7 x=x+3 x=x/2だと順序を変えてもエラーは出ないけど結果は間違える。x=7 x1=x+3 x2=x1/2だとエラーになる。
つまり、最初の例では3つの異なるxが同じ名前を共有してるってこと。単一代入はそれを明示的にしてるだけだよ。
僕はそんなに良いプログラマーじゃないのかな。変えられない変数がどう役に立つのか、なんでそれを使うのか、よく分からないんだ。
実際動くコードってどうやって書くの?
immutableとconstantの違いって何?constantの方がずっと前から使われてるよね?なんでみんなimmutableって呼ぶの?
僕が好きな考え方だと、不変データがデフォルトで、関数が純粋だと、純粋関数をブラックボックスとして扱えるんだ。関数の内部を知る必要もないし、関数も外部の状態を知る必要がない。データの形が契約になるんだね。
つまり、どこでも局所的なコンテキストって考えるのが、可変な世界の人にとって一番分かりやすいかも。プログラム全体の状態を知る必要はなくて、データと関数だけ知ってればいい。この関数をテストしたりデバッグしたりするのに、プログラム全体を動かす必要もないんだ。送られてきたデータだけあればいいし、そのデータはプログラムの他の部分に決して変更されないからね。
モジュール性やカプセル化は、コンポーネントを分かりやすくしたり、メンテナンスしやすくするのに素晴らしいツールだよね。
でも、結局は全体を構築しようとしてるんだから、プログラム全体を理解する必要があるんじゃないの?
それに、プログラム全体の状態が変わらないなら、何も起こってないことになるよね。どこかに可変な状態はあるはずだから、それはどこに移動したの?
不変性の利点は過大評価されがちだし、みんな、物事が連続体で存在してるってことをよく見落とすんだ。可変と不変を単純に対立させるだけじゃ、たくさんの複雑さを避けてることになるよ。
例えば、Haskellコミュニティが型システムに合うように、実質的に可変性を再発明しようとあらゆる努力をしてるのを見るのは、僕には endlessly amusing(とてつもなく面白い)んだ。彼ら自身、それが自分たちのしてることだって気付いてないことさえある。
結局、目標はいつも同じだよ。副作用の影響をより良く制御して保証し、最小限の手間で済ませること。Carmackのアプローチは理にかなってるね。反復計算のように、意味のある場所では柔軟性を保ちつつ、デバッグしやすく、推論しやすい方法を求めてるんだ。
Immutableとconstantは同じだよ。rendawはmutableって言葉を使ってないね。誰かが”mutable”って言葉を使う一つの理由は、それがアイデアを簡潔に表現する方法だからだよ。同じアイデアを表現する他の方法は、もっと長い単語(changeable、non-constant)になるね。
新しい値が必要なら、新しいものを作るだけだよ。
fooAに対して操作をしたいなら、fooAを書き換えるんじゃなくて、fooB = MyFunc(fooA)って呼び出して、fooBを使うんだ。
このことの良い点は、fooAへのポインタをあちこちで渡しても、他の何かが裏でそれを変えてしまう心配がないってことだね。
プライベート変数を保護する必要もないよ。だって内部の動作は書き換えられないからね。他のコードはそれをコピーできるけど、邪魔はできないんだ。
不変性の考え方はシンプルでさ、既存の値をいじるんじゃなくて、新しい値を作るんだ。例えばリストだと、既存のリストに値を追加するんじゃなくて、古いリストと新しい値を含んだ新しいリストを作るんだよ。これで、元のリストが勝手に変わる心配がなくなるから、多くのバグを防げるんだぜ!
[1]ってあるけど、最適化の話は保証と同じだってさ。
「Constant」って言葉はもっと広い意味合いがあるんだよ。「ループで何度も作ってるなら、Constantじゃないだろ?」って感じ。でも「Immutable」は「どんな変数でも、それがどんなに長く存在しようと、変更できない」って意味で、余計な文脈を抜きにしてるんだ。
「ループで何度も作り直してたら、Constantじゃないだろ?」って言ってたけど、そもそもConstantって変えられないじゃんか。
Rustだって、同じ名前の変数でシャドウイングできるんだよ。確かに別物だけど、人間から見たら同じ名前だよね。Rustがこう決めたのは、x1、x2、x3みたいなコード書くのがクソ面倒だからだろうな。
「プログラムの元のリストが予期せず変わらない」ってのが、なんで役に立つのかわかんねぇな。古い配列は間違ってるんだから、誰も使っちゃダメだろ?リストを更新して新しいリストができたら、なんで古い間違ったリストを誰かに使わせたいんだよ?
その人は、変数がループ内で定義されてるって言いたいのさ。だから、Constantだけど繰り返し再定義されてる、ってことだね。
あの例は簡単すぎてピンとこないな。配列の合計を計算する関数を、組み込みのsum関数なしでどうやってコード化するんだ?もし一つ一つの足し算を自分で書かなきゃいけないなら、どうなるんだよ?興味ある(自分で調べるか、Claudeに聞くこともできるけどね)。
それは、まともなエンジニアが「Constant」って言葉で意味することと真逆だよ。
それは、いつ、どこで、なんで変更が起きるかを考えさせるし、後でその変更について考えるのに役立つんだよ。スレッドセーフティも大きな利点だね。
不変なアプローチは、可変みたいに場所、時間、抽象的な同一性を混同しないんだ。可変モデルだと、抽象的なオブジェクトがメモリ上の位置で表現されるから、オブジェクトの同一性がポインタの同一性になっちゃう。でも、同じオブジェクトの別バージョンを持ちたい時、これは問題だ。オブジェクトの同一性をポインタ以外(名前とかIDとか)で表せば、同じオブジェクトの複数バージョンをいろんな場所で同時に扱えるんだ。これで、同時に読み書きできるし、シリアライズ/デシリアライズも楽になるよ。特定の場所にオブジェクトを置く必要がないから、ディスクに保存したり、送ったりもできるんだ。
x1とかx2みたいな変数に、もっと分かりやすい名前つけたら、コードがもっと読みやすくなるのにさー。
「fooAをミューテートせずにfooB = MyFunc(fooA)を使う」ってところが全然ピンとこないんだけど。なんでそんなことするの?fooAとfooBが両方必要になる状況なんて想像できないし、正しいfooBと間違ったfooAが残ってても役に立たないじゃん?
「自分たちが不変性を使っているって気づいてない」って言うけどさ、そうじゃないんだよ。体系的で予測しやすい方法で状態を分離してるんだから。
Clojureなら不変性ってすごく簡単にできるんだけど、Pythonでやろうとするとかなりの規律が必要だよね。チームじゃなくて一人でPythonを書いてるJohn Carmackでさえ、そこは苦労してるみたいだしさ。
JavaScriptみたいな言語だと、『不変』と『定数』って理論上は同じに見えるかもしれないけど、実際は全然違うよ。constは変数への再代入ができないって意味で、ローカルな話。一方、『不変』は値そのものがその場で変更できないって意味で、グローバルな性質なんだ。不変な配列はどこに渡してもずっと不変でしょ?JSには昔からfreezeっていう実行時の不変性があったし、TypeScriptみたいなツールだとコンパイル時に不変性を保証してくれるreadonly型もあるね。
John Carmackがループ内の反復計算を唯一の例外にしてるよね。
「真の反復計算ループ以外では、変数の再代入や更新は避けるべきだ」って。
これを完全に不変な設定でやろうとしたら、再帰関数を使うことになるだろうね。ML系言語みたいな不変性重視の言語だと、このパターンはよくサポートされてて最適化もされてるけど、標準的な命令型言語だとあまり実用的じゃないんだ。
例えば、こんなsum関数みたいにね。def sum(l):<br> if not l: return 0<br> return l[0] + sum(l[1:])
もちろん、これは順序保証にもほとんど左右されないし、不変性は、例えばオブジェクトが同時に変更されないことを保証するみたいなケースでも役立つんだ。
なるほど、それじゃあ例えば「このパラメータの塊」みたいなのは不変にするけど、「この16KBくらいの浮動小数点数の塊」は常に変わる普通の変数ってこと?
それとも、その浮動小数点数のブロックは「この部分からは不変じゃない」って感じ?つまり、サンプルを処理するコードやバッファを埋めるコードは書き込めるけど、他の場所からはダメってことかな?
配列は特に顕著な例だよね。JavaScriptやTypeScriptでは、constで宣言した配列に同じスコープ内で要素を追加(append)できるし。あれ、いつもすごく変だと感じてるんだけど。
Lensesって、結局別の名前のミューテーションだよね。不変システムの上で状態を再構築してるだけだし、結局のところ、概念的には何も変わってないんだから、それが面白いんだよ。
結局、世界はステートフルだし、どんなに純粋な抽象化だって、どこかで現実と向き合わなきゃいけないんだ。Haskellの作者たちだって、副作用を追跡するための方法としてモナド型システムを考えたわけで、副作用そのものを完全に排除したかったわけじゃないしね。
PythonってJavaScriptとそんなに違うの?JavaScriptだとすごく簡単だよ。varやletじゃなくてconstを使い始めればいいだけ。それで問題が起きたら、どう対処するか考えればいいし。どうしても解決できなかったら「ねえAI、constを使い続けながらこれをどうやったらいい?分かんないんだ」って聞けばいいじゃん。
Schemeでの経験から、関数型プログラミング(FP)の利点をJavaやPython、JavaScript、TypeScriptしか使わない人たちに理解してもらうのが本当に難しかったよ。関数を使う代わりに変数を変更するやり方じゃ、どれだけコードがきれいになってテストしやすくなるか、みんな気づかないんだ。PythonだとFPスタイルで読めるコードを書くのは難しいし、適切なFPデータ構造も見つけにくいから再帰処理を工夫する必要がある。結局、Clojureみたいなのを仕事以外で学ぶ好奇心旺盛な人と、9時5時で働いてFPに触れたことない人との差だよね。
もっとコメントを表示(1)
不変性をデフォルトにして、変更可能な場合はキーワードで示すべきだね。IDEが変更されたことを視覚的に教えてくれるといいな。ほとんどの言語機能についてそう願ってるよ。例えば、リストに追加できるか選ぶ必要はなくて、追加しないなら追加できないようにしてほしい。データ構造の種類も、パフォーマンスに影響しないなら別に気にしない。CPUが10コアあるから、必要になったらループを最適化するさ。
僕のIntelliJ(最新版)では、Javaでこんな小さな関数を書くと、def変数に下線が引かれて、「Reassigned local variable」ってヒントが出るんだ。abcには下線は引かれない。Javaを書くときは、できるだけfinalキーワードを使って変数スコープを最小限にするようにしてるよ。その方が読みやすくて、保守しやすいコードになるからね。
余談だけど、インライン推論アノテーションも面白いかもしれないね。https://www.jetbrains.com/help/idea/annotating-source-code.h… を見てみて。作者が明示的に書いてなくても@NotNullが表示されることで、コードの理解に役立つし、考慮すべき分岐が減るよ。
まず、ループで++defを使うのは変だよ。次に、abcをループで使うなら、for (int def = 7, abc = 3; ...)みたいにループ内で定義すべきだね。あと、これはIntelliJのバグだよ。サンプルコードのdefもabcも常に定義されてるんだから。
++defが変だって?僕はC++の出身で、++iを使うのが常に推奨されてきたから、ただの習慣だよ。++iを読むのがそんなにストレスになるかい?
たぶんね… でも、Javaには一時変数の定義がないんだよね。
これ、RustRoverでも動くよ!すごく便利だね。
Rustの型システムは、もっと強力なツールを可能にするんだよ。例えばこれね: https://github.com/willcrichton/flowistry
これがベストな選択とは思わないな。バグやパフォーマンス問題につながるかも。暗黙の変更じゃなくて、明示的なオプトインが欲しいね。IDEが警告して、本当にオプトインが必要か、それともコードを再構築すべきか考えさせてほしいね。
ただ、可能な限り最適化される設計を選ぶという考えには賛成だよ。Rich Hickeyのセットとリストの話を思い出すな。小さいハッシュセットが小さい配列より遅いのは面白い。コンパイラがサイズやアクセスパターンを認識して最適化できたらすごいよね。
そうだね、SQLオプティマイザがいい例だよ。理論上は最適な方法を「知っている」はずだけど、これらの決定は実行時のクエリ分析に基づいて行われるから、ロジックのちょっとした変更がパフォーマンスに大きな影響を与えることがあるんだ。
仕事でSwiftを使ってるけど、コンパイラが教えてくれるよ。可変変数が一度も変更されない場合、不変にすることを提案してくれるんだ。逆もまた然りだね。
うん、すごく良いよ。定数じゃない変数は全部疑わしい目で見る習慣がついちゃうんだ。
TypeScriptもそうだよ。少なくともBiomeでLintするとね。
TypeScriptへのちょっとした不満は、constって2文字増えることかな。冗談はさておき、TypeScriptのconst変数について推論するのが少し難しいと感じるよ。const変数は再代入できないけど、参照する値はまだ変更可能だからね。TypeScriptにはもっと不変な値型が欲しいな(いくつかあるのは知ってるけど)。Swiftも理論上は同じ問題を抱えてるけど、Swiftでは不変な値型(struct)を使いやすいから、少し軽減されてるね。
ESLintにもこれがあるよ: https://eslint.org/docs/latest/rules/prefer-const
君のIDEはこれに対応してるかもね。JetBrainsの機能には、変数への全読み書きを見つけ出すのがあるよ。
あと、変更された変数を違うスタイルで表示する機能も持ってるんだぜ。
うん、ハイライトスキームによるけどね。残念ながら、全てのスキームがデフォルトでこれを見せるわけじゃないんだ。最初は些細なことだと思ったけど、非自明なコードを扱うときにとっても役立つことが分かったよ。大規模なメソッドでも、宣言上は不変じゃなくても実際に不変に振る舞う変数を直接見分けられるからね。
俺には役立つアイデアはないけど、もしこの種のリンターを作るなら“mutalator”って呼ぶことを提案するよ。
Pylintが助けになるかも?少なくとも変数の再定義チェックはあるぜ: https://pylint.pycqa.org/en/latest/user_guide/messages/refac…
でも、型に対してだけだよ。
もしErlangで書くなら、Emacsがデフォルトでやってくれるぜ ;)
Clang-tidyのmisc-const-correctnessがこれについて警告してくれる。Claude codeと連携させれば、変更されない全ての可変変数をconstにしてくれるぞ。
ああ、変数がデフォルトで不変で、すべてが式だったらいいのにね。
まあいいや、しつこいPythonの台頭に積極的に脅かされているClojureプログラマーとしての日常業務を続けるとするか。
日中の仕事がPythonプログラマーで、Clojureに興味津々だけど残念ながら個人プロジェクトでしか使えず、今はしつこいTypeScriptの台頭に脅かされてる俺には、この気持ちがよく分かるぜ。
TypeScriptやES6にはconstとletがあるって話だね。
letもconstも真の意味では不変じゃないんだ。constは再代入を止めるけど、オブジェクトや配列みたいなミュータブルな値は変更できちゃうからね。
Object.seal(foo)で変更を防げるんじゃないかな?まだ使ったことないけど。
Object.freezeがまさに探し求めてるものだよ。でもconstとObject.freezeを組み合わせるのは覚えるのが面倒だし、Carmackが求めるデフォルトの不変性とは違うんだ。Rustがデフォルトで不変性を採用したのはすごく良い選択だね。
constやletについては別にいいんだけど、言語のエルゴノミクスとかエコシステム、それより技術的な政治的決定がマジでイラつくんだよな。
もっとコメントを表示(2)
constはオブジェクトや配列を不変にはしないんだよ。
構造は不変にするけど、期待してたのとはちょっと違うんだよね。非オブジェクトには問題ないんだけどさ。
Javaの”final”は”const”よりも紛らわしくなくて、もっと良い選択だったんじゃないかなって思うよ。
メタプログラミングを避けてシンプルなコードを書くなら、PythonとTypeScriptってほとんど同じ言語に感じるよね。JSやTSにリスト内包表記がないのはちょっと寂しいけど、それくらいかな。
ザツなコードを書く障壁をなくすことは、言語の機能の一つだよね。だからJavaScriptやPythonみたいな言語って、感覚的に書けるから魅力的なんだよ。
Rustは、純粋な関数型じゃなくても全てが式である必要はないって教えてくれたんだ。Rustを使い始めて以来、他の言語もそうであってほしいと願ってるよ。ミューテーションの範囲を制限するのに最高の方法だね。
ClojureはPythonより常に速いよ。だから、少なくともそれだけは言えるね。
君はClojureプログラマーじゃなくて、Clojureで問題を解決する人だよね。職場の言語ベースの争いは残念だけど、コーディングエージェントのおかげで生産性が上がってる今、そんな人工的な境界線で疎外感を感じる必要はないよ。これを読むのをオススメするよ:
https://www.kalzumeus.com/2011/10/28/dont-call-yourself-a-pr…
その記事を覚えてるし、同意するよ。でも、10年の経験がある言語のエコシステムでは、他の「代替品」より速く、効率的で、慣用的になれるし、何がダメかもわかる。これは、違う環境に移る時に失われるものを認めるってことなんだ。言語は構文だけじゃない、機能、イディオム、ライブラリ、ツール…と続くよ。
コーディングエージェントで解決できるけど、長年の経験がショートカットできるかはわからない。新しいツールを学ぶコストは最初にかかるし、自分の興味で学ぶのと、トップダウンで強制されて学ぶのは違うんだ。
この記事が出た時に読んだけど、実践する習慣がなくなってたよ。思い出させてくれてありがとう!またこれらのアイデアを実践できるように頑張るね。
数年前、スレッドセーフティのために厳格な不変性を適用したプロジェクトをやったんだ。不変オブジェクトは複数のスレッドから安全に読み取れるからね。何が変わるか変わらないか追跡しやすくなって、コードが読みやすくなったよ。このコンセプトの大ファンだね。
当時、Rustは使えなかったんだ。驚かないと思うけど、僕もRustの大ファンだよ。
深いミューテーションは適切に使うけど、変数のシャドーイングはよくやるよ。resultを後処理するならresult = result.process()って書く方が好きだな。preresultよりいいし、開発中に仮説をテストする時とかに便利なんだ。中間結果にいい名前があればつけるけど、result_without_processingとかはつけない。コード読めばわかるさ。
君は抽象的に話してるから、すごく一般的な言葉を使ってるんだと思うよ。でも、ほとんどの状況で、計算の各ステップにはもっと分かりやすい具体的な名前が使えるはずだよ。
“自明”な非一般的な名前が簡単に見つかるって意見には反対だな。“命名”はコンピュータサイエンスで一番難しいことの一つだからさ。
俺はJSON < Generatorクラスのgenerate関数でresultって名前を使ってるよ。こういうのは普通だろ。
もし一般的な名前を使うと決めてるなら、processed_resultみたいな名前で何が悪いんだ?
彼が説明してるフローだと、processed_processed_processed_resultって名前になっちゃうだろ。
AbstractFactoryResultFactoryProcessedResultProcessedResultProcessorBeanFactory
…BeanFactoryContextConfigって感じ。まずコンテキストを設定して、それを使ってbean factoryで処理を開始するわけさ。
その名前、ちょっと冗長じゃない?resultって時点でprocessedって意味してるんだからさ。