Altconf hector matos cover

神奇的类型擦除

在 Swift 的世界中,如果我们将协议称之为国王,那么泛型则可以视作皇后,所谓一山不容二虎,当我们把这两者结合起来使用的时候,似乎会遇到极大的困难。那么是否有一种方法,能够将这两个概念结合在一起,以便让它们成为我们前进道路上的垫脚石,而不是碍手碍脚的呢?答案是有的,这里我们将会使用到类型擦除 (Type Erasure) 这个强大的特性。

在本次 AltConf 的讲演中,Hector Matos 讲解了在 Swift 中将泛型和协议结合起来使用时的一些缺陷,然后具体介绍了类型擦除的含义。更为重要的是,他通过一个真实的 App Store 上架应用,解释了为什么在同时使用协议和泛型时需要类型擦除的帮助。到目前为止,最常见的一个问题就是为什么我们会需要在 Swift 当中使用类型擦除呢?

在本次讲演的最后,您会对如何在应用中更好地将协议和泛型结合起来有着更好的思路和想法,您会发现类型擦除非常简单,而且十分有用。


概述 (00:00)

今天我要给大家分享的主题是:神奇的类型擦除 ( 通过这个「魔法」,我觉得我将成为一名神奇的「魔法师」),我将会用现场编程的方式来给大家分享,这对我来说是一个不小的挑战。关于类型擦除,网上有不少优秀的演讲 (例如 Gwen 的什么是类型擦除 ),此外也有不少的优秀文章(例如 Russ Bishop、Rob Napier 的,文章的链接参见文末)。

具体类型与抽象类型 (01:39)

当您开始学习一门编程语言的时候,您很有可能是从 stringint 或者 float 开始学习的:这些都是所谓的「具体类型 (concrete type)」,也就是编译器能够在编译时确定此类型所占的空间大小——这使得编译器对具体类型的处理非常友好。如果某个类型可以被初始化——也就是说您可以调用它的初始化方法——那么就说明它是一个具体类型。

另外一种类型就是「抽象类型 (abstract type)」( 也被称为存在类型 (existential) )。对于抽象类型来说,编译器无法知道这个类型的确切功能。当编译器处理抽象类型的时候,它无法知晓其所占的空间大小;甚至可能会认为这个类型是不存在的。事实上,您很可能见到过这样的错误描述「我无法为您找寻到此类型」。

在 Swift 当中,抽象类型的普遍表述方式使用 associatedType 来描述的,之前我们是使用 typealias 来定义的,此外泛型 <T> 同样也是一个抽象类型。如果编译器无法在编译时知晓类型的所占空间大小,或者您无法将其初始化,也就是说您没办法调用它的 init 的方法:那么就说明这个类型很可能是抽象类型。

什么是 Swift 中的类型擦除? (03:08)

有很多地方会用到类型擦除,并且它们的作用的各不相同。在 Java 当中,它用来让编译器知晓其相关内容,它会抹除 <T> 的存在,然后用一个具体类型将其替代。在 Swift 当中,我们不必处理这些琐事,我们可以自行实现——有很多您可以选择的实现方式。对于类型擦除来说,绝大多数语言当中的基本概念是相同的,但是在 Swift 当中,它解决了这个问题。

protocol SpellDelegate {
    associatedtype SpellType
    func spell(spell: SpellType, hitEnemy enemy: Wizard)
}
class Spell {
    var delegate: SpellDelegate //ERROR
}

Receive news and updates from Realm straight to your inbox

让我们来看一下这个协议,SpellDelegate 当中有一个名为 SpellTypeassociatedtype。假设我们需要执行一个委托操作,从而允许您使用符咒来攻击某个巫师。因此您就试图去使用这个委托方法,然后将其声明为了一个属性,这个时候您就会得到一个错误:「协议 SpellType 只能够作为泛型约束 (Generic Constraint) 来使用,因为它内含了必需的 Self 或者 associatedtype。」

类型擦除就是在代码中 让抽象类型具体化 的一个过程。很多人往往会选择开始讲解分类原理、讲解类型原理,甚至讲解编译器内部的一些规则。但是我现在不想讲述这些内容,我想要尽可能让这个概念变得更简单一些。

闭包是一等公民 (05:17)

在我们讨论什么是类型擦除之前,我想要给大家讲解一些基础内容:

