⛰️

(改訂版)三項演算子は本当に読みにくいのか。TypeScript で分かった 後置 else if メソッド の効果

2024/11/10に公開1

本記事はこちらの改訂版です。コードも改良しています。

三項演算子は本当に読みにくいのか

コードをドキュメントのように読みやすくすることは非常に多くのメリットがあります。 そして、プログラミング言語自体にも読みやすくするためだけに存在する構文があります。 その1つが三項演算子です。

const  value = x === 1 ? "A" : "B";

いやいや、三項演算子は読みにくいだろう、と思われた方は多いと思います。 しかし、読みやすいケースがあることも私は経験しています。 あなたも経験しているかもしれません。 なぜ、読みやすさに差が出るのか。調べていった結果、ポイントが分かりました。しかし、それを言語仕様として持っているものはありません。 そこで、TypeScript で読みやすい三項演算式を書くためのメソッドを開発したのでご紹介します。

そのメソッドを使った TypeScript のコードは、次のようになります。 Python の条件式(三項演算子)に似ていますが少し違います

const  message =_( `名前: ${name}` ).elseIf(!name).then("");

できることはこれだけです。 とてもシンプルです。 しかし、この書き方が良い!と直感できた方は、最後に書かれたクラスの定義だけコピペするだけですぐに使えます。 長い説明を読む必要はありません。

三項演算式を使わないと、次のようなコードになります。 コードの量や見た目だけではなく、どちらで読んだときに内容が理解しやすいのか、なぜ理解しやすいのか、実践的なサンプルを見ながら比較してみたいと思います。

if (!name) {
    var  message = "";
} else {
    var  message = `名前: ${name}`;
}

❗注意: 本記事で紹介している 後置 else if メソッドに、すべての三項演算子や条件分岐を置き換えるべきといっているわけではありません。本文で説明している読みやすくなるケースでのみお使いください。

Python の条件式(三項演算子)は読みやすさにこだわって作られたはずだが

まず、三項演算子の読みやすさや読みにくさについて話をしましょう。

TypeScript、Java、C 言語 などの三項演算子は、次のようになります。

const  value = x == 1 ? "A" : "B";

= の次に == が書いてあってびっくりしますね= の次に => が書いてあってもびっくりしますが)。 そして、? を読んでしばらく考えてからそれが三項演算子だと気づくことがよくあります。 条件式をカッコで囲むとびっくりしにくくなるとは思いますが、値を期待しているのに条件が来るという違和感はまだ存在します。

const  value = (x == 1) ? ("A") : ("B");

なぜ、しばらく時間がかかるのでしょうか。 それは、読む機会が少ないからです。 記号や略語は、読む機会が多いときに効果的なのであって、読む機会が少ないのであれば適さない表現方法です。 毎日見ている X(旧Twitter)ですら X とは何を指しているのか分かりにくいですよね。 ローカル変数なら定義が近くに書いてあるから略語を使う、という判断基準も適切ではありません。 法則を作りたがるのは中級プログラマーの悪いクセです。 なので、三項演算子は避けるべきという コーディング ルール があったりします。

Python の条件式(三項演算子)は C言語の三項演算子の読みにくさを踏まえて、読みやすさを考慮してできた言語仕様といえるでしょう。 記号を否定する思想が伺えて良いですね。

value = "A" if x == 1 else "B"

普段から英文を読んでいる人にとっては、このように if を後(右)に書いてあるほうが英文と一致していて読みやすいでしょう。 一応説明しておくと、このコードは、次のような条件分岐ブロックのコードと全く同じ処理を実行します。

if x == 1:
    value = "A"
else:
    value = "B"

なぜ、if を右に書くのでしょうか。 それは、読む人に、重要ではない例外的な内容を最初に読ませないようにするためだと言われています。 たとえば、次の日本語を読んでみてください。

ただし、1000円以上のご利用がないと対象外になります。そうでなければ、ご利用金がの10%相当のポイントをプレゼント!

