Goに三項演算子が採用されない理由
Goには「なぜ三項演算子がないの?」という意見を時々見かけます。言語開発側の意見と僕の見解をまとめていきますー。
FAQ
その回答はGoのFAQに明瞭に書かれています。
Goに
?:
演算子がないのはなぜですか?
Goには3項テスト操作がありません。 同じ結果を得るには、次を使用できます。if expr { n = trueVal } else { n = falseVal }
Goに
?:
がない理由は、言語の設計者が、操作が頻繁に使用されて不可解な複雑な式を作成するのを見ていたためです。 if-else形式は、長くなりますが、間違いなく明確です。 言語に必要な条件制御フロー構造は1つだけです。
ネストを許す
GoもPythonもif-elseが文
であり、式
として扱えない方針を採りました。式
として扱えないということは、一定の構文でのみ記述が可能ということです。三項演算子はその性質上式
として扱えることになります。
式
として扱える場合なにが書けるようになるのかというと、各項や条件に式
が書けるためにネストが許容されるようになるということです。
このことは三項演算子否定派のもっとも懸念するところです。
ぱっと見で以下のa
がどれがどういう時に何になるのかが若干パズルっぽくなってしまう。
もちろん4つの分岐であることに変わりないのでデバッグ時はどの関数がよばれるのか追う必要があるのです。
int a = cond1 ? cond2 ? f1() : cond3 ? true : false ? f2() : f3() : f4();
Pythonの場合
この三項演算子導入に関しては全く同じことがPython2.3(十数年前)以前でも議論されていました。Pythonの言語設計者は当初、Goの言語開発者と同様に三項演算子の導入に否定的で明確なのでif-else構文の利用を推奨していました。そして繰り返し必要なら同等の関数を宣言して使おうと。
最終的にPython2.4にはValueT if cond else ValueF
という三項演算子に近いものが導入されました。その理由はPython特有の内包表記と組み合わせる場合にフィルタ処理記述の容易さやパフォーマンス上のメリットがあったからです。
複数行にわたってfor-loopを書くGo言語にとってPythonの場合のようなメリットはありません。
しかし、Pythonにおいてもネスト利用される場合の挙動がコードを読む人にとってわかりにくいという問題はあります。なので内包表記の外でこの表記を使っている事例は少ないと思います。
余談: Python3.8にて:=
による代入式(PEP572)の導入でも言語設計者とコミュニティとで揉めたという事件が有名です(古来Pythonは記号に独自の意味を持たせるのを嫌う文化だった)。
コードカバレッジについて
三項演算子はCPUにとっては分岐そのものでしかありません。条件によって「左の項を処理する」のか「右の項を処理する」のかが分岐します。
以下のような記述でA()
とB()
は必ずどちらかしか呼び出されません。
a = condition ? A():B()
そうなると、コードカバレッジはこれらの分岐をそれぞれカバーできているか否かをトラッキングするのが正しい計測になります。
しかし、多くのテストツールのコードカバレッジの計測は行単位で行われることが多く、特にGo標準のテストツールは行単位での計測しかしません。そこに三項演算子を導入する場合、計測精度が落ちるのを妥協するかテストツールの改良が必要になります。
代替手段
素直にif-elseを書く
FAQの回答通り。これだとCPUが行う処理と齟齬がないし、コードの読み手が誤解することもないし、テストカバレッジ計測も精度よく測れて何も問題がないのです。
関数を宣言する
func conv(cond bool, T, F string) string {...}
var(
a = conv(cond1, "hi", "bye")
)
最近のGoのコンパイラは賢くなってきていてインライン展開した方が有利ならインライン展開実装になります。
ただ、左右の値が関数呼び出しを伴うような場合は三項演算子に比べるとデメリットがあります。
a = conv(cond1, f1(), f2())
上記の場合、f1(),f2()が両方よばれてしまいます。
そういう時は以下のように関数参照を受け取るように書きましょう。
func conv(cond bool, T, F func() string) string {...}
var(
a = conv(cond1, f1, f2)
)
switch構文
Goのswitch構文が柔軟な条件分岐を提供することができます。
意外と知られていないように思うのですが、switchに渡す値を省略した場合、以下のように任意の条件式で分岐をスマートに記述することができます。三項演算子をネストするのに比べるとより明確な分岐を記述することができますし、コードカバレッジの計測も(ry。
switch {
case cond1:
// do something
case cond2:
// do something
case cond3:
// do something
}
ルックアップテーブル
三項演算子が欲しい人の中には分岐式が欲しいというよりは簡潔な「ルックアップテーブル」を必要としている人もいると思います。
その場合は以下のように記述することで実現できます。
a := map[bool]string{true:"A", false:"B"}[条件式]
もちろん条件がboolに限らず、enum(iota)や数値、文字列定数でも分岐せずに処理可能です。
ビルドタグによる切り替え
いかのような2つの実装を同じパッケージフォルダ「moge」に置いておきます。
// +build !hoge
package moge
const (
A = 123
B = 234
)
// +build hoge
package moge
const (
A = 223
B = 334
)
以下の様にビルドタグ指定でどちらの定数セットを利用するのかが選択できます。
go build .
go build -tags hoge .
まとめ
- Goに三項演算子は言語開発者の否定的な認識から初期の段階で採用は見送られました
- 結局、分岐は行をわけて分岐が目で追えるほうがデバッグしやすい
- 分岐を式に落とし込むとネストを許すことになり構文解析の複雑化や可読性の低下などが懸念されます
- また、テストのコードカバレッジが計測しにくくなるためテストツールの改良が必要になります
- コミュニティは賛成派40%、否定派60%程度。しかし賛成比率が上がってもそれだけで採用されることはなさそう
- Goのウリはコンパイルの速さと可読性なのでそれらに害があるという時点で採用は望み薄(コンパイル時間への影響が軽微だとしても)
- 実用的なメリットが行数を減らすだけなので上記のデメリットに対するリターンとしては弱すぎます
- 何度となく三項演算子を追加するプロポーザル(Go2含む)が挙げられてきましたが現在まで全てクローズされました
- 一度不採用になった仕組みを導入するには余程のメリットがないと採用されることはなさそう(たとえGo以外のほとんどの処理系が持っている機能であっても)
- Pythonでは内包表現が増えシナジー効果があったから採用されました。Goでもそういう事があれば見直されるかもしれませんが今のところ無さそう。
- 文字数や行数がかさむけれど、明示的な代替方法がいくつかあります
- 三項演算子は「分岐」と「ルックアップテーブル」の役割を兼ねているけれどGoではそれぞれ別の記述でというように必要十分な代替方法があります
個人的には「ルックアップテーブル」をショートハンドでかけるのなら欲しいくらい。例えば以下のように書いたら型省略時はmapまたはsliceでキーや値の型推論が効いてリテラルが作れるとか。
var (
a = {true: "hoge", false: "moge"}[cond]
b = {"hoge", "moge"}[index]
)
ああ、これでも型宣言すればいいだけかも。
type M map[bool]string
type S []strinig
var (
a = M{true: "hoge", false: "moge"}[cond]
b = S{"hoge", "moge"}[index]
)
余談
三項演算子作れた!
package main
import (
"fmt"
)
func ʔ(cond bool, t, f string) string {
if cond {
return t
}
return f
}
func main() {
fmt.Println(ʔ(true, "yes", "no"))
fmt.Println(ʔ(false, "yes", "no"))
}
そしてジェネリクスを使うと・・・?
package main
import (
"fmt"
)
func ʔ[T any](cond bool, t, f T) T {
if cond {
return t
}
return f
}
func main() {
fmt.Println(ʔ(true, "yes", "no"))
fmt.Println(ʔ(false, "yes", "no"))
}
Discussion
反応のいくつかみて思ったんですが、
Goはconst宣言にこだわれないという事もひとつの理由かも。Goではプリミティブな型の値でしかconst宣言できないから三項演算子があっても今のままではconst宣言には使えない。constを状況別に宣言したければビルドタグでファイル分離という手法を使います。どちらにせよやはり三項演算子は不要ということになりそう。
ネストを制限すれば良くなるんじゃないかっていう指摘は確かにそうで、ネストを制限するのにif-elseで書くのが自然な結果なのだと思います。
ネストしないのならルックアップテーブルで十分、ネストするくらいならswitchで明瞭に書きましょうというのがここでの提案なのです。
この記事でGoは書き手を信頼しないという誤解を与えたのならすまんです、、、。
最初のきっかけはややこしいものを書いている事だったかもしれませんが、多くのプロポーザルを棄却してる主な理由は「導入コストに対しGoにとって大きなメリットがない」です(Pythonのようなシナジーを生む仕掛けにならないから)。
キャラクタ端末時代はステップ数を圧縮することに意味があったので三項演算子が重宝されたこともありましたが,私が大昔に参加した C 言語プロジェクトでは三項演算子の使用をコーディング規約で禁止しているところもありましたし,メンタル・モデル的に副作用の大きい機能だとは思います。
ちなみに今流行りの Rust にも三項演算子はありません。
Rustに三項演算子が無いのはif式だからで、文中に書かれている様にGoがif式と三項演算子の代わりにif文に統一した話とは別と思いました。(本文に関するツッコミでなくてすみません)
三項演算子の良いところは、下記のように簡潔に書けることで
var result = expression ?
value1 :
value2 ;
仮に三項演算子が無いと…
var result = null;
if(expression) {
result = value1;
}
else {
result = value2;
}
といったように、resultという変数名が通算3回も出現することになり煩雑極まりない上、宣言時の初期値代入(初期値の無い変数は作るべきでない)なども考慮する必要が出てくることです。
これを、不可解な式が云々で禁止するのはいががなものか、と思ってしまいます。
そもそも、関数型言語などはこれに類する操作の連続であり、それを不可解の一言で切ってしまうのは少しでも計算機科学の素養があるなら考えられないことかと思いますがどうなのでしょう…。
Goは手続き型の代表格みたいなものなので、関数型処理系で常識でもGoでもそうすべきという話とは異なると思います。
あと、初期値のない変数は「極力」作るべきではないのは確かにその通りで、標準ライブラリではよく以下の様に書かれているので出現回数は2回ですね。
最後の
func ʔ[T any](cond bool, t, f T) T
ですが、cond
に関わらずt
もf
も評価されてしまう(短絡評価されない)ので、三項演算子やif文とは似ているのですが違う動作になりますね。最後の実装はもちろんジョークですが、そこも互換を取りたい場合は文中にあるように、t,f をTを返す関数にしてください。
もちろん記述性は落ちますし、Goでは「代替手段」に書いた手法のほうが結局誤読なくすっきり書けますのでそちらを使いましょうということです。
大切なことは「3項演算子」はCPUコードになるとあくまで「分岐」でしかないということ。
「分岐」はifやswitchで書いたほうが素直で誤解がないということなんじゃないでしょうか。