オブジェクト指向プログラミングには、「コレクションオブジェクト」「ファーストクラスコレクション」と呼ばれる、オブジェクトのリストをカプセル化したオブジェクトを作るテクニックがあります。
コレクションオブジェクトとは
コレクションオブジェクトは、値オブジェクトの一種で、次のような特徴を持ったオブジェクトです。
- 特定のオブジェクトのリストである。
- ビジネスロジックを持っている。
例えば、「商品コレクションオブジェクト」は、複数の「商品オブジェクト」を持ちます。ビジネスロジックとしては、商品オブジェクトの価格を合計して、合計金額を返すといった処理を持たせたりします。
JavaScriptでコレクションオブジェクトを実装してみる
コレクションオブジェクトはJavaScriptに固有の概念ではありませんが、JavaScriptでも実装することができます。
例えば、記事のリストである、記事コレクションオブジェクトを考えてみましょう。
まず、記事オブジェクトですが、話を単純にするために、IDと題名を持つオブジェクトとします:
const article1 = { id: 1, title: 'JSの変数入門' }
次に、記事コレクションオブジェクトですが、これは記事を複数保持できるような実装にするが出発点です:
class Articles {
// private
_articles = []
add(article) {
this._articles.push(article)
console.log('OK: 記事を追加しました', article)
}
}
// 記事コレクションオブジェクト
const articles = new Articles()
articles.add({ id: 1, title: 'JSの変数入門' })
articles.add({ id: 2, title: 'JSのクラス入門' })
articles.add({ id: 3, title: 'JSのオブジェクト指向入門' })
これだけだと、ただ配列をラップしただけのオブジェクトなので、ビジネスロジックを持たせます。例えば、「記事IDが重複した記事はadd
できない」といったロジックです:
class Articles {
/**
* @private
*/
_articles = []
add(article) {
// 記事IDの重複はゆるさない
if (this._articleIdExists(article.id)) {
throw new Error('Error: 記事IDが重複しています')
}
this._articles.push(article)
console.log('OK: 記事を追加しました', article)
}
_articleIdExists(id) { /*...*/ }
}
これで、ただ配列をラップしたオブジェクトから抜け出して、ビジネス上の知識を持った一人前のコレクションオブジェクトになりました。
const articles = new Articles()
articles.add({ id: 1, title: 'JSの変数入門' })
//=> OK: 記事を追加しました { id: 1, title: 'JSの変数入門' }
articles.add({ id: 2, title: 'JSのクラス入門' })
//=> OK: 記事を追加しました { id: 2, title: 'JSのクラス入門' }
articles.add({ id: 2, title: 'Javaの変数入門' })
//=> Error: 記事IDが重複しています
最後の正しくないadd
は、バリデーションが働いて記事ID:2の重複を阻止してくれます。いい感じです。
これで、記事コレクションオブジェクトの主要な機能が作れました。
危険なtoArray
の実装例
ただ、このままだとArticles
クラスは、記事を追加できるものの、中身を取り出すことができません。
そこで、toArray
メソッドを生やして、記事を配列として取り出せるようにしてみましょう。Articles
の_articles
プロパティは、配列なので、それをそのままreturn
すれば良さそうです:
class Articles {
_articles = []
/* 中略 */
toArray() {
return this._articles
}
}
これで、記事コレクションから記事一覧を取り出すことができます:
const allArticles = articles.toArray()
for (const article of allArticles) {
/* なんかの処理 */
}
このtoArray
メソッドは、一見すると大丈夫そうですが、実は問題があります。
どのようなものかと言うと、toArray
を介して取得した配列に破壊的な操作をすると、記事コレクションオブジェクトが隠蔽している_articles
プロパティにもその影響が及んでしまうという問題です。
例えば、記事ID:2が入っている記事コレクションから、
const articles = new Articles()
articles.add({ id: 1, title: 'JSの変数入門' })
articles.add({ id: 2, title: 'JSのクラス入門' })
配列を取得し、
const articleArray = articles.toArray()
そこに、別の記事ID:2のオブジェクトをpush
します:
articleArray.push({ id: 2, title: 'Javaの変数入門' })
すると、記事コレクションの中身も変わってしまいます:
console.log(articles)
// => Articles {
// _articles: [
// { id: 1, title: 'JSの変数入門' },
// { id: 2, title: 'JSのクラス入門' },
// { id: 2, title: 'Javaの変数入門' } ← 意図せず加わった
// ]
// }
せっかくadd
メソッドでID重複チェックを行っているのに、意図せずそれをすり抜けてしまう事故があり得るのです。
コレクションオブジェクトのtoArray
を安全にする方法
コレクションオブジェクトを実装するにあたって、「中身を返す場合は、それを改変できないようにして返すべし」という鉄則があります。
しかし、JavaScriptには手軽に配列を不変にする方法がありません。
なので、考え方を変えて、「返した配列が変更されてもコレクションオブジェクトに影響しないようにする」というアプローチで対応します。
具体的には、toArray
が呼び出されたときにArrayオブジェクトをコピーする方法です:
class Articles {
/* 中略 */
toArray() {
return [...this._articles]
}
}
こうしておけば、toArray
で取り出された配列に対して、破壊的な配列操作がされたとしても、記事コレクションの配列には影響しません:
const articles = new Articles()
articles.add({ id: 1, title: 'JSの変数入門' })
articles.add({ id: 2, title: 'JSのクラス入門' })
// toArrayで、配列を取得し、
const articleArray = articles.toArray()
// そこに記事2をpushしても、
articleArray.push({ id: 2, title: 'Javaの変数入門' })
// コレクションの中身も変わりません^o^
console.log(articles)
// => Articles {
// _articles: [
// { id: 1, title: 'JSの変数入門' },
// { id: 2, title: 'JSのクラス入門' }
// ]
// }
コレクションオブジェクトの設計を見直す
toArray
メソッドを安全にする話はここまで終わりです。
ここからはもう少し設計面でコレクションオブジェクトを安全にできないか考えてみたいと思います。どういうことかというと、toArray
メソッドが本当に必要なのか?ということです。
toArray
メソッドを生やすのは、コレクションに生えているメソッドだけでは、必要な操作ができないからではないでしょうか。例えば、記事コレクションなら、「投稿日でソートしたい」、「あるユーザの投稿だけに絞り込んだリストがほしい」といったニーズがあるのに、記事コレクションに生えているメソッドだとそれができない、だからtoArray
を生やしてそれに対応する、といった具合です。
しかし、よくよく考えてみると、「投稿日でソートしたい」などのニーズはどれもビジネスロジックです。これらのニーズは本来、コレクションオブジェクトで吸収してあげるべきです。そうしていけば、toArray
メソッドがコレクションオブジェクトに要らない場合も多々出てくるはずです。toArray
がコレクションからなくなれば、安全面での心配が少なくなります。
しかしながら、そのようにつぶさに対応していっても、最後に残ってしまいがちなニーズが、「コレクションをfor
で回したい」というものです。だからといって、「ループのためにはtoArray
は必要」と結論づけるのは早計です。for
で回したいだけなら、イテレーターをコレクションオブジェクトに実装する選択肢があるからです:
class Articles {
_articles = []
/* 中略 */
*[Symbol.iterator]() {
yield* this._articles
}
}
この[Symbol.iterator]
というメソッドを生やしておけば、for
に対応させることができます:
const articles = new Articles()
articles.add({ id: 1, title: 'JSの変数入門' })
articles.add({ id: 2, title: 'JSのクラス入門' })
for (const article of articles) {
console.log(article)
}
//=> { id: 1, title: 'JSの変数入門' }
//=> { id: 2, title: 'JSのクラス入門' }
ちなみに、どうしても配列がほしいとなったときは、コレクションオブジェクトに対してスプレッド演算子を使うと、[Symbol.iterator]
が呼び出され、配列を手に入れることもできます:
const articleArray = [...articles]
最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→Twitter@suin