Goへの誤解について


よくGoで誤解されるポイントについて個人的な見解を書いておきます。

今回の記事はGoアドベントカレンダー2017 その3の20日目の記事です。

使ってないパッケージがコンパイルエラーって面倒じゃね?

さっさとgoimportsかgoreturnsを保存時に自動実行するエディタ環境を使いましょう。 gofmtも一緒に実行されていいことずくめですよ!

インターフェースがnil判定出来ないパターンがあるのダメじゃん?

最初は私もそう思いました。しかし、typed-nilがnilリテラルと比較できなくなったのは 「nil判定サボったままinterface型に変換した」からでサボらなければ全く問題にならないのです。

map,sliceが不便?

map,sliceはメソッドが一切ありません。 極論をいうとGoのプリミティブ型みたいなものなのです。 ユーザーが欲しいものはmapやsliceを駆使して各自実装するということ。 また、別名の型おこしてメソッドを生やしてダックタイピングすることもできます。

JSONやXMLの扱いめんどくさい?

ちゃんと対応する構造体定義を用意することでより堅牢になるし、周辺もハンドリングで困らなくなるのです。 構造体定義があればパース時点でゆるく検証が済み、追加の検証コードは少なくて済みます。 カッチリ目に検証する場合であれば結果としてコード全体トータルではコンパクトになります。

型がふわふわしたものをふわふわしたまま扱おうとするからコードは煩雑になっちゃうのです。 ちゃんと型を決めていないJSONやXMLを投げ合うのは自分も周辺も不幸になる悪習なので早めにやめたほうがいいよ!

Goにはジェネリクスがない?

いろんな型を横断する処理を「多相的処理」と呼ぶと仮定して、 ジェネリクスは多相的処理のひとつの解決手段にすぎないのです。 Go言語には確かにジェネリクスそのものはないですが、いくつか多相的処理の解決方法を持っています。

ここに解決方法のリストと利点不利点は綺麗にまとめられています。

https://appliedgo.net/generics/

  1. その多相的処理は本当に必要なのか。より具体的な設計とすることで不要にならないかレビューする。
  2. 小さなコードであれば似て異なるコードをコピーペーストする。
  3. 明確なインターフェースを定義してダックタイプにより多相的処理を実装する。
  4. 空インターフェースから動的タイプアサーションにより多相的処理を実装する。
  5. リフレクションにより多相的処理を実装する。
  6. go-generateによりパラメトリック多相処理を実装する。

まあ1.と2.は冗談に見えるかもしれませんが、本質的には重要なポイントです。 わずかな行数のためにわざわざ複雑なことをするのは未来への負債になっちゃいます。

また、mapとsliceはジェネリクスっぽく使えるのもあって、 1.〜5.の解決方法で他言語におけるジェネリクスで解決してる要件のほとんどをカバーできます。

残りはひと手間増えるけれどgo-generateでカバー出来るし、 パフォーマンス・型安全性ともにジェネリクスと同等にできます。 サンプルとしては https://gist.github.com/nobonobo/371729193a17775f9ccb396fe89f58c4 を参考に。

「ジェネリクスがないなんて有りえない」という表現がよく見受けられますが、 「ジェネリクスがない=多相的処理支援がない」という解釈なら確かにその通りだと思います。 でも、Goはそのような状況にあるわけじゃなくてGoには多相的処理を支援する仕組みがあります。

そのおかげか、多くのGoユーザーはジェネリクスをなにがなんでも欲しいとは考えていません。 本当に要望が高いのであれば、まず手始めにgo-generateの素晴らしい実装を誰かが作るはずです。

仮になんらかのジェネリクスがGo2で採用されたとしても次は 「オーバーロードのないジェネリクスなんて本物のジェネリクスじゃ無い」 という状態になって現状に不満を持つ人々の不満は解消されることはないのだろうと思います。

あとGo2うんぬんは原文をよく読んでね。

Toward Go 2

例外処理が無いの不便では?

従来の例外処理はエラーハンドリングが集約出来るのが利点だったんですが、 現状エラー通知は例外以外の方法が増えてしまいました。

つまり例外処理だけではハンドリングしきれません。特に非同期周り。 例外処理はエラー通知を一旦集約してしまうのと広域離脱があるおかげで 集中してハンドリングロジックを書くことができたのが大きな利点だったのですが、 非同期エラー通知のハンドリングを別のところに書かなきゃいけない状態になりました。 非同期エラー通知を受けたところで例外に載せ替えようとしても スタックが異なるのでそう簡単には例外処理に集約はできません。

モダン処理系は「async/await」で「非同期処理でも例外をハンドリングできるように」しようとしてますが、 原因とハンドリングとの距離が離れるのは回避できないし、 従来の方式が非推奨にはなってないのでハンドリングロジックの書きどころが集約されないままです。

