Dictionary(grouping:by:)を使って合成したSwiftの値をキーごとにまとめ、その後mapValuesとreduceを使って各合計値を計算します。このMac向けチュートリアルでは、直接累積する方法と、入力が空の場合、合計がゼロの場合、整数オーバーフローの場合のチェックも紹介します。

目的は、形状を表す中立的なラベルと整数の数量を格納したメモリ上の配列を、1つの[String: Int]辞書に変換することです。すべての入力は合成したリテラルです。この計算では外部レコードを読み込まず、ネットワークリクエストを送信せず、結果も保存しません。

キーごとの合計値の意味を定義する

コレクションのメソッドを選ぶ前に、結果の仕様を定義します。ここでは、入力要素はそれぞれのラベルの合計値に数量を1回ずつ加算します。ラベルには通常のSwift文字列の等価性を使い、空白の除去や大文字小文字の変換は行いません。辞書のキーはHashableに準拠する必要があり、Swiftでは文字列をキーとして使えます。AppleのDictionaryのドキュメントを参照してください。

例の入力には、circleの値が2つ、squareの値が2つ、triangleの値が1つ含まれています。期待される合計値は、手計算でも簡単に確認できます。

ラベル 入力された数量 期待される合計値
circle 4、6 10
square 3、5 8
triangle 0 0

この例では、次のルールを使います。

  • 入力が空なら、空の辞書を返します。
  • 数量がゼロのラベルも、合計値ゼロのキーとして残ります。
  • 負の数量も許可され、通常の加算で合計値に反映されます。
  • 入力に存在しないラベルは、結果にも含まれません。
  • すべての中間合計値はIntに収まる必要があります。収まらない場合、関数はスローし、合計値を返しません。

ラベルが存在しないことと、合計値がゼロであることの違いは重要です。ラベルがなければ、一致する入力がありません。ラベルが存在して値がゼロなら、明示的なゼロを表す場合も、数量が相殺された結果を表す場合もあります。結果を読む側が別の扱いを必要とするのでなければ、この違いを保ってください。

Macで完全な例を実行する

インストール済みのSwiftツールチェーンを使います。Swift.orgのmacOSインストールガイドには、ツールチェーンのセットアップ方法と、実行可能なパッケージの作成・実行コマンドが記載されています。ツールチェーンの準備ができたら、Terminalを開き、小さなローカルプロジェクトを作成します。

mkdir SyntheticTotals
cd SyntheticTotals
swift package init --name SyntheticTotals --type executable

Sources内に生成された実行可能ファイルのソースを開き、内容を以下のプログラム全体に置き換えます。生成されたファイルは、ターゲット名のサブフォルダ内にある場合があります。Swiftの型と計算メソッドは標準ライブラリに含まれているため、生成されたパッケージマニフェストはそのまま使い、依存関係やインポートは追加しません。

struct SyntheticValue: Sendable {
    let key: String
    let quantity: Int
}

enum TotalError: Error {
    case overflow(key: String)
}

func checkedAdd(_ total: Int, _ value: SyntheticValue) throws -> Int {
    let addition = total.addingReportingOverflow(value.quantity)
    guard !addition.overflow else {
        throw TotalError.overflow(key: value.key)
    }
    return addition.partialValue
}

func groupThenTotal(_ values: [SyntheticValue]) throws -> [String: Int] {
    let groups = Dictionary(grouping: values, by: { $0.key })
    return try groups.mapValues { group in
        try group.reduce(0) { total, value in
            try checkedAdd(total, value)
        }
    }
}

func totalDirectly(_ values: [SyntheticValue]) throws -> [String: Int] {
    return try values.reduce(into: [String: Int]()) { totals, value in
        let previous = totals[value.key, default: 0]
        totals[value.key] = try checkedAdd(previous, value)
    }
}

let sample: [SyntheticValue] = [
    SyntheticValue(key: "circle", quantity: 4),
    SyntheticValue(key: "square", quantity: 3),
    SyntheticValue(key: "circle", quantity: 6),
    SyntheticValue(key: "triangle", quantity: 0),
    SyntheticValue(key: "square", quantity: 5)
]

let expected = ["circle": 10, "square": 8, "triangle": 0]

 do {
    let groupedTotals = try groupThenTotal(sample)
    let directTotals = try totalDirectly(sample)
    precondition(groupedTotals == expected)
    precondition(directTotals == expected)

    let emptyGrouped = try groupThenTotal([])
    let emptyDirect = try totalDirectly([])
    precondition(emptyGrouped.isEmpty && emptyDirect.isEmpty)

    let cancellation = [
        SyntheticValue(key: "circle", quantity: 2),
        SyntheticValue(key: "circle", quantity: -2)
    ]
    let zeroGrouped = try groupThenTotal(cancellation)
    let zeroDirect = try totalDirectly(cancellation)
    precondition(zeroGrouped == ["circle": 0])
    precondition(zeroDirect == ["circle": 0])

    for key in groupedTotals.keys.sorted() {
        if let total = groupedTotals[key] {
            print("\(key): \(total)")
        }
    }

    let tooLarge = [
        SyntheticValue(key: "circle", quantity: Int.max),
        SyntheticValue(key: "circle", quantity: 1)
    ]
    do {
        _ = try totalDirectly(tooLarge)
        preconditionFailure("Expected overflow to be reported")
    } catch TotalError.overflow(let key) {
        print("Overflow check: rejected \(key)")
    }
} catch {
    fatalError("Unexpected calculation failure: \(error)")
}

パッケージのディレクトリから、Swift.orgのmacOSガイドに記載されたコマンドで実行ファイルを実行します。

swift run SyntheticTotals

ビルドメッセージを除いた、プログラムの期待される出力は次のとおりです。

