代数的データ型を利用した抽象化とクラスシステムによる継承との比較


以前、ReasonMLについての記事を書きました。

その時は、ReasonMLにおけるADT=Algebraic Data Type(代数的データ型)実装であるVariantの用途がイマイチ分からず、列挙型のナンカスゴイ版として扱っていました。

ReasonMLにおけるVariantとは下記のようなもので、RustだとEnumに当たります。

type animal =
  | Dog
  | Cat
  | Duck;

いわゆる直和っていうもので、色んな型の和として別の型を表現するような感じですかね。

で、これをどう使うかというと、

let cry = () => switch(Dog) {
  | Dog => "wan!"
  | Cat => "nyaa!"
  | Duck => "kuwa!!"
}

Js.log(cry()) // ただのconsole.logです。

上の例だと、wan!って出力されます。

説明のために、この例を出してみたんですが、多分あまり旨味を感じないと思うので、少し複雑な例を出します。

Amazonの書籍購入

Amazonで書籍を購入する時って、Kindle(電子本)とPaperback(紙の本)を選べるんですね。

で、その二つをモデリングした場合、

book <-> kindle paperback

っていうようなis-a関係があることがわかります。(kindle is a book. paperback is a book)

(正確には、kindle is a kind of book.かも。まあ良いか。。。)

このとき、bookというのは、kindleとpaperbackの抽象的な表現であることがわかります。

つまり、下記のようなグラフの状態です。

(ここら辺についてよく分からない方は、苫米地英人氏による論文をご覧ください。→「空」を定義する ~現代分析哲学とメタ数理的アプローチ。この方は難しいと思われていることを易しく教える天才です。)

じゃあ、こういう関係をクラスシステムではどう表現していたかというと、

class Book {
}

class Kindle extends Book {
}

class Paperback extends Book {
}

(JavaScript)

というような継承を用います。

で、「電子本には割引が発生する」という仕様があるとします。

その場合の値段計算のロジックを考えてみましょう。

class Book {
}

class Kindle extends Book {
  constructor(price, discount) {
    super()

    this.price = price
    this.discount = discount
  }

  calcPrice() {
    return this.price * (1 - this.discount)
  }
}

class Paperback extends Book {
  constructor(price) {
    super()

    this.price = price
  }

  calcPrice() {
    return this.price
  }
}

となったとします。

で、この仕様について話し合ったところ、「本の値段計算は、一律で 値段×割引率 にする」と決まったとします。(なんか無理やりだなあ。。。)

今後、本の形態が増えても、同じくロジックで対応したいため、「本」に関する一般的なロジックとして扱うように修正したとしましょう。

class Book {
  constructor(price, discount) {
    this.price = price
    this.discount = discount
  }

  calcPrice() {
    return this.price * (1 - this.discount)
  }
}

class Kindle extends Book {
  constructor(price, discount) {
    super(price, discount) // Bookのコンストラクタの呼び出し
  }
}

class Paperback extends Book {
  constructor(price) {
    super(price, 0) // 紙の本は、割引が発生しないから割引率は「0」
  }
}

const kindle = new Kindle(100, 0.1)

console.log(kindle.calcPrice()) // 90

では、これと同じことをVariantで行うとどうなるでしょう。

まず、先ほどのanimal型は、DogCatの抽象型として機能していることを思い出してください。

その考え方からいくと、

type book =
| Kindle
| Paperback;

とできます。また、Variantはコンストラクタを受け取れるので、そこに値段や割引率を設定できるようにしましょう。

type book =
| Kindle(int, float) /* int: 値段 float: 割引率 */
| Paperback(int); /* int: 値段 */

では、classの代わりにこれらをBookモジュールに入れてあげます。

値段計算のロジックはどうなるでしょう。

let calcPrice = (x) => switch(x) {
| Kindle(price, discount) => Js.Int.toFloat(price) *. (1.0 -. discount) /* intのfloat変換が必要 */
| Paperback(price) => price
}

このように書けます。

では先ほどと同様に計算ロジックを一般化させるために、クラスではなくBookモジュールを使いましょう。

Bookモジュールには、型の定義と共用のロジックを入れ込んでしまいましょう。

module Book = {
  type book =
  | Kindle(int, float) /* int: 値段 float: 割引率 */
  | Paperback(int); /* int: 値段 */


  let calcPrice = (x) => {
    let calc = (price, discount) => Js.Int.toFloat(price) *. (1.0 -. discount)

    switch(x) {
    | Kindle(price, discount) => calc(price, discount)
    | Paperback(price) => calc(price, 0.0) /* 紙の本は割引なし = 割引率0 */
    }
  }
}

このモジュールを使うように、先ほどの実装を直してみます。

Js.log(Book.calcPrice(Book.Kindle(100, 0.1))) /* 90 */

というように、表現することができました。

よって、ADTは抽象化の手段として使えるのではないかな、という比較でした。