Swift 风味各异的类型擦除

2022-04-04 15:50:56 浏览数 (1)

前言

Swift的总体目标是既强大到可以用于底层系统编程,又足够容易让初学者学习,这有时会导致相当有趣的情况——当Swift的类型系统的力量要求我们部署相当高级的技术来解决乍一看可能更微不足道的问题。

大多数Swift开发人员会在某一时刻或另一时刻(通常是马上,而不是日后)会遇到这样一种情况,即需要某种形式的类型擦除才能引用通用协议。从本周开始,让我们看一下是什么使类型擦除在Swift中成为必不可少的技术,然后继续探索实现它的不同 “风味(Flavors)”,以及每种风味为何各有优缺点。

什么时候需要类型擦除?

一开始,“类型擦除”一词似乎与 Swift 给我们的关注类型和编译时类型安全性的第一感觉相反,因此,最好将其描述为隐藏类型,而不是完全擦除它们。目的是使我们能够更轻松地与通用协议进行交互,因为这些通用协议对将要实现它们的各种类型具有特定的要求。

以标准库中的Equatable协议为例。由于所有目的都是为了根据相等性比较两个相同类型的值,因此Self元类型为其唯一要求的参数:

代码语言:javascript复制
protocol Equatable {
    static func ==(lhs: Self, rhs: Self) -> Bool
}

上面的代码使任何类型都可以符合Equatable,同时仍然需要==运算符两侧的值都为同一类型,因为在实现上述方法时符合协议的每种类型都必须“填写”自己的类型:

代码语言:javascript复制
extension User: Equatable {
    static func ==(lhs: User, rhs: User) -> Bool {
        return lhs.id == rhs.id
    }
}

该方法的优点在于,它不可能意外地比较两个不相关的相等类型(例如 UserString ),但是,它也导致不可能将Equatable引用为独立协议(例如创建 [Equatable] ),因为编译器需要知道实际上确切符合协议的确切类型才能使用它。

当协议包含关联的类型时,也是如此。例如,在这里我们定义了一个Request协议,使我们可以在一个统一的实现中隐藏各种形式的数据请求(例如网络调用,数据库查询和缓存提取):

代码语言:javascript复制
protocol Request {
    associatedtype Response
    associatedtype Error: Swift.Error

    typealias Handler = (Result<Response, Error>) -> Void

    func perform(then handler: @escaping Handler)
}

上面的方法为我们提供了与Equatable相同的权衡方法——它非常强大,因为它使我们能够为任何类型的请求创建通用抽象,但也使得无法直接引用Request协议本身,例如这:

代码语言:javascript复制
class RequestQueue {
    // 报错: protocol 'Request' can only be used as a generic
    // constraint because it has Self or associated type requirements
    func add(_ request: Request,
             handler: @escaping Request.Handler) {
        ...
    }
}

解决上述问题的一种方法是完全按照报错消息的内容进行操作,即不直接引用Request,而是将其用作一般约束:

代码语言:javascript复制
class RequestQueue {
    func add<R: Request>(_ request: R,
                         handler: @escaping R.Handler) {
        ...
    }
}

上面的方法起作用了,因为现在编译器能够保证所传递的处理程序确实与作为请求传递的Request实现兼容——因为它们都基于泛型R,而后者又被限制为符合Request协议。

但是,尽管我们解决了方法的签名问题,但仍然无法对传递的请求进行实际的处理,因为我们无法将其存储为Request属性或[Request]数组,这将使继续构建我们的RequestQueue变得困难。也就是说,除非我们开始进行类型擦除。

通用包装器类型擦除

我们将探讨的第一种类型擦除实际上并没有涉及擦除任何类型,而是将它们包装在一个我们可以更容易引用的通用类型中。继续从之前的RequestQueue示例开始,我们首先创建该包装器类型——该包装器类型将捕获每个请求的perform方法作为闭包,以及在请求完成后应调用的处理程序:

代码语言:javascript复制
// 这将使我们将 Request 协议的实现包装在一个
// 与 Request 协议具有相同的响应和错误类型的泛型中
struct AnyRequest<Response, Error: Swift.Error> {
    typealias Handler = (Result<Response, Error>) -> Void

    let perform: (@escaping Handler) -> Void
    let handler: Handler
}

接下来,我们还将把RequestQueue本身转换为相同的ResponseError类型的泛型——使得编译器可以保证所有关联的类型和泛型类型对齐,从而使我们可以将请求存储为独立的引用并作为数组的一部分——像这样:

代码语言:javascript复制
class RequestQueue<Response, Error: Swift.Error> {
    private typealias TypeErasedRequest = AnyRequest<Response, Error>

    private var queue = [TypeErasedRequest]()
    private var ongoing: TypeErasedRequest?

    // 我们修改了'add'方法,以包含一个'where'子句,
    // 该子句确保传递的请求已关联的类型与队列的通用类型匹配。
    func add<R: Request>(
        _ request: R,
        handler: @escaping R.Handler
    ) where R.Response == Response, R.Error == Error {
        //要执行类型擦除,我们只需创建一个实例'AnyRequest',
        //然后将其传递给基础请求将“perform”方法与处理程序一起作为闭包。
        let typeErased = AnyRequest(
            perform: request.perform,
            handler: handler
        )

        // 由于我们要实现队列,因此我们不想一次有两个请求,
        // 所以将请求保存下拉,以防稍后有一个正在执行的请求。
        guard ongoing == nil else {
            queue.append(typeErased)
            return
        }

        perform(typeErased)
    }

