⛰️

三項演算子は本当に読みにくいのか。TypeScript で分かった三項演算式クラスの効果

2024/09/08に公開

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 = `名前: ${name}` if name  // それ以外は〜

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

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

ただし、例外的な分岐ではなく、一般的な分岐であれば、三項演算子を使うべきではありません

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

なぜなら、2種類の値を取る場合でも1種類が例外的であれば、実質 1種類の値しかないのですが、そうでない場合は、三項演算子を使わないほうが、2種類の値を取ることが明確になるからです。 このあたりの違いは、文法から判断する コーディング ルール で改善するものではなく、文章力によって読みやすさが改善するものです。

TypeScript による仮実装

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

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

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

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

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

// Readable ternary operator (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 オブジェクトに対するアクセス例外が発生してしまう

TypeScript による本実装と、遅延評価の必要性

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

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

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

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

もし、アロー関数を渡さなければ、object!.name の実行は !object の条件分岐より前に行われてしまい、undefinednull オブジェクトに対するアクセス例外が発生してしまいます。 それを回避するためにアロー関数を渡して遅延評価をさせます。 例外が発生する可能性がある ! を書いたらアロー関数を書く方針で構いません

まれにですが、then の引数にアロー関数を渡す必要があるかもしれません。

また、そもそも null/undefined 許容型 のオブジェクトが式に含まれないのであれば、アロー関数は必要ありません。

もう 1つの問題、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" );

TypeScript で読みやすい三項演算式を書くためのクラスの完全な実装は以下のようになります。

// Readable ternary operator
//      Example:
//          const  value =_( data ).elseIf(data === "").then("empty");
//          const  value =_(()=>( object!.name )).elseIf(!object).then("");  // lazy evaluation
function  _<T>(value: T): ElseIfMethodChain<TOrReturnType<T>> {  // The function name is '_'
    return  new ElseIfMethodChain<TOrReturnType<T>>(value);
}
type  TOrReturnType<T> = T extends (...args: any[]) => any ? ReturnType<T> : T;
class  ElseIfMethodChain<T> {
    constructor(
        public  value: any
    ) {}
    elseIfCondition = true;

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

    then(elseValue: T): T {
        if ( ! this.elseIfCondition) {
            if (typeof this.value === 'function') {
                return  this.value();
            } else {
                return  this.value;
            }
        } else {
            if (typeof elseValue === 'function') {
                return  elseValue();
            } else {
                return  elseValue;
            }
        }
    }
}

地味ながら効果は絶大

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

Discussion