如何在Swift中创建一个混合类型的数组?

包含多种类型实例的数组通常被称为异构数组,在动态类型语言中非常常用——比如PHP、JavaScript和Ruby。然而,在静态类型语言(如Swift)中,以一种整洁的方式建模这样的数组有时是相当具有挑战性的。

例如,假设我们正在开发一个应用程序,显示两种主要类型的内容-视频和照片-我们已经实现了这些变体作为两个独立的模型类型,都包含共享的属性,以及数据,每个变体的独特:

struct Video {
    let id: UUID
    var title: String
    var description: String
    var url: URL
    var length: TimeInterval
}

struct Photo {
    let id: UUID
    var title: String
    var description: String
    var url: URL
    var size: CGSize
}

创建一个只包含上述两种类型之一的实例的数组是非常简单的——我们只使用[Video]或[Photo]——如果我们想在同一个数组中混合这两种类型的实例,事情马上就会变得复杂得多。

使用Any

在这种情况下,一个选择是完全绕过Swift的强类型系统,使用Any(字面意思是任何类型)作为我们想要创建的数组的元素类型-像这样:

func loadItems() -> [Any] {
    var items = [Any]()
    ...
    return items
}

这并不是很好,因为我们总是需要执行类型转换来实际使用返回数组包含的任何实例-这两者都使事情更笨拙,更脆弱,因为我们没有得到任何编译时保证,我们的数组将实际包含什么样的实例。

使用Protocol

另一种选择是使用一些面向协议的编程,并将视频和照片之间的共同部分抽象到一个协议中,使这两种类型都符合:

protocol Item {
    var id: UUID { get }
    var title: String { get }
    var description: String { get }
    var url: URL { get }
}

extension Video: Item {}
extension Photo: Item {}

这就好多了,因为我们现在可以给loadItems函数一个强返回类型,方法是用上面的Item协议替换Any -这反过来让我们在访问数组元素时使用该协议中定义的任何属性:

func loadItems() -> [Item] {
    ...
}

Protocol 继承

然而,如果我们在某个时候需要向Item添加一个要求,使其成为泛型(generic),那么上述方法可能会出现一些问题——例如,让它继承标准库的可识别协议(Identifiable)(包含一个相关类型)的要求:

protocol Item: Identifiable {
    ...
}

尽管Video和Photo都已经满足了我们新添加的需求(因为它们都定义了id属性),但是当我们尝试直接引用Item协议时,我们现在会得到以下错误,就像我们在loadItems函数中所做的那样:

协议“Item”只能作为通用约束使用 因为它有Self或相关的类型需求。

Protocol 组合

在这种情况下,解决这个问题的一种方法是将符合Identifiable的要求与我们自己的属性要求分开 -例如,通过移动这些属性到一个AnyItem协议,然后组成一个新的协议与Identifiable,以形成一个Item type alias:

protocol AnyItem {
    var id: UUID { get }
    var title: String { get }
    var description: String { get }
    var url: URL { get }
}

typealias Item = AnyItem & Identifiable

上述方法的美妙之处在于,我们的Video和Photo类型仍然可以符合Item,就像以前一样,这使它们与所有需要Identifiable实例的代码兼容(如swifitui的ForEach和List)

func loadItems() -> [AnyItem] {
    ...
}

使用Enum

上面的另一种方法是使用枚举来建模两个独立的变量,如下所示:

enum Item {
    case video(Video)
    case photo(Photo)
}

因为Video和Photo的id属性都使用了相同的类型(内置的UUID类型),我们甚至可以让上面的枚举也符合Identifiable-通过让它作为当前包装的底层模型实例的代理:

extension Item: Identifiable {
    var id: UUID {
        switch self {
        case .video(let video):
            return video.id
        case .photo(let photo):
            return photo.id
        }
    }
}

组合使用Struct和Enum

上述模式的另一个变体是将Item实现为一个结构体,将两个变体之间的所有共同属性移动到该结构体中,然后只对这两个变体中唯一的属性使用枚举——像这样:

struct Item: Identifiable {
    let id: UUID
    var title: String
    var description: String
    var url: URL
    var metadata: Metadata
}

extension Item {
    enum Metadata {
        case video(length: TimeInterval)
        case photo(size: CGSize)
    }
}

最后两种方法的优点是,它们实际上让我们将异构数组转换为同构数组,因为Item现在被实现为独立的类型,这意味着我们现在可以将这些数组传递到要求所有元素都是相同类型的函数中——就像《基础》中关于泛型的文章中的这个:

extension String {
    mutating func appendIDs<T: Identifiable>(of values: [T]) {
        for value in values {
            append(" \(value.id)")
        }
    }
}

就是在Swift中实现混合类型数组的几种不同方式。我希望您觉得这个答案有用,如果您想了解更多关于这个主题的知识,那么我建议您阅读“Handling model variants in Swift”,它详细介绍了上面的一些技术。