Carpe Diem

備忘録

振る舞いをデータとしてモデル化する

背景

モデリングでは要件に対して

  • データの要件(〜を持つ。〜がある)
  • 振る舞いの要件(〜ができる。〜しなければならない)

のどちらかをまず判断します。

そしてオブジェクト指向では、オブジェクトが

  • データ(属性、フィールド)
  • 振る舞い(メソッド)

を持つという形で、先ほどの要件を表現します。

一方関数型プログラミングでは、振る舞い自体もできるだけデータとしてモデル化していく指向になります。

今回はそれを具体例を交えながら説明します。

振る舞いをデータとしてモデル化するとは

ここでいう振る舞いはロジックを指します。
つまりロジックをデータ構造に変換し、データモデルの制約によって、単純に実装するロジックでは抜け漏れが発生する様なケースに対処するということを意味します。

具体例

以前紹介した記事では次のような要件がありました。

christina04.hatenablog.com

要件:図書館の書籍

  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,
                publishedAfter: Int,
                publishedBefore: Int): List[Book]

手続き的に考えた場合

おそらく手続き的に考えた場合はこのようなロジックになるでしょう。

def searchBooks(books: List[Book],
                genres: List[String],
                authors: List[String], 
                searchByAvailability: Boolean,
                publishedAfter: Int,
                publishedBefore: Int): List[Book] = {

  books.filter { book =>
    // 1. ジャンルでフィルタリング(空リストなら条件なしとみなす)
    val genreMatch = genres.isEmpty || genres.contains(book.genre)
    
    // 2. 著者でフィルタリング(空リストなら条件なしとみなす)
    val authorMatch = authors.isEmpty || authors.contains(book.author)
    
    // 3. 出版年でフィルタリング
    val publicationYearMatch = book.publicationYear >= publishedAfter && book.publicationYear <= publishedBefore

    // 4. 利用可能状態でフィルタリング
    val availabilityMatch = if (searchByAvailability) {
      book.isAvailable
    } else {
      true  // searchByAvailabilityがfalseの場合は状態に関わらずマッチ
    }
    
    // すべての条件を満たしているかチェック
    genreMatch && authorMatch && publicationYearMatch && availabilityMatch
  }
}

引数が多いため初見では何がどう条件として使われるのかをコードを読むまで分かりづらく、またプリミティブ型が多いため引数の順番なども間違える余地があります。

関数型プログラミングで考えた場合

次に関数型プログラミングで考えた場合です。

Booksモデルの改善

まずBooksモデル自体を前回の通りに改善します。

// 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
}

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

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

def isBookAvailable(book: Book): Boolean = {
  book.status match {
    case BookStatus.Available => true
    case BookStatus.CheckedOut(_, _) => false
  }
}

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

これによって引数の順序、一部のパラメータの有限集合(ジャンル)可能性などを考慮できるようになります。

検索メソッドの改善

次に検索メソッドの改善です。先のモデルの改善を加えると、次のようなシグネチャになるでしょう。

def searchBooks(books: List[Book],
                genres: List[BookGenre],
                authors: List[Author], 
                searchByAvailability: Boolean,
                publishedAfter: Int,
                publishedBefore: Int): List[Book]

しかしこれをそのまま使うのはまだ不十分です。なぜなら

  • パラメータの型に対する意味をプログラマが考えだし、理解し、伝える必要がある
    • genres: List[BookGenre]が空であった場合どう扱うのかが不明。おそらくフィルタ条件外とするだろうが曖昧さが残る
  • 一部のパラメータは組み合わせて使った場合にのみ意味を持つことをプログラマが覚えておく必要がある
    • publishedAfterpublishedBeforeパラメータはペアで使わないと意味がない。
    • genreauthorは一緒に使うことでAND条件的にフィルタされるのかが曖昧

という課題があるためです。

そこで次のように検索フィルタ条件(ロジック)をADT(代数的データ型)を使ってモデル化します。

enum SearchCondition {
  case SearchByGenre(genres: List[BookGenre])
  case SearchByAuthor(authors: List[Author])
  case SearchByAvailability(availability: Boolean)
  case SearchByPublishedYears(start: Int, end: Int)
}

そして検索メソッドのシグネチャはこうします。

def searchBooks(books: List[Book],
                requiredConditions: List[SearchCondition]
): List[Book]

中の実装はこのようになります。

def searchBooks(books: List[Book], requiredConditions: List[SearchCondition]): List[Book] = {
  books.filter { book =>
    requiredConditions.forall(condition =>
      condition match {
        case SearchCondition.SearchByGenre(genres) => genres.contains(book.genre)
        case SearchCondition.SearchByAuthor(authors) => authors.contains(book.author)
        case SearchCondition.SearchByAvailability(availability) => if (availability) {isBookAvailable(book)} else {false}
        case SearchCondition.SearchByPublishedYears(start, end) => book.publicationYear >= start && book.publicationYear <= end
    })
  }
}

非常に見通しが良く、また前述の課題である

  • パラメータの型の意味
  • パラメータの組み合わせ

も解決できています。

使い方

具体的に使用する際は次のようになります。

// 条件なし
searchBooks(books, List.empty)

// 1つの条件
searchBooks(books, List()

// 複数の条件
searchBooks(books, List(SearchCondition.SearchByGenre(List(BookGenre.Fantasy)),
                                           SearchCondition.SearchByPublishedYears(1990, 2010)))

モデル化するメリット

前述の具体例を元に、振る舞いをデータとしてモデル化するメリットを述べます。

型による制約で意図しないエラーを防ぐ

プリミティブ型(文字列や整数)だけでロジックを記述すると、異なる条件を区別しにくくなり、意図しないエラーが生じやすくなります。
データモデルに変換することで、プログラムは条件の意味や制約を型レベルで理解できるため、誤った組み合わせや不要なデータが使われるリスクが減ります。

条件の組み合わせ漏れ防止

検索条件のようなケースは条件が複雑になりやすく、手動で組み合わせを記述すると抜け漏れが生じやすいです。
そこで例えば、ジャンル、著者、といった複数の条件を組み合わせる際に、ADTを使ってそれぞれを型として定義しておくと条件の抜け漏れを防ぎやすくなります。

パターンマッチングによる安全な制御

加えてScalaのような関数型プログラミング言語では、パターンマッチングを用いて条件ごとに対応する振る舞いを明示的に記述でき、コンパイルによって全て網羅されているかチェックできます。
これにより、漏れが発生しやすい複雑な分岐や組み合わせに対しても、安全な制御が実現できます。

ロジックの見通しと拡張性が向上

条件ごとのロジックがデータモデルの制約で整理されているため、拡張やメンテナンスが容易になります。
例えば条件が追加された場合も、新しいcaseをデータモデルに追加するだけで済み、既存のロジックを変更する必要が少なくなります。

まとめ

前回は「データの要件」をnewtype、ADT(代数的データ型)によってモデル化しましたが、今回は「振る舞いの要件」をADTを使ってデータとしてモデル化しました。