circle: 10
square: 8
triangle: 0
Overflow check: rejected circle

このプログラムには、例の合計値、空の入力、ゼロへの相殺、意図的なオーバーフローを確認するチェックが含まれています。これらは、例とドキュメントに記載されたAPIから導かれる期待結果です。利用可能な環境にSwiftコンパイラがなかったため、コードは確認しましたが、準備中には実行していません。

グループ化してから集約する方法

最初の関数では、処理を明確な段階に分けています。Dictionary(grouping: values, by: { $0.key })は、一致する要素を配列として格納した辞書を作成します。Appleのドキュメントによると、各グループは空ではなく、元のシーケンスにおける要素の順序を保ちます。init(grouping:by:)を参照してください。

この例の中間型は[String: [SyntheticValue]]です。circleグループには数量4と6が、squareグループには3と5が、triangleグループには0が含まれます。ローカル計算の別の処理でも各グループの要素を調べる必要がある場合、この中間表現が役立ちます。

次に、mapValuesは辞書のキーを維持しながら、各グループ配列を1つの整数に変換します。キーが維持されることは、mapValues(_:)のドキュメントに記載された動作です。triangleの合計値がゼロでも、その項目が残る理由もこれで説明できます。

その変換の中で、reduce(0)はゼロから始め、各要素を順に結合クロージャへ渡します。クロージャは次の累積値を返します。Appleのドキュメントには、reduceが順次処理することと、空のシーケンスをreduceすると初期値が返されることが記載されています。reduce(::)を参照してください。

ここには2種類の「空」の場合があります。空の数量配列をゼロからreduceすると、ゼロになります。しかし、空の入力をグループ化してもグループは作られないため、関数全体ではゼロ合計のラベルを作らず、空の辞書を返します。

合計値を直接累積する場合

必要な結果が最終的な合計値だけなら、totalDirectlyを使います。累積用の辞書[String: Int]は空の状態で始まります。各入力要素について、そのラベルの前回の合計値を検索し、数量を加算して、更新した合計値を保存します。

デフォルト値付きの検索は、キーが見つからない場合にゼロを返します。デフォルト値を読み取るだけでは項目は作成されません。次の行で代入することで、計算した合計値が保存されます。Appleのデフォルト値付き添字のドキュメントには、値のセマンティクスを持つ値について、フォールバック検索とその場での更新の両方が説明されています。

reduce(into:)は、クロージャに累積値への変更可能なアクセスを与えます。Appleは、辞書のようにコピーオンライト動作をする結果を作る場合、通常のreduceよりもこの形式を推奨しています。reduce(into:_:)を参照してください。

次の処理で必要なもの 選択肢 理由
グループの要素とその合計値 グループ化してから集約 中間配列を調べたり再利用したりできます。
ラベルごとに1つの整数だけ 直接累積 中間グループ配列を作らずに済みます。
繰り返し可能なテキスト出力 どちらかを選び、キーをソート 出力順は集約とは別に決めます。

中間データの保存量の違いはコードから分かります。グループ化ではすべての入力要素をグループ配列に保持しますが、直接累積では異なるキーごとに1つの累積値を保持します。これは実装の比較であり、実測した性能に関する主張ではありません。この例には処理時間のベンチマークはありません。

オーバーフローと安定した出力の扱い

checkedAddはaddingReportingOverflowを呼び出し、途中結果とオーバーフローフラグを返します。Appleの仕様では、フラグがfalseの場合に限り、途中結果に完全な合計値が含まれます。オーバーフローが起きた場合、返される途中結果は切り詰められます。そのため、プログラムはそれを保存する前にスローします。addingReportingOverflow(_:)を参照してください。

この仕様では、各キーについて入力順に行うすべての加算をチェックします。たとえば、1つのラベルに対する数量がInt.max、1、-1の場合、数学上の最終合計値がIntに収まっていても、2回目の加算で拒否されます。このような入力を扱うには、別の算術方法が必要です。切り詰められた中間結果を受け入れると、仕様が変わります。

複数のグループでオーバーフローが発生した場合、グループ化を使う実装では、どのラベルのエラーが最初に表示されるかは保証されません。辞書の反復順序は予測できないためです。直接累積する実装では、要素をソースの順序で処理します。どちらもオーバーフローした計算を拒否しますが、最初に報告されるキーは異なることがあります。

結果のキーをソートすると、表示される合計値の順序が予測可能になります。Swiftの辞書には順序がないため、辞書を直接出力して表示順を決めるべきではありません。AppleのDictionaryのドキュメントには、この順序に関する制約が説明されています。この例では集約後にだけキーをソートするため、計算自体は変わりません。

FoundationやSwiftの並行処理で計算は変わるか

この値の仕様にFoundationは不要です。Appleのドキュメントでは、参照セマンティクスが必要な場合の代替手段としてNSDictionaryが紹介されています。ここでは、型付きのSwift辞書で、ラベルから整数の合計値への対応をすでに表せます。FoundationのNSDictionaryのドキュメントを参照してください。

この計算は同期的に行われ、変更可能な累積値は関数内に保持されます。サンプルのSyntheticValueは明示的にSendableに準拠します。これは並行処理に関する値の仕様を示すもので、reduceを並行実行する指示ではありません。SwiftのConcurrencyガイドには、Sendableな値と、格納プロパティがSendableな構造体の要件が説明されています。

この小さなデスクトップ計算では、どちらかの関数を直接呼び出し、返された辞書を確認します。グループの要素も使うならグループ化を、合計値だけで十分なら直接累積を選びます。空の入力とゼロ合計のルールを保ち、算術オーバーフローを明示的に拒否し、結果を表示するときだけソートしてください。

出典