    private func perform(_ request: TypeErasedRequest) {
        ongoing = request

        request.perform { [weak self] result in
            request.handler(result)
            self?.ongoing = nil

            // 如果队列不为空,则执行下一个请求
            ...
        }
    }
}

请注意,上面的示例以及本文中的其他示例代码都不是线程安全的——为了使事情变得简单。有关线程安全的更多信息,请查看“避免在Swift 中竞争条件”。

上面的方法效果很好,但有一些缺点。我们不仅引入了新的AnyRequest类型,还需要将RequestQueue转换为泛型。这给我们带来了一点灵活性,因为我们现在只能将任何给定的队列用于具有相同 响应/错误类型 组合的请求。具有讽刺意味的是,如果我们想组成多个实例,将来可能还需要我们自己实现队列擦除。

闭包类型擦除

我们不引入包装类型,而是让我们看一下如何使用闭包来实现相同的类型擦除,同时还要使我们的RequestQueue非泛型且通用,足以用于不同类型的请求。

使用闭包擦除类型时,其思想是捕获在闭包内部执行操作所需的所有类型信息,并使该闭包仅接受非泛型(甚至是Void)输入。这样一来,我们就可以引用,存储和传递该功能,而无需实际知道功能内部会发生什么,从而为我们提供了更强大的灵活性。

更新RequestQueue以使用基于闭包的类型擦除的方法如下:

代码语言:javascript复制
class RequestQueue {
    private var queue = [() -> Void]()
    private var isPerformingRequest = false

    func add<R: Request>(_ request: R,
                         handler: @escaping R.Handler) {
        // 此闭包将同时捕获请求及其处理程序,而不会暴露任何类型信息
        // 在其外部,提供完全的类型擦除。
        let typeErased = {
            request.perform { [weak self] result in
                handler(result)
                self?.isPerformingRequest = false
                self?.performNextIfNeeded()
            }
        }

        queue.append(typeErased)
        performNextIfNeeded()
    }

    private func performNextIfNeeded() {
        guard !isPerformingRequest && !queue.isEmpty else {
            return
        }

        isPerformingRequest = true
        let closure = queue.removeFirst()
        closure()
    }
}

虽然过分依赖闭包来捕获功能和状态有时会使我们的代码难以调试,但也可能使完全封装类型信息成为可能——使得像RequestQueue这样的对象可以在没有真正了解在底层工作的类型的任何细节的情况下进行工作。

有关基于闭包的类型擦除及其更多不同方法的更多信息,请查看“Swift 使用闭包实现类型擦除”。

外部特化(External specialization)

到目前为止,我们已经在RequestQueue本身中执行了所有类型擦除,这有一些优点——它可以让任何外部代码使用我们的队列,而不需要知道我们使用什么类型的类型擦除。然而,有时在将协议实现传递给API之前进行一些轻量级转换,既可以使事情变得更简单,又可以巧妙地封装类型擦除代码本身。

对于我们的RequestQueue,一种方法是要求在将每个Request实现添加到队列之前对其进行特化——这将把它转换为RequestOperation,如下所示:

代码语言:javascript复制
struct RequestOperation {
    fileprivate let closure: (@escaping () -> Void) -> Void

    func perform(then handler: @escaping () -> Void) {
        closure(handler)
    }
}

与我们之前使用闭包在RequestQueue中执行类型擦除的方式类似,上面的RequestOperation类型将使我们能够在扩展Request时执行该操作:

代码语言:javascript复制
extension Request {
    func makeOperation(with handler: @escaping Handler) -> RequestOperation {
        return RequestOperation { finisher in
            // 我们其实想在这里捕获'self',因为不这样话
            // 我们将冒着无法保留基本请求的风险。
            self.perform { result in
                handler(result)
                finisher()
            }
        }
    }
}

上述方法的优点在于,无论是公共API还是内部实现,它都让我们的RequestQueue更加简单。它现在可以完全专注于作为一个队列,而不必关心任何类型的类型擦除:

代码语言:javascript复制
class RequestQueue {
    private var queue = [RequestOperation]()
    private var ongoing: RequestOperation?

    // 因为类型擦除现在发生在request被传递给 queue 之前,
    // 它可以简单地接受一个具体的“RequestOperation”的实例。
    func add(_ operation: RequestOperation) {
        guard ongoing == nil else {
            queue.append(operation)
            return
        }

        perform(operation)
    }

    private func perform(_ operation: RequestOperation) {
        ongoing = operation

        operation.perform { [weak self] in
            self?.ongoing = nil

            // 如果队列不为空,则执行下一个请求
            ...
        }
    }
}

然而,这里的缺点是,在将每个请求添加到队列之前,我们必须手动将其转换为RequestOperation——虽然这不会在每个调用点添加大量代码,但这取决于必须完成相同转换的次数,它最终可能会有点像样板。

结语

尽管 Swift 提供了一个功能强大得难以置信的类型系统,可以帮助我们避免大量的bug,但有时它会让人觉得我们必须与系统抗争,才能使用通用协议之类的功能。必须进行类型擦除最初看起来像是一件不必要的杂务,但它也带来了一些好处——比如从不需要关心这些类型的代码中隐藏特定类型信息。

在未来,我们可能还会看到 Swift 中添加了新的特性,可以自动化创建类型擦除包装类型的过程,也可以通过使协议也被用作适当的泛型(例如能够定义像Request<Response,Error>这样的协议)来消除对它的大量需求,而不仅仅依赖于相关的类型)。

什么样的类型擦除是最合适的——无论是现在还是将来——当然很大程度上取决于上下文,以及我们的功能是否可以在闭包中轻松地执行,或者完整包装器类型或泛型是否更适合这个问题。

0 人点赞