若要在忽略关联值的同时抑制连续出现的 Swift 枚举情况,请比较单独的 case 键,并保留每段连续序列中的第一个完整值。这个本地 Mac 示例会保留顺序,并允许某个 case 在另一个 case 之后再次出现。

明确重复项的定义

假设桌面图形预览会生成一组模拟步骤:空闲、绘制一个命名形状,以及显示一个命名预览。你想要得到一份精简的 case 转换列表。即使形状名称不同,两个连续的绘制步骤也算重复。

Swift 枚举的每个 case 都可以携带不同的关联值。这些值是枚举值的一部分,因此,若要忽略它们,就需要明确指定比较规则。Swift 语言指南在枚举中说明了这一区别。

对于此任务,采用以下约定:

  • 保留第一个输入值。
  • 后续值若其 case 与前一段连续序列的 case 相同,则抑制该值。
  • 保留每个留下的元素所携带的完整关联值。
  • 某个 case 在不同的 case 之后再次出现时,仍予以保留。
  • 保留输入顺序;空输入会产生空结果。

以下示例展示了预期行为。所有名称均为模拟数据。

输入值 处理方式 原因
.idle 保留 第一个值
.drawing("Circle") 保留 case 已改变
.drawing("Square") 抑制 仍在绘制
.ready("Preview A") 保留 case 已改变
.ready("Preview B") 抑制 仍处于就绪状态
.drawing("Triangle") 保留 就绪之后出现绘制
.idle 保留 case 已改变
.idle 抑制 仍处于空闲状态

因此,保留下来的绘制值是 \"Circle\" 和 \"Triangle\"。\"Square\" 负载会被有意排除。如果桌面视图必须显示每个更新后的形状名称,这个约定会丢弃该视图需要的变化;对于这种使用场景,应选择对负载敏感的比较方式。

在 Mac 上运行完整的本地 Swift 示例

请使用已配置好 Swift 开发环境的 Mac。Swift.org 建议使用 Xcode 进行 Apple 平台开发,并介绍了使用 Swift Package Manager 构建命令行可执行文件的流程。如果需要准备开发工具,请参阅安装 Swift — macOS。

在 Terminal 中创建一个新的工作目录,并在其中创建一个小型可执行程序包:

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

将生成的可执行源文件内容替换为以下完整程序。该程序使用 Swift 标准库类型,无需导入模块或添加程序包依赖。输入和输出都保存在内存中;程序会打印保留下来的模拟步骤。

enum PreviewStep: Equatable {
    case idle
    case drawing(String)
    case ready(String)

    enum Kind: Equatable {
        case idle
        case drawing
        case ready
    }

    var kind: Kind {
        switch self {
        case .idle:
            return .idle
        case .drawing(_):
            return .drawing
        case .ready(_):
            return .ready
        }
    }

    var label: String {
        switch self {
        case .idle:
            return "idle"
        case .drawing(let shape):
            return "drawing: \(shape)"
        case .ready(let name):
            return "ready: \(name)"
        }
    }
}

func suppressConsecutiveCases(
    _ steps: [PreviewStep]
) -> [PreviewStep] {
    var result: [PreviewStep] = []
    var previousKind: PreviewStep.Kind? = nil

    for step in steps {
        let currentKind = step.kind
        if let previous = previousKind,
           previous == currentKind {
            continue
        }

        result.append(step)
        previousKind = currentKind
    }

    return result
}

let input: [PreviewStep] = [
    .idle,
    .drawing("Circle"),
    .drawing("Square"),
    .ready("Preview A"),
    .ready("Preview B"),
    .drawing("Triangle"),
    .idle,
    .idle
]

let expected: [PreviewStep] = [
    .idle,
    .drawing("Circle"),
    .ready("Preview A"),
    .drawing("Triangle"),
    .idle
]

let output = suppressConsecutiveCases(input)

precondition(output == expected)
precondition(suppressConsecutiveCases([]).isEmpty)
precondition(
    suppressConsecutiveCases([
        .drawing("Circle"), .drawing("Square")
    ]) == [.drawing("Circle")]
)
precondition(
    suppressConsecutiveCases([
        .idle, .drawing("Circle"), .idle
    ]) == [.idle, .drawing("Circle"), .idle]
)

for step in output {
    print(step.label)
}

在程序包所在目录中运行它:

swift run EnumCaseDemo

程序的预期输出如下,构建消息除外:

idle
drawing: Circle
ready: Preview A
drawing: Triangle
idle

这些示例已对照检索到的文档进行检查;由于研究环境中没有 Swift 可执行文件,因此未在此处运行。所含的前置条件会检查预期保留的负载、空输入、关联负载不同的连续值,以及某个 case 在转换后再次出现的情况。

