見出し画像

JavaScriptのGeneratorを使うとなぜ関数型プログラミングが捗るのか調べてみた


こんにちはnoteでエンジニアをやっている山形です。
この記事はnote株式会社 Advent Calendar 2022の18日目の記事です。

JavaScriptのGeneratorはご存知でしょうか?
聞いたことあるけど使ったこと無いという人多いんじゃないでしょうか。僕も存在は知っているけど今まで全然使ったことなくて「あ〜なんかyieldとかnext()とか書くやつだよね」くらいの認識で実際に使うメリットがよく分かっていなかったのですが、関数型プログラミングが捗るという噂を聞きつけて調べてみました。

更新履歴:
2022/12/18 21:19 コード例を一部修正しました
2022/12/19 09:48 コード例を一部修正しました

Generatorの書き方

Generator関数の書き方ですが、もちろんMDNに載っています。

アスタリスクをfunctionキーワードの後に書くのね、はいはい。
中でyieldすると処理がそこで止まるよね、はいはい。
next()を呼び出すとyieldキーワードの右側の値が返るね、はいはい。

で、なに…

ここで終わってしまうと凄くもったいないんです。少しづつ解説していきます。

Generatorは実は普通の構文だけでも書ける

Generatorの function* を使わなくてもPure JavaScriptだけで表現できます。

function sequence(list) {
  let index = 0
  return () => {
    if (index < list.length) {
      const item = list[index]
      index += 1
      return item
    }
  }
}

const gen = sequence([1,2,3])
gen() // => 1
gen() // => 2
gen() // => 3
gen() // => undefined

listGeneratorはindexを閉じ込めたclosureを返すので、listの中身が尽きるまで中身を返し、なくなればundefinedを返すようになっています。
なのでこれをwhileで回せばforEachのように使えますね

function sequence(list) {
  let index = 0
  return () => {
    if (index < list.length) {
      const item = list[index]
      index += 1
      return item
    }
  }
}

const gen = sequence([1,2,3])

let item = gen()
while (item !== undefined) {
  console.log(item) // => 1 2 3が順にlogに出力される
  item = gen()
}

Generatorを使った場合だとこうなる

function* sequence(list) {
  for (const item of list) {
    yield item
  }
}

const gen = sequence([1,2,3])

for (const value of gen) {
  console.log(value) // => 1 2 3が順にlogに出力される
}

closureを返すパターンよりもコードが素直で読みやすくなりました。
でもまだ何が便利なのかわからないです。

よくある配列操作とは

普通にArrayのメソッドを使う場合

実際の仕事で書いてる配列操作って、上のサンプルコードみたいに無意味なconsole.logじゃなくてこんな感じじゃないでしょうか。

// APIからデータを取得
const res = await fetch('/api/products')
const data = await res.json()

// 利用可能なitemに絞る
const availableItems = data.items.filter((item) => item.isAvailable)

// 表示用に整形する
const formattedItems = availableItems.map((item) => ({
  name: `商品名: ${item.name}`,
  price: `${item.price.toLocaleString()}円`
}))

配列から必要なものを取り出して、整形して、といった感じで配列から別の配列へそして別の配列へ…のように中間段階でいくつもの配列が必要になることが多いです。

これだと計算量O(n)のループが連続していて、最悪の場合だとAPIから受け取った配列の長さそのままで何度もループしなければいけません。

通常のArray関数でループ処理を行った場合は複数回のループと中間データが発生する

reduceでループ処理をまとめた場合

ただし一回のループで済ませたいならreduceを使えばできますね。

data.items.reduce((acc, item) => {
  if (item.isAvailable) {
    acc.push({
      name: `商品名: ${item.name}`,
      price: `${item.price.toLocaleString()}円`
    })
  }
  return acc
}, [])

これはこれで悪くありません。まだそこまで読みにくくないし、計算量がかなり減らせました。
ただし拡張性となると話は別です。たとえばセール中のフラグがあるものだけに絞った配列も別で用意したいとなるとif文の条件が変わったりとか、関数の処理が長くなるほどメンテナンスコストが上がっていきまし、テストも書きにくくなってきます。

つまり我々は計算量が少なく見通しが良くテストが書きやすいコードを求めているのです。

Generatorだったらできるよ

なんとGeneratorなら夢のコードが実現できてしまうのです。
順を追って説明していきましょう。

