JavaScript 処理分岐どう書く問題

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 ifelse にしたいかもしれませんが, そうしてしまうと新しく要素が追加されたときに壊れます.

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 パターンなんかと使い分ける方が良いのだろうけれど統一感がなくなってにゃんこ.

もっと良い方法あったら教えてください.