ダブル・ディスパッチ~典型的な関数プログラミング・イディオム~

元ネタはダブル・ディスパッチ~典型的なオブジェクト指向プログラミング・イディオム~ です。 これをF#でやってみるとどうなるかやってみましょう。

レンタルショップの例(レベル1)

商品としてCDやDVDを取り扱うレンタルショップを想像・・・するのは面倒でしょうから、コードで示しますね。

type MemberKind =
  | Common
  | Gold

type Member = {
  Kind: MemberKind
}

type ItemKind =
  | CD
  | DVD

type Item = {
  Kind: ItemKind
}

module RentalShop =
  let calculateRentalFee (item: Item) (member_: Member) =
    (* 金額を計算する *)

レンタル料の計算が、商品種別と会員種別の組み合わせによって変わるようです。 一般会員(Common)の場合はどちらのそのままの値段で、ゴールド会員(Gold)の場合はCDが50円引き、DVDが100円引きとかになるんでしょうか。

RentalShop.calculateRentalFeeを何のひねりもなしに実装すると、こうなります(後で明らかにしますが、もっと別の方法があります)。

let calculateRentalFee (item: Item) (member_: Member) =
  match item.Kind with
  | CD ->
      match member_.Kind with
      | Common -> (* 一般会員がCDを借りる場合の料金計算 *)
      | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *)
  | DVD ->
      match member_.Kind with
      | Common -> (* 一般会員がDVDを借りる場合の料金計算 *)
      | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)

matchが入れ子になっていてなんだか「イヤな感じ」がしますね。

レンタル・ショップの例(レベル2)

レンタル・ショップのモデルを少し修正しましょう。 Itemが価格を持っていないわけがないので、Priceを追加します。 ついでに、レンタル料金の計算は商品オブジェクトにやってもらうようにします。

type MemberKind =
  | Common
  | Gold

type Member = {
  Kind: MemberKind
  (* 多分こいつも名前とか色々持ってる *)
}

type ItemKind =
  | CD
  | DVD

module CD =
  let calcRentalFee price = function
  | Common -> (* 一般会員がCDを借りる場合の料金計算 *)
  | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *)

module DVD =
  let calcRentalFee price = function
  | Common -> (* 一般会員がDVDを借りる場合の料金計算 *)
  | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)

type Item = {
  Kind: ItemKind
  Price: int
}
with
  member this.CalculateRentalFee(member_: Member) =
    match this.Kind with
    | CD -> CD.calcRentalFee this.Price member_.Kind
    | DVD -> DVD.calcRentalFee this.Price member_.Kind

module RentalShop =
  let calculateRentalFee (item: Item) (member_: Member) =
    item.CalculateRentalFee(member_)

関数を分けたので、matchのネストがひとつ減りました。 これが「よい感じ」かどうかは置いておきましょう。 さぁさらに複雑にしていきますよ!

レンタル・ショップの例(レベル3)

モデルにさらに手を加え、MemberとItemを相互依存させちゃいましょう!

type MemberKind =
  | Common
  | Gold

type ItemKind =
  | CD
  | DVD

type Member = {
  Kind: MemberKind
}
with
  member this.CalculateRentalFeeForCD(item: Item) =
    match this.Kind with
    | Common -> (* 一般会員がCDを借りる場合の料金計算 *)
    | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *)
  member this.CalculateRentalFeeForDVD(item: Item) =
    match this.Kind with
    | Common -> (* 一般会員がDVDを借りる場合の料金計算 *)
    | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)

and Item = {
  Kind: ItemKind
  Price: int
}
with
  member this.CalculateRentalFee(member_: Member) =
    match this.Kind with
    | CD -> member_.CalculateRentalFeeForCD(this)
    | DVD -> member_.CalculateRentalFeeForDVD(this)

module RentalShop =
  let calculateRentalFee (item: Item) (member_: Member) =
    item.CalculateRentalFee(member_)

実際の計算ロジックはすべてMember内に集約されました! これで、会員の種類が増えたとしても手を入れる必要があるのはMemberのみで、Itemなどに手を入れる必要はありません。

しかし、RentalShop.calculateRentalFeeやItem.CalculateRentalFeeは処理を受けて流すだけの内容になってしまい、 これらはノイズとまでは言いませんが、処理を追うのが面倒になりました。

また、扱う商品の種類が増えた場合はItemとMember両方に手を入れなければならないので面倒です。

さらに、例えば新しい軸(曜日によって割引率が変わる、とか)が増えた場合も、どうすればいいんでしょうかこれ。

レンタル・ショップの例(レベル4)

いっそ、モデルの定義とロジックを分割してみましょう。

type MemberKind =
  | Common
  | Gold

type Member = {
  Kind: MemberKind
}

type ItemKind =
  | CD
  | DVD

type Item = {
  Kind: ItemKind
  Price: int
}

module RentalShop =
  let calculateRentalFee (item: Item) (member_: Member) =
    match item.Kind, member_.Kind with
    | CD, Common -> (* 一般会員がCDを借りる場合の料金計算 *)
    | CD, Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *)
    | DVD, Common -> (* 一般会員がDVDを借りる場合の料金計算 *)
    | DVD, Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)

実はこれ、最初のバージョンのmatchのネストを、タプルのパターンマッチに書き換えただけです。 これでよかったんやー。

共通処理の括りだし

常識的に考えて、一般会員に特別な値引きがあるとは思えません。 おそらく、一般会員は商品のレンタル料金そのままの値段でのレンタルになるでしょう。 レベル3のコードだと、その場合の処理の共通化をしようと思うと、関数に括りだすしかありません。

member private this.CalculateCommonMemberFee(item: Item) = item.Price
member this.CalculateRentalFeeForCD(item: Item) =
  match this.Kind with
  | Common -> this.CalculateCommonMemberFee(item)
  | Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *)
member this.CalculateRentalFeeForDVD(item: Item) =
  match this.Kind with
  | Common -> this.CalculateCommonMemberFee(item)
  | Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)

あまり見通しは良くないですね。 それに対して、レベル4のコードだと

let calculateRentalFee (item: Item) (member_: Member) =
  match item.Kind, member_.Kind with
  | _, Common -> item.Price (* 一般会員はそのままの値段 *)
  | CD, Gold -> (* ゴールド会員がCDを借りる場合の料金計算 *)
  | DVD, Gold -> (* ゴールド会員がDVDを借りる場合の料金計算 *)

と、見通しの良い記述が可能です。

曜日によって割引率が変わりました

という場合でも、レベル4のコードであればRentalShop.calculateRentalFeeに引数を増やすだけで対応可能です。

let calculateRentalFee (item: Item) (member_: Member) (dayOfWeek: DayOfWeek) =
  match item.Kind, member_.Kind, dayOfWeek with
  | CD, Common, Tuesday
  | CD, Gold, _ -> (* 火曜日は一般会員もCDがゴールド会員と同じ割引ですって *)
  | DVD, Common, Tuesday
  | DVD, Gold, _ -> (* 火曜日は一般会員もDVDがゴールド会員と同じ割引ですって *)
  | _, Common, _ -> item.Price

まぁ複雑になりすぎる場合は、コンビネータを合成する方向性の方がいいと思いますが、 オブジェクト指向プログラミングでのダブルディスパッチよりはかなり簡潔に書けていると思います。

ダブルディスパッチしたくなったら、言語を変更できないか考えてみてもいいかもしれませんね。