Carpe Diem

備忘録

代数的データ型を用いたデータモデリング

概要

データモデリングをする際に、エンジニアは次のことに注意しなくてはいけません。

  1. パラメータの順序
  2. パラメータを組み合わせの意味
  3. 一部のパラメータの値を有限集合にする

これらを解決する手法として、関数型プログラミングでは代数的データ型があります。

要件

例えば以下の様な要件があるとします。

図書館の書籍

  1. 書籍のリストを検索できなければならない。
  2. 各検索では、ジャンル、著者、出版年、利用可能かどうか、貸出中の期間など、様々な組み合わせをサポートする必要がある。
  3. 書籍にはそれぞれ名前、ジャンル、著者、出版年、利用可能かどうか、貸出中の期間、がある。

これを愚直に考えたBookモデルを以下の様に表現してみます。

case class Book(title: String,
                author: String,
                genre: String, 
                publicationYear: Int,
                isAvailable: Boolean, 
                checkoutDate: Int,
                returnDate: Int)

また検索する関数を以下の様に表現してみます。

def searchBooks(books: List[Book],
                genres: List[String],
                authors: List[String], 
                searchByAvailability: Boolean,
                availableAfter: Int,
                availableBefore: Int): List[Book]

課題

しかしこのモデリングは先に挙げた課題がまるっと刺さります。

1. パラメータの順序

Book("The Catcher in the Rye", "Fiction", "J.D. Salinger", 1951, true, 0, 0)

この例は著者とジャンルの順序を間違えています。

2. パラメータを組み合わせの意味

Book("1984", "George Orwell", "Dystopian", 1949, false, 0, 0)

isAvailableがfalseであれば、checkoutDateとreturnDateが設定されるべきですが、この例ではfalseでありながら日付が設定されていません

3. 一部のパラメータの値を有限集合にする

Book("Moby Dick", "Herman Melville", "Fcition", 1851, true, 0, 0)

Fictionとあるべき所を、タイポでFcitionとなっています。String型で柔軟に受け取ってしまうことで、不適切な値を許容してしまっています。

解決方法

代数的データ型を用いたデータモデリング

これらを解決する方法として、関数型プログラミング言語(今回はScala)では

があります。

newtypeはDDDでいう値オブジェクトのようなもので、定義することでプリミティブ型の曖昧さを制限することができます。

代数的データ型は直和型と直積型のデータ型を組み合わせたものです。

直和型

直和型を簡単に説明すると以下です。

  • 有限集合の値だけをとることができる型。
    • 複数のケースがあり、一度にそのうち一つにしかなれないもの。
  • enumで表現することが多い。

音楽のジャンルなどのように有限集合である場合は直和型を使って表現するのが最適です。

enum MusicGenre {
    case HeavyMetal
    case Pop
    case HardRock
}

これを使うことで、次のように

import MusicGenre._

val genre: MusicGenre = Pop
->Pop

val x: MusicGenre = HeavyMeta
->コンパイルエラー

val y: MusicGenre = "HeavyMeta"
->コンパイルエラー

多くの作業をコンパイラに任せられるようになります。

直積型

直和型を簡単に説明すると以下です。

  • structのようにパラメータの組み合わせを表現する型
    • パラメータを組み合わせることの意味をプログラマが考慮しなくても、型として定義できる

例えば住所を表現する場合、いくつかの必須情報が組み合わさります。

case class Address(city: String, postalCode: String, country: String)

ただこれだけでは先ほどのBookモデルと同じですね。
そこで直和型と組み合わせた代数的データ型が威力を発揮します。

直和型+直積型

代数的データ型は直和型と直積型を組み合わせることで、より複雑なデータの表現が可能になります。

例えば支払い方法にはいくつかの選択肢があり、それぞれが異なるデータを持つ場合があります。クレジットカード、デビットカードPayPalのような選択肢です。
それを代数的データ型であれば次のように

enum PaymentMethod {
  case CreditCard(cardNumber: String, expiryDate: String)
  case DebitCard(cardNumber: String)
  case PayPal(email: String)
}

直和型のそれぞれのcaseを直積型で表現しています。

こうすることで、注文(Order)のようなモデルを作る際に、次のように直感的なデータモデルとして表現でき、かつコンパイラによってデータの不整合や不正な状態を防ぐことができます。