闭包 (Closures) 是一等公民 (first-class citizens)。这意味着您可以给一个变量赋值一个闭包,您可以将其作为参数传递到一个函数当中,您还可以从函数中将其作为返回值返回……等等。同样,函数很明显也是一等公民。在某种程度上,闭包和函数是可以互换的。如果闭包变量和函数签名 (signature) 是相同的,那么这个这个函数就可以赋值给这个闭包变量。

此外还有一些基础步骤:

  • 创建一个名为 Any 的封装因为当我们在执行类型擦除的时候,这是我们实际需要操作的类型)。在 Swift 标准库当中,您会看到一个名为 AnySequence 的协议。它就是序列协议当中的一个典型的类型擦除示例(我们可将其视作类型擦除的最佳实践)。
  • 确保您的类型擦除类型是泛型,因为比起抽象类型以及协议中的 associatedtype 来说,编译器处理泛型更加得心应手。
  • 让这个类型实现此泛型协议。
  • 将任何遵守该协议的初始实例注入到这个类当中。
  • 将所有此协议必须要执行调用发送到基础类型当中——也就是发送到您刚刚注入的那个类型擦除类当中。
  • 大功告成

Demo 1

我将给大家展示一个真实的应用示例,我在这款在 App Store 上架的应用当中应用了类型擦除。我是 Capital One 的一名资深 iOS 开发工程师,我参与了「Level Money」这款应用的开发工作。Level Money 允许在您连接到银行系统之后,就允许您查看您的交易记录,同时也允许您进行财务管理。对于每个交易记录来说,您都可以通过单击单元格来对交易记录进行编辑。

我创建了一个跟踪器 (Tracker):Level Money 的跟踪器允许您将所有的交易记录进行编组,从而可以看到每个月您花费了多少钱。我同样还创建了另一个名为 Food (Doughnuts) 的跟踪器。在这种情况下,我准备将其添加到我的 Doughnut 跟踪期当中。他们希望我将这个图标修改为一个加载指示器。我们尽可能不使用加载屏幕,因为这会造成整个应用出现卡顿的情况,因为我们需要在后台进行家在操作。这样用户就可以任意去访问其他的内容,而不是等待这个 API 调用结束。基于这种架构,这使得用户也允许在某个交易记录正在加载的过程中去访问其他的交易记录,并且还可以进行编辑(这样就不必等待了)。我们使用了加载指示器。但是这仍然不够,这就是为什么我需要在这种情况下使用类型擦除的原因。

这些就是我们的交易记录;它们由一个 Transaction 结构体所表示。但是应用当中的其余位置仍然会用到相同的 UI。如果我们前往「账单」页面,会发现它们的结构是很相似的。用户可以进入到「账单」界面,然后对账单进行编辑。如果您需要的话,您可以将它们添加到跟踪期当中。例如,您可以编辑它们的日期。操作完成后,您会发现这个图标变成了一个加载指示器。但是,这是由 Bill 结构体所管理的操作。这里我们拥有了两个截然不同的数据类型,我可以选择违反 DRY 原则 (Don’t Repeat Yourself),创建另一个拥有相同函数的协议,然后在交易记录和账单详情中进行共享。但是我不喜欢这样做。

我所做的是,创建了一个泛型协议。当用户按下「Save」之后,为了让主屏幕知道「需要加载这个单元格」,因此我决定使用委托方法(我们已经实现)。我们创建了一个名为 CellReloadable 的协议,在这里我们需要知道所选择的索引路径,也就是用户按下了哪个单元格。因为我们的操作是在后台进行的,因此我们可以使用 loadingCellObjects 来同时加载多个单元格。此外,我们还需要一个能告知「已经完成此交易记录的编辑」或者「已经完成此账单的编辑」的函数。这就是这个 CellReloadable 协议的样子,我这里给它传了 Transaction 以便能够处理交易记录。如果我们要处理账单的话,传 Bill 即可。在我们的应用当中,我为每个需要处理不同数据类型的单独屏幕实现了这个协议。这个委托方法是非常简单的。

这是一个很简单的表视图。它现在显示的是 Hooray。我们通过具体的类型来使用这个 Transaction 协议。然后运行;这个时候我们会看到 “Hey, AltConf!”。这是一个按钮,当我按下之后就会触发委托方法,告诉 HomeViewController 去加载单元格。然后它现在就会变成 “Loading”。

这就是我们在 Level Money 当中所做的,非常简单。

Demo 2

