Swift & Objective-C

[Swift中級 #5] Swift高階関数の実践まとめ、lazyシーケンスまで

map・filter・compactMap・flatMap・reduceの違いは、クロージャが何を返すかで決まります。forループとの使い分け、チェーンが生む中間配列、lazyシーケンスが解決する問題をまとめました。

読了 8 分
[Swift中級 #5] Swift高階関数の実践まとめ、lazyシーケンスまでのカバー画像

mapとfilterは誰もが使います。問題はその先です。compactMapとflatMapは名前が似ていて混乱しやすく、reduceはシグネチャが難しくて避けがちです。チェーンを長くつなぐと、「これ、配列を何回作り直しているんだろう?」という性能面の不安もじわじわ湧いてきます。

中級シリーズ第5回は、高階関数の実践まとめです。5つの関数の正確な違い、forループとの使い分け、lazyシーケンスが解決する問題まで扱います。クロージャ編で固めた基礎の上に積み上げる実務編です。

5つの関数の正確な役割 — シグネチャが語ること

高階関数(higher-order function)とは、関数を引数として受け取る関数です。配列処理の5つの代表的な関数は、クロージャのシグネチャ、正確には「1つの要素が何に変わるか」で整理できます。

map: 要素 → 新しい要素。個数は維持。 n個入ればn個出てきます。変換するだけで、絞り込みはしません。

filter: 要素 → Bool。個数は減ることがあり、要素自体はそのまま。 条件に合う要素だけを残し、変換はしません。

compactMap: 要素 → オプショナル。nilを捨て、アンラップして返す。 mapとfilterを組み合わせた特殊形で、変換に失敗する可能性がある処理に使います。

let inputs = ["1", "2", "3", "4"]
let numbers = inputs.compactMap { Int($0) }  // [1, 2, 4]

Int(“三”)はnilになり、compactMapがそれを除外します。もしmap { Int($0) }だったら[Int?]が返り、利用箇所ごとにオプショナル処理を繰り返すことになったでしょう。「境界でオプショナルをアンラップする」というオプショナル編の原則を、コレクション向けに実装したものがcompactMapです。

flatMap: 要素 → 配列。ネストを1段階平坦化する。 1つの要素が複数に展開される場合に使います。sentences.flatMap { $0.split(separator: " ") }のように文章の配列から単語の配列を作ると、mapなら[[단어]]となるネスト結果が1段階平坦化されます。compactMapとの違いはこう覚えれば十分です。クロージャがオプショナルを返すならcompactMap、配列(シーケンス)を返すならflatMapです。

reduce: コレクション全体 → 1つの値。 合計や最大値の計算、辞書の作成など、複数の値を1つに畳み込む処理の一般形です。reduce(0, +)は合計を求めるもので、最初の引数が初期値、2番目が「これまでの結果と次の要素をどう結合するか」です。性能面では1つだけ覚えておきましょう。配列や辞書を累積するときはreduce(into:)を使います。通常のreduceは各ステップで累積値をコピーしますが、into版は1つの値を変更し続けるため、大量データでは大きな差になります。

forループ vs 高階関数 — 可読性の実体

「forループの代わりに高階関数を使うのがSwiftらしい」という言い方は、半分正しく、半分危険です。判断基準を整理します。

高階関数の本当の利点は短いことではなく、意図が宣言されることです。filterを見た瞬間、読み手は「絞り込む処理で、要素は変わらない」とシグネチャのレベルで理解できます。forループでは本体を最後まで読まないと判断できません。第1回で扱った表現力、「意図がコードにそのまま現れる」の実例です。

ただし、この利点は各ステップが単純な場合に限られます。クロージャ内に条件分岐が3重に入り、外部変数の変更やprintなどの副作用まで混ざると、「filterだから絞り込むはず」という期待が裏切られ、forより読みにくくなります。最小驚きの原則にも反します。判断基準は次のとおりです。

  • 変換・フィルタリング・集計の単純な組み合わせ → 高階関数。
  • 各段階で複雑な分岐や副作用、中間脱出(break)が必要 → forループ。特にbreakに相当する適切な高階関数はないため、「条件を満たす最初の要素で止める」ならfirst(where:)がありますが、それ以上の早期終了ロジックにはループが自然です。
  • インデックスが必要な処理 → enumerated()を使う高階関数もありますが、複雑になったらループにします。

1つ、明確に避けたいアンチパターンがあります。forEachの中で外部配列にappendするコードです。var result: [Int] = []; items.forEach { result.append($0 * 2) }はmapなら1行で済む処理を手動で再実装し、可変状態だけを増やしています。forEachは通知を送るなど、各要素に副作用を起こす場面に向いています。何かを生成するならmap系が適切です。

中間配列が積み重なるチェーンと、要素を1つずつ通すlazyパイプラインを対比した図
チェーンは各段階で中間配列を作り、lazyは要素を1つずつ通過させます

チェーンのコスト — 中間配列という隠れた訪問者

高階関数をつなぐと優雅に見えますが、各段階で新しい配列が作られることを知っておく必要があります。

let result = products
    .filter { $0.inStock }      // 中間配列 1
    .map { $0.price }           // 中間配列 2
    .prefix(5)                  // 最終

要素が100万個あると、filterは最大100万個の配列を作り、mapがさらに作ります。最初の5個だけ必要でも、全体を2回走査し、2回割り当てることになります。

この問題の標準的な解決策がlazyです。products.lazy.filter{...}.map{...}.prefix(5)のようにlazyを挟むと、パイプラインの性質が変わります。各段階を即時実行して配列を作る代わりに、「何をするか」だけを記録した遅延シーケンスが作られ、最終的に消費される時点で要素が1つずつパイプライン全体を通過します。5個そろった瞬間に走査が止まるため、100万個のうち先頭付近の一部だけを処理して終了します。中間配列もありません。

では常にlazyを使えばよいかというと、そうではありません。lazyは結果を保存しないため、同じ遅延シーケンスを2回消費すると計算も2回行われます。クロージャが参照として保存され、後で実行される構造なので、escapingに関する制約が生じることもあります。基準はシンプルです。データが大きく、一部だけを消費する場合(prefixとfirst(where:)の組み合わせ)はlazyが効果を発揮します。すべてを消費して配列として保存するなら、即時実行のほうがよいでしょう。プロパティの便利なlazy varと同じく、「計算を遅延するメリットがあるか」という問いのコレクション版だと考えると、概念が1つにつながります。

実践的な組み合わせレシピ

よく使う組み合わせをいくつかレシピとして残します。

辞書を作る。 Dictionary(uniqueKeysWithValues: users.map { ($0.id, $0) })でID検索用テーブルを作ります。キーが重複する可能性があるなら、Dictionary(grouping: orders, by: { $0.customerID })がグルーピング版です。

オプショナルを安全に通過させる。 サーバー応答の文字列ID配列をURL配列に変換するなら、ids.compactMap { URL(string: $0) }です。失敗は静かに除外され、型は[URL]として確定します。

ソートの組み合わせ。 items.sorted { $0.priority > $1.priority }の代わりにKeyPathを使うitems.sorted(using: KeyPathComparator(\.priority, order: .reverse))も覚えておくと便利です。KeyPathについては後で別途扱います。

集計。 カートの合計金額はcart.reduce(0) { $0 + $1.price * Double($1.quantity) }です。ただし単純な合計なら、cart.map(\.subtotal).reduce(0, +)のように分けたほうが読みやすいことも多いです。reduceクロージャが複雑になったら、分割するサインです。

高階関数とforループの分かれ道にある標識を読む開発者のイラスト
単純な変換なら高階関数、分岐・副作用・早期終了ならループ

まとめ

  • 判断基準はクロージャが返すものです。新しい要素ならmap、Boolならfilter、オプショナルならcompactMap、配列ならflatMap、全体を1つに畳み込むならreduce(累積コレクションにはreduce(into:))です。
  • 高階関数の利点は意図を宣言できることです。クロージャが複雑になったり副作用が混ざったりすると、その利点は失われるので、forループに戻します。
  • チェーンは各段階で中間配列を作ります。大きなデータの一部だけを消費するならlazyで遅延パイプラインを作り、すべてを消費するなら即時実行を維持します。
  • forEachで配列を作っているなら、map系に置き換えるべき場所です。

次回は、JSONと格闘するすべてのiOS開発者に関わるテーマ、Codableの応用です。CodingKeys、ネストした構造、日付戦略、そして「1つのフィールドのために全体のデコードに失敗する」問題への対処法まで扱います。

あわせて読みたい