使用 Dictionary(grouping:by:) 按键收集合成的 Swift 值,然后使用 mapValues 和 reduce 计算每组的总和。本 Mac 教程还介绍直接累加,并检查空输入、零总和和整数溢出。

任务是将一个由中性形状标签和整数数量组成的内存数组转换为一个 [String: Int] 字典。所有输入都是合成字面量。计算不会读取外部记录、发出网络请求或保存结果。

明确每个键对应一个总和的含义

在选择集合方法之前,先定义结果约定。这里,每个输入元素都会将其数量恰好一次计入其标签对应的总和。标签使用普通的 Swift 字符串相等性;程序不会执行去除空格或大小写转换。字典键必须遵循 Hashable,Swift 支持将字符串用作键。请参阅 Apple 的Dictionary 文档。

示例输入包含两个圆形值、两个正方形值和一个三角形值。预期总和很简单,可以手动核对:

标签 输入数量 预期总和
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]]。圆形分组包含数量 4 和 6;正方形分组包含 3 和 5;三角形分组包含 0。如果本地计算的其他部分也需要检查每个分组中的成员,这种中间表示就很有用。

接下来,mapValues 会保留字典的键,并将每个分组数组转换为一个整数。这种保留键的行为正是 mapValues(_:) 文档所述的内容。这也解释了为什么总和为零的三角形条目仍会保留。

在这个转换过程中,reduce(0) 从零开始,并将每个后续元素传递给合并闭包。闭包会返回下一个累计总和。Apple 文档说明了顺序归约,并规定对空序列进行归约时会返回初始值。请参阅 reduce(::)。

这里有两种不同的空情形。对空的数量数组从零开始归约,结果会是零。但是,对空输入进行分组不会生成任何分组,因此完整函数会返回空字典,而不会凭空添加一个总和为零的标签。

何时应直接累加总和?

如果只需要最终总和,请使用 totalDirectly。它的累加器从一个空的 [String: Int] 字典开始。对于每个输入元素,它会查找该标签之前的总和,加上该数量,然后存储更新后的总和。

带默认值的查找会在键缺失时提供零。仅读取默认值不会创建条目;下一行中的赋值才会存储计算结果。Apple 的默认值下标文档说明了回退查找,以及对具有值语义的值进行原地更新的方式。

reduce(into:) 会让其闭包获得对累加器的可变访问权限。对于构建具有写时复制行为的结果(例如字典),Apple 建议优先使用这种形式,而不是普通的 reduce。请参阅 reduce(into:_:)。

下一步所需内容 选择 原因
分组成员及其总和 先分组,再归约 中间数组可供检查或重用。
每个标签只需要一个整数 直接累加 无需创建中间分组数组。
可重复的文本输出 两种方式均可,然后对键排序 输出顺序是独立于聚合的决定。

中间存储的区别可从代码看出:分组会在各分组数组中保留所有输入元素,而直接累加只会为每个不同的键保留一个累计总和。这是实现方式的比较,并非经过测量的性能结论。示例没有提供计时基准。

如何处理溢出并确保输出顺序稳定?

checkedAdd 会调用 addingReportingOverflow,后者会返回一个部分结果和一个溢出标志。Apple 说明,只有当该标志为 false 时,部分值才包含完整总和。发生溢出时,返回的部分值会被截断。因此,程序会在存储该值之前抛出错误。请参阅 addingReportingOverflow(_:)。

此约定会按照输入顺序检查每个键对应的每次加法。例如,同一标签的数量依次为 Int.max、1 和 -1 时,会在第二次加法处被拒绝,即使它们的数学最终总和能用 Int 表示。要支持这类输入,需要采用不同的算术策略;接受被截断的中间结果会改变此约定。

如果多个分组都溢出,分组实现不会保证先报告哪个标签的错误:字典的迭代顺序不可预测。直接实现则会按源顺序处理元素。两种方式都会拒绝发生溢出的计算,但最先报告的键可能不同。

对结果键排序可以让显示的总和具有可预测的顺序。Swift 字典没有固定顺序,因此不应直接打印字典来确定展示顺序。Apple 在其 Dictionary 文档中说明了这一顺序方面的限制。示例只在聚合完成后对键排序,不会改变计算过程。

Foundation 或 Swift 并发会改变这项计算吗?

对于此值约定,不需要 Foundation。Apple 文档指出,在需要引用语义时,可以使用 NSDictionary 作为替代方案。这里,类型明确的 Swift 字典已经能表示所需的标签到整数总和的映射。请参阅 Foundation 的 NSDictionary 文档。

此计算是同步的,并且会将可变累加器保留在函数内部。示例中的 SyntheticValue 明确遵循 Sendable;这表示一种与并发相关的值约定,并不意味着要并发运行归约。Swift 的并发指南介绍了可发送值,以及存储属性均为可发送类型的结构体所需遵循的要求。

对于这项小型桌面计算,直接调用任一函数并检查其返回的字典即可。如果分组成员仍有用,请选择分组;如果总和已足够,请选择直接累加。保留空输入和零总和规则,明确拒绝算术溢出,并且只在展示结果时排序。

来源