这些代码您可能会很熟悉。当我选中一个单元格之后,我就推出 TransactionDetail 页面。我将 delegate 设为 self。在上面这个委托方法当中,我处理了我所需要的一切——当这一行的单元格加载之后我就可以说「这个单元格需要进入加载状态」。如果我进入到了 TransactionDetailViewController 中的话,则这个委托是 CellReloadable

让我们把这个协议泛型化。首先我们要给它加一个 associatedtype,我将其命名为 DataType,因为它应该能够处理我们应用当中的所有数据类型。一旦我这样做了,就会报错:”… 只能够作为泛型约束 (Generic Constraint) 来使用,因为它内含了必需的 Self 或者 associatedtype。”

这时候我们就要引入类型擦除了:我需要创建一个封装类 (Wrapper Class)。我将其命名为 AnyCellReloadable,然后让其遵循 CellReloadable 协议。为了实现这个协议,我至少需要添加这三个东西。这里之所以报错是因为我们有添加构造器,因为现在我的 loadingCellObjects 还没有完成初始化。

现在它对 DataType 报错了:我不知道 DataType 现在是什么。还记得我说过要让类泛型化吗?我可以使用这个 <T>,然后将其易名为 <ErasedDataType>,因为我们所要做的就是要「擦除」这个抽象类型 DataType。我准备使用这个泛型(它仍然是一个抽象类型),只不过编译器能够很好地处理泛型。没有任何错误发生!

让我们回到这个 TransactionDetailViewController 来。让我们将这个 CellReloadable 改为 AnyCellReloadable,因为它是泛型的。这是一个交易记录详情的视图控制器,因此它应该只能够处理 CellReloadable,从而处理交易记录对象。我现在需要告诉编译器,ErasedDataType 需要替换为 Transaction 类型。

现在让我们前往 HomeViewController 来,我们在这里实现委托方法。self 没有在 AnyCellReloadable 当中;我们应该能够使用 AnyCellReloadable 然后注入 self。这就是所谓的「依赖注入 (dependency injection)」。

现在让我们来修正这个编译错误。我们想要能够注入某些东西:那么让我们创建一个构造器。这个构造器应该包含某个名为 CellReloadable 的东西。我们在这里遇到了和之前一样的编译错误:「因为它内含了必需的 Self 或者 associatedtype」。它只能够被用作泛型约束。

所以让我们把它变成泛型约束。在我们的 init 方法中,我们加上 Injected,这意味着它将成为 CellReloadable,然后调用这个 Injected 类型。现在我们就没有编译错误。

现在如果我们回看 HomeViewController 的话,我们现在就没有编译错误了,因为 HomeViewController 现在已经遵循了 CellReloadable,现在我们已经成功实现了注入。我们的所有代码现在都准备好使用这个了。

但是我们需要更进一步,让底层类型也能够调用 AnyCellReloadable;底层类型则是我们刚才创建的 Injected 类型。让我们继续,现在初始化我们的这些变量。selectedIndexPath 将会变为 reloadable.selectedIndexPathloadingCellObjects 将会等价于 reloadable.loadingCellObjects

现在我们遇到了编译错误了,因为类型不匹配。Injected.DataType 不匹配这个古怪的 Underscore。让我们给它更多的信息。如果我们进入到泛型约束当中,我们希望 Inejceted 是一个 CellReloadable,并且 Injected.DataType (这是我们的 associatedtype) 等价于 ErasedDataType。非常好!

现在我们的 selectedIndexPathloadingCellObjects 都可以被处理了,但是函数本身呢?如果我让它结束编辑,会发现它毫无反应。

现在让我们实现一个类型参数与这个函数匹配的闭包。我们添加一个 private let(因为不需要知道内部实现);让我们将其命名为 didFinishEditing,它接收 BoolErasedDataType 为参数。然后返回 Void。现在我们需要将其初始化。

它们完成了编辑,然后等价于 reloadable.didFinishEditing。因为我加了括号,因此我是在调用函数。但是我不想这样做——我只想获取函数签名,然后将其赋值给闭包。这就是我们最后需要做的:didFinishEditing 指向原始的函数签名,也就是底层类型,我们的 Injected 类型。

让我们看下我们所创建的这个闭包的调用。我们成功地擦除了泛型协议的类型。但是仍然还有个问题:loadingCellObjects 是值类型。当我对其进行赋值时,它简单地复制并写值,这意味着我在类型擦除类当中对 loadingCellObjects 所做的任何操作都不会影响到初始的 loadingCellObjects 字典(因为它是写时拷贝的)。