case class Order(id: String,
                 paymentMethod: PaymentMethod,
                 shippingAddress: Address)

val order = Order(
  "ORD12345",
  PaymentMethod.CreditCard("1234-5678-9012-3456", "12/25"),
  Address("New York", "10001", "USA")
)

具体的な解決方法

では元のBookモデルに対して、newtypeと代数的データ型を使った改善を行ってみます。

newtype

タイトルや著者をnewtypeを使って値オブジェクトで表現します。

// BookTitleを新しい型として定義
opaque type BookTitle = String

object BookTitle {
  def apply(value: String): BookTitle = value
  extension (t: BookTitle) def value: String = t
}

// Authorを新しい型として定義
opaque type Author = String

object Author {
  def apply(value: String): Author = value
  extension (a: Author) def name: String = a
}

改善後のモデル

次のようにプリミティブ型の曖昧さがなくなったことで、パラメータの順序を間違えたりしてもコンパイルエラーとして検知できるようになります。

case class Book(title: BookTitle,
                author: Author,
                genre: String, 
                publicationYear: Int,
                isAvailable: Boolean, 
                checkoutDate: Int,
                returnDate: Int)

// 有効な本のインスタンス
val bookTitle = BookTitle("1984")
val author = Author("George Orwell")
val book = Book(bookTitle, author, BookGenre.Dystopian, 1949, BookStatus.Available)

// 無効なインスタンス(コンパイルエラー)
val wrongBook = Book("1984", Author("George Orwell"), BookGenre.Dystopian, 1949, BookStatus.Available)

直和型

ジャンルは有限集合なので、直和型で表現します。

// ジャンルを直和型で定義
enum BookGenre {
  case Fiction, NonFiction, Mystery, Fantasy, SciFi, Biography, Dystopian
}

代数的データ型(直和型+直積型)

最後に「貸し出し可能なのか?できないのであれば貸出日と返却日はどうなっているか」という複雑な状態を、代数的データ型で表現します。

// 書籍のステータスをADTで定義
enum BookStatus {
  case Available
  case CheckedOut(checkoutDate: Int, returnDate: Int)  // 貸出日と返却日を含む
}

改善後のモデル

上記を全てまとめると、Bookモデルはのように改善されます。

case class Book(title: BookTitle,
                author: Author,
                genre: BookGenre, 
                publicationYear: Int,
                status: BookStatus)

具体的に使う例は以下です。

利用可能な本

val book1 = Book(BookTitle("The Hobbit"),
     Author("J.R.R. Tolkien"),
     BookGenre.Fantasy,
     1937,
     BookStatus.Available)

貸出中の本

val book2 = Book(BookTitle("1984"),
     Author("George Orwell"),
     BookGenre.Dystopian,
     1949,
     BookStatus.CheckedOut(20230915, 20231015))

この改善によって、最初に挙げた課題をそれぞれ解決できるようになります。

  • パラメータの順序ミス
    • BookTitleやAuthorを新しい型として定義したことで、Stringの誤使用が防げる。
  • タイポのようなミス
    • BookGenreやBookStatusを直和型として定義することで、ジャンルやステータスが制約された有限の選択肢からのみ選べられる。
  • パラメータの組み合わせの意味(データの一貫性)
    • 貸出中の書籍には貸出日と返却日が設定されており、利用可能な本と混同されない。

他言語との比較

例えばGo言語でも型定義やinterfaceによって似たようなことは表現できます。

しかし同じようなことを表現しようとした場合、

  • 値オブジェクトを定義してもリテラルが使えてしまう
  • Scalaのようなパターンマッチングのサポートがなく、コンパイル時に不完全なマッチングが検出されない
  • 代数的データ型を表現すると冗長な記述になる

といった課題もあるため、オブジェクト指向寄りのGo言語では表現できない部分があります。

このようにGoやJavaScript等、多言語で関数型プログラミングをやろうとした場合に越えられない壁が関数型プログラミング言語にはあるため、「Goで関数型プログラミング!」と無理に採用するとチーム内のレベル差によって思想が混ざったコードが生まれるので注意が必要です。

まとめ

関数型プログラミングで代表的な代数的データ型のメリットを伝える形でデータモデリングの改善サンプルを紹介してみました。

参考