JavaScript で (バリアント的な) 複数の可能性がある値について処理を分岐するときに, どのように書くのが良いかという話.
ここでは具体例として簡単な数式の計算プログラムを考えます. 数式を表すクラスは以下の通り.
// literal class Lit { constructor(val) { this.val = val; } } // left + right class Add { constructor(left, right) { this.left = left; this.right = right; } } // left - right class Sub { constructor(left, right) { this.left = left; this.right = right; } }
以下ではこれらを使って表した数式を計算する処理 evaluate
をいくつかの方法で書いて比較してみます.
各方法の評価ポイントは,
- (Flow を使って) 静的に型検査ができるか (ここでは型書かないけど)
- 新しく要素 (例えば掛け算など) を追加したときに, 対応していないと静的にエラーを出せるか
- 柔軟性 (デフォルト処理 (例えばリテラル以外ならば, のような) のようなものが書けるか, など)
- 見た目
- パフォーマンス
あたりです.
メソッドを追加する
最も簡単には各クラスにメソッドを追加すれば良いでしょう.
// literal class Lit { constructor(val) { this.val = val; } evaluate() { return this.val; } } // left + right class Add { constructor(left, right) { this.left = left; this.right = right; } evaluate() { return this.left.evaluate() + this.right.evaluate(); } } // left - right class Sub { constructor(left, right) { this.left = left; this.right = right; } evaluate() { return this.left.evaluate() - this.right.evaluate(); } } // (2 + 4) - 1 console.log( new Sub(new Add(new Lit(2), new Lit(4)), new Lit(1)).evaluate() ); // -> 5
利点
- Flow による静的な型検査が可能
- 新しく要素を追加したとき,
evaluate
メソッドがなければ静的にエラーを出せる
欠点
- 柔軟性は悪い
- ちょっとした分岐がある度にメソッドを追加するのはつらい
- そもそもメソッドを追加できないときには不可能
- 定義の場所が要素ごとに散らばる
というわけで以下ではメソッドを使わずに, 外部の関数として定義する方法を考えます.
instanceof
を使う
オブジェクトがどのクラスに所属するかを判別するためには instanceof
が使えます.
// literal class Lit { constructor(val) { this.val = val; } } // left + right class Add { constructor(left, right) { this.left = left; this.right = right; } } // left - right class Sub { constructor(left, right) { this.left = left; this.right = right; } } function evaluate(expr) { if (expr instanceof Lit) { return expr.val; } else if (expr instanceof Add) { return evaluate(expr.left) + evaluate(expr.right); } else if (expr instanceof Sub) { return evaluate(expr.left) - evaluate(expr.right); } else { throw new Error("unknown expression"); } } // (2 + 4) - 1 console.log( evaluate(new Sub(new Add(new Lit(2), new Lit(4)), new Lit(1))) ); // -> 5
利点
- 静的な型検査が可能
- デフォルト処理が簡単に書ける
欠点
- 新しく要素を追加したとき, 動的にしかエラーを出せない
- 見た目が悪い (きがする)
instanceof
のパフォーマンスはあまり良くない
ちなみに最後の else if
を else
にしたいかもしれませんが,
そうしてしまうと新しく要素が追加されたときに壊れます.
typeOf
メソッドを用意
あらかじめ一般的に使えるような, 各クラスの種類を表す値を返すようなメソッド typeOf
を用意しておきます.
const Type = { Lit: "lit", Add: "add", Sub: "sub" }; // literal class Lit { constructor(val) { this.val = val; } typeOf() { return Type.Lit; } } // left + right class Add { constructor(left, right) { this.left = left; this.right = right; } typeOf() { return Type.Add; } } // left - right class Sub { constructor(left, right) { this.left = left; this.right = right; } typeOf() { return Type.Sub; } } function evaluate(expr) { switch (expr.typeOf()) { case Type.Lit: return expr.val; case Type.Add: return evaluate(expr.left) + evaluate(expr.right); case Type.Sub: return evaluate(expr.left) - evaluate(expr.right); default: throw new Error("unknown expression"); } } // (2 + 4) - 1 console.log( evaluate(new Sub(new Add(new Lit(2), new Lit(4)), new Lit(1))) ); // -> 5
利点
- デフォルト処理が簡単に書ける
- 見た目は良さ気
- パフォーマンスは良い
欠点
- 新しく要素を追加したとき, 動的にしかエラーを出せない (追記あり)
- (Flow では) 静的な型検査ができない
typeOf
は自己申告なので, 本当にそのクラスなのかの保証がない
追記
typeOf
をメソッドではなくプロパティとして持たせたらいけた.
// @flow type Expr = Lit | Add | Sub // literal class Lit { type: "lit"; val: number; constructor(val) { this.type = "lit"; this.val = val; } } // left + right class Add { type: "add"; left: Expr; right: Expr; constructor(left, right) { this.type = "add"; this.left = left; this.right = right; } } // left - right class Sub { type: "sub"; left: Expr; right: Expr; constructor(left, right) { this.type = "sub" this.left = left; this.right = right; } } function evaluate(expr: Expr): number { switch (expr.type) { case "lit": return expr.val; case "add": return evaluate(expr.left) + evaluate(expr.right); case "sub": return evaluate(expr.left) - evaluate(expr.right); default: throw new Error("unknown expression"); } } // (2 + 4) - 1 console.log( evaluate(new Sub(new Add(new Lit(2), new Lit(4)), new Lit(1))) ); // -> 5
Visitor パターン
有名な Visitor パターンです.
あらかじめ accept
メソッドを定義しておいて,
そこに各クラスに対応する処理を持った visitor を渡してやる方法です.
これ書いてて思ったんですけど, ほぼ Scott エンコーディングですね.
// literal class Lit { constructor(val) { this.val = val; } accept(visitor) { return visitor.visitLit(this.val); } } // left + right class Add { constructor(left, right) { this.left = left; this.right = right; } accept(visitor) { return visitor.visitAdd(this.left, this.right); } } // left - right class Sub { constructor(left, right) { this.left = left; this.right = right; } accept(visitor) { return visitor.visitSub(this.left, this.right); } } const visitor = { visitLit: val => val, visitAdd: (left, right) => evaluate(left) + evaluate(right), visitSub: (left, right) => evaluate(left) - evaluate(right) }; function evaluate(expr) { return expr.accept(visitor); } // (2 + 4) - 1 console.log( evaluate(new Sub(new Add(new Lit(2), new Lit(4)), new Lit(1))) ); // -> 5
利点
- 静的な型検査が可能
- 新しく要素を追加したとき, 対応していなければ静的にエラーを出せる
- 見た目は良い
- 各パラメータを変数に束縛することもできる (上の例のように)
- パフォーマンスも良い
欠点
- 柔軟性はやや悪い
- デフォルト処理だけなら
visitDefault
みたいなメソッドを作ってやればできなくはないが
- デフォルト処理だけなら
まとめ
以上をまとめるとこんな感じ.
静的型検査 | 要素追加時のエラー | 柔軟性 | 見た目 | パフォーマンス | |
---|---|---|---|---|---|
メソッド追加 | ◯ | ◯ | ✕ | △ | ◯ |
instanceof |
◯ | ✕ | ◯ | △ | ✕ |
typeOf メソッド |
✕ | ✕ | ◯ | ◯ | ◯ |
type プロパティ |
◯ | ✕ | ◯ | ◯ | ◯ |
Visitor パターン | ◯ | ◯ | △ | ◯ | ◯ |
(おまけ) 本当のパターンマッチ | ◯ | ◯ | ◯ | ◯ | ? |
個人的には柔軟性が高いのが良くて, かつ静的型検査ができないのは論外という感じで,
普段雑に書いている時は instanceof
を使いがちです.
適材適所で Visitor パターンなんかと使い分ける方が良いのだろうけれど統一感がなくなってにゃんこ.
もっと良い方法あったら教えてください.