Filter処理をGenerator化してみる

まずfilter関数を定義してみます。ここでは特定の条件を埋め込んでしまうのではなくて、条件部分をpredicate関数として渡せるようにして拡張性の高いfilter関数を用意しつつビジネスロジックに合わせた関数も作ってみます。

function createFilter(predicate) {
  return (sequence) => {
    function* filterGenerator() {
      while (true) {
        const item = sequence.next()
        if (item.done) {
          return // 要素を出し尽くしたら処理を終了させる
        }
        if (predicate(item.value)) {
          yield item.value // 条件に当てはまるときだけyieldする
        }
      }
    }
    return filterGenerator() // Generator化を実行して返してあげる
  }
}

const availabilityFilter = createFilter((item) => item.isAvailable)
const saleFilter = createFilter((item) => item.isSale)

条件部分とfilterのロジックを完全に分離することができました。

ちなみに処理の流れをわかりやすくするためにwhileを使ってみましたが、これだけの処理なら for…of を使うとdoneの確認などを省略することができます。

function createFilter(predicate) {
  return (sequence) => {
    function* filterGenerator() {
      for (const item of sequence) {
        if (predicate(item)) {
          yield item
        }
      }
    }
    return filterGenerator()
  }
}

const availabilityFilter = createFilter((item) => item.isAvailable)
const saleFilter = createFilter((item) => item.isSale)

処理が明快になって超絶読みやすいですね!

このコードをテストするなら以下のようになるのではないでしょうか。

describe('availabilityFilter', () => {
  it('yields only available items', () => {
    function* testGenerator() {
      yield { id: 1, isAvailable: true }
      yield { id: 2, isAvailable: false }
    }
    const result = Array.from(availabilityFilter(testGenerator()))
    expect(result).toEqual([{ id: 1, isAvailable: true }])
  })
})
describe('saleFilter', () => {
  it('yields only sale items', () => {
    function* testGenerator() {
      yield { id: 1, isAvailable: false, isSale: true }
      yield { id: 2, isAvailable: false }
    }
    const result = Array.from(saleFilter(testGenerator()))
    expect(result).toEqual([{ id: 1, isAvailable: false, isSale: true }])
  })
})

条件毎にテストケースを書き分けられるので非常に助かる感じです。

注意点としてはGeneratorの実行結果を配列として扱うためには Array.from() に渡してあげるか […result] のようにSpread構文を使って配列に変換するという処理が必要です。

map処理もGenerator化してみる

map関数も汎用的なcreateMap関数をベースにお好きなmap関数を作れるようにしてみます。

function createMap(mapping) {
  return (sequence) => {
    function* mapGenerator() {
     for (const item of sequence) {
       yield mapping(item)
      }
    }
    return mapGenerator()
  }
}