いきなり「ただし」とは何の話だ!?となりますね。 プレゼントされるのは分かるけど主題がはっきりしません。 順番が違うだけなのに主題を失ったように読めてしまいます。 リーダブルなコードを書くには、この文章のような重要ではない例外的な内容を最初に読ませることがないようにすることです。 効果が絶大なのでこだわる価値があります。

実際 Python の条件式は読みにくい

以上の理由で、Python の条件式(三項演算子)は読みやすい表現方法です。 と言いたいところですが、実際はそれほど読みやすくはありません。

value = "A" if x == 1 else "B"

普段から英文を読んでいる一般人は読みやすいかもしれませんが、プログラマーが普段読んでいるのはプログラミング言語であり、上記のような条件A、値A、値B という順番で書かれている条件分岐のブロックです。 値A、条件A、値B という順番で書かれている英文ではありません。 Python の条件式を 値A、条件B、値B と読み間違えたことはありませんか。 日本人なら特にあるでしょう。 外国人でもあると思います。 日本語に近いオブジェクト指向的な書き方を外国人も受け入れているので。 なお、TypeScript では条件分岐のブロックは次のようになります。 x === 1 は間違いではないですよ。

if (x === 1) {
    var  value = "A";
} else {
    var  value = "B";
}

ちなみに余談ですが、TypeScript では、上記の varletconst に置き換えると文法エラーになります。 var が気に入らないですか? 上のほうで let で宣言して使わない値で初期化をわざわざ書いたところで、それは読む価値のないコードであり無駄を増やすだけです。

var は変数名の衝突を「静的に」検出できないですが、読む量が増えるとそれだけ間違える可能性が高まります。変数を適切に書けば99%以上衝突は起きませんし、Visual Studio Code で変数名をクリックしたときに誤って同じ変数名の別の変数まで強調させてしまうので気づきやすいですし、動かせばだいたい正しく動きません(「動的に」検出できます)。

読みやすい三項演算子(条件式)とは

読みやすい三項演算子はどのようなものかについて説明してきましたが、ここでまとめます。

  • 記号や略語を使わないこと
  • 条件は後で書く(右に書く)
  • 条件分岐ブロックと同じ順番で書く

今回開発したメソッドを使って書くと次のようになります。

const  value =_( "A" ).elseIf( x == 0 ).then( "B" );

カッコが増えて読みにくいというかごちゃごちゃした感じがすると思いますが、そこは本質ではありません。 文の構造的には、次のようになり、シンプルになっています。

const  value = "A"  // ただし、〜のときは〜

三項演算式は、例外的な条件があるときによく使います。 例えば、こんな感じになります。

const  message = Boolean(name) ? `名前: ${name}` : "";
                //  ↓  書き換え
const  message =_( `名前: ${name}` ).elseIf(!name).then("");
                //  ↓  意味
const  message = `名前: ${name}` // ただし、〜のときは〜

このように書き換えると、message 変数とは 名前: ${name} が入るものだと理解できます。 空文字 "" が入ることは例外的な値なので、コードを読んで理解する段階(初期段階)では不要な情報です。 読んでいるコードが例外的なコードであると理解したとき、私はその情報を頭から捨てて、期待する情報を探しに次へ読み進めます。 余計なデータは捨てなければ次へ進めません。

よく、学ぶために紹介される サンプル コード では、理解しやすくするために例外的な処理を書かないサンプルになることが多いです。 それは上記のコードの elseIf 以降が書かれていないことに相当します。 elseIf の前まで読めば理解に十分です。

const  message = `名前: ${name}`;

三項演算子を禁止した下記のコードを最初から読んでみてください。そして、比べてみてください。 下記のコードは前半にある例外的な内容を主題として認識してしまいがちですが、前半を読み捨てなければ内容がよく分からないと思います。 また、varconstlet に置き換えることもでき、適切に書くことができます。

if (!name) {
    var  message = "";
} else {
    var  message = `名前: ${name}`;
}

