Swift协议与关联类型
作者丨狐友技术团队
来源丨搜狐技术产品(ID:sohu-tech)
本文字数:9972字
预计阅读时间:25分钟
Swift协议与关联类型
目录
前言
问题
关联协议的限制
使用关联协议需要做泛型改造
使用关联协议失去了动态类型派发的能力
关联协议与泛型的关系
解决问题的方案
组合方案
添加泛型函数
为关联类型添加约束
结语
参考
前言
在Swift语言当中,泛型(Generic)
和协议(Protocol)
都是非常重要的语言特性。使用泛型让你能根据自定义的需求,编写出适用于任意类型的、灵活可复用的函数及类型。你可以避免编写重复的代码,而是用一种清晰抽象的方式来表达代码的意图;使用协议能够让你设计一个蓝图,遵循协议的具体类型,帮助你实现某一特定的任务或者功能的方法、属性,特别是协议可以作为类型使用,使其具有了动态派发的能力;本文将讨论Swift协议(Protocol)
中特殊的关联类型(Associated Types)
,它与泛型(Generic)
有相似性和又有区别。为了简化文字描述,后续将带有关联类型的协议(Protocol With Associated Types)
,简称为关联协议;而把普通的不包含任何关联类型的协议(Plain Protocol)
简称为普通协议。
问题
我们将首先讨论一个业务开发中的具体问题,定制UITabbar和UITabBarController。
图1 定制UITabbar元素示意图
有两种TabBarItem类型,一种是SNSTabBarItem
,其中ImageView是图片类型;另一种是SNSTabBarLotItem
,其中ImageView是LottieView,即动画类型;为了通用化设计,统一属性名称和调用流程,我们考虑通过设计协议来解决这个问题。
协议代码如下所示:
public protocol SNSTabBarItemProtocol {
var itemLabel:UILabel! { get }
associatedtype itemImageViewType:UIView
var itemImageView:itemImageViewType! { get }
//创建TabBarItem内部UI元素
func createElements(superView: UIView, position: CGRect, backgroundImage:UIImage?)
//.......
}
其中,使用了关键字associatedtype
,定义了一个关联类型;满足实际使用中,不同类型的TabBarItem中ImageView类型的变化,同时,又对其增加的限制,要求ImageView必须是继承UIView
的子类。另外,定义了createElement
函数,它会在自定义的CustomTabBarController中被调用,不同类型item其内部实现不同,满足不同UI布局的定制需求。
实现协议的两种TabBarItem类型:
//第一种,SNSTabBarItem
class SNSTabBarItem: UITabBarItem, SNSTabBarItemProtocol {
var itemLabel:UILabel!
var itemImageView:UIImageView! //静态图片类型
func createElements(superView: UIView, position: CGRect, backgroundImage:UIImage? = UIImage()){
//......内部实现不同
}
}
//第二种,SNSTabBarLotItem
class SNSTabBarLotItem: UITabBarItem, SNSTabBarItemProtocol {
var itemLabel:UILabel!
var itemImageView:HYLotSwitchView! //Lottie动画类型
func createElements(superView: UIView, position: CGRect, backgroundImage:UIImage? = UIImage()){
//......内部实现不同
}
}
接下来,在自定义CustomTabBarController中创建所有item,并调用协议中定义的方法createElements
;
// 创建自定义图标
func createCustomIcons(_ containers: [String:UIView]) {
guard let items = self.tabBar.items, !items.isEmpty,!containers.isEmpty else{
return
}
let barItemWidth: CGFloat = self.tabBar.bounds.size.width / CGFloat(items.count)
for item in items { //遍历元素
//..........
if let item = item as? SNSTabBarItemProtocol { //错误,Protocol 'SNSTabBarItemProtocol' can only be used as a generic constraint because it has Self or associated type requirements
item.createElements(superView: container, position: CGRect(x: CGFloat(index) * barItemWidth, y: 0, width: barItemWidth, height: self.tabBar.bounds.size.height), backgroundImage: nil)
//..............
}
//..........
}
}
代码中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 TypesProtocols 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
,关联协议只能修饰泛型。
通过一个具体的例子来验证:
protocol Proto{
}
var delegate:Proto
这段代码运行正常,接下来改造一下,增加associatedtype
关联类型
protocol Proto{
associatedtype T
}
var delegate:Proto //Protocol 'Proto' can only be used as a generic constraint because it has Self or associated type requirements
改为关联协议后就会出现与前面例子相似的错误,那么我们来引入泛型进行修改。
protocol Proto{
associatedtype T
}
class C<T:Proto> {
var delegate:T
init() {
}
}
运行正确,从这里我们可以得到结论,每一个从前使用普通协议的的地方,现在为了使用associatedtype
,需要进行改造,引入泛型,使用关联协议修饰泛型参数,就能够避免产生错误。
使用关联协议失去了动态类型派发的能力
但是,改造后class C
变成一个泛型类,带有泛型T
,T
遵循Proto
协议,然后在C
内部,delegate
的类型是T
,也就是说原本一个普通的class
类型,需要被改造成泛型class
,很多时候这不是我们设计的本意,而存粹是为了支持使用associatedtype
,不得不进行的改造。这样失去了dynamic dispatch
动态类型派发的能力!
比如有一个数组,其内部存储的类型是不同的,但是遵循相同的协议,这在使用普通协议时,是可行的,而使用带有associatedtype
的关联协议就不可行了,失去了动态派发的能力,多态的能力,只能变成一个统一的类型,而不能支持不同类型 ,因此我们失去了一个重要的语言特性。
关联协议与泛型的关系
关联协议,从外部看,使用associatedtype
更像是提供了一个语法糖,提供有意义的名字做占位;从内部看,建立类型的语意要求,使用typealias
显示或者隐式指明具体类型;利用associatedtype
相当于定义了一个未知类型的占位符,并且这个占位符可以在协议定义的整个生命周期内使用。
我们来对比两段代码:
protocol Animal{
associatedtype Food
func eat(food:Food)
}
协议Animal
定义了每种动物要eat某种类型的Food
,到现在为止,我们还不知道哪种动物吃哪种Food
;
struct Animal<Food>{
func eat(food:Food)
}
Animal
结构体,支持泛型参数Food
,定义每种动物eat某种类型的Food
;
这种场景下,使用关联协议和使用泛型参数作用非常相似, 但是他们之间仍然不完全相同。
由于目前的语言限制,协议中无法使用泛型,我们假设可以使用泛型协议,写出类似下面的代码,然后与使用关联协议的代码进行对比分析:
//假设泛型协议成立
protocol Animal<Food> {
func eat(food:Food)
}
struct Grass:Food{
}
struct Cow:Animal<Grass>{ //泛型参数指定具体遵循协议的类型
func eat(f: Grass) {
}
}
//使用关联协议
protocol Animal{
associatedtype Food
func eat(food:Food)
}
struct Cow:Animal{
func eat(f: Food) {
Self.Food //通过类似属性的方式,直接获取到关联类型的名字
}
}
从外部看,我们使用泛型协议方式,只能看到遵循协议的具体类型,即Grass
是一个遵循Food
协议的类型;对比使用associatedtype
的关联协议,我们可以通过类似属性的方式,可以直接获取到关联类型的名字,这使得某些情况下,添加参数类型的约束限制成为可能。还不止于此,如果有多个关联类型,或者关联类型需要被其他关联协议限制,或者同时使用多个协议,这些复杂的情况组合,就使得假设的泛型协议很难代替关联协议,并且泛型协议不得不把这些(原本可以通过associatedtype
隐藏在内部的)信息全部暴露给外部使用者。
另外,关联协议利用associatedtype
解决的问题是面向对象的类型关系继承,来看下面例子:
protocol Food{
}
struct Grass:Food{
}
protocol Animal {
func eat(f:Food)
}
struct Cow:Animal {
func eat(f: Grass) { //Type 'Cow' does not conform to protocol 'Animal'
}
}
首先定义了Food
协议,Grass
作为一种具体的食物遵循Food
协议;另外,我们通过Animal
协议,规范动物需要eat
食物Food
,具体是哪种Food
没有确定,最后Cow
作为一种具体的动物,遵循Animal
协议,实现了eat
方法,参数指定Grass
类型,Grass
遵循Food
协议,但是编译器提示错误,Cow
没有遵循Animal
协议,只能改为func eat(f: Food)
;
我们可以发现:遵循普通协议的具体类型,其内部遵循的协议类型不能捕获复杂的类型关系
接下来改造Animal
协议为关联协议
protocol Food{
}
struct Grass:Food{
}
protocol Animal {
associatedtype FoodType //关联类型
func eat(f:FoodType)
}
struct Cow:Animal {
func eat(f: Grass) { //Grass遵循Food协议,OK
}
}
Cow().eat(f: Grass())
有了associatedtype
的帮助,可以完成面向对象的类型继承关系使用。
解决问题的方案
现在我们讨论文章开头提出的的问题如何解决,有两种方案可供参考:
组合方案
typealias Codable = Decodable & Encodable
我们经常使用Codable
协议进行数据序列化,这里可以参考Codable
的设计模式,采用组合方案;
把SNSTabBarItemProtocol
协议拆分成两个协议:
//协议只包含需要遵守的属性
public protocol SNSTabBarItemElements{
var itemLabel:UILabel! { get }
associatedtype itemImageViewType:UIView
var itemImageView:itemImageViewType! { get }
}
//协议只包含需要遵守的方法
public protocol SNSTabBarItemFunctions{
//创建TabBarItem内部UI元素
func createElements(superView: UIView, position: CGRect, backgroundImage:UIImage?)
}
//协议组合
public protocol SNSTabBarItemProtocol: SNSTabBarItemElements & SNSTabBarItemFunctions {
}
经过这样改造之后,我们修改调用处协议:
// 创建自定义图标
func createCustomIcons(_ containers: [String:UIView]) {
//..........
for item in items { //遍历元素
//..........
if let item = item as? SNSTabBarItemFunctions { //OK,不再报错,避开了关联协议问题
item.createElements(superView: container, position: CGRect(x: CGFloat(index) * barItemWidth, y: 0, width: barItemWidth, height: self.tabBar.bounds.size.height), backgroundImage: nil)
//..............
}
//..........
}
}
原来的转换为SNSTabBarItemProtocol
协议的方式,更改为使用SNSTabBarItemFunctions
这个子协议,而两个具体的UITabBarItem
子类仍然遵循SNSTabBarItemProtocol
协议,保持不变;这样通过组合的方式,我们绕开了关联协议只能修饰泛型的问题,把它变成了当前场景下只使用普通协议,调用协议内限定的函数;
添加泛型函数
既然关联协议只能用作泛型约束,因为它有关联类型要求,那么我们是否可以选择另一个方案:创造一个泛型函数,封装createElements
的调用并添加参数的泛型约束,我们来试试:
//item改为泛型参数,遵守SNSTabBarItemProtocol协议
func loopElements<E:SNSTabBarItemProtocol >(item:E,superView:UIView,position: CGRect,backgroundImage: UIImage?){
item.createElements(superView: superView, position: position, backgroundImage: backgroundImage)
}
//遍历元素内部使用loopElements方法
func createCustomIcons(_ containers: [String:UIView]) {
//..........
for item in items { //遍历元素
//..........
loopElements(item: item, superView: container, position: CGRect(x: CGFloat(index) * barItemWidth, y: 0, width: barItemWidth, height: 0), backgroundImage: nil) //错误,Global function 'loopElements(item:superView:position:backgroundImage:)' requires that 'UITabBarItem' conform to '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
协议。
protocol MySequence: Comparable {
associatedtype Element: Comparable
var storage: [Element] { get set }
}
由于对 Element
添加了协议限制,Comparable
协议需要实现的比较方法就可以给出实现;
extension MySequence {
static func < (lhs: Self, rhs: Self) -> Bool {
for (left, right) in zip(lhs.storage, rhs.storage) {
if left < right {
return true
}
}
return false
}
}
另外,我们也可以在关联类型约束里使用协议,用 where
从句实现更复杂的约束;
protocol MySequence: Comparable {
associatedtype Element: Comparable
associatedtype Slice: MySequence where Slice.Element == Element
var storage: [Element] { get set }
}
这里协议可以作为它自身的要求出现,即Slice
拥有两个约束,它必须遵循 MySequence
协议,同时它的Element
的类型必须是和storage
数组中元素Element
类型相同。
我们也可以为关联类型添加默认值,如下面所示Element
默认为Int
类型:
protocol MySequence: Comparable {
associatedtype Element: Comparable = Int
var storage: [Element] { get set }
}
并且可以为为默认的 Associated Type
提供方法的默认实现。
protocol MySequence4: Comparable {
associatedtype Element: Comparable = Int
var storage: [Element] { get set }
func summed() -> Element
}
Element
现在默认是 Int
,接下来通过extension
给出函数summed
的默认实现。
extension MySequence {
func summed() -> Element {
return storage.reduce(0, +) as! Self.Element //Cannot convert value of type '(Int) -> Int' to expected argument type '(Int, Self.Element) throws -> Int'
}
}
但是此处会提示错误,无法推断出默认类型是Int
,即 extension
中的 Element
只受“约束”的影响,即只受 Comparable
和 where
从句的影响,并没有接受默认值。所以我们需要针对extension
增加限制。
extension MySequence where Element == Int {
func summed() -> Element {
return storage.reduce(0, +)
}
}
只有满足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
-End-
最近有一些小伙伴,让我帮忙找一些 面试题 资料,于是我翻遍了收藏的 5T 资料后,汇总整理出来,可以说是程序员面试必备!所有资料都整理到网盘了,欢迎下载!
面试题
】即可获取