const toViewMap = createMap((item) => ({
  name: `商品名: ${item.name}`,
  price: `${item.price.toLocaleString()}円`
})

組み合わせるとこうなる

// APIからデータを取得
const res = await fetch('/api/products')
const data = await res.json()

// 利用可能なitemに絞りつつ表示用に整形する
const formattedItems = Array.from(toViewMap(availableFilter(data.items)))

filer, mapの定義が別の箇所に書いてあるとすると、上記のようにかなりシンプルに処理を記述できるようになりました。

ログを仕込んで処理の順番を追ってみよう

とにかくログを出してみる

全コードにログを仕込んでいき、処理がどのような順番で呼ばれているのかを確認してみます。
sequenceからitemを取り出したらconsole.logして見るようにしてみました。

function createFilter(predicate) {
  return (sequence) => {
    console.log('start filter')
    function* filterGenerator(sequence) {
      for (const item of sequence) {
        console.log('---> filter', item)
        if (predicate(item)) {
          yield item
        }
      }
    }
    return filterGenerator()
  }
}

function createMap(mapping) {
  return (sequence) => {
    console.log('start map')
    function* mapGenerator(sequence) {
      for (const item of sequence) {
        console.log('---> map', item)
        yield mapping(item)
      }
    }
    return mapGenerator()
  }
}

const availabilityFilter = createFilter((item) => item.isAvailable)

const toViewMap = createMap((item) => ({
  name: `商品名: ${item.name}`,
  price: `${item.price.toLocaleString()}円`
})

const items = [
  {
    id: 1,
    isAvailable: true,
    name: 'Product A',
    price: 1520
  },
  {
    id: 2,
    isAvailable: true,
    name: 'Product B',
    price: 8590
  },
  {
    id: 3,
    isAvailable: false,
    name: 'Product C',
    price: 4270
  }
]

const formattedItems = Array.from(toViewMap(availabilityFilter(items)))
console.log(formattedItems)

// Log
start filter
start map
---> filter { id: 1, isAvailable: true, name: 'Product A', price: 1520 }
---> map { id: 1, isAvailable: true, name: 'Product A', price: 1520 }
---> filter { id: 2, isAvailable: true, name: 'Product B', price: 8590 }
---> map { id: 2, isAvailable: true, name: 'Product B', price: 8590 }
---> filter { id: 3, isAvailable: false, name: 'Product C', price: 4270 }
[
  { name: '商品名: Product A', price: '1,520円' },
  { name: '商品名: Product B', price: '8,590円' }
]

filterとmapが交互に実行されて、3つ目のitemのときはfilterの条件に当てはまらないのでmapが呼ばれずに処理が終了しています。

通常のfilter, map関数を使った場合、配列の全要素をまず全部filterにかけて、次に内容が減った配列がmapにかけられるというように2つのループが実行されるのでログは filter, filter, filter -> map, map という順序になるはずです。

なぜこうなるのか

こうなるのはGeneratorがyieldした時点で処理が止まるという特徴を生かしているからです。

const formattedItems = Array.from(toViewMap(availabilityFilter(items)))

この処理を関数の実行毎に行を分けてみると以下のようになります。
Array.fromにもconsole.logを仕込んでみました。

const filtering = availabilityFilter(items)

const mapping = toViewMap(filtering)

const formattedItems = Array.from(mapping, (item) => {
  console.log('---> Array.from', item)
  return item
})

// Log
start filter
start map
---> filter { id: 1, isAvailable: true, name: 'Product A', price: 1520 }
---> map { id: 1, isAvailable: true, name: 'Product A', price: 1520 }
---> Array.from { name: '商品名: Product A', price: '1,520円' }
---> filter { id: 2, isAvailable: true, name: 'Product B', price: 8590 }
---> map { id: 2, isAvailable: true, name: 'Product B', price: 8590 }
---> Array.from { name: '商品名: Product B', price: '8,590円' }
---> filter { id: 3, isAvailable: false, name: 'Product C', price: 4270 }

実行はfilterから始まっていますが、yieldすることで各アイテム毎にループ処理が止まり、次の関数へと処理が渡るようになります。
filter => map => Array.fromまで到達して処理の続きがなくなると、最初のfilterが次のitemを取り出して、また同じように繰り返すのです。
これによりループの回数は全てまとめて一回しか実行されません。

Generatorを使うと複数のループ処理が一回のループで行える

Pipeline Operatorでもっと読みやすくなる

Generatorを使うと効率よくループ処理が行えることが分かりました。
しかし関数を入れ子にした記述はお世辞にも読みやすいとはいい難いです。
そこでJavaScriptの新構文としてPipeline Operatorというものが提案されています。

これによると |> という演算子を使うことで処理の順序と同じように関数をならべることができるようになります。
つまり今回の処理に当てはめると以下のようになるはずです。

const formattedItems = items
  |> availabilityFilter
  |> toViewMap
  |> Array.from

ちょっと不思議なシンタックスですが |> の後に関数を書くとその関数に一つだけ引数を与えて実行され、更にその結果を次の |> にわたすということができるようになります。
Array.fromが最初に来る書き方よりだいぶ直感的な記述ができるようになります。

まとめ

今までGeneratorなにそれおいしいの?状態でしたが、今回の学びにより仕事でもうまく使えばかなり効果的なのではと感じています。
テストが書きやすかったり、部品を組み合わせていろいろな処理に使えそうな予感もしますね。
あとはループ処理以外でもGeneratorが活躍する場面もあるようなので(例えばState Machineでの活用など)そのあたりも今後調べてみようと思います。

以下のような場面ではGeneratorを活用してみるといいと思います。

  • 工程の多いループ処理

    • かつ拡張性・再利用性を求められる場面は特に

    • かつテストが複雑化してしまっている場面も特に

それにしても、はやくPipeline Operatorが使えると嬉しいですね…

参考リンク