龙空技术网

泛型对于 Swift 的重要性

字节跳动技术团队 323

前言:

今天我们对“泛型的应用”都比较关怀,小伙伴们都想要剖析一些“泛型的应用”的相关知识。那么小编也在网上收集了一些关于“泛型的应用””的相关内容,希望你们能喜欢,同学们快快来了解一下吧!

Swift 泛型历史

我们首先来回顾一下 Swift 中对于泛型支持的历史变更,看看现在在 Swift 中,泛型都支持哪些特性

Swift 泛型是 Swift 语言中的一个重要特性,在历届 WWDC 大会都有被提及,网上可以参考的资料也很多。这次会议上讨论了泛型特性的一些设计思路

泛型对于 Swift 的重要性

考虑一个如下的一个集合类型

对于这样的一个集合类型,我们并不能定义他的 get/set 方法对应的变量类型,充其量只能定义一个万能类型(如 OC 中的 id 或 C++ 中的 void * )。Swift 中也有这样的一个万能类型 Any ,但是这样会带来非常不好的开发体验,看看下面的这个例子

对于 words 变量而言,可能你一直把它当作一个字符串数组来使用,但是实际上有可能在别处塞入了一个非字符串类型的变量进去,从而导致强制解包失败引起程序崩溃,这是一个令人心塞的经历。

实际上,对于以上的例子,内存管理上的问题就更为突出。

譬如对于一个整型数组来说,他的内存布局是非常紧凑的

如果是一个 Any 类型的数组,内存占用就会变得很大,因为这个时候需要预留足够多的内存空间给所有可能的变量类型使用,这样就是一个极大的浪费。

考虑一下如果用 Any 来包裹一个值类型的变量的话,内存布局上将会更加复杂了

在 OO 语言的时代,要解决以上问题的话,一般采用参数多态(Parametric Polymorphism)技术,在 Swift 中而言,就是泛型

譬如对于上述的例子,我们用Swift泛型来定义的话,应该是这个样子

这样子,我们就可以告诉编译器,Buffer 中应该包含有哪些类型的变量。因此,如果写了错误的代码的话,编译器就会马上报错,如

对于泛型类型来说,在初始化的时候,编译器如果拥有足够的信息去推导变量类型的话,我们可以不用显式声明泛型的具体变量类型,如

这样子的话,我们就能对于不同的变量类型,也能做到内存中的紧凑布局,而不会浪费掉不必要的内存空间

基于类型推导技术,我们就能对泛型写更加便利的代码了,譬如

然而,对于 Buffer 来说,如果直接按照 Int 的预设去写代码的话,依然会遇到编译错误,譬如

要解决这个问题,我们只需要给泛型类型添加约束即可

或者我们稍微扩展一下,定义协议也是可以的,这个时候就不仅限于 Int 的使用了

协议设计

在上面的例子,我们已经创建了一个泛型类型,但是我们的抽象化还不够。如果我们想要把泛型适配的范围扩展得更大一点,我们需要用协议去定义行为。我们以大家熟悉集合类型来看看怎样定义一个合理的集合协议

如果我们要为集合类型添加下标操作的话,可以这样子做

实际上,这样的设计过于简单化了,考虑一下 Array 和 Dictionary 的场景,对于 Array 而言,通过下标去寻找元素是比较容易而且实现也是显而易见的,但是对于 Dictionary 而言,我们还需要合适的包装,譬如

基于以上的考虑,我们可以将集合的协议再通用化声明一次,看看效果会不会更好

这里我们考虑到了下标操作的各种场景,并且用更灵活的 Index 类型去定义下标操作对应的索引行为,这样子基本覆盖到了我们日常遇到的场景,而将更多变的实现方式留给了具体的算法实现代码中。举个例子

或者我们可以高效一点,将约束条件直接写到协议中去,这样子我们就不需要针对每一个具体的泛型类型去定义相对应的约束条件了

定制点( Customization Points )

我们来考虑对于同一个协议中的函数声明,我们可以有不同的实现方式,如

