Goに三項演算子が採用されない理由

4 min read読了の目安(約4100字 6

Goには「なぜ三項演算子がないの?」という意見を時々見かけます。言語開発側の意見と僕の見解をまとめていきますー。

FAQ

その回答はGoのFAQに明瞭に書かれています。

https://golang.org/doc/faq#Control_flow

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)や数値、文字列定数でも分岐せずに処理可能です。

まとめ

  • 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]
)

余談

三項演算子作れた!

https://play.golang.org/p/UYV6YkihVYu
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"))
}

そしてジェネリクスを使うと・・・?

https://go2goplay.golang.org/p/1emGrSKdqWx
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"))
}