我该怎么解决这个问题呢?现在就需要 Objective-C 的黑科技了。我们创建一个 setter 和 getter。我准备做的就是创建一个 getter:getLoadingCellObjects。让我们来匹配类型。它们需要返回我所需要字典的类型。并且它还应该是 Void。现在实现一个 setter:setLoadingCellObjects。它和 getter 本质上是相反的。

现在我们在 Swift 实现了这些,也就是为 loadingCellObjects 属性添加了 get/set 属性值。对于 get 来说,我们希望其返回 getLoadingCellObjects 闭包。对于 set 也是如此,只是我们需要传入一个 newValue。本质上来说,我们对 loadingCellObjects 的任何操作都将影响到原始实例。但是我们现在都还没有把这些完成初始化。

现在让我们使用下闭包的黑科技。如果我们执行了 getLoadingCellObjects,我们给它一个闭包。我们希望返回某些东西。让我们返回 reloadable.loadingCellObjects。为什么这里能够运行,而当我们实际赋值的时候就不能运行了呢?原因是因为闭包会对状态进行捕获。它们会在外部捕获某些东西,这就是为什么我能够在闭包当中访问原始 reloadable,然后返回其实际的 loadingCellObjects。对于 set 来说,reloadable.loadingCellObjects 将等同于 $0。现在我们成功对所有的东西进行了类型擦除。(我这里出现了编译错误,是因为我为其添加了 get/set,但是实际上并不需要)。

如果一切顺利的话,那么让我们回到起点。记住,我所需要做的就是使用我的泛型协议。我使用这个带有泛型协议的委托方法。然后让我们单击 “Hooray”。让我们单击 “Hey, AltConf!”,好的现在它变成了 “Loading”。我成功地对泛型协议进行了类型擦除。

如果您仔细想想,简单构建一个闭包然后对其进行类型擦除,那么您就能够成功擦除任何类型了。

参考链接 (23:13)

问答时间到! (24:06)

问:这些代码看起来是比较多的。但是现在,我们在 Xcode 当中可以使用脚本的方式,我想我们可以编写一个扩展,它能够选择某些我们需要的内容,然后自动为我们生成这些相关的代码?您觉得这样做合理吗?

Hector:Xcode 拥有代码片段功能,在这里我可以很轻易地使用它。但是,我所要做的、以及我需要理解的是:我是否已经熟记了这种代码模式,这样它就不会占用我太多的时间。我一遍又一遍地重复这些编码,我也在实际工作当中使用它,因为我想让您知道,实现这种模式并不会很麻烦。如果您看到我有在用代码片段的话,就说明我并没有熟记这段代码,也不是很清楚它内部的原理;说实话,如果我没有熟记的话,那么我也不可能来给大家进行这个演讲。这就是为什么我要这样做,并且我也需要借此来梳理我的思路。

问:对于向团队成员教授如何阅读您刚才所写的代码,您有没有什么好的建议呢?我通常想要做一些类似的工作,或者当我在开源项目当中看到类似的优秀模式的时候,虽然对于这些模式来说我不得不冥思苦想才能灵活贯通。

Hector:当我第一次看到类型擦除这个概念的时候,我觉得它对我来说是非常难以理解的。我的意思是,Rob Napier 的演讲很不错,Russ Bishop 的文章也很优秀,但是当我看完他们的代码后,我也百思不得其解。我无法很好地理解我所看到的、我所读到的。我只好一遍又一遍地阅读这些代码,直到最后灵光一闪,我终于能够明白它们的实际作用了。希望您也能够向您的同事分享我的这次讲演,因为我已经竭尽全力让讲演内容尽可能地简单了。但是解决这个问题还有别的方法:如果您觉得自己的教学水平还不错的话。您可以将这些代码划分为块,这样或许他们就能够更直观地理解代码的作用。在您的日产生活中,这是一个非常强大的工具。但是,您并不需要它。您只需要不停地多用几个协议之类的东西进行练习。这些内容其实是非常简单的,但是如果您想要掌握并写出优雅的代码的话,练习是唯一的方式。对于向团队成员进行教授而言,请找出什么方法他们能学得更快。有些人喜欢阅读博文,有些人喜欢观看视频。有些人喜欢讨论中进行学习。我觉得我们现在天时地利:我们有了讲演视频,也有了相关博文,然后现在您也在场,您可以私下与他们进行讨论学习。

