🦜
Goのなぜ問答
はじめに
Goはできるだけ冗長な機能セットを増やさずに応用の効くシンプルで強力な機能セットに絞り込んだ設計であることを目指した言語処理系です。なのでリッチな機能を持つ言語処理系経験者からするとたくさんの「なぜ?」を感じると思います。
しかし、Goの開発者たちは他の言語処理系にある機能だからGoにも採用しようとは一切考えません。あくまで大きなゲイン(デメリットをメリットが大きく上回る)を示されるまでは採用しません。特に言語仕様についてはより変更を嫌う傾向があります。「Go1の約束」というものがあり、Go1.0向けに書かれたコードはGo1.xでも動くもしくは機械的にコードにパッチを当てることで移行可能にするということをずっと守っています(約9年?)。
最近になりGo2プロポーザルがたくさん書かれ、それらの提案のうち言語仕様に関するものは最終的に2〜3個に絞り込まれ順次採用されていくという計画です。その結果、次期リリースGo1.18にてこれまでにおける最大の変更であるジェネリクス機能が言語仕様に加わります。
逆にいうと、この上位2〜3個に匹敵する優先度でない提案はリジェクトされるということです。もちろん他の言語に似せるだけのような提案は通りません。
なので、「GoにはなぜXXなの?/なぜXXがないの?」というような疑問は多々存在しつつ、それらの多くがおそらく将来変更されることはありません。個人的な認識を紹介してその理由を列挙していこうと思います(理由に関する認識は将来変化するかもしれませんので参考程度に)。
Goはなぜポインタを採用したの?
- 空とnot空の概念そのものは必要
- ポインタの概念を排しても結局別の似たような概念は必要になる
- CPUフレンドリーかつ直接的な表現
- ポインタとしての悪手は極力できない仕掛けがある
- 空になりうるかどうかを意識することはコードを書く上では元々必要なこと
- どんなに便利なイディオムや型があっても空になるかどうかという意識と使い分けが必要なのは同じ
Goはなぜ多値返しを採用したの?
- C派生の返値は一つだけという方が逆に不自然だった
- そのために引数にて結果値を受け取るようなインターフェースのライブラリが大量に作られた
- また、バッファの長さを引数にわたすが、関連する結果の長さは返値で返され、その処理失敗は負の数で表すなど不自然な使われ方をしてきた
- 多値返しはC系の基本であるスタックに積む挙動の拡張版で低コスト
- 多値返しはC系の不自然な結果の返し方を自然な形に置き換えることができる
- 多値返しはあらかじめ確保したスタックに積むだけでオブジェクトを返すよりもCPUフレンドリー
- 生成されるコードコストはレジスタ返し<スタック返し=多値返し<オブジェクト返し
- 正常系と異常系の返値セットの基本は直和で返すのが慣習になっている
- だったらタプルやEther、モナドで返すほうがスマートという意見はあるが、オブジェクトを生成するためのコストが上がってしまう
- もちろんパフォーマンスが重要でないインタープリタ系ではオブジェクト返しをするデメリットはほとんどないのでオブジェクトで返すという選択は間違いではない
- さらに直積で返す需要がレアケースで存在していてEtherやモナドを採用する場合、このレアケースをどうハンドリングするのかを決めなくてはならない
- Goでは異常系をパニックとエラーに分類してハンドリングしつつ、エラーを多値返しで統一できているところが素晴らしい(直積ケースでトリッキーなハンドリングを要求されたり、エラーの受け取り方法が複数あったりするわけでもない)
Goはなぜasync/awaitを採用しなかったの?
- ほとんどの非同期サポート言語が採用しているasync/awaitは採用せず
- Goはgoroutine、chan、selectを用いたCSPスタイルを採用した
- async/awaitは非同期の記述を抽象化できるメリットはあるが、大きなデメリットも引き込んでいる
- 返値や例外オブジェクトをスタックを跨いでawait親スレッドに順次引き上げるというコストを常に払うことになる(要/不要に関係なく)
- ここをGoは必要ならchanでEnd-to-Endで受け渡すだけ(階層の深さを無視できる)
- async/awaitはキャンセルの方法を規定していないイディオムのため、キャンセル方法が言語処理系や呼び出す機能によってまちまち
- Goではcontext.Contextを使ったキャンセル方法に統一する方向に既に進んでいる
- 複数のasync関数をawaitする方法が言語処理系ごとにまちまち
- ここをGoではchanとselectという組み込みイディオムで対応できる
- つまり、非同期処理を順番に記述できる1手法を提供するのがasync/awaitで、細かい非同期・並行処理に関する要求には応えていないので他の実装でそこを埋める必要がある
- goroutineとchan、selectという言語組み込み機能は筋力は要求されるが組み合わせ次第で細かい非同期・並行処理に関する要求に対応することが可能
- また、goroutineはブロッキング処理の混入でもレイテンシを落とさない仕組みを持つが、他のメジャー言語処理系の多くはブロッキング対策がないため混入しないよう細心の注意が必要になる
- goroutineは同期処理も非同期処理もブロッキング処理を含むかどうかに関係なく内包できる強みがあり、あらゆる処理をgoroutineに載せられる
- async/awaitの場合、async関数じゃないブロッキングな処理があるとそこはasync対応な記述に書き直す必要が生まれる
Goはなぜ継承がないの?
- Goの設計者は「継承を機能拡張やサポート範囲の拡大に使うこと」は悪手と考えている節がある
- 継承とは特化でありそれがマッチする用途は実は限定的(GUIにおけるクラスhierarchyぐらい?)
- Goでは機能の拡張のために「構造体への型の埋め込み」という機能を提供した
- Goでは型横断でオブジェクトを受け取る側が必要なインターフェースを宣言するという考え方の元に継承という概念を捨てた
Goはなぜ例外機構を辞めたの?
- 上記に書かれた通り、例外には例外のツラミというものがある
- また、スタックが分断される非同期の世界における従来の例外機構は実装が複雑でCPUに優しくない
- Goにおける例外は非同期の世界に階層化中断機構をもたらしたcontext.Contextであるという意見もある(非同期の世界では広域脱出よりも中断の方が重要)
Goはなぜif式や三項演算子を採用しないの?
- if式も同じ理由で採用されないのだと思われる
- 高頻度に必要なら各自そのような関数を用意すれば良い
- Go1.18のジェネリクスにより近似機能がライブラリベースで追加される可能性は若干残っているが、言語仕様をいじってまで採用されることはない
- Goで書かれた過去のコード、将来のコードにとって大きな相乗効果メリットがない
Goはなぜイミュータブル修飾がないの?
- ユーザー定義構造体かつ非ポインタ型の定義であれば毎回コピーされつつ不変性を利用可能
- ユーザー定義構造体に対する場合コピーコストやメモリ占有量が増えることになる
- イミュータブルとミュータブルを相互に変換するのにはコンストラクタやコピー機能が必要になる
- Goの既存機能でGetterメソッドのみのインスタンスを作ることはでき、こちらの方がコントローラブル
- つまり、必要なら既存の機能でベターに実装可能ではある(ベストな方法ではないが)
- イミュータブル修飾が効率的に機能するには関数型言語としての最適化機能もなければ片手落ち
- イミュータブル修飾を採用することはコードの書き手にもコンパイラにも複雑さを増やすことになる
Goはなぜletやconstによる変数束縛方法がないの?
- 「:=」はES6のletに近い挙動
- 同じシンボルに対する「:=」では同じスコープではエラーとなり、
- 違うスコープではシャドウイングによる広域側シンボルの隠蔽という扱いになる
- つまり同じシンボルの再束縛はもともとできない
- 実質的にはES6のletやconstと同様再束縛することを自然と避けた形になる
- ES6のconst修飾は代入も禁止できるが、値がオブジェクトの場合、子孫要素には代入が可能
- Goのconstは真の定数(コンパイルタイムに確定する)を宣言するもの
- Goの変数束縛はJS(ES5.1以前)のvarほど問題にならないし、十分ということ
GoはなぜEnum型がないの?
- iotaとdefined-type、stringerツールで十分Enumとしての機能を果たすことができる
- Enumに要求される機能に対し、一般的なEnum型の仕様は複雑すぎる
- Enum型の高機能に触れると継承して拡張しようというアイディアが生まれよりEnum型に高機能を要求してしまう
- Enumのひとつのタイプに関する宣言は1箇所にまとめられないと把握しづらい
後日書いた
Goはなぜ組み込み型にメソッドがないの?
- 従来のオブジェクト指向言語処理系では当然あった機能の多くはメソッドでは提供されず
- ライブラリや組み込み関数でのみ操作機能が提供される
- 特定のアルゴリズムがメソッドとして組み込まれるとある面で有利な方法が別実装された時、後方互換として残すことになる
- Goではプリミティブ型の派生型で型横断性のある実装提供はしない(その場合interfaceを使う)
- そのためメソッドオーバーライドにより振る舞いを変更することもないのでメソッドである必要がない
- また、互換のコンテナを作る時に持っているメソッドを一通り実装しなくてはならなくなる
- この辺りが面倒で関数で処理を実装してしまい、ある操作はメソッドで特殊な操作は関数でといったアンバランスな実装が作られがち
- 限定機能のコンテナを作る時、プライベートフィールドにプリミティブコンテナを置き、アプリケーションが要求する機能のメソッドを生やすだけで良い(これだけで用意された以外の操作ができてしまう余地は無い)
- 必要最低限の機能だけがあるということは、読み手が認識していない挙動がほぼないということ
- 生やしたメソッドはレビューで確認すべき実装であることが明確
- これらはレビューの時の効率をかなり引き上げる
- SliceもMapも遅くなるような機能をあえて持たせていないことで、高機能なコンテナ実装の多くはSliceやMapをベースに実装される(ゼロベースでSliceやMapが再実装されることがない)
- これにより実装の分断が発生せず、SliceやMap実装の最適化の恩恵が広く得られる
- Sliceの1要素削除に要求される性能は実際にはアプリ次第でばらついているので非効率で安パイな1要素削除機能を提供してしまうとそれが多用されてしまう恐れがあるために提供しないという考え方
-
s=append(s[0:x],s[x+1:]...)
がx番目の要素削除であることを理解するにはメモリのコピーが発生することを意識する必要がある(つまり削除の処理量が想像できる) - そして効率的な削除が必要ならMapで代用にならないかという検討をするきっかけになる
Goはなぜオペレーターオーバーロードがないの?
- JSで
[1,2,3]+[4,5,6]
が"1,2,34,5,6"
になる悲劇 - ユーザーの期待とオペレーターオーバーロード挙動のミスマッチはちょくちょく発生している
- 特殊なオペレータを実装してしまう衝動を断ち切って
- バランスよく期待される挙動サポートとエラー処理を実装することができる人は実は少ない
- これは言語拡張であり、後述の考え方によりおそらく今後も採用されることはなさそう
Goはなぜマクロや言語拡張サポートがないの?
- Goではプログラミングスタイルを変形させることができるような機能は特に採用しない
- 言語を拡張するような機能は多様なスタイル差を生み出してしまい、個人差を拡大する
- マクロ相当はGoでは最適化によりインライン化される関数定義で十分だと考えている
- 言語自体を拡張するような機能も同様(S式などはGoの考え方の対極にある)
Goはなぜオーバーロードがないの?
- GoはCと同じくシンボルと実装を1対1に対応づけることを優先した
- Cと違うのはパブリックなシンボルではパッケージ名のプレフィックスが付与されていること
- 低レイヤのデバッグでこのことは最大限効果を発揮する
- アセンブリレベルでコードを追うときに助かる
- オーバーロードは実は使い所や実装範囲を決めるのが難しい機能でその選択にスキル差が大きく出る
- ジェネリクスがオーバーロードの役割を部分的にサポートできる
- つまり、オーバーロードよりもジェネリクスの方がメリットが大きく優先度が高かったのと、デバッガビリティを悪化させるオーバーロードを採用することはなかったということ
- この辺りの考え方はRustも同様らしい
まとめ
- Goにはベストの書き方を提供できなくてもベターに書ければOKという考え方がある
- また、提供される機能が必要最低限であることもGoおよび標準ライブラリの特徴
- そのかわり余計なことはしないうえ、効率は極力損なわない作りになっている
- そのためあらゆる派生ライブラリの多くはゼロベースで作られるものがあまりない
- 他所の言語処理系の機能をただ似せるためだけに採用はしない
- 他所で成功している機能であってもGoにとってゲインが大きく得られなければ採用はしない
- 特に言語仕様はこの10年で2〜3個に抑えられる見込みで将来もそう大きく変更することはない
- 言語自体を拡張していくような機能は特に採用されない(マクロなど)
- Goの機能チョイスは「余計なことしない・できない」というものを選んでいるように見える
- 読みやすさのためGoのチュートリアルに登場するイディオムだけでコードを表現
- 他の言語に比べ、「えっこんな書き方もできるんだ」に遭遇する回数はかなり少ない
- 同様にテストコードも既知のイディオムだけで記述できるというところが重視されている
Discussion