ファーストクラスコレクションの実装と、その使い方
この記事は何?
最近仕事でバックエンドにも触れて、その時に思い出したように学び直したデザインパターンの1部をまとめる記事です。
本投稿のゴール
ファーストクラスコレクションの解説と、自分の経験を踏まえた上で使う/使わないの判断材料を提示する、といったところを着地点にしたいと思います。
「ファーストクラスコレクション」 is 何?
すでにご存じの方はこの項を飛ばしてください。
ざっくり解説すると、ArrayやList、Collection(言語ごとにそれ相当のものに読み替えてください)をそのまま扱わずにクラスを定義してラップする、という実装手法です。
上記の配列相当の機能を言語仕様のプリミティブ型として扱い、個別にコレクションに意味を、コレクション操作 ≒ 振る舞いを持たせることができます。
やってみよう
よくサンプルに出てくるTODOリストをイメージして書いてみます。
// 扱う対象のオブジェクト
class ToDo {
private readonly _text: string;
private _dateTime: Date;
constructor(
text: string,
dateTime: Date,
) {
this._text = text;
this._dateTime = dateTime;
}
get text(): string {
return this._text;
}
get dateTime(): Date {
return this._dateTime;
}
set dateTime(dateTime: Date) {
this._dateTime = dateTime;
}
}
↑のようなオブジェクトをそのままArrayで扱うサービスとかが出来上がると
class ToDoService {
constructor(private toDos: ToDo[]) {
}
addToDo(item: ToDo): void {
this.toDos.push(item);
}
// 以下ほかのメソッドが続く...
}
ざっくりですが、こんな感じでしょうか。
これがまた大きくなると、似たようなメソッドが乱立したりするわけですね。
配列を操作する処理自体も、それに伴って散らばり、同じ処理を記述する箇所も出てくることでしょう。
関数として同じ処理は纏めてきたのですから、配列の操作も纏めてあげましょう。
class ToDoList {
private constructor(private toDoList: ToDo[]) {
}
get count(): number {
return this.toDoList.length;
}
public static of(toDos: ToDo[] | readonly ToDo[]): ToDoList {
return new ToDoList([...toDos]);
}
public add(todo: ToDo): ToDoList {
return new ToDoList([...this.toDoList, todo]);
}
public delete(index: number): ToDoList {
return new ToDoList([...this.toDoList.splice(index, 1)]);
}
// 複数のToDoリストを合体させる
public merge(item: undefined, ...lists: ToDoList[]): ToDoList;
public merge(item: ToDo, ...lists: ToDoList[]): ToDoList;
public merge(
item: ToDo | undefined = undefined,
...lists: ToDoList[]
): ToDoList {
const items = lists.flatMap((list) => list.getToDos());
if (item) {
return new ToDoList([...this.toDoList, ...items, item]);
} else {
return new ToDoList([...this.toDoList, ...items]);
}
}
public getToDos(): ReadonlyArray<ToDo> {
return Object.freeze([...this.toDoList]);
}
}
試しにこれを使って書いてみると
const item = new ToDo("今日は快晴なり", new Date());
const toDoList = ToDoList.of([item]);
const item2 = new ToDo("ヨシ!", new Date());
const addedToDoList = toDoList.add(item2);
console.log(addedToDoList.count); // 2
const freezed = addedToDoList.getToDos();
// freezed.push(); 取り出した配列に変更をさせないようにしてあるためエラーになる
const regenerate = ToDoList.of(freezed); // 再度追加など操作したい場合は新しく生成するところからになるため、不用意な変更を防ぐ
console.log(regenerate.count); // 2
regenerate.add; // 省略するが呼び出しが可能になり、変更が可能になった
const otherList: ToDoList = ToDoList.of; // 省略
// アイテムやほかのリストと結合
regenerate.merge(undefined, otherToDoList); // 副作用なしのため、regenerate自体に変化が起きないため✖
const merged = regenerate.merge(undefined, otherToDoList); // 都度新しいインスタンスを生成するため◎
console.log(merged.count); // 5
merged.delete(2);
console.log(merged.count); // 4
console.log(regenerate.count); // regenerate自体は一切変更されないため2のまま
また、各メソッドが新しいToDoList
のインスタンスを返すため
const toDo = ToDoList.of([item, item2]);
const updated = toDo
.add(item3)
.delete(1)
.merge(undefined, anotherList)
.getToDos();
のように今どきのコレクション操作らしく、メソッドチェーンで繋いで記述できるのため見た目も良いですね。
扱うオブジェクトもArray<ToDo>
からToDoList
型になり、オブジェクト指向らしくなったでしょうか。
また、処理が纏まったことで、テストを書く時にも追加や削除の処理自体が正しいかのテストはあくまでToDoList
クラスのテストが担保すればよくなり、利用箇所はそれぞれの文脈に合わせたテストを書けばOKと出来たかと思います。
まとめと、使う/使わないの判断って?
ここまで、サンプルコードでファーストクラスコレクションの実装例や呼び出し、使うモチベーションなんかを解説しました。
さて、ここまでメリットを解説してきましたが、冒頭話したように使いたい/使わない状況というのは何なのかについて提示して終わろうと思います。
使いたい場合というのは、
- すでに複数回出現するとわかっている
- リファクタ期
になります。
というのも、0→1フェーズの「そもそも本当に対象が複数回出現するのか未定」みたいな状況や「確実にこのエンドポイントで1回しか出てこない」とわかっているものにここまでやるのは、コード管理の手間や単純な記述量の増加もあり、オーバー気味と言えるでしょう。あくまで整理術、素直にArray
として扱って問題ないと思います。
一方で、
これがまた大きくなると、似たようなメソッドが乱立したりするわけですね。
配列を操作する処理自体も、それに伴って散らばり、同じ処理を記述する箇所も出てくることでしょう。
と先述したように、すでに散らかっている全貌が見えていてリファクタ、リニューアルをするぞというタイミングではきっと効果的でしょう。
最近、リファクタのタイミングもあってちょっと勉強しなおしたのですがPRでのやり取りを通して、「どこでこのパターンが有効か」を考えることがあったためそれも交えてまとめました。
ここまで読んでいただいた方がいらっしゃれば、ありがとうございました。
自分の理解不足、経験値不足もあるかと思いますので、改善点などあればコメントいただけると助かります。🙏
Discussion