😊

ファーストクラスコレクション

2022/03/13に公開

はじめに

ファーストクラスコレクションの出典を知らなかったので、調べて、結果とメリデメをまとめました。サンプルコードはGoで書いています。

ファーストクラスコレクションの定義

ファーストクラスコレクションとは、ThoughtWorksアンソロジーで登場するデザインパターンです。以下のように定義されています。

プログラミング言語で提供されているリストやマップなどのコレクションをプリミティブと見なして、それをラップしたクラスをファーストクラスコレクションと呼んでいる

そして、次のように書かれています。

コレクションを持つクラスには、他のメンバ変数を持たせないようにしてください。各コレクションをそれぞれ独自のクラスにラップすることで、コレクションに関する振る舞いをそのクラスに置くことができるようになります。

注意してほしいことですが、ファーストクラスコレクションでは、配列やコレクションをイミュータブルにすることは定義されていません。例えば、ファーストクラスコレクションと完全コンストラクタパターンを暗に併用している場合、配列やコレクションがイミュータブルとなるように実装されていることがあります。

メリット

配列やコレクションを扱うコードが、プログラムのあちこちに散らばらない

データと振るまいをセットで実装すると、データの振るまいに関するコードがプログラムのあちこちに散らばりません。

例えば、Zennのようなブログ投稿サービスを考えます。
ブログ投稿サービスは「記事」というエンティティを持ちます。ユースケースとして、あるユーザーが投稿した記事一覧を取得する、とします。

package entity

type Article struct {
	ID          string
	UserID      string
	Title       string
	IsPublished bool
}
package usecase

...

func FindPublishedArticlesByUserID(userID string) ([]*entity.Article, error) {
	articles, err := FindArticlesByUserID(userID)
...

	var publishedArticles []*entity.Article
	for _, article := range articles {
		if article.IsPublished {
			publishedArticles = append(publishedArticles, article)
		}
	}
	
	return publishedArticles, nil
}

上記のサンプルコードでは、usecase packageで、ユーザーIDに紐づく記事一覧から公開済みの記事を抽出しています。他の関数でも公開済みの記事一覧を取得する場合、抽出する処理を複数箇所に書く必要があるので、変更容易性が低いです。

ファーストクラスコレクションを実装するため、ArticleのスライスをArticleList型と定義します。そして、公開済みの記事一覧を抽出するPublishedメソッドを以下のように実装します。

type ArticleList []*Article

func (a ArticleList) Published() ArticleList {
	var publishedArticles ArticleList
	for _, article := range a {
		if article.IsPublished {
			publishedArticles = append(publishedArticles, article)
		}
	}
	
	return publishedArticles
}
func FindPublishedArticlesByUserID(userID string) (entity.ArticleList, error) {
	articles, err := FindArticlesByUserID(userID)
...
	
	return articles.Published(), nil
}

データと振るまいがセットで実装されたため、抽出するロジックが変わったときに、複数箇所の実装を変更する必要がなくなりました。そのため、一つ前のサンプルコードと比べて変更容易性が高いです。同時に、entity.ArticleListを使うFindPublishedArticlesByUserID関数の実装が単純になりました。

entity.ArticleListに振るまいを持たせることで、後から参加する開発者や運用者に、プログラム上の意図とヒントを残せます。例えば、entity.ArticleListの実装を確認すれば、記事一覧がどういう振るまいを持つのかわかります。

変更後の実装はドメインモデルパターンと呼ばれます。ドメインモデルパターンでは、データとドメインロジックを一つのオブジェクトにまとめる実装をするので、ファーストクラスコレクションを使うことがあります。

配列やコレクションをイミュータブルにできる

ファーストクラスコレクションを使うと、配列やコレクションをイミュータブルな実装にできます。

ArticleListを構造体で定義し、非公開フィールドの型をArticleのスライスにすることで、別のpackageの処理からArticleListのフィールドが直接変更されることを防ぎます。しかし、Goでは同じpackageからは非公開フィールドを参照することができるので、他のプログラミング言語で実装するときのように厳密にイミュータブルにすることはできません。

type ArticleList struct {
	v []*Article
}

配列やコレクションに名前がつけられる

ファーストクラスコレクションを使うことで、配列やコレクションに意味のある名前をつけることができます。例えば、Goのtime.Time型のスライスをChangeLog型と定義すると、更新履歴を扱う型だとわかります。

type ChangeLog []time.Time

func (u ChangeLog) Latest() time.Time {
...
}

デメリット

コードを管理するコストが増えることがある

データに振るまいがないときにファーストクラスコレクションを使うと、ファーストクラスコレクションの恩恵を受けないまま、実装するコード量と管理する型だけが増えます。そのため、配列やコレクションに名前をつけるためだけにファーストクラスコレクションを使うことは少ない気がします。

データの振るまいが少ない場合でも、ファーストクラスコレクションを使わないことがあります。

例えば、ArticleはChangeLogという「記事の更新履歴」を表すフィールド名を持つとします。そして、「更新履歴」は「記事」でしか使われません。「記事の更新履歴」は「最新の更新履歴を抽出する」振るまいのみ持つとき、ChangeLog型をつくらず、ArticleにChangeLogに関するメソッドを実装します。

type Article struct {
	ID          string
	UserID      string
	Title       string
	IsPublished bool
	ChangeLog   []time.Time
}

func (a *Article) LatestChangeLog() time.Time {
...
}

まとめ

ファーストクラスコレクションの定義とメリデメについてまとめました。

ファーストクラスコレクションを使うかどうかを判断する基準は、実装対象によって変わるので、言語化しにくいです。私の意見ですが、データをイミュータブルにしたい or データの振るまいが多いときに、ファーストクラスコレクションを使うことが多いような気がします。

ファーストクラスコレクションはWebアプリケーションで実装することが多いので、定義とメリデメを把握するほうが良いと思いました。

参考文献

Discussion