ただ、それだけでは、Python の条件式(三項演算子)にもある特長です。 今回 TypeScript のライブラリ的なメソッドでは、条件分岐ブロックと同じ順番、(条件Aは省略)、値A、条件B、値B という順番になるように開発しました。 つまり、Python との違いは、if else が elseIf then になっていることです。 これにより、書いてある条件が 値A に対するものではなく 値B に対するものになり、条件分岐ブロックと同じように読めて、非常に理解しやすいです。

一方、Python の書き方では 値A、条件A、値B になるので、同じ A に属する if も読まなければならないと判断することでしょう。

    message = f"名前: {name}" if name  // それ以外は〜

読みやすい三項演算式はメソッドで実装したので、カッコなどが必須になってしまうのですが、これまでも標準的な三項演算子を使うときは、カッコをあえて書いた方が三項演算子の構造が明確になって読みやすかったので、カッコなどが気になる方もすぐに慣れると思います。 カッコが書いてないと x の次の === で一瞬、戸惑いませんか? さらに続きを読んで ? を見つけて初めて読めた!となっていたと思います。 : の前後の値にもカッコがあるほうが、三項が統一された書き方になっていますし、?: が注目されやすくなっていて三項演算子であることへの理解を助けます。

const  value = x === 1 ? "A" : "B";
    //  ↓
const  value = (x === 1) ? ("A") : ("B");

ただし、値A と値B に例外的という主従関係がないのであれば、後置 else if を使わず一般的な分岐で書くべきです

if (isNumber(nameOrID)) {
    var  message = `ID: ${nameOrID}`;
} else {
    var  message = `名前: ${nameOrID}`;
}

ちなみに、三項演算子であってもインデントを書けば多少は読みにくくはならないでしょう。 三項演算子なら const を書くこともできます。 しかし、最初は必ず条件式を読むことになりますし、変数名が 1つしか書かれなくなることから、値が 2種類あることが視覚的に分かりにくくなるため、読む効率は下がります。

const  message = (isNumber(nameOrID)) ? (
    `ID: ${nameOrID}`
) : (
    `名前: ${nameOrID}`
);

後置 else if は、重要ではない例外的な内容を最初に読ませないようにする書き方なので、例外的という主従関係があるときだけ使うべきです。 たとえば、給与明細の時給を概算的に表示する仕様があったとします。時給は、

支給額 / 勤務時間

ですね。概要を知る上でこれ以上の情報は不要です。「ただし」、0割エラーを避けるために、勤務時間が0なら時給0と表示する仕様もあったとします。この場合、0は支給額/勤務時間という定義があって初めて決まる定義なので、主従関係があります。 仕様の文章に「ただし」があれば主従関係があると見ていいでしょう。

const  hourlyWage = _(()=>( payment / workingHours )).elseIf(workingHours===0).then(0)

()=>( ___ ) については、遅延評価の章を参照してください。

「ただし」のときの仕様のコードが何行にもなる場合は、三項演算子と同様に一般的な分岐で書くべきです。 主従関係が無く、後置 else if の対象外のものは多くあります。 例外的という主従関係がない場合は、三項演算子を使わないほうが、2種類の値を取ることが明確になるので良いです。 このあたりの違いは、文法から判断する コーディング ルール で改善するものではなく、文章力によって読みやすさが改善するものです。

TypeScript による仮実装

前置きが長くなりましたが、TypeScript で読みやすい三項演算式を書くためのメソッドの実装を紹介したいと思います。

勘の良い方なら、すでに気づいているかもしれませんが、.elseIf.then は メソッド チェーン です。

const  message =_( `名前: ${name}` ).elseIf(!name).then("");

そして、もっと勘の良い方なら気になっていたと思いますが、イコールの右に _ があります。 実はこれは _ という名前の関数の呼び出しです。 通常なら空白文字があるところに _ を書くことで目立たないようにしています。 厳密な設定の Lint ではイコールの右に空白が無いと警告されるかもしれませんが無視させてください。 文法をすべて明らかにすることが正義ではありません。 主題を明らかにすることこそ正義です。 もし、文法を明らかにすることこそ定義が明らかになるのではなく、elseIf または then の定義へジャンプしても明らかになります。 それで十分です。