为什么单独的 case 键有效

kind 属性会把每个完整枚举值转换为一个不带负载的简短键。.drawing("Circle") 和 .drawing("Square") 都会生成键 .drawing。过滤函数比较这些键,同时追加原始的 step,因此留下的关联值会保持完整。

该函数只需记住一个键。开始时,previousKind 是 nil,因此第一个元素会通过。保留某个元素后,函数会记住该元素的类型。被抑制的元素类型相同,因此即使更新已记住的键,结果也不会改变。

此实现会遍历输入一次。对于这个固定枚举及其固定大小的键,它对后续每个元素进行一次 case 键比较,并使用常量级的辅助状态,另加返回数组。Swift 的 Sequence 文档介绍了顺序迭代和通常的线性遍历模型。

完整的 PreviewStep 也遵循 Equatable,因此前置条件可以验证完整结果,包括保留的字符串。对于关联值遵循 Equatable 的合适枚举,Swift 支持合成相等性;详情请参阅协议。在 Kind 中进行 case 比较,可以让这项过滤操作拥有明确的独立规则,而无需在整个程序中重新定义相等性。

kind 中穷尽所有情况的 switch 也提供了一个有用的维护检查点。添加新 case 时,应添加对应的键和映射。避免使用兜底映射,以免意外地将新 case 与现有 case 归为一类。Swift 在枚举中介绍了穷尽式枚举切换。

在 Combine 中使用相同的比较方式

如果 Mac 代码已经使用 Combine 发布者,Apple 提供了 removeDuplicates(by:) 来执行自定义比较。当第二个值应视为重复项时,其谓词会返回 true。Apple 明确支持在所需的比较方式不同于输出类型的常规相等性时使用此运算符。它适用于 macOS 10.15 及更高版本。请参阅 removeDuplicates(by:)。

以下是一个扩展代码片段,不是独立程序。请在可使用 Combine 的 Mac 上,将它追加到上面的完整示例中。它会重用已有的 PreviewStep 定义和 input 数组:

import Combine

let subscription = input.publisher
    .removeDuplicates { previous, current in
        previous.kind == current.kind
    }
    .sink { step in
        print("Combine: \(step.label)")
    }

这会打印相同的五个保留步骤,并为每行添加 \"Combine: \" 前缀。谓词比较 case 键,发布者则传递原始的保留枚举值。Apple 自己的运算符示例也采用了相同原则:比较一个属性,同时忽略另一个属性。removeDuplicates(by:)

请在所需的订阅生命周期内保持返回的订阅有效。Apple 文档说明,释放 sink(receiveValue:) 返回的值会终止订阅。此重载适用于失败类型为 Never 的发布者,例如 Apple 的序列发布者示例。sink(receiveValue:)

根据需求边界调整代码

如果已有一个有限的本地列表,请使用数组函数。如果值已经通过发布者传入,请使用 Combine 运算符。这里的两个版本表达的是相同的 case 转换约定。

需求 合适的比较方式或处理方法
保留每段 case 连续序列中的第一个完整值 按照示例比较 kind
保留关联字符串的变化 使用常规相等性比较完整值
保留每段连续序列中的最后一个完整值 缓存待处理值,并在 case 转换时及结束时将其写入结果
某个 case 在另一个 case 之后再次出现时仍予以保留 比较连续序列,而不是维护全局的已见 case 列表
分别过滤多个数组 分别调用函数;每次调用都会从空的比较状态开始

保留最后一个负载需要采用不同算法,因为此程序会立即追加第一个值。同样,将一个逻辑序列拆分到多个数组中会重置比较状态:如果一个批次末尾是绘制值,另一个批次开头也是绘制值,分别调用函数时两者都会保留下来。如果多个批次必须按同一个序列处理,就要在批次边界之间保留上一个键。

Foundation 为 Apple 平台应用提供集合和其他基础功能,但此计算完全使用 Swift 类型实现。发布者版本则明确需要 Combine;添加 import Foundation 并不会导入该运算符。Apple 在 Foundation 和 removeDuplicates(by:) 中分别介绍了这些框架。

同步数组函数也无需使用并发机制:它的比较状态属于每次函数调用。如果之后引入共享的桌面展示状态,请单独定义该状态的隔离方式。Apple 将 MainActor 说明为一个单例 actor,其执行器等同于主分发队列;重复抑制本身不会选择 actor。MainActor

对于此处展示的本地任务,关键在于每段连续序列中要保留哪个负载。选定第一个负载后,单独的 case 键就能让行为易于阅读和验证:在 case 转换时保留原始值,抑制连续匹配的键,并允许再次出现的 case 通过。

来源