三項演算子について思うところ
はじめに
他の記事を見ていただいた方は分かっていると思うけど、僕は主にC#を使っている。
他にはPython、C、C++、TypeScript、その他ちょっとしたDSLも使う機会がある。
つまるところ、僕は三項演算子に普通に慣れ親しんでいる人間であるので、本稿を読むときはそれを踏まえておいてもらいたい。
あと、タイトルは検索性を考慮して三項演算子と記述したが、項を3つ受け取るが条件分岐ではない結果を返す演算子をサポートする言語も多数存在するので三項形式の条件演算子[1]と呼ぶのが厳密だ。
以降、本稿では三項条件演算子と表現する。
三項条件演算子の定義
本稿において三項条件演算子とは次の性質を満たす言語機能をいう。
- 式である。つまり、評価結果は何らかの値を返す。
-
condition
,true-clause
,false-clause
の3つの式を内包する。 -
短絡評価:
condition
の評価結果に基づき、true-clause
/false-clause
のうちたかだか1つだけが評価される。全体の評価が成功したならその結果が式全体の結果となる。-
condition
が例外をスローするなど大域脱出を踏んだ場合にはtrue-clause
/false-clause
のどちらも評価されないということが起きてもよい。
-
- 演算子。言語仕様が演算子と表明している。
if式は条件4.を除き三項条件演算子の性質を満たすが、elif
キーワード、コードブロックの許容など、より高度な機能を導入している場合もある。
三項条件演算子は読みにくいのか?
A. 人による。
何度も言われていることだけど、これは本当にその通りだから何度も言われているのだ。
今から新規に言語を設計するならif式の方が素性が良いと思うが、C言語スタイルに慣れた人にしてみれば今更?
:
スタイルの三項演算子も特に苦労なく読めるだろう。
一応、三項演算子も可読性上のメリットはある。
全体がシンプルなら追加の文字数が最小限に収まるし、改行を前提とするならtrue-clause
/false-clause
のインデントが揃い対称性が保たれる利点もある。
// C++
template <typename T>
const T& elementAtOrDefault(const std::vector<T>& v, size_t i, const T& default_) {
return i < v.size()
? v[i] // true-clauseと
: default_ // false-clauseが同じインデントで並ぶ
;
}
ここら辺はthen
キーワードを持つif式では同じ書き味になる[2]が、if
/else
だけからなるif式では割とスタイルに悩む部分ではないかと思う。
とはいえtrue-clause
/false-clause
にコードブロックを許容する言語ならこのような対称性記法が毎回適用できるというわけでもない。
キーワードを増やすのもコストになることを考えると、最終的には設計者の哲学によるのではないだろうか。
使う側としては可読性が保てるようなコーディング規約を決めて守る、それができないのであれば禁止する、ということに尽きるだろう。
三項条件演算子を禁止するとき
三項条件演算子の利用を禁止する規約を導入するケースはしばしば耳にするし理解もするが、代替表現をオレオレ実装することはやめたほうがいいと思う。
この場合は冗長ではあるがより平易に等価な表現ができる言語機能[3]で妥協するか、せめてデファクトスタンダードなOSSライブラリの機能を使うのがいい。
基本的に、言語の標準機能というのはよほどペーペーの新人でもなければ自分で勉強して覚えてくるものだ。
つまりよほど変な使い方をしていなければ読みやすさはさておき読むことはみんなできる。
誤解されやすいことが禁止の理由なのであれば、冗長だとしても誰でも知ってて使える書き方のほうがメリットは大きいのではないか。
もう一つの理由として、オレオレ実装はしばしば元の言語機能の完全な代替にならないケースがある、という点がある。
三項条件演算子の場合、特に短絡評価性が軽視されがちだ。
引数の範囲検査[4]というごく単純なバリデーションでさえ短絡評価性抜きには実現できない。[5][6]
バリデーションでの利用機会が多い三項条件演算子において短絡評価性は根幹的性質と言える。
そんな訳で、三項条件演算子の代替表現は常に短絡評価性を持っているべきだと考える。
即値だと困る場合のためにオプトインでクロージャも受け取ります、ではなくクロージャであることを強制すべきだと思う。
式の性質に基づきユーザーに使い分けを強いるのはたいていバグを生むものだ。
三項条件演算子が短絡評価ではないときに壊れる処理の例
// TypeScript: `Number.toFixed`は引数が処理系定義の範囲を超えるとRangeErrorをスローする。
const text =
x <= 20
? 123.456.toFixed(x)
: '';
// C#: 変数`enumerable`はパターンマッチが失敗したときには無効。
var x = obj is IEnumerable<int> enumerable
? enumerable.Sum()
: -1;
前述のC++サンプルも短絡評価でないときはvectorの範囲外を踏んでstd::out_of_range
がスローされてしまう。
しかし僕の持論を誰とでも共有できるわけではないのでやっぱり思い通りの仕様ではないオレオレ実装は生まれてしまう。
それであれば最初からそんなもの使わなくていいんじゃないか、という思いは拭えないのである。
C#erにとっての三項演算子
ここまでは三項条件演算子について言語中立に書いてきた(つもり)だが、ここからはC#erとしてのポジショントークに特化した内容を書いていく。
もともとLINQによりメソッドチェーンを伸ばすことで式が肥大化することに慣れていたC#界隈であったが、C# 6でexpression-bodied membersが導入されたことでいよいよワンライナー傾向が強くなりだした。[7]
// CSharp
public static double? ParseOrNull(this JsonValue jvalue)
=> jvalue.GetValueKind() switch // switch式
{
JsonValueKind.Number => jvalue.GetValue<double>(), // ↓式中の変数宣言
JsonValueKind.String => double.TryParse(jvalue.GetValue<string>(), out var result)
? result
: double.NaN, // 三項条件演算子
_ => null,
};
public static double GetFooBarBazOrNaN(this JsonNode? root)
=> root
?.AsObject()?["foo"] // null条件演算子
?.AsObject()?["bar"] // null条件演算子
?.AsObject()?["baz"] // null条件演算子
?.AsValue() // null条件演算子
?.ParseOrNull() // 拡張メソッド+null条件演算子
?? double.NaN; // null合体演算子
式で書ける表現は今後も増えていくと思われるので、ワンライナー性に貢献している三項条件演算子も利用機会は増えていくだろう。
.NetではTryParseパターンによるバリデーションが主流だったのも三項条件演算子への忌避感を和らげているのではないか。
つまり、C#erはできることならメソッドは式一つで実装したいのである。
そんな事情があるので、C#界隈は他のC言語ファミリーと比べてかなり気軽に三項条件演算子を使う傾向があるように思う。
三項条件演算子の是非はそれ単体だけではなく、言語仕様や標準ライブラリのアーキテクチャも含めた全体としての整合性に基づいて語られるべきだ、という話だ。
まとめ
- 三項条件演算子をどうするかはコーディング規約で明確にすべきだ。個人的にはそんなに忌避するものではないと感じるが、別の意思決定をするとしても何の異論もない。
- 三項条件演算子を禁止するのであれば、余計なオレオレ実装に頼らず平易で標準的な言語仕様に倒すべきだ。Goの設計思想なんかは参考になるだろう。
- 三項条件演算子の是非は言語のエコシステム全体とも関わってくるので、その言語特有の事情も考えながら意思決定しよう。
-
二項形式の条件演算子を持つ言語も存在するため、単に条件演算子と呼ぶのも厳密性を欠く。 ↩︎
-
幸いなことに
then
はelse
と同じ文字数である。それだけかと思われるかもしれないが、対称性を保ちたいコードにおいてトークンの文字数が一致しているというのは非常に大きな意味がある。 ↩︎ -
つまりほとんどの言語におけるif文 ↩︎
-
昨今ではnull条件演算子やnull合体演算子、あるいは
Maybe
/Option
型を持つ言語が増えているため、nullチェックのために三項条件演算子を使う機会はむしろ減っている。今でも三項条件分岐をnullチェックに使いたいのはC、C++、Pythonくらいではないか。特にPythonは何かしら改善してほしいと思う。 ↩︎ -
これは
true-clause
の副作用性とは何の関係もない。 ↩︎ -
このユースケースを無視する人が散見されるのはなぜなのか考えたが、そういえばTypeScriptのように配列の境界外アクセスに例外を投げない言語もあることを思い出した。とはいえ、それらでもRangeErrorを投げる関数が別に存在したりするのでやはり肯定的に見ることはできない。 ↩︎
-
恐らくは関数型プログラミングに親しんだ人々が合流したことによる変化なのではないかと考えている。 ↩︎
Discussion