Goは依然として良くない
(blog.habets.se)- Go言語設計におけるさまざまな決定は、不必要であったり既存の経験を無視したまま行われてきた
- エラー変数のスコープ管理の問題が、コードの可読性とバグの追跡を難しくしている
- nilの二重性、メモリ使用、コードのポータビリティなど、さまざまな点で直感的でなく現実に合わない設計が見られる
- defer構文の限界と標準ライブラリの例外処理方式により、例外安全性の確保が難しくなっている
- メモリ管理やUTF-8処理の不備など、蓄積された問題がGoコードベースの品質に長期的な悪影響を及ぼしている
Go言語に対する長期的な批判
- 以前の投稿(Why Go is not my favourite language, Go programs are not portable)で述べたように、私は10年以上にわたってGo言語のさまざまな問題点を指摘してきた
- とりわけ、すでに知られている良い実践を無視した不必要な設計判断が、ますます残念に感じられる
エラー変数スコープの非直感性
- Goの文法は、エラー変数(err)のスコープを不必要に広げ、ミスの可能性を高めている
- 例示コードではerr変数が関数全体で生き続け、再利用され、その結果コードの可読性と保守性が低下する
- 熟練した開発者であっても、このスコープ問題のせいでバグ調査において誤解や時間の浪費を経験する
- 変数を適切に局所化する方法が文法上許されていない
2つの形のnil
- Goには、interface型とポインタ型でそれぞれnilが異なる挙動を示すという混乱がある
- 下の例のように、s(ポインタ)とi(interface)にnilを代入しても、s==iの評価が異なるなど、一貫性のない挙動を示す
- これはnull処理で一般に避けたい問題であり、設計上十分に検討されたとは思えない痕跡である
コードの移植性の限界
- 条件付きコンパイルのためにコメントを使う方式は、保守性とポータビリティの面で著しく非効率だ
- 実際にポータブルソフトウェアを作った経験があれば、この方式が煩雑でエラーを招くことはわかる
- 歴史的に蓄積された経験(コードのポータビリティ、実務上の事例)が無視されている
- 詳細はGo programs are not portableを参照
appendの所有権の不明確さ
- append関数とスライスの所有権関係が明確でないため、コードの予測が難しい
- 例を通じて、foo関数でスライスにappendしたとき、実際に元のデータへどんな影響があるのかを事前に把握しづらい
- 言語の覚えておくべき「quirk」が増え、ミスを招く
defer構文設計の不備
- RAII(Resource Acquisition Is Initialization)の原則のように、リソース解放を明確に支援していない
- JavaやPythonの構造化されたリソース管理構文と比べ、Goではどのリソースをdeferで解放すべきかが明確でない
- 例のようにファイル操作では、double-close問題まで自分で扱わなければならず、正しい解放順序や方法が不明瞭だ
標準ライブラリの例外処理
- Goは明示的な例外(exception)をサポートしない構造だが、panicなどの例外的状況は依然として発生する
- panicが一部の状況では完全にプログラムを終了させず、潜り込んでしまう場合もある
- 標準ライブラリ(fmt.Print、HTTPサーバーなど)には例外を無視するパターンがあり、真の例外安全性の保証は不可能だ
- 結局、例外安全なコードを書くことは必須だが、例外を直接使うことはできない
UTF-8処理と文字列
- string型に任意のバイナリデータを入れても、Goは特別な検証なしに動作する
- UTF-8エンコーディング以前に作成されたファイル名などが、静かに欠落してしまうケースを経験しうる
- バックアップなどで重要なデータが失われる可能性があり、実務の状況を反映しない単純な処理方式だ
メモリ管理の限界
- RAM使用量を直接制御しにくく、GC(ガベージコレクション)の信頼性にも限界がある
- Goのメモリ使用量が増え、長期的にはコストや性能の問題につながる
- 複数インスタンスやコンテナ環境で、コストとスケーラビリティの問題が実際に発生する
結論: もっと良い道があった
- すでに効果的であると証明された言語設計が存在していたにもかかわらず、Goは多くの点でそれに背を向けた
- Java初期案の問題点とは異なり、Goが登場した当時にはすでにより良いアプローチがあった
参考資料
- Uber: Data race patterns in Go
- FasterThanLime: Lies we tell ourselves to keep using Golang
- FasterThanLime: I want off Mr Golang’s wild ride
1件のコメント
Hacker Newsの意見
Goはpre-1.0の頃から、ほぼすべてのフルタイムの職場で使ってきた。チームメンバーが基礎を学ぶにはシンプルで、概ね安定して動く。Goの最新バージョンに更新するときも心配することはほとんどなく、たいてい役立つ機能は標準で入っている。コンパイルが速いのも魅力だ。並行処理は少し厄介だが、時間をかければデータフローを表現するのに向いてくる。型システムはたいてい便利だが、ときどき冗長でもある。全体として信頼できる道具だ。ただ、記事で挙げられていたいくつかの指摘には共感する。Goには、旧世代の開発者たちが原則にこだわりすぎて実用的な利便性を取りこぼした部分が確かにある。もちろんこれは私の感覚で、欠点をすべて解消していたら今より悪くなっていたかもしれないとも思う。ここ数年は、癖のある部分を直すことに前向きになってきた空気も感じる。かつてジェネリクスやカスタムイテレータが追加されるとは想像もしなかった。RAMや移植性に関する指摘は、やや個人的な不満にも感じる。改善されればよいが、GCが大半のプログラムで深刻な問題を起こすことはきわめてまれで、デバッグも難しくない。そしてGoはほぼすべての重要なプラットフォームをサポートしている。ただし、エラーと
nilの扱いには相変わらず不便さを感じる。Result[Ok, Err]やOptional[T]のような構文がよく恋しくなる私はむしろ、Goは原則に固執したのではなく、目の前の問題を素早く片づける利便性に執着したのだと思う。問題を根本から分析して正しく解決したのではなく、"Not Invented Here"の精神を捨てて場当たり的に作って使ったような感じだ。GoのファイルシステムAPIがその典型例だ。ファイルを開く関数が必要なら、単に
func Open(name string) (*File, error)みたいに作って終わり。ではファイル名がUTF-8でなかったら? 5年間その問題が起きないから気にしないGoの設計原則は、しばしば「コンパイラを簡単に作り、コンパイルを速くしよう」という目標に偏りすぎているように感じる。開発者の利便性より、コンパイラやコンパイルそのものにだけ焦点が当たっている構造だ
20年ぶりにコンパイル言語として、新しい職場で初めてGoを本格的に使っている。個人の好みだろうが、正直使っていて不快に感じることもある。デフォルト引数がない、エラー処理のやり方が気に入らない、本番環境でまともなスタックトレースがない。オブジェクト指向の構文は各関数にぎこちない参照を付ける必要があって見栄えが悪い。ポインタも負担だ。結局、C/C++の古い技術に戻った感じがする。大学時代の1999年ごろのプログラミングの空気そのままだ
Goは並行処理の面で、私の経験ではマルチコアCPU環境で言語として並行性を自然に扱える唯一のシステムだ。CSPスタイルのgoroutine/channelの定式化のおかげで、並行処理ロジックを直感的に表現できる。PythonはGILとわかりにくいasyncライブラリ群で悩まされる。C、C++、Javaなどは言語外の追加ライブラリが必要なので、言語レベルで並行処理を推論しやすくない。だからHTTPサーバーやサービス用途にはgoが完璧に合っていると思う。私の経験ではこれに匹敵する代替はない
開発者の観点から見ると、ergonomics、つまり標準化と一貫性という面では完璧だと感じた。複数のマイクロサービスのコードベースでもスタイルの違いを心配する必要がなく、フォーマット論争もいらない。ただ、Go独自の標準的なやり方を選ぶ際に、少し古いスタイルにこだわりすぎた気がする。今の開発者は
map/filterのような関数型メソッドをもっと期待するが、Goはインデックスミスの危険があるループしか提供しない。TypeScriptほど賢い型システムでもない。エラー処理も不便だ。こうした機能を追加すると「創造的だが良くない使い方」が増える懸念は理解できるが、JS世代にgoを納得させるのが難しいという実感もある5年以上、大規模なGolangプロジェクトに専念してきたが、メモリ使用を最小限にしなければならないコンポーネントを作ると、Goの粗さによくぶつかる。GCが素早く回収しなかったり、ヒープ断片化の問題が深刻になったりする(GoがコンパクティングGCではないために起きる問題だ)。そのせいで割り当てを完全に避けようとするが、バグが入りやすい。デバッグも極めて厄介だ。ヒーププロファイルを取っても生き残ったオブジェクトの情報しか見えず、実際に溜まったゴミや断片化の内訳は見えないので推測に頼るしかない。たとえば、X関数がヒープを1KBしか割り当てていないように見えても、ループ内で呼ばれ続ければ数十MBのゴミが発生する。そこで静的バッファを先に確保して再利用するが、所有権の問題が複雑になり、
appendのような落とし穴も生まれる。標準ライブラリを自分で再実装しなければならないことさえある。自分たちのケースが一般的でないのはわかっているが、本当に言語と戦っている感じがして残念だこういう場合は、むしろメモリをheapの外に移したほうが苦痛が少ないかもしれない。もちろんGC言語なので簡単ではないが、あまりにもC++/Rust的なコードをGoで無理やり実装するくらいなら、その部分だけでも最初からその言語に置き換えたほうがよい
こういう状況でgo言語を選んだこと自体が問題だったと思う。C/C++/Rust/Zigのほうが適しているという意見だ
新しい"Green Tea"ガベージコレクタが助けになるかもしれないという話がある。これはメモリ中心ではないにせよ、メモリ的に近接したオブジェクトをよりうまく扱う並列マークアルゴリズムだ。関連情報はこちらで参照できる
arena実験が進んでいたが、現在は中断されている。それでも興味を持つ価値はある
役に立つ話ではなくて申し訳ないが、今の状況を見ると、言語選択が完全に間違っていたと思う。おそらく社内の公式な言語ポリシーのせいで無理にgoを使っているのではないかと推測する。大企業では広く使われている言語でしか本番投入を認めないことがよくある
Goの
deferが関数スコープでしか動かず、レキシカルスコープには適用されない理由がいまだに理解できない。これを知ったきっかけも、ループ内でファイルを処理していたところ、ファイルリストが大きくなるとdeferが関数終了までハンドルを閉じず、クラッシュした経験からだ。周囲のGo開発者には、ループ本体を無名関数で包めと言われた。そのほかいくつか細かい点を除けば、Goは快適に感じられ、効率のよい文法で、無駄な「見せびらかし」文化を防いでくれる。C#プロジェクトを大規模にGoへリライトしたことがあるが、機能は10分の1なのにコード量はむしろ少なかった。GC割り当てを強制するのではなく、高性能なデフォルトを使うよう促してくれるし、serializationのような作業では組み込みのコード生成機能が便利だ。ORMのように何でも言語で置き換えようとするC#の文法と違って、GoではSQLはそのままSQLで書き、gRPCもprotobuf仕様で扱うという空気があるときにはレキシカルスコープの
deferが必要で、ときには関数スコープが必要だ。たとえば、ループ内で複数のファイルを開き、関数が終わるまで全部開いたままにしたいなら関数スコープが必要になる。今は関数スコープだが、レキシカルスコープが必要なときはfuncで包めばよい。もしレキシカルスコープしかサポートされず、関数スコープが必要ならどうするのかは曖昧だラップ用の関数なしでインデントを1段減らせること、動作がコールスタックやスタックアンワインディングと結びついていること、Cの
goto failスタイルから見れば自然なことなどが利点だ。もちろん、ループ内でdeferを使うときに別の関数で包まなければならないのは少し不便だブロックレベル言語と関数レベル
deferの両方を使った経験があるが、ときどき条件文の中でも関数レベルdeferが使えたらいいのにと思うことがあるそんなに深い理由があるようには思えないし、実際それが重要なのかとも思う
C#でもSQLやprotobuf仕様で作業できる。ただ、別の選択肢もあるというだけだ
Goにも欠点は多いが、サーバーサイド言語という範囲では、これほどバランスの取れた言語はないと感じる。NodeやPythonより速く、型システムもより良いと思う。Rustより参入障壁が低く、標準ライブラリやツール群も優れている。シンプルな文法と、ひとつのやり方だけを強制する部分も気に入っている。エラー処理には問題があるが、Nodeのように
catchにどんなエラーでも入ってくるよりはましだ。もしこの条件を全部満たすもっと良い言語があるなら知りたい。自分はGoの狂信者ではなく、キャリアの大半でNodeでバックエンドを書いてきたが、最近Goを実験的に使っているところだ実際、これらの長所はJavaやC#にもそのまま当てはまる気がする
"Node"をプログラミング言語と呼ぶのは引っかかる。NodeはJavaScriptランタイムで、今どきNodeで動くプロジェクトのかなり多くはTypeScriptで書かれている。つまり、Nodeと言っても使っている言語が明確ではないということだ。TypeScriptを基準に見るなら、むしろGoの型システムより生産的だと感じる。Rustと比較しても同じ主張はできる
たいていの言語にはそれなりに不便な点がある。Goはパフォーマンスとポータビリティ、ランタイムやエコシステムにも優れている。一方で、
nilポインタ、zero value、デストラクタがないこと、マクロがないことといった欠点もある(Goでマクロ不在を回避しようとしてコード生成が乱用されがちだ)。より良い言語もあるが(例: Rust)、そういう場合はGoよりずっと複雑になる。その理由は、Goの作者がシンプルさを最優先にしたことから来る問題だ最近のPythonの型システムの進歩を考えると、Goよりはるかに先を行っていると思う。structural typingだけを見てもPythonのほうが印象的だ
Goの型システムはかなり不足していると思う
Go製のstatic site generatorを拡張したことがあるが、コードは非常に明快で読みやすい一方、言語としての穴のせいで拡張性が低かった。単純な変更でも、コードのあちこちを苦労して直さなければならなかった。さまざまなレベルのカプセル化や抽象化が難しく、「シンプルさ」のために抽象化が犠牲になっている。抽象化は、抽象化しやすいコード、つまり拡張可能なコードを作る最重要の方法なのに、Goは拡張性より単純さを選んでいる。概してGoのプログラムは「拡張性のないシンプルさ」にとどまる感じがする。人々はGoはそういう言語だと言い張るが、私の経験では納得できない。せめて「開発体験」だけは悪くない
Goについての会話はいつもどこか奇妙に感じる。批判すると、たいてい「その言語はもともとそういうものだ」と受け入れろという空気になる。シンプルさを強みだと言うが、
mapのキー一覧を取り出すのに自分でループを書かなければならないのは、本当によりシンプルなのか疑問だGoを少しかじっただけで、こういう批判を簡単に下せるのかと聞きたい。私は2015年から無数の大規模なGoコードベース(数百万行)を経験し、複数のチームで働いてきた。Goの拡張性はC、C#、Javaと比べて特別劣ってはいない。Goは表現力より明快さを選ぶ傾向が強い。そのため抽象化レイヤーは少なく、より具体的で明示的に書く習慣がつく。しかし、それが拡張不能につながるとは思わない。モジュール的で拡張可能な設計は、言語ではなく開発者が学んで身につける領域だ。君が扱ったコードが設計不良だっただけで、Go言語の限界ではない
Goを数年使ってきて、小さいものは素早く作れるが、規模が大きくなるほど数多くの小さな不便に悩まされる。特にデバッグは悪夢で、未使用のXがあると(デバッグ中に特定部分をコメントアウトしたときにいつも起きる)、そもそもコンパイルが通らない。不要な様式、特殊なファイル名、予約済みフィールド名が多いのも面倒だ。標準ライブラリに潜むpanicや、予想外のヒープコピーも遅くて厄介だ。Goの「魔法」っぽい部分の大半は、既存の仕組み(特殊ファイル名、大文字小文字など)を無理やり流用した副作用だ。本当に
publicのように公開を表したいならpubと書けばよかったのに、妙に頑固だ。今はAIがかなり良くなっていて、Rustで型の問題や借用チェッカの問題が出ても、AIにすぐ聞いて素早く解決できるのでずっと楽しい。昔のように文書やSOを漁って時間を無駄にする必要がない最近Rustを本格的にやってはいないが、昨年12月に少し触ったとき、AIがRustにあまりにもよく対応していて驚いた。詳細な文法と明示的な型情報が多いので、むしろ人間よりAIのほうがうまく解ける
Goでデバッグ中のコンパイルエラーに不満を言うと、Go界隈では「ちゃんとしたツールを使え」とたしなめられる。原則を極端に適用しすぎて不便だ
このデバッグ周りの不便さをGoの創始者に話したことがあるが、その人ですら問題を理解していなかった。あまりにアマチュアっぽくて失望した。ちなみに、AIはGoをむしろあまりうまく扱えない。言語としてはシンプルな部類なのに、ChatGPTはJava、C#、Pythonのほうをよりよく支援する
個人的にはGoは好きではなく、致命的な欠点も多く見えるが、それでも人気が高い理由は明らかだ。Goは比較的速く、goroutineベースのおかげでマルチスレッドなしでも安定した信頼性の高い高並行サービスを簡単に書ける。GoogleがGoを出した当時、似たように大衆的で静的なコンパイル言語はほとんどなかった。今でも似た立ち位置の競合はJava(今はvirtual threadをサポート)くらいだ。async/awaitを持つ言語も似た約束をするが、実際には複雑さが多い(非同期タスクでのブロッキング回避、function coloringなど)。Erlangも同じカテゴリではない。結局、欠点は多くても、goroutineとGoogle製プロジェクトというネームバリューで人気が高い
徐々にJVMがGoとのギャップを縮めつつある。virtual threads、zgc、lilliput、Leyden、Valhallaのようなプロジェクトによって、どんどん良くなっている。Java 8から25までの変化は非常に大きい。今後はさらに使いやすくなりそうだ
Goの明示性とシンプルさは、LLM支援プログラミングに非常に向いている。昔のGo 1.xコードもそのまま最新バージョンでよく動く
実際、Google内部ではvirtual threadsを導入したJavaのほうがGoよりずっと頻繁に使われている
新しいプロジェクトに最も向いている「モダンな言語」は何だと思う?
1.0リリース前からGoが好きだったが、「いまだにできていない」という評価には共感できない。もちろん欠点や不満はあるが、創始者がプロジェクトを離れると中央集権的なビジョンを保つのが難しくなり、言語が悪化する危険もあると思う。「サーバー言語」としてだけ位置づけられているのも、結局RustやPythonなどへ流れていく原因になるだろう。Visual Basicも馬鹿にされていた時代があったが、結局必要な人たちはうまく使っていた
Goの欠点を扱った批判記事は、実際に細かく見ていくと大きな問題とまでは言えない内容が多い。ほとんどは技術的には正しいが些細なことだ。逆に本当に深刻な言語設計上の問題は、zero value、コンストラクタ非対応、null処理の弱さ、デフォルトでmutableであること、ジェネリクスを念頭に置いていない型システム、任意精度をサポートしない
int、所有権が曖昧なsliceなどだ(関連イシュー 1、関連イシュー 2)。sum typeがないこと、文字列補間をサポートしないことなども欠点だGoについて本を書くほど偏っているかもしれないが、10年以上Goを使ってきた立場からすると、最初は本当に新鮮に感じた。Javaよりボイラープレートが少なく、習得しやすく、性能も十分だ。最高の言語はなく、用途ごとに最良の選択はあるが、典型的なバックエンド作業では後悔のない選択だ