関数 _ と、メソッド チェーン .elseIf, .then を持つ ElseIfMethodChain クラス の定義を示します。(注:まだ、最終形ではありません)

// Readable postfix else-if ternary operator method (tutorial version)
//      Example:
//          const  value = _( data ).elseIf(data === "").then("empty");
function  _(value: any): ElseIfMethodChain {  // The function name is '_'
    return  new ElseIfMethodChain(value);
}
class  ElseIfMethodChain {
    constructor(
        public  value: any
    ) {}
    elseIfCondition = true;

    elseIf(condition: boolean): ElseIfMethodChain {
        this.elseIfCondition = condition;
        return  this;
    }

    then(elseValue: any): any {
        if ( ! this.elseIfCondition) {
            return  this.value;
        } else {
            return  elseValue;
        }
    }
}

動作順序を説明します。

  • _ 関数 を呼び出し、引数の値を含んだ ElseIfMethodChain クラス のオブジェクトを返します
  • そのオブジェクトの elseIf メソッド を呼び出し、条件判定の結果をオブジェクトのプロパティに含ませて、再びそのオブジェクトを返します
  • そのオブジェクトの then メソッド を呼び出し、条件判定の結果に応じて、どちらか適切な値を返します

この仮実装は、シンプルなケースでは期待通りに動きますが、実用となると次の問題があります。

  • then の返り値が any 型 なので、Visual Studio Code のインテリセンスが機能しなくなる
  • 値の評価がメソッド呼び出しの前に行われるため、undefinednull オブジェクトに対するアクセス例外が発生してしまう

遅延評価の必要性

今回開発したメソッドを使って読みやすい三項演算子を書くときは、場合によっては遅延評価(lazy evaluation)を行うために、アロー関数 ()=>( ___ ) を引数に渡す必要があります。(言語仕様になれば、こういうことは不要になるのですが)

const  value =_( object!.name ).elseIf(!object).then("");
            //  ↓
const  value =_(()=>( object!.name )).elseIf(!object).then("");

書き方ですが、アロー関数は脇役なので左に詰めて書き、主役である object!.name の左右には空白文字を入れてカッコも書いて目立たせています。 また、object!.name! は、null/undefined 許容型 である object に対して静的解析によるエラーを回避するために必要です。 書かないとエラーで教えてくれます。 つまり、

  • null/undefined 許容型に関する警告がされたら、オブジェクトの右に ! を書き、アロー関数で囲みます

アロー関数(または無名関数)を渡すと、そのアロー関数の内容を実行するのは、そのアロー関数を呼び出すタイミングになります。 上記のコードで言えば、object!.name の実行は、then の中でどちらの値を返すべきかが決定した後のタイミングになります。 それをここでは遅延評価と呼んでいます。 その仕組みは、Jest などの一般的に普及している テスト フレームワーク では必ずといっていいほど使われているものなので、あまり躊躇されなくてもいいと思います。 また、アロー関数を指定すると関数呼び出しのオーバーヘッドによりわずかながら遅くなります。

もし、アロー関数を渡さなければ、object!.name の実行は !object の条件分岐より前に行われてしまい(先行評価されてしまい)、undefinednull オブジェクトに対するアクセス例外が発生してしまいます。 それを回避するためにアロー関数を渡して遅延評価をさせます。 まれにですが、then の引数にアロー関数を渡す必要があるかもしれません。 また、そもそも null/undefined 許容型 のオブジェクトが式に含まれないのであれば、アロー関数は必要ありません。

then の返り値が any 型 であるために Visual Studio Code のインテリセンスが機能しなくなる問題は、ElseIfMethodChain クラス の実装にジェネリクス条件型(Conditional Types)を使って any 型を排除することで回避しています。

ちなみに、返り値が any 型であっても、返り値を代入する変数の宣言に型を書けばインテリセンスは機能します。 ただし、その型を間違えて書いてしまっても警告されないため良くありません。 型推論は積極的に使いましょう。 型が知りたければ マウス カーソル を動かしましょう。

const  value: string =_( "A" ).elseIf( x !== 1 ).then( "B" );
            //  ↓
