ファーストクラスコレクション
はじめに
僕がはじめて「ファーストクラスコレクション」という名前を知ったのは
@masuda220 さんの
「現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法」から。
何が嬉しいの?
ファーストクラスコレクションっていうだけだと Listをラップするだけなんですが、もうちょっと こうしたらイイよねというのをコード例を添えて。
コード
実装
- リストの要素
public class Item { // 不変となる実装をしておくこと private final String value; public Item(String value) { this.value = value; } public String getValue() { return value; } }
- ファーストクラスコレクション
public class FirstClassCollection { private final List<Item> items; private FirstClassCollection(List<Item> items) { this.items = items; } public static FirstClassCollection ofWithUnmodifiableList(List<Item> items) { // nullを許容しません List<Item> list = Objects.nonNull(items) ? Collections.unmodifiableList(items) : Collections.emptyList(); return new FirstClassCollection(list); } public static FirstClassCollection ofWithCopyOf(List<Item> items) { // nullを許容しません List<Item> list = Objects.nonNull(items) ? List.copyOf(items) : Collections.emptyList(); return new FirstClassCollection(list); } // 加工をした後もimmutable public FirstClassCollection filterBy(String value) { List<Item> filterd = this.items.stream().filter(e -> e.getValue().equals(value)).collect(Collectors.toList()); return FirstClassCollection.ofWithCopyOf(filterd); } // 要素の変換はcallbackで public <R> List<R> apply(Function<Item, R> function) { return this.items.stream().map(item -> { return function.apply(item); }).collect(Collectors.toUnmodifiableList()); } // 副作用の伴う操作もcallbackで public void accept(Consumer<Item> consumer) { this.items.forEach(item -> { consumer.accept(item); }); } // テスト用にアクセッサを作っているけれど無くなっても大丈夫なのでプロパティを不変にできます public List<Item> getItems() { // インスタンス生成時に不変を保証しているので、そのまま返却しても問題ありません. return this.items; } }
説明
Nullを許容しない
インスタンス変数でリストを扱うメリットはNullをゼロサイズのリストに出来るところです。
どうしても「Nullだったことを残したい」場合は、、Optional
を使うかそれ用のインスタンス変数を追加して判定できるようにすると良いと思います。*1
インスタンス生成時にimmutable
内部ロジックで加工・編集をする際でも、うっかりインスタンス変数を上書きしてしまわないようにガードをかけます。
やってみて分かったことなのですが、Collections#unmodifiableList
だと元とのなるリストに要素を追加すると、インスタンス変数のリストの状態も更新されてしまいました。
Java10以降のList#copyOf
を使うのが良さそうです。
加工をした後もimmutable
要素の絞り込みをした場合や加工をした場合も戻り値をファーストクラスコレクションにしておけば「インスタンス生成時にimmutable」になります。
List<?>
をそのまま返却してしまいがちですが、そこは ぐっとこらえてファーストクラスコレクションにします。
要素の変換はcallbackで
ドメインオブジェクトからDTOへの変換など、getterにしたいところが必ずあります。
ただ、そこでインスタンス変数を開放してしまうと、直接操作(ループを回す)というロジックがどうしても外に出てしまいます。
そういうのを発生し辛くするという意味で、getterよりもcallbackを使って「リストへの操作を利用側が決める」という口を準備すると良いかな?と思います。
副作用の伴う操作もcallbackで
「リストの全要素を標準出力に書き出す」「リストの全要素をRepositoryを使ってDBへインサートする」というような副作用をループで回したい場合はこちら(Consumer)を使います。
アクセッサは無くても大丈夫
ただしくは「要素の変換は」とか「副作用の伴う操作」のcallbackを使ったら内部情報を外に出すことが出来る裏口はあるので直接インスタンス変数を開放する必要は無いです。
むしろアクセッサがあるとついついリスト構造をつかって、ファーストクラスコレクションの外でリストの操作をしてしまいやすくなるので無いくらいでも良いかもしれません。
テスト関連
リストの変換後用のオブジェクト
public class Item2 { // 不変となる実装をしておくこと private final String value; public Item2(String value) { this.value = value; } public String getValue() { return value; } }
public class FirstClassCollectionTest { @Test void test不変の確認() { ArrayList<Item> items1 = new ArrayList<>(); items1.add(new Item("1")); items1.add(new Item("2")); var fstClasse1 = FirstClassCollection.ofWithCopyOf(items1); var fstClasse2 = FirstClassCollection.ofWithUnmodifiableList(items1); // インスタンス変数では不変になっているので更新、追加、削除は不可 assertThrows(UnsupportedOperationException.class, () -> fstClasse1.getItems().set(0, new Item("3"))); assertThrows(UnsupportedOperationException.class, () -> fstClasse1.getItems().add(new Item("3"))); assertThrows(UnsupportedOperationException.class, () -> fstClasse2.getItems().set(0, new Item("3"))); assertThrows(UnsupportedOperationException.class, () -> fstClasse2.getItems().add(new Item("3"))); items1.add(new Item("3")); // OK:List.copyOf だとitemsの元インスタンスへの要素の追加による影響を受けない assertEquals(2, fstClasse1.getItems().size()); assertFalse(items1 == fstClasse1.getItems()); // NG:Collections.unmodifiableList だとitemsの元インスタンスへの要素の追加による影響を受ける assertEquals(3, fstClasse2.getItems().size()); // ListとArrayListとして相違ありとなってはいるが参照先としては同じもの参照している様子 assertFalse(items1 == fstClasse2.getItems()); } @Test public void testFilterBy() { var fstClass = FirstClassCollection.ofWithCopyOf(List.of(new Item("1"), new Item("2"))); var filterdClass = fstClass.filterBy("1"); assertEquals(1, filterdClass.getItems().size()); assertEquals("1", filterdClass.getItems().get(0).getValue()); } @Test public void testApply() { var fstClass = FirstClassCollection.ofWithCopyOf(List.of(new Item("1"), new Item("2"))); List<Item2> convertedItemList = fstClass.apply(item -> new Item2(item.getValue())); assertEquals(2, convertedItemList.size()); assertEquals("1", convertedItemList.get(0).getValue()); assertEquals("2", convertedItemList.get(1).getValue()); } @Test public void testAccept() { var fstClass = FirstClassCollection.ofWithCopyOf(List.of(new Item("1"), new Item("2"))); List<Item> items = new ArrayList<>(); // 本来は標準出力などの副作用のあるロジックの実行を想定 fstClass.accept(item -> items.add(item)); assertEquals(2, items.size()); assertEquals("1", items.get(0).getValue()); assertEquals("2", items.get(1).getValue()); } }
参考
First Class Collection | BLOG - DeNA Engineering
ファーストクラスコレクションとは【デザインパターン】 - 正三雑記
さいごに
このクラス、ちょっと作り変えてファーストクラスの抽象クラスにして基底にしても良いかもしれない?
*1:ちなみに、僕だったらどうしてもという場合だったら後者を選びます。