どんな例外をあげる可能性があるのか把握するのには正確なドキュメンテーションが必要ですが、 ほとんどのドキュメントは例外を網羅して記述しきれていません。 また、ミドルレイヤーの開発者も下位レイヤがどんな例外をあげるのか把握しきれていないということも多々あります。 結果としてコードを深く読む必要があります。

「きっちりした例外ハンドリングを書く」のと「エラーチェックのif羅列」とで 大して記述量変わらないしフラットなので後者のほうが読みやすいという意見もあります。

Exceptions(written:Joel Spolsky)

Goの場合、error型の戻り値を愚直にチェックする文化。 各層が下位のエラーハンドリングの責務を常に意識させられます。 エラーを受け取ったらログに書くのかコンテキストを加えて上層に投げるのか、 破棄するのか、リトライするのかといった判断を迫られます。 エラーが発生したら即対応することで発生箇所とハンドリングが近くなりトラブルシュートコストは低く抑えられます。

  • エラーをいったん集約しちゃうからエラータイプの分岐が必要になるのです。
  • 即時対応してる場合、エラータイプの分岐が必要になることは少ないです。
  • 広域離脱するからスタックトレースが欲しくなるのです。
  • 即時対応してる場合、スタックトレースはさほど重要じゃないです。

エラーハンドリングは漏れがとにかく怖いです。 より適切なタイミングで適切に処理してることがコードから読み取れる方が安心できます。 広域離脱できたりハンドリング手法が複数あるとこの安心を得るにはとてもコードを深く読まなければならなくなっちゃう。

つまり堅牢な実装を書くのに際し、例外が無いことで特に不便はありません。

ダックタイプは型不安全なのでは?

動的にタイプアサーションさせれば確かに安全性は落ちますが、これは他の言語でも動的な型を扱う限り同じ事です。 コンパイル時に解決可能な記述も可能だしそのように記述するならば型安全は保たれます。

Goはプリミティブ型やスライス、関数ですら別名型を起こせばメソッドを生やし、ダックタイプできますので、 生やしたメソッドに対応するインターフェースを定義し、静的解決できるタイプアサーションをすれば型安全は保たれます。

ヌル安全じゃないよね?

Goではポインタのアドレス操作はできないようになっています。 またGoのnilは必ず型情報が付与されるので、 レシーバがnilでもメソッドを呼んだりできます。

つまりGoのポインタ型はOptionalと同等なのです。 GoのポインタもOptionalも本質は変わらない。 空かどうかチェックしてから参照するだけです。

ノンヌル修飾も非ポインタ型で空じゃない状態を強制できますし、 イミュータブル修飾もゲッターのみをもつ構造体を定義することで書き換え不可にできます。

ヌル安全の概念は空チェック必要群と不要群に分類して 必要群に対しチェックしていないことをコンパイル時に検出するのが目的ですね?

Goにおいてポインタ型は適時空チェックをもれなく行う考えの文化なので チェック漏れコードは目立つし、コードレビューで落とすし落とされます。 errorcheckというerrの空チェック漏れを検出する静的解析ツールもあります。

つまりGoはヌル安全第一ではないが意図しないヌル参照を防ぐ手段はあります。 gometalinter+errorcheckをバックグラウンドで動かすエディタ環境を使いましょう。 そうするとチェック漏れしてる箇所に警告がでるようになるはずです。 なにがなんでもコンパイラに仕事をさせる必要はないのではないでしょうか。

Goはイテレータないの?

言語支援はrangeのみで使える型は限られています。 実装方法はいくつか。以下のオーダーで検討をオススメします。

  • コールバック関数を使って値を返す(最もオススメ)
  • ステートフルな構造体に値を返すNextメソッドを生やす&それを持つインターフェースを定義
  • クロージャを使って束縛変数に応じて値を返す
  • chan+goroutineでイテレータのように構築

最後のは初心者にはオススメしません。 他の言語のイテレータやジェネレータは中断してもスコープから外れたらちゃんとメモリ解放されますが、 最後の方法の場合、ちゃんとした中断ロジックがないとgoroutineが止まらずリークしちゃいます。

goroutineはコルーチンみたいなものでしょ?

OSスレッドプールを渡り歩くのとプリエンプティブなスケジューラを持ってる点が違います。 まるでネィティブスレッドの様にブロックIOを記述しても問題ありません。 同期的に記述しつつ軽量なのでC10Kを軽くさばけます。

goroutineはOSスレッドプールでスプレッド実行されますのでマルチコアにかなり均等に負荷を散らしてくれます。

ネイティブスレッドと軽量スレッドのいいとこ取り。

ネイティブスレッドと軽量スレッドとではやっていいこといけないことが大きく異なります。 1スレッドが担当するのに適した処理量というのも大きく違います。 goroutineは双方の利点が利用できる上にやっちゃいけないことも最小化されています。

唯一軽量スレッドのみの処理系に比べて面倒なのはプリエンプティブなので シェアしてるリソースアクセスには排他処理ががっつり必要になったことくらい。 シェアせずにchanで通信しようというのがGoの流儀です。

goroutineやchanを使うと遅くない?