const  value =_( "A" ).elseIf( x !== 1 ).then( "B" );

💡 先行評価されてしまうことは 後置 else if がメソッドであることから自明なのですが、三項演算子では常に遅延評価されるために、後置 else if メソッドでも遅延評価することを想定してしまう人もいるでしょう。 しかし、一般的に良いとされるコーディングをしていればその問題は発生しません。それは、

  • 引数に副作用があるコードを書かないこと
  • 引数から再起呼び出しをする関数を呼び出さないこと

です。 引数に副作用があるコードを書いてもいいじゃないか、と分かりにくいコードをそのままにするような方や、再起呼び出しは普通によく使う分野のプログラムを書く方には、遅延評価するかどうかを自ら注意しなければならなくなるでしょう。 しかし、例外的な関係がある引数に該当しつつ、副作用や再起のコードを書くケースは今まで見つかっていません。 例外的な関係がないのであれば、普通の条件分岐を書くほうが適しています。 また(副作用や再起が必要なので)遅延評価するかしないかを気をつけなければならないぐらいなら、常に ()=>( ___ ) を書くように制限する実装にすればいいという考えもありますが、そもそもの読みやすくするための工夫なのに逆行してしまい、本末転倒です。 ヨーダ記法のようにこう書けば必ず間違いは起きないといいつつ、結局こう書かなければならない条件があるみたいな本末転倒です。 なお、これらの問題は、後置 else if が言語仕様になっていないから発生する問題なので、後置 else if という書き方自体の問題ではありません。

💡 後置 else if メソッドには、以下の制限事項があります。

  • 関数を返すことができない

関数を指定すると内部で関数呼び出しが行われて、その返り値を返すからです。 しかし、返った値が関数だと思って関数呼び出ししようとすると静的に警告されるので特に注意する必要はありません。 関数を指定したのだから関数を返すべきだと仕様を否定して警告を無視して呼び出し続ける愚かなことをすれば不具合を作り込むのは当たり前です。 後置 else if メソッド を使うときに注意することはありません。 ただ、関数を返すような方法がないかを探してしまうかもしれません。 それで時間が取られてしまうことがないように、後置 else if の実装のコメントに制限事項があることを書いています。 どうしても関数を分岐したい場合は、通常の条件分岐を使ってください。

実装の詳細説明

後置 else if メソッドの実装コードは最後に示します。 ここでは、どうしてそのような実装になったを説明しています。 実装が気になる方だけ読んでいただければと思います。

infer を積極的に使えばもっと少ない文字数で実装コードを書くことができますが、あえて冗長なコードにしています。 なぜなら、infer が何かをうまく説明できているサイトや AI の返事が得られなかったからです。 公式ですらいい感じに推論するとしか書いていません。 infer が残ってはいますが、そこが読めなくても WidenType が何かを理解できるようにしています。

💡 多くの中級プログラマー(メンバーを育てられない自称上級プログラマー)は、自分の頭にすでに入っている情報を前提に読みやすいかどうかを判断してしまうので、情報が無い初めて読む人や将来の自分にとって読みにくいコードを書きがちです。 多くの具体的な概念を知った上で定義されるプログラミング言語の抽象的で難しい文法や記号や略語を多用して、文字数としては少なくなったコードは、情報が頭に入っている人であれば読みやすいように思えるのは確かですが、情報が無い人にとっては、その謎の文法や記号や略語に対して情報ゼロになってしまうので読めるわけがありません。 そういうコードは技術負債としてアンタッチャブルなコードになるでしょう。 まわりのレベルが低いのではなく書いた人のコーディングのレベルが低いのです。 俺はこのコードが読めるからレベルが高いのではなく書いた人のレベルが高いのです。 ではどうすればいいかというと、1つの方法は、あえて簡単に理解できるケースについて冗長であっても分けてシンプルなコードも書くことです。 簡単なケースを理解して頭に入ってれば、難しいコードは理解しやすくなります。

