Swift协议与关联类型

Swift协议与关联类型
GuoYanjun- 文/一月筠
-- 转载请注明 --
- 本文将讨论Swift协议Protocol中特殊的关联类型Associated Types
在Swift语言当中,泛型(Generic)
和协议(Protocol)
都是非常重要的语言特性。使用泛型让你能根据自定义的需求,编写出适用于任意类型的、灵活可复用的函数及类型。你可以避免编写重复的代码,而是用一种清晰抽象的方式来表达代码的意图;使用协议能够让你设计一个蓝图,遵循协议的具体类型,帮助你实现某一特定的任务或者功能的方法、属性,特别是协议可以作为类型使用,使其具有了动态派发的能力;本文将讨论Swift协议(Protocol)
中特殊的关联类型(Associated Types)
,它与泛型(Generic)
有相似性和又有区别。为了简化文字描述,后续将带有关联类型的协议(Protocol With Associated Types)
,简称为关联协议;而把普通的不包含任何关联类型的协议(Plain Protocol)
简称为普通协议。
[toc]
问题
我们将首先讨论一个业务开发中的具体问题,定制UITabbar和UITabBarController。
图1 定制UITabbar元素示意图
有两种TabBarItem类型,一种是SNSTabBarItem
,其中ImageView是图片类型;另一种是SNSTabBarLotItem
,其中ImageView是LottieView,即动画类型;为了通用化设计,统一属性名称和调用流程,我们考虑通过设计协议来解决这个问题。
协议代码如下所示:
1 | public protocol SNSTabBarItemProtocol { |
其中,使用了关键字associatedtype
,定义了一个关联类型;满足实际使用中,不同类型的TabBarItem中ImageView类型的变化,同时,又对其增加的限制,要求ImageView必须是继承UIView
的子类。另外,定义了createElement
函数,它会在自定义的CustomTabBarController中被调用,不同类型item其内部实现不同,满足不同UI布局的定制需求。
实现协议的两种TabBarItem类型:
1 | //第一种,SNSTabBarItem |
接下来,在自定义CustomTabBarController中创建所有item,并调用协议中定义的方法createElements
;
1 | // 创建自定义图标 |
代码中if let item = item as? SNSTabBarItemProtocol
这里会遇到一个致命问题Protocol ‘SNSTabBarItemProtocol’ can only be used as a generic constraint because it has Self or associated type requirements,这是什么原因呢?我们通过简单分析这个错误提示,可以得出一些线索:带有associatedtype
的关联协议只能修饰泛型,这与普通协议相比带来了明显的差异和使用限制。
关联协议的限制
使用关联协议需要做泛型改造
我们来回顾一下Swift官方文档关于协议作为类型(Protocols as Types)
的描述:
Protocols as Types
Protocols don’t actually implement any functionality themselves. Nonetheless, you can use protocols as a fully fledged types in your code. Using a protocol as a type is sometimes called an existential type, which comes from the phrase “there exists a type T such that T conforms to the protocol”.
You can use a protocol in many places where other types are allowed, including:
- As a parameter type or return type in a function, method, or initializer
- As the type of a constant, variable, or property
- As the type of items in an array, dictionary, or other container
挑重点进行说明,协议本身实际上并不实现任何功能,但是你可以在代码中使用协议作为完善的类型。
这说明我们前面问题中的使用方法针对普通协议是正确的,而针对关联协议就不再正确;从错误提示可以得到答案,Protocol 'xxx' can only be used as a generic constraint because it has Self or associated type requirements
,关联协议只能修饰泛型。
通过一个具体的例子来验证:
1 | protocol Proto{ |
这段代码运行正常,接下来改造一下,增加associatedtype
关联类型
1 | protocol Proto{ |
改为关联协议后就会出现与前面例子相似的错误,那么我们来引入泛型进行修改。
1 | protocol Proto{ |
运行正确,从这里我们可以得到结论,每一个从前使用普通协议的的地方,现在为了使用associatedtype
,需要进行改造,引入泛型,使用关联协议修饰泛型参数,就能够避免产生错误。
使用关联协议失去了动态类型派发的能力
但是,改造后class C
变成一个泛型类,带有泛型T
,T
遵循Proto
协议,然后在C
内部,delegate
的类型是T
,也就是说原本一个普通的class
类型,需要被改造成泛型class
,很多时候这不是我们设计的本意,而存粹是为了支持使用associatedtype
,不得不进行的改造。这样失去了dynamic dispatch
动态类型派发的能力!
比如有一个数组,其内部存储的类型是不同的,但是遵循相同的协议,这在使用普通协议时,是可行的,而使用带有associatedtype
的关联协议就不可行了,失去了动态派发的能力,多态的能力,只能变成一个统一的类型,而不能支持不同类型 ,因此我们失去了一个重要的语言特性。
关联协议与泛型的关系
关联协议,从外部看,使用associatedtype
更像是提供了一个语法糖,提供有意义的名字做占位;从内部看,建立类型的语意要求,使用typealias
显示或者隐式指明具体类型;利用associatedtype
相当于定义了一个未知类型的占位符,并且这个占位符可以在协议定义的整个生命周期内使用。
我们来对比两段代码:
1 | protocol Animal{ |
协议Animal
定义了每种动物要eat某种类型的Food
,到现在为止,我们还不知道哪种动物吃哪种Food
;
1 | struct Animal<Food>{ |
Animal
结构体,支持泛型参数Food
,定义每种动物eat某种类型的Food
;
这种场景下,使用关联协议和使用泛型参数作用非常相似, 但是他们之间仍然不完全相同。
由于目前的语言限制,协议中无法使用泛型,我们假设可以使用泛型协议,写出类似下面的代码,然后与使用关联协议的代码进行对比分析:
1 | //假设泛型协议成立 |
从外部看,我们使用泛型协议方式,只能看到遵循协议的具体类型,即Grass
是一个遵循Food
协议的类型;对比使用associatedtype
的关联协议,我们可以通过类似属性的方式,可以直接获取到关联类型的名字,这使得某些情况下,添加参数类型的约束限制成为可能。还不止于此,如果有多个关联类型,或者关联类型需要被其他关联协议限制,或者同时使用多个协议,这些复杂的情况组合,就使得假设的泛型协议很难代替关联协议,并且泛型协议不得不把这些(原本可以通过associatedtype
隐藏在内部的)信息全部暴露给外部使用者。
另外,关联协议利用associatedtype
解决的问题是面向对象的类型关系继承,来看下面例子:
1 | protocol Food{ |
首先定义了Food
协议,Grass
作为一种具体的食物遵循Food
协议;另外,我们通过Animal
协议,规范动物需要eat
食物Food
,具体是哪种Food
没有确定,最后Cow
作为一种具体的动物,遵循Animal
协议,实现了eat
方法,参数指定Grass
类型,Grass
遵循Food
协议,但是编译器提示错误,Cow
没有遵循Animal
协议,只能改为func eat(f: Food)
;
我们可以发现:遵循普通协议的具体类型,其内部遵循的协议类型不能捕获复杂的类型关系
接下来改造Animal
协议为关联协议
1 | protocol Food{ |
有了associatedtype
的帮助,可以完成面向对象的类型继承关系使用。
解决问题的方案
现在我们讨论文章开头提出的的问题如何解决,有两种方案可供参考:
组合方案
1 | typealias Codable = Decodable & Encodable |
我们经常使用Codable
协议进行数据序列化,这里可以参考Codable
的设计模式,采用组合方案;
把SNSTabBarItemProtocol
协议拆分成两个协议:
1 | //协议只包含需要遵守的属性 |
经过这样改造之后,我们修改调用处协议:
1 | // 创建自定义图标 |
原来的转换为SNSTabBarItemProtocol
协议的方式,更改为使用SNSTabBarItemFunctions
这个子协议,而两个具体的UITabBarItem
子类仍然遵循SNSTabBarItemProtocol
协议,保持不变;这样通过组合的方式,我们绕开了关联协议只能修饰泛型的问题,把它变成了当前场景下只使用普通协议,调用协议内限定的函数;
添加泛型函数
既然关联协议只能用作泛型约束,因为它有关联类型要求,那么我们是否可以选择另一个方案:创造一个泛型函数,封装createElements
的调用并添加参数的泛型约束,我们来试试:
1 | //item改为泛型参数,遵守SNSTabBarItemProtocol协议 |
修改遍历元素内部的代码,调用loopElements
泛型函数,确实满足了关联协议的要求。
但是,新的问题会产生,调用loopElements
会提示 Global function ‘loopElements(item:superView:position:backgroundImage:)’ requires that ‘UITabBarItem’ conform to ‘SNSTabBarItemProtocol’ ,这是因为tabbar中的items数组元素,类型只能是UITabBarItem,不能添加SNSTabBarItemProtocol
的限制,因为SNSTabBarItemProtocol
是关联协议,只能修饰泛型,items中数组元素不是泛型,因此,这个方案不能继续推进成功。
为关联类型添加约束
接下来我们跳出问题本身,继续讨论为关联协议中的关联类型添加约束;它可以进一步来要求遵循的类型满足约束,这在很多场景下具有很多实际价值,能够抽象代码避免重复。
例如,下面的代码定义了MySequence
协议,MySequence
协议遵循Comparable
协议,其中的关联类型Element
也遵循Comparable
协议。
1 | protocol MySequence: Comparable { |
由于对 Element
添加了协议限制,Comparable
协议需要实现的比较方法就可以给出实现;
1 | extension MySequence { |
另外,我们也可以在关联类型约束里使用协议,用 where
从句实现更复杂的约束;
1 | protocol MySequence: Comparable { |
这里协议可以作为它自身的要求出现,即Slice
拥有两个约束,它必须遵循 MySequence
协议,同时它的Element
的类型必须是和storage
数组中元素Element
类型相同。
我们也可以为关联类型添加默认值,如下面所示Element
默认为Int
类型:
1 | protocol MySequence: Comparable { |
并且可以为为默认的 Associated Type
提供方法的默认实现。
1 | protocol MySequence4: Comparable { |
Element
现在默认是 Int
,接下来通过extension
给出函数summed
的默认实现。
1 | extension MySequence { |
但是此处会提示错误,无法推断出默认类型是Int
,即 extension
中的 Element
只受“约束”的影响,即只受 Comparable
和 where
从句的影响,并没有接受默认值。所以我们需要针对extension
增加限制。
1 | extension MySequence where Element == Int { |
只有满足Element
类型是Int
的,才能使用summed
的默认实现。这样就可以保证准确。
结语
本文从业务场景的实例出发,详细讨论了使用关联类型的协议可能会出现的问题,并且对比了与普通协议的不同;我们可以看到关联协议更类似范型参数;如果要使用关联类型的协议,就必须进行范型改造,这种方式使得类型失去了动态派发的能力,需要根据具体情况合理选择。另外,我们也详细介绍了如何为关联类型添加约束,通过添加约束可以实现更复杂的要求,如添加默认类型和默认类型的方法实现,优化代码设计方式,避免重复。
参考
- https://betterprogramming.pub/swift-protocols-with-associated-types-and-generics-373b2927baed
- https://zhuanlan.zhihu.com/p/80672557
- https://www.hackingwithswift.com/example-code/language/how-to-fix-the-error-protocol-can-only-be-used-as-a-generic-constraint-because-it-has-self-or-associated-type-requirements