マイクロベンチ書いてサブマイクロ秒以下のオーダーでgoroutineスイッチするような実装かいてしまって 「遅い」という評価になってるパターンがとても多いように見えます。

実用的な実装で継続的に高速スイッチするような実装は 普通しないので全く参考になりません。

これはgoroutineやchanの間違った使い方なので 自戒を含めてそういう実装にならないように気をつけましょう。

他の言語でGoより早くできるよ?

しっかり最適化のかかる処理系ならネイティブスレッドと軽量スレッドを両方使って 妥協せずに作り込めばGoより効率的な実装は確かに作れます。 しかし複数のスレッドモデルを使い分けつつそれぞれのスレッドモデルの罠を踏まないようにするのはコストが大きいです。 また環境が変わった時の配分調整が必要になりがちです。

Goはgoroutineという単一のスレッドモデルが標準で提供され、 パラメータ無しにバランスよくマルチコアに負荷をかけてくれます。

また、標準でスレッドモデルが提供されていない処理系では サードパーティライブラリの分断があります。同じスレッドモデル依存同士でしか一緒には使えません。

標準というのはとても良い効果が実際に現れていて、 Goのサードパーティライブラリは組み合わせて使う制約がほとんどありません。

このような特徴を持つ処理系はメジャーどころではGoかHaskellかErlangくらいしかないかと思います。

そして今後マシンごとのコア数は増えていくのでシングルコア性能はそんなに重要ではなくなっていくはずです。 上記以外の処理系でマルチコアでも効率の良い実装の実現はどうしてもゴツくなっちゃいます。 小さなライブラリには収まらずフレームワークのようなものが必要になってしまうのです。

Rustと競合?

似てるのは例外機構を捨てたこととバイナリがポータブルなことぐらい。得意な用途は全く異なります。

Rustは昨年に非同期サポートを標準から切り離しました。事実上システム記述用途に特化していく模様です。 C/C++の資産をゼロオーバーヘッドで取り込みつつC/C++利用領域を置き換えていくと思われます。 また、ランタイムを薄くしてるので組み込み用途にも強いという特徴があります。 Goはランタイムが厚くOSカーネルの機能依存が大きいのでマイコンには不向きです。

しかし、Rustで高負荷向きのネットサービスを実装する場合は 上級者の用意したライブラリ利用(または上級者になる)が前提になります。 例えばhttpサーバー実装のチュートリアル版と実用版は全く異なる実装です。

Goは非同期処理をシンプルに書きつつマルチコア性能を引き出すのが強みです。 Rustは徹底してオーバーヘッドを排除してシングルコアの限界性能を引き出せるのが強みです。 (もちろんスレッドプールとtokioを併用するなら並行処理性能も引き出せますが、Goよりは複雑です) これらの強みが関係ない分野だけが競合しています(CLIツールなど)。

Goユーザーは複雑さを許容してまで限界性能を追求したいわけじゃありません(性能差は札束でカバーできればOK)。 Rustユーザーは目の前のハードウェアで余すことなく性能を引き出すことに関心がありますのでGoにするメリットは全くありません。

というわけでそれぞれのユーザーは他方の領域に関心がないので競合しないのです。

GoがGoogleだからもてはやされてる?

いやGoogleだから採用検討時とても心配でした。Noopという前例もありますし。 今はコアメンバーの旗振りをめっちゃ信頼してます。 外部からの提案を広く募集してるし、 現実的なメリットがないものは ちゃんと理由をつけてNoとする素晴らしい運営です。

マスコットキモくない?

特徴的であり確かにキモさを持っていて、 初見は目を背けたくなりますね。 しかし長く目にするうちに 愛らしさを醸しだしてキモカワイイに変化してきます。

またアーティストの方々によるアレンジはより キュートなバリエーションが生まれています。

Goのサイトのfaviconが雑じゃね?

ちゃんと特徴だせてますので、アイコンとしての役割果たしてます。

まとめ

Go言語に対するよくある言及であれ? っと思ったものに自分なりにに回答してみました。

Goは機能追加の優先順位が

「ライブラリ>>外部ツール>>言語仕様」

というのを徹底しています。 課題を解決する方法にいくつかの方法があれば より優先度の高い方を選びます。

おかげで、Goを気に入ってから5年目だけど その間、変化した言語仕様はごく小さな変更数点しかありません。

なので便利な静的解析ツール群はずっと安定して使えています。 コード補完やジャンプ、コードフォーマッタなどが充実し、 高品質を維持できている要因でもあります。

また、言語仕様がシンプルで有名なschemeと同じくらいのシンプルさなので 静的解析ツール自体を作るコストもメンテコストも極端に小さいです。

この状況をGo1の間はキープすることをGoコアメンバーは約束してくれていますし、 また、Go2になってもGo1コードを機械的に変換しスムーズに移行可能にするとも宣言しています。

今のGoが気に入らなければきっとGo2も気に入らないと思います。 今のGoが気になるなら今すぐ触って見ましょう!