Avoiding problematic cases when using Swift enums

毫无疑问,Swift的enums功能是该语言最受欢迎和最强大的功能之一。Swift枚举远不止基于整数的常量的简单枚举,而且支持关联值和复杂的模式匹配等功能,这使它们成为解决许多不同类型问题的最佳候选对象。

然而,有一些枚举案例是可以避免的,因为它们可能会导致我们遇到一些棘手的情况,或者让我们的代码感觉不像我们想要的那样“惯用”。让我们来看看一些这样的例子,以及如何使用Swift的其他语言特性来重构它们。

Representing the lack of a value

举个例子,假设我们正在开发一个播客应用程序,并且我们已经使用枚举实现了应用程序支持的各种类别。 该枚举当前包含每个类别的情况,以及两个有点特殊的情况,用于播客没有一个类别(none),以及一个类别,可用于一次引用所有类别(all):

extension Podcast {
    enum Category: String, Codable {
        case none
        case all
        case entertainment
        case technology
        case news
        ...
    }
}

然后,当实现filtering等特性时,我们可以使用上面的枚举对用户在UI中选择的Category值(它被封装在Filter模型中)执行模式匹配:

extension Podcast {
    func matches(filter: Filter) -> Bool {
        switch filter.category {
        case .all, category:
            return name.contains(filter.string)
        default:
            return false
        }
    }
}

乍一看,上面的两段代码看起来很好。 但是,如果我们仔细想想,我们现在添加了一个特殊的none case来表示缺少一个类别,这一事实可能有点奇怪,因为Swift确实有一个为这个目的量身定制的内置语言特性——可选。

因此,如果我们将Podcast模型的category属性转换为optional属性,那么我们将获得完全免费表示缺失类别的支持-加上我们现在可以利用Swift可选选项支持的所有特性(比如if let语句)来处理这些缺失的值:

struct Podcast {
    var name: String
    var category: Category?
    ...
}

关于上述变化,真正有趣的是,我们之前在播客上使用的任何详尽的switch语句。类别值仍将像以前一样工作-因为事实证明Optional类型本身也是一个使用none case来表示缺少值的枚举 - 意味着像下面这样的代码可以保持完全不变(除了将其参数修改为可选):

func title(forCategory category: Podcast.Category?) -> String {
    switch category {
    case .none:
    return "Uncategorized"
    case .all:
        return "All"
    case .entertainment:
        return "Entertainment"
    case .technology:
        return "Technology"
    case .news:
        return "News"
    ...
    }
}

上面的工作多亏了Swift编译器的一点魔力,当在模式匹配上下文中使用可选选项时(比如switch语句),它会自动平坦化可选选项,这让我们既可以在Optional类型本身中处理用例,也可以在我们自己的Podcast.Category enum 中定义用例,都在同一个语句中。

如果我们想,我们也可以用case nil代替case.none,因为它们在上述类型的情况下功能相同。

Domain-specific enums

接下来,让我们把注意力转向我们的Podcast.Category枚举的所有情况,如果我们仔细想想,这也有点奇怪。

因此,与其将这种情况包含在我们的主Category枚举中,不如创建一个特定于过滤域的专用类型。这样,我们可以实现一个相当整洁的关注点分离,而且由于我们使用嵌套类型,我们可以让我们的新枚举使用相同的Category名称,只是这次它将嵌套在我们的Filter模型中-像这样:

extension Filter {
    enum Category {
        case any
        case uncategorized
        case specific(Podcast.Category)
    }
}

值得注意的是,我们在这里也可以选择使用可选的方法,用nil表示任何或未分类的,但由于在这种情况下有两个潜在的候选,我们可以通过使用专用案例来更清楚地说明我们的意图。

上述方法的真正优点是,我们现在可以使用Swift的模式匹配功能来实现我们的整个过滤逻辑——通过 switching on被过滤的类别,然后使用where子句为每个case附加额外的逻辑:

extension Podcast {
    func matches(filter: Filter) -> Bool {
        switch filter.category {
        case .any where category != nil,
             .uncategorized where category == nil,
             .specific(category):
            return name.contains(filter.string)
        default:
            return false
        }
    }
}

上面的所有更改就绪后,我们现在可以继续,从主Podcast.Category enum中删除none和all case-留给我们一个更直接的列表,我们的应用程序支持的每个类别:

extension Podcast {
    enum Category: String, Codable {
        case entertainment
        case technology
        case news
        ...
    }
}