当我们要使用 count 的时候,大部分的场景下,我们只想调用简单实现,并不太在意性能,而有些时候我们又希望能够采用高性能的解决方案,在泛型中,我们可以引入定制点的概念,从而满足各种定制化场景的需求

在以上的例子,我们在协议声明的时候,预埋了 count 函数的默认实现方式,而对于 Dictionary 类型来说,将会采用更好的 count 实现方式,从而在保持泛型声明的前提下,优雅地封装了同一函数的不同实现方式。

协议继承( Protocol Inheritance )

Swift 提倡一种面向协议的编程方式,因此,我们很自然地把以前在面向对象编程中的一些很好的特性迁移到协议的设计中,考虑一下当我们需要对上述的集合协议扩展一些特殊的功能时,譬如 lastIndex 、shuffle 等,这个时候使用继承是比较合适的。用一个具体的代码表示如下

在面向协议编程的设计中,我们要时刻记得,协议是用于定义行为确定,但实现各异的场景,而不是过于特化的去设计接口,我们来看一个不好的设计方式

仔细考虑一下,对于 ShuffleCollection 而言,需要的是两种行为

随机访问元素

修改内部变量实现 shuffle

因此,通过定义粒度更细的协议,可以让上诉行为展示出更多的多态性

合理的协议设计思路,我们可以用如下的类图来表达

自上而下看,泛化的协议会变得越来越特化,对应于越来越窄的适用场景(然而这个时候还是要尽可能确保场景适用面广),从下往上看,超类的协议总是能够把更多态的行为抽象出来表达。协议的继承关系应该比类的继承关系更用心地去设计,尽可能地保留协议行为的原子性和多态性,让代码更加易于扩展和组装。

条件一致性( Conditional Conformance )

条件一致性在 Swift 4.1 中引入,表达了这样的一个语义:泛型类型在特定条件下会遵循一个特定的协议,譬如

在 Swift4.2 中,条件一致性能力的增强,使得我们可以做更多的事情。譬如多协议的条件一致性检查也是可以的

泛型和类怎样抉择

Swift 是一个多编程范式的语言,你既可以将它用于面向协议编程(POP),也可以用于面向对象编程(OOP)。

如果采用对象继承的方式,要时刻记得『里氏替换原则』,我们来重温一下它的具体概念

里氏替换原则( Liskov Substitution Principle LSP )面向对象设计的基本原则之一。 里氏替换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP 是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。

因此,继承的原则是:继承必须确保超类所拥有的性质在子类中仍然成立。也就是说,当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系,即构成继承关系。

简单的代码演示如下

不过在实际编程中应用 OOP 常常会遇到这样的困境:超类的设计往往跟子类有关,如果当一个新的子类出现,是修改超类行为从而覆盖新的子类呢,还是变成组合关系?

我们使用 POP 的话,结合泛型可以提供了行为的多态性,在某种意义上来说对于是一种更柔性的解决方案。在 Swift 中,协议可以拥有默认实现,可以增加约束,可以有条件一致性提供查询,最重要的,还能提供继承关系。这些特性都可以帮助我们更好地在 POP 下重用代码。

在 POP 下,里氏替换原则依然适用于协议的继承关系,如下述代码所示

下面我们考虑一下 POP 下的工厂模式实现,这是一个教科书式的例子来帮助我们理解 Swift 中,协议相关的强大特性

最后,在 Swift 中我们还可以用 final 来修饰一个类,用于表达这个类是不能被继承的

总结

本次 Session 探讨了很多泛型和协议相关的话题,我们来简单回顾和总结一下要点

1.泛型设计对于 Swift 语言来说是一个很重要的特性,能够既保持静态类型的特点又能够达到代码重用的目的

2.协议的设计要遵循自上而下和自下而上的原则

3.自上而下:协议的继承表达了更特化的行为描述

4.自下而上:父类协议应该能够将更抽象的行为进行封装从而达到代码重用的效果

5.继承的设计要遵循里氏替换原则

标签: #泛型的应用 #泛型的应用解决了什么问题 #泛型 使用场景