真のときの値の型(T)と偽のときの値の型(U)が違うケースはあえてサポートしていません。 後置 else if は、例外的な主従関係があるときに、例外的なケースでも例外的ではないときの値に合わせるようにすることですから、T|U 型を返すということは値を合わせないことになり、本末転倒です。 そのケースではない場合でも、問題の先送りであり、後で何らかの分岐が発生します。 ならばその分岐も含めて大きいブロックからなる一般的な分岐を書くほうが適しています。

💡 T|U 自体が何であるかを知ることは難解ではないのですが、T|U で返すことがどういう意味かを知ることが難解です。 T だけならどういう型であるかを考えなくて済みます。 T|U なら、TとUの違いが何かを調べたくなってしまいます。 汎用性を求めてインターフェースの定義を難解にするより、制限があっても明快なほうが良いと考えています。 必要が無いケースなのに考慮することは行き過ぎた抽象化です。

TypeScript による 後置 else if メソッドの実装コード

サンプルも含んだコードは、こちら

// Readable postfix else-if ternary operator method
//      Example:
//          const  value =_( data ).elseIf(data === "").then("empty");  // early evaluation
//          const  value =_(()=>( object!.name )).elseIf(!object).then("");  // lazy evaluation
//      Limitation:
//          "then" method cannot return function, because of lazy evaluation support.
//          Declare any object type with "Any" type, because no warning is given that lazy evaluation is required.
function  _<T>(value: T): PostfixElseIfMehodChain<WidenType<T>> {  // The function name is '_'
    return  new PostfixElseIfMehodChain<WidenType<T>>(value as WidenType<T>);
}
class  PostfixElseIfMehodChain<T> {
    constructor(
        public  value: T
    ) {}
    elseIfCondition = true;

    elseIf(condition: boolean): PostfixElseIfMehodChain<T> {
        this.elseIfCondition = condition;
        return  this;
    }

    then(elseValue: TOrFunctionType<T>): TOrReturnType<T> {
        if ( ! this.elseIfCondition) {
            if (typeof this.value === 'function') {
                return  this.value();
            } else {
                return  this.value  as TOrReturnType<T>;
            }
        } else {
            if (typeof elseValue === 'function') {
                const  elseFunction = elseValue as ((...args: any[]) => any);
                return  elseFunction();
            } else {
                return  elseValue  as TOrReturnType<T>;
            }
        }
    }
}
type  TOrReturnType<T> =
    T extends (...args: any[]) => any
        ? ReturnType<WidenType<T>>
        : T;
type  TOrFunctionType<T> =
    ((...args: any[]) => T)
    | TOrReturnType<T>
    | T;
type WidenType<T> =  // e.g.) type 1 => type number, type "A" => type string
    T extends number ? number :
    T extends string ? string :
    T extends boolean ? boolean :
    T extends (...args: any[]) => infer R ? (
        R extends number ? (...args: any[]) => number :
        R extends string ? (...args: any[]) => string :
        R extends boolean ? (...args: any[]) => boolean :
        (...args: any[]) => any ) :
    T;
type Any = Record<string, any> | null;  // "any" type property object
type AnyOrPrimitive = Any | number | string | boolean;

地味ながら効果は絶大

例外的なケースを目立たなくするように三項演算式を使うようにしてからは、読んだのに脳内から捨てるコードの量がかなり減りました。 それはすなわち、開発効率が2倍以上になったことになります。 混乱するとかなり時間を取られるため、それを考慮すれば開発効率は 3倍4倍になるでしょう。 ぜひ試して体験してみてください。

Discussion

shoheikunishoheikuni

主旨は理解できる。
しかしif無しにelseという言葉が出てくるのが変だし英文法的にも変だ。
elseIfではなくbutIfにするとよいのでは。

// The value will be A. But if x == 0, then it will be B.
const  value =_( "A" ).butIf( x == 0 ).then( "B" );

また、メソッドチェーンでelseIfを繋げられるのが変なので、まだ改良できそう。

// ナニコレ
const  value =_( "A" ).elseIf( x == 0 ).elseIf(y == 0).elseIf(z == 0).then("B");