Custom cases and custom types

当提到像Podcast.Category这样的枚举时,在某种程度上,引入某种可用于处理一次性用例的自定义用例是非常常见的,或者通过优雅地处理将来可能添加到服务器端的用例来提供前向兼容性。

实现它的一种方法是使用一个有关联值的情况-在我们的情况下,一个表示自定义类别的原始值的字符串,像这样:

extension Podcast {
    enum Category: Codable {
        case all
        case entertainment
        case technology
        case news
        ...
        case custom(String)
    }
}

不幸的是,尽管关联值在其他上下文中非常有用,但这并不是其中之一。
首先,通过添加这样的情况,我们的枚举不再是String支持的,这意味着我们现在必须编写自定义编码和解码代码,以及实例与原始字符串之间的转换逻辑。

所以让我们来探索另一种方法,将Category枚举转换为rawrepresentation结构体,这再次让我们利用Swift的内置逻辑来编码、解码和处理此类类型的字符串转换:

extension Podcast {
    struct Category: RawRepresentable, Codable, Hashable {
        var rawValue: String
    }
}

因为我们现在可以自由地从任何我们想要的自定义字符串创建Category实例,我们可以轻松地支持自定义和未来的类别,而不需要任何额外的代码。然而,为了确保我们的代码保持向后兼容,并使引用任何内置的、目前已知的类别变得容易——让我们也用静态api来扩展我们的新类型,以实现所有这些东西:

extension Podcast.Category {
    static var entertainment: Self {
        Self(rawValue: "entertainment")
    }

    static var technology: Self {
        Self(rawValue: "technology")
    }

    static var news: Self {
        Self(rawValue: "news")
    }
    
    ...

    static func custom(_ id: String) -> Self {
        Self(rawValue: id)
    }
}

虽然上面的更改确实需要添加一些额外的代码,但是我们现在得到了一个更加灵活的设置,它几乎完全向后兼容。实际上,我们需要做的唯一更新是对Category值执行exhaustive switches的代码。

例如,我们前面看到的title函数打开了这样一个值,以返回与给定类别匹配的title。由于我们不能再在编译时获得每个Category值的详尽列表,所以现在必须使用不同的方法来计算这些标题。例如,在这个特殊的例子中,我们可以将此视为将这些字符串移动到Localizable.strings的绝佳机会文件,然后像这样解析我们的标题:

func title(forCategory category: Podcast.Category?) -> String {
    guard let id = category?.rawValue else {
        return NSLocalizedString("category-uncategorized", comment: "")
    }

    let key = "category-\(id)"
    let string = NSLocalizedString(key, comment: "")

    // Handling unknown cases by returning a capitalized version
    // of their key as a fallback title:
    guard string != key else {
        return key.capitalized
    }

    return string
}

另一种选择便是在Category类型中解析我们的本土化标题,并添加一个可选的标题属性,让我们的服务器能够为我们的应用原生并不支持的自定义类别发送预先本地化的标题。

Auto-named static properties

作为一个提示,上述基于结构的方法的一个缺点是,我们现在必须手动定义每个静态属性的底层字符串原始值,但这是我们可以使用Swift的#function关键字解决的问题。由于该关键字将自动被调用其封装函数的函数名(或者,在我们的例子中,属性)所取代,这将为我们提供与使用枚举时相同的自动原始值映射:

extension Podcast.Category {
    static func autoNamed(_ rawValue: StaticString = #function) -> Self {
        Self(rawValue: "\(rawValue)")
    }
}

有了上面的实用程序,我们现在可以在每个内置的类别api中调用autoNamed(), Swift会自动为我们填充这些原始值:

extension Podcast.Category {
    static var entertainment: Self { autoNamed() }
    static var technology: Self { autoNamed() }
    static var news: Self { autoNamed() }
    ...

    static func custom(_ id: String) -> Self {
        Self(rawValue: id)
    }
}

不过,值得注意的是,在使用基于#function的技术时,我们必须小心不要重命名上述任何静态属性,因为这样做也会更改属性Category的基础原始值。然而,在使用枚举时也是如此,另一方面,我们现在还可以防止手工定义每个原始字符串时可能发生的输入错误和其他错误。

Conclusion

Swift枚举很棒(事实上,我就这个主题写了超过15篇文章),但在某些情况下,另一种语言机制可能是我们正在寻找的更好的选择,随着项目的发展和发展,我们可能需要在几个不同的机制和方法之间切换,这总是有可能的。