JavaScript: コレクションオブジェクトのtoArrayを安全にする


オブジェクト指向プログラミングには、「コレクションオブジェクト」「ファーストクラスコレクション」と呼ばれる、オブジェクトのリストをカプセル化したオブジェクトを作るテクニックがあります。

コレクションオブジェクトとは

コレクションオブジェクトは、値オブジェクトの一種で、次のような特徴を持ったオブジェクトです。

  • 特定のオブジェクトのリストである。
  • ビジネスロジックを持っている。

例えば、「商品コレクションオブジェクト」は、複数の「商品オブジェクト」を持ちます。ビジネスロジックとしては、商品オブジェクトの価格を合計して、合計金額を返すといった処理を持たせたりします。

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