问:我这里有一个疑惑。我才刚开始学习 Swift,但是我之前所学的那门编程语言当中的泛型实现并不会出现此类问题,当然这里我也不提那门语言是什么了。我发现了一篇文章,我觉得这很可能是您所撰写的。我所读的这篇文章当中,您好像提到了自己的某一位同事,将这个技术称之为 Thunk。在开源社区当中,无可厚非,我们需要让技术变得容易理解,这样才能够与他人更好地分享我们的代码。那么是什么驱动着您决定使用类型擦除呢?

Hector:实际上这的确是我的文章。关于这个问题我想从两个方面回答。如果您刚开始研究类型擦除的话,您会发现里面有很多难以理解的东西。至少对于 Swift 来说,类型擦除是非常类似「Thunk」的。而 Thunk 则完全是一个独立的架构。哦对了,Thunk 这个东西,实际上是一个基于您所想封装的东西的封装类,它会将对其的调用操作转发给其封装的东西当中。您可能会在 Xcode 当中的某些奇怪的编译错误或者运行时错误当中看到 Thunk 的存在。但是,我们这里所做的,只是将抽象类型给擦除掉。我们有两种方法来实现。而我们只想使用最有意义的哪一个。在这种情况下,Thunk 将会是最有意义的那个,但是我不想再过多地与大家介绍更复杂、更烦人的专业术语。因此我觉得「类型擦除」已经可以包含我们所要实现的功能了。

问:您的讲演很棒。类型擦除是一个非常复杂的东西。我不禁想到,在符号模式 (sign pattern) 与黑科技之间存在着一条界限。而我们往往都可能会倾向于使用黑科技。这正是语言的「语法糖」导致的您无法清晰地表述这一点。您对这有什么看法呢?

Hector:这绝对是一个黑科技。说实话,如果不使用 Thunk 的话,我们将寸步难行。我们所要做的就是等待 Swift 团队创建某种类似于 Java 在背后为您所做的那些机制。您就不用担心这方面的内容了。Swift 则完全相反。有很多相关的理由:比如说协变 (covariance) 和逆变 (contravatiance)。这些内容还没有内置到泛型当中去。如果您有时常在看邮件列表的话,您会发现关于这一点有着海量的讨论。实际上,有人说我们应该在协议当中默认使用协变的特性。通过所谓的泛型协议。这的确是一个非常困难的目标。此外还有一些原因。我之后可能会去通过 Google 搜索一下,但是现在我们必须要通过这个黑科技,除非我们发现了 Swift 还有其他更好的解决途径,因为我们都仍然在不断完善,如果 Swift 实现了这方面的内容,那么这可以让开发者们能够不再去关注这些黑科技了。

问:您的演讲很棒,我们希望能够使用它来解决一些问题。我觉得我们都是属于喜爱协议的那一类人,鉴于我们还有几分钟的时间,您能否分享一下那个,我们姑且成为「协议黑科技」的内容呢?

Hector:协议黑科技?很多时候我都会滥用协议扩展,这我觉得是不好的。但是这已经是我所能够想出来的最神奇的黑科技了。Swift 当中协议的其余部分都比较健全了。我的意思是,这方面的内容并不是很多。这也是我认为我能够在协议当中使用的唯一一个黑科技。

问:关于泛型协议这方面,我遇到的一个问题就是协议相等性了。如果您在这种情况下使用类型擦除的话,当之后进行对象比较的话,是不是直接使用这个封装类呢,如果用的话又在哪里使用呢?

Hector:我觉得我能给您的答案可能会有比较严重的错误,但是我觉得相等性是能够用在泛型协议上的,比如说在某种判等函数当中,您需要确保各个数据类型是等价的,或者数据类型的本身相等。不过我不是非常确定。我想说的是,如果数据类型等价,并且关联类型也是等价的,那么就应该没问题了。我并没有试过这样做,但是我之后我很有兴趣去研究下这方面的内容。如果您希望的话,您可以在任何地方使用类型擦除。只要它能够让您的代码更有意义,就大胆地用就行了。

About the content

This talk was delivered live in June 2016 at AltConf. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Hector Matos

Raised by llamas in the great state of Texas, Hector grew to be an avid couch potato who likes spending his precious couch time playing The Legend of Zelda or yelling at the TV whilst watching Game of Thrones, and his other time with his lovely daughter and wife. When not vegging at home or blogging about Swift, you can find him sitting at the office writing mobile apps for iOS & Android for Capital One. With a particular penchant for great mobile UI/UX, Hector writes the code that makes the world go round.

4 design patterns for a RESTless mobile integration »

close