Go言語のイケてない部分
最近色々あって仕事でGo言語を使っています。 色々割り切っている言語なので、こんなこと言ってもしゃーないんですが、言語設計はミスってるんじゃなかなぁ、と思わざるを得ない点が多々あります。 使い始めて1か月くらいなので間違ったことを書いているかもしれませんので、何かあれば指摘していただけるとありがたいです。
本文ではネガばかり羅列していますが、ランタイムとツール周りは気に入っています。 Goのランタイムを使う、もっと洗練されたAlt Go的なものがあるといいのに(もしくはジェネリクスのったGo2を早くリリースしてほしい)、と思う日々です。
追記: なんか意図とは違った受け取られ方をしている方もいるので追記します。 この記事はあくまで、「Go言語を学ぶにあたって躓いた点」を列挙し、まとめ、理由を考えてみる(教えてもらう)ために書いたものです。 Go言語自体はDisってますが、Go言語ユーザーをDisる目的では書いていません。
また、以下のような意見も見られました。
まずひとつめについてですが、自分はPythonやRubyのようないわゆるLL使いではありません。 典型的でありがちな批判かどうかは自分では判断できませんが、観測範囲でこのような批判がまとまっているものを見た覚えはないです。 そのため、この記事を書くためにいろいろと調べましたし。 それと、多値にこだわっているのではなく、言語仕様が初学者が躓きやすい(し、理由を調べにくい)ようになっているのでは?という指摘こそが言いたいことです。
ふたつめの慣れれば問題ないという指摘も、慣れるまでの障壁の話を書いているのでこの記事に対しては意味を持ちません。 人にもよりますが、ルールの推測しずらい(シンプルでない)ものを覚えるのは苦手なので、自分と同じような人間が躓かないように気にしていただければと思います。
みっつめですが、避けられない理由が「いろいろ」あるのです。察してください。
よっつめのIssueにすればよい、という意見ですが、このエントリはあくまで「ここが分かりにくい」というところを(理由があればそれも込みで)まとめたものであって、 言語仕様を自分の思い通りに変えたいという話ではありません。 Issueにすればどうなるというのでしょうか。 互換性を壊すということで却下されてそれで終わりだという確信があります。 それよりは、エントリにまとめて周知した方が生産的だと思いますし、IssueでやりあうほどのGo愛を自分は持ち合わせておりません。
多値まわり
Go言語には多値という機能*1が言語に組み込まれています。 しかし、これが中途半端なうえ、多値っぽい構文で多値ではないものがいくつかあるため、初心者を混乱させる原因になっています。
まず、多値はこのように使います。
// 多値を返す関数minmax func minmax(x int, y int) (int, int) { if x < y { return x, y // 条件演算子がないのも割り切りだとわかっていてもつらい。 } return y, x } // 多値の受け取り min, max := minmax(20, 10) fmt.Println("min:", min, ", max:", max) // => min: 10, max: 20
これを念頭に読んでください。
追記: 多値に関して、別記事としてまとめました。
こちらを読んでいただければ、多値はダメだからタプルを入れるべきだった、という話をしていないことが分かっていただけるかと思います。
多値をひと塊で扱える例外: 多値を多値として返す
Go言語では多値は基本的にひと塊のオブジェクトとして扱えません(ので、他の言語のにあるようなタプルとは違います)。
tuple := minmax(20, 10) // コンパイルエラー
しかし、例外がいくつかあります。 ひとつめが、他の関数の戻り値をそのまま自分の関数の戻り値として返す場合です。
func minmax2(x int, y int) (int, int) { return minmax(x, y) // そのまま返す }
この場合、結果が同数*2かつすべて同じ型である必要があります。
多値をひと塊で扱える例外: 同じ形の多値を取る関数にそのまま渡す
ふたつめが、同じ形の多値をとる関数にそのまま渡す場合です。
fmt.Println(minmax(20, 10)) // => 10 20
fmt.Println
が interface{}
という何でも渡せる型を可変長引数として複数受け取れる関数なので、 minmax
関数の結果をそのまま渡せています。
しかし、可変長だからと言ってこのようなことはできません。
fmt.Println("min, max:", minmax(20, 10)) // コンパイルエラー
できてもよさそうなものですが、なぜかできません。 出来ないようにしている理由はわかりません。
多値っぽい構文なのに多値ではない機能: for range構文
主にコレクションを走査するときに使う for range構文というものがあります。 例えば、Go言語にはジェネリクスがないのでいわゆるmap処理を行いたい場合、
ys := make([]int, len(xs)) for i, x := range xs { ys[i] = x * 2 }
のように書きます。
i, x
の部分は多値のように見えますが、これは多値ではなく構文です。
多値では値を受け取らないということはできませんが、この構文は x
の側(この記事では2nd valueと呼びます)を無視できます。
min := minmax(20, 10) // コンパイルエラー
ys := make([]int, len(xs)) for i := range xs { // OK. xsの要素ではなく、インデックスが取れる ys[i] = i }
要素の方ではなくインデックスの方が取れるのが使いにくい感じがしますが、これはmapとの統一をしたかったためと思われます。 mapでは1st valueがキー、2nd valueが値となるので、mapでもスライスでも1st value側を取れるようにした、ということでしょう。 mapは値を走査するよりもキーを走査する方が多いでしょうから。
多値ではないものはまだあります。
多値っぽい構文なのに多値ではない機能: mapへのアクセス
Go言語では、mapへのアクセスにはスライスや文字列などと同様、 []
を使います。
その結果として、値のほかにキーが存在したかどうかを表す bool
値が受け取れます。
m := map[string]int { "a": 10, "b": 20 } n, ok := m["c"] // 2nd valueとしてbool値が受け取れる if !ok { n = 0 } fmt.Println("n:", n) // => n: 0
これも n, ok
の部分は多値に見えますが、多値ではありません。
まず、 ok
の部分が省略できますし、多値ではできる「他の関数にそのまま渡す」ができません。
func f(n int, ok bool) { } f(m["c"]) // コンパイルエラー
これは ok
が省略できることと両立できないからでしょう。
上のようなコードが許された場合、下のようなコードでどういう挙動にすればいいのかという微妙な問題が出てきます。
// 1st valueだけを表示すべきなのか、2nd valueも表示すべきなのか fmt.Println(m["c"])
多値っぽく見せるなら、省略なぞ許さずに多値にしてしまったほうがよかったと思います。
あり得ない話だとは思いますが、もしGoが演算子オーバーロードを許すようになったとしても、 []
の結果を多値にしては組み込みのmapと同じになりません。
ではどうするのか。省略可能を示す ?
みたいな機能を増やすんでしょうかね。
多値っぽい構文なのに多値ではない機能: 型アサーション
型アサーション(キャストのようなもの)も、mapへのアクセス同様に2nd valueとして成否を表す bool
値を返します。
func f(x interface{}) int { n, ok := x.(int) // 多値ではない if !ok { n = -1 } return n }
mapと同じことが言えますね。
多値っぽい構文なのに多値ではない機能: チャネルからの受信
同上なので略。
多値が使えると便利そうなのに使えないし、別の意味になる: switch構文
Go言語でのswitch構文には多値が使えません。
// コンパイルエラー switch minmax(20, 10) { case 10, 20: }
ですが、 case 10, 20
の部分は多値ではない意味として使われています。
switch 20 { case 10, 20: fmt.Println("10 or 20") default: fmt.Println("other") }
Go言語では、 case
はフォールスルーしませんので、複数の選択肢で同じ処理をさせたい場合は上のようにカンマを使います。
switch
で多値が使えたら、 ok
とそうでない場合とか、 err
が nil
かそうでないかなどで分岐処理が書けたのに、残念な文法の選択をしたと思います。
多値が使えると便利そうなのに使えない: チャネル
Go言語における同期処理の入力、出力と言えば、関数の引数とその結果*3です。 関数の引数は多値を許しますし、関数の結果も多値を許します。対称性が取れていますね。
Go言語における非同期処理の入力、出力と言えば、チャネルへの送信と受信ではないでしょうか。 しかし、チャネルへの送信にも受信にも、多値は使えません。対称性は取れていますね。でも、同期処理と非同期処理で統一性はないように思います。
なぜチャネルで多値が使えないかの理由は思い浮かびません。 実装が難しいとかなんでしょうか。CSPよりもアクター派なんで詳しくはわかりません。
コレクション
ジェネリクスがないので、基本的には言語組み込みのものしか使いません。 また、ジェネリクスがないので、いわゆるmap関数だとかは用意できないので基本的にはfor文を書くことになります。 他の言語では1行で書けることが、Go言語では3行~6行程度必要になることは多いです。 コンパイル速度のためとはいえ、今どきの言語としてはイケていない部分でしょう。
mapには2nd valueがあるのに配列, スライス, 文字列には2nd valueがない
多値の項でも言及した通り、mapへのインデックスアクセスは2nd valueを持ちます。 しかし、同じく言語組み込みのコレクションである配列やスライス、文字列には2nd valueがありません。
そのため、範囲外のインデックスアクセスが発生するとこれらはpanicを起こします。 効率のためかもしれませんが、そうであるならmapに対しても2nd valueを(多値と同じような構文で)用意するべきではなかったと思います。 それよりは、mapに対して組み込み関数で多値を返すようなものを用意してほしかったです(それか、配列やスライスなどにも2nd valueを用意する)。
:=
:=
は var
+ =
の略記法、だけではないんです。
シャドーイングと再代入
=
は再代入*4、 :=
はシャドーイング、という説明を見たような気がします。
が、これだけでは :=
の性質をすべて語っていません。
まず、Go言語では同一スコープでのシャドーイングはできません*5。
x := 10 x := 20 // コンパイルエラー
ですが、みなさんこんなコード書いてませんか?
x, err := f() if err != nil { return nil, err } y, err := g(x) // ...
これ、同一スコープに err
という同じ名前の変数があるので、シャドーイングできているようにも見えます。
しかし、実はこれは再代入なのです。
func f() (int, error) { return 0, nil } func g(x int) (string, string) { return "", "" }
としたうえでコンパイルすると、 string
は error
を実装していない、というコンパイルエラーになります。
また、 :=
では新たな識別子を導入しないとコンパイルエラーになるため、
x, err := f() x, err := g()
のようなことはできません。 これだと2行目では新たな識別子が導入されておらず、「short variable declaration(短い変数宣言)」にならないからです。
でも、短い変数宣言という名前なのに多値に対しては再代入が起こり得るというのはどうなんでしょうか。 そうするくらいなら、同一スコープでのシャドーイングを許してしまった方がよかったと思います。
このせいで error
しか返さないような関数が混ざると残念なことになります。
x, err := f() err := g() // コンパイルエラー err = g() // こうするか、 err2 := g() // こうする。上かな。
もし同一スコープでのシャドーイングを許していたらこう書けました。
x, err := f() err := g()
err
は :=
で受ける。統一されていると思いませんか。
追記:
ちなみにerrに何度も代入するところは「if err := g(); err != nil {}」と書くのでなにも問題ない。
— のぼのぼ☂️ (@nobonobo) 2018年11月8日
error
のみを返す関数は、if文のSimpleStmtで受け取る書き方を常時行えば問題ないとのこと。
処理がif文の中に紛れ込むのを完全には賛成できませんが、これは好みの問題でしょう。
同一スコープでのシャドーイングが欲しかったという意見は変わりませんが、今後はこの書き方で回避したいと思います。
別の意味に同じ記号
Go言語において、 :=
は実は2つの意味を持ちます。
- short variable declaration
- for range構文でのRangeClause
これらは、意味としてはとても似通っているのですが、別物です。
前者は3値や4値も当然受け取れますが、後者はインデックスと値の2値のみです*6。 前者は右項の値すべてを受ける必要がありますが、後者は2nd valueが必要ない場合は省略できます。
個人的には、for range構文に :=
は不要だったのではないかな、と思っています。
// コンパイルエラーだけどこれでよかったのでは? for i, x range xs { }
こうなっていない理由として考えられるのは、実はfor range構文では :=
のほかに =
も使えるからというのがあるのでしょう。
=
の方を使うと、ループの最後の値がループの外から参照できる、という利点があります。
func f(xs []string) { var i int var x string for i, x = range xs { // ちなみに、ここにはvarはそもそも置けない(ので、:=はvar+=の略記法だけではない) fmt.Println(i, x) } fmt.Println("last index:", i, "last value:", x) }
しかし、これは少々技巧的で、条件演算子すら省くGo言語の思想とは相容れないように思えます。
なので、for range構文ではシンプルに :=
の機能だけに絞って :=
自体は省略してしまった方がいろいろと分かりやすくなったのでは、と思います。
ちなみに、紛らわしいことに、C風のfor構文で使える :=
はshort variable declarationです。
// short variable declaration for i := 0; i < n; i++ { }
defer
スコープではなく、関数に属する
そのままです。罠ですよね。 一応、無名関数をその場で呼び出すことで回避できますが、スコープに属するようにしてくれればよかったのに、と思います。 そうなっていない理由は効率なのでしょうか。わかりません。
追記:
if xxx {
— いわた (@wonderful_panda) 2018年11月8日
defer con.Close()
}
とか書けた方がうれしいことあるやろ?だからdeferは関数に属するようにしたんやで
みたいなのをどこかで見た
Twitterで納得度の高い理由を教えてもらいました。
なるほど、これは確かにブロックスコープだと困りますね。
ループの中で defer
して死というのはまだ分かりやすいですし、この点に関してGo言語の選択は正解かもしれません。
パッケージ
階層構造を取れない
これもそのままです。 複数の粒度でのグルーピングは不要ということでしょうか。 地味に困ります。
struct / interface
構文上の違いがないのに区別が必要
型の表現方法(struct/interface)によってポインタを渡すべきかどうか変える必要が出てくる(効率を考えた場合の話)のに、それが構文上区別できないのはつらいです。
C#のように命名規約で回避するくらはしてくれてもよかったように思います。
それか、C言語のように struct
を明示するだとかでもこの際よかったかな、と思います。
echo
JSONメソッド
Go言語ではなく、echoというフレームワークに対する愚痴です。
echo(この名前もググラビリティ低い)ではレスポンスを組み立てるために Context
オブジェクトのメソッドを呼び出します。
そして、アクセスされた際に呼び出されるハンドラーは error
を返します。
だいたいこんな感じになるわけです。
e.GET("/path", func (c Context) error { return c.JSON(http.StatusOK, f()) })
これ見てどう思いますか。
c.JSON
を呼び出すことでレスポンスオブジェクトが作られ、それが error
インターフェイスを実装している、そう感じませんか。
どうやらechoの作者はそうではなかったようで、このメソッドで レスポンスに対してJSONの書き込みが走 るように作られています。
そして、書き込みに失敗したら error
を返すのです。
func f(c Context) ([]SomeResult, error) { // なんやかんや処理 if reason != nil { return c.JSON(http.StatusInternalServerError, reason) } return result }
とかやって、
e.GET("/path", func (c Context) error { res, err := f(c) if err != nil { return err } return c.JSON(http.StatusOK, res) })
とやったらどうなりますか。
そうです。 reason
が nil
ではなかった場合、reason
がレスポンスに書き込まれますが、レスポンスへの書き込み自体が失敗しなければ c.JSON
は nil
が返ってくるので、
ハンドラーの中の if
には入らず、再度 c.JSON
が呼び出され、ステータスOKでエラーレスポンス(reasonの内容)が返されます。
さらに、エラーレスポンスの後ろに []
というゴミが付いて しまいます。
なぜなら c.JSON
はレスポンスに書き込むだけだから。
ハンドラーの最後の return
で res
を書き込んでいますから。
多くの場合エラーがあれば res
は空ですから。
当然、レスポンスには []
が付いてきますよね。
・・・。
ドキュメントを読まなかった自分も悪いんですが、こういう関数に JSON
なんて宣言的に見える名前を付けてほしくなかった。
WriteJson
とか、操作的に見える名前ならこんな間違いはしなかった。
vim
テキストオブジェクトとの相性の悪さ
GoLandを使っているのですが、当然(?)Vim Pluginを使っています。 ですが、Goの構文は括弧を省略しているため、「if文の条件部分に対して操作」・・・あっ・・・できない! となることが多いです。 Vimmerに優しくない*7。
まとめ
細かいところではまだまだあるのですが、好みの部分も多い*8のでこのくらいにしておきます。 後半力尽きて適当アンド愚痴ですが、こんな感じで今のところGo言語イケてない。ダサい。という認識です。 この言語のどこがシンプルなんじゃ!むずかしいやろ!どちらかというとイージー方向の機能満載やろ!!!という気分です。 「いいやそこは見方が悪いんだ、こういう見方をするとイケてるんだぜ!」という意見があれば是非聞きたいです。 よろしくお願いします。
*1:他の言語でのタプルのようなもの。ただし、タプルのようにひと塊として扱えるものではなく、単に関数から戻るときに複数の値がスタックに残っているような状態と思った方がいいです。
*2:多値は2値だけでなく、3値でも4値でも返せます。
*3:ちなみに、戻り値型と呼ばないのは多値は(Goでは)型ではないからです。
*4:varでの=は除く
*5:例えばF#は同一スコープでのシャドーイングができているように見えるのですが、実はletがスコープを作っているので同一スコープでのシャドーイングを実際にしているわけではありません。見た目的には同一スコープのシャドーイングに見えるので置いておきましょう。
*6:チャネルは2nd valueを取りませんが、ここでは省きます。
*7:そもそもIntellij/GoLandのvim pluginって文字列リテラルの中でテキストオブジェクト使えないという致命的な欠点があってつらいんですが、それはまた別の話。
*8:型指定の書き方とか。