ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Swift继承陷阱与替代方案:从底层原理到并发安全实践

Swift继承陷阱与替代方案:从底层原理到并发安全实践 1. 先说我为什么越来越谨慎地使用继承作为一个写了多年Swift的开发者我在项目里经常看到类似这样的场景一个BaseViewController里面塞了网络请求、弹窗提示、埋点上报、空页面展示然后七八个子类把它当作万能基类去继承。没过多久需求一变某个子类要拦截某个行为某个子类要禁用某个功能于是大家开始在基类里加各种if判断甚至有的人直接改父类方法来适配子类的特殊要求。一路走下来基类越来越臃肿子类越来越脆弱。到后面只要动父类一行代码整个页面继承链上几十个类都心里打鼓。这不是Swift的问题而是实例化继承用错了场合。我写下这篇文章就是想把自己在Swift继承上的理解、产出实践和踩过的坑整理清楚聊一聊什么情况下继承是合理的、什么情况下需要用协议或者组合来替代以及Swift中继承机制和并发安全之间那些容易被忽视的摩擦。这篇文章适合正在转型到Swift的开发者、项目里出现过“继承链越拉越长”的老iOS开发以及准备用继承封装通用逻辑但还想长期维护项目的人。读完你至少能搞清楚Swift的继承到底给了你什么、限制你在哪里、用继承时的访问控制如何设计、为什么多继承缺失反而可能是好事以及写继承代码时要如何避免把并发安全的坑埋进去。2. 继承的底层规则Swift到底在编译期和运行期做了什么2.1 只有类可以继承而结构体和枚举不行Swift里继承只适用于类这一点和Java、C类似。结构体不支持继承是因为Swift把结构体设计成值类型值类型按值拷贝继承会让值语义变得混乱。你在开发中真正需要继承时第一件事就是确认这个类型必须是引用类型不然SubView对这种结构体写继承编译器会直接告诉你无法从非类类型继承。这里有个很现实的场景很多人想做一个“统一配置模型”希望一个Config结构体被扩展于是尝试用继承。但结构体直接没法继承一开始会觉得“Swift真不灵活”实际上这是Swift刻意控制复杂度的设计目的就是让值类型保持简单和可预测。2.2 继承链上的方法派发机制怎么决定调用哪个实现Swift的方法派发默认采用动态派发也就是每个类会维护一张虚函数表类似C的vtable。子类重写父类方法后运行时会根据实际对象类型找到正确的实现。你在代码里写parent.someMethod()哪怕这个parent是父类类型只要实际实例是子类调用到的一定是子类重写后的方法。正因为这种动态派发我们才能在OC和Swift中实现“面向协议编程”和“接口隔离”。但动态派发也有代价每次方法调用都要查一次表性能不如静态派发。所以Swift提供了final关键字如果方法或类被标记为final编译器就不用再动态查表可以直接静态调用还能在编译期做内联优化。这个看似很细节的机制直接决定了继承层的性能设计和维护成本。我给团队定的规范是除非明确要被子类重写否则所有类、属性、方法都默认加final只在必须开放的点去掉final。这样既保证了性能也减小了被外部滥用继承的可能。有一个项目优化后核心热路径上的方法调用时间从15毫秒降到8毫秒代价就是把那些永远不会被重写的BaseModel和Tools类全部标记为final。2.3 重写规则与super的调用顺序重写方法时Swift要求带override关键字。这个设计是硬性安全检查能防止你写错方法名而误重写。我见过从Objective-C转来的开发者不写override导致编译器直接报错然后开始抱怨“像重写这种琐事都要声明”但久了就会觉得强制写override反而逼你明确你是在覆盖而不是新增。在init函数里的super调用顺序是很多人踩坑的重灾区。Swift规定必须先将当前类的所有存储属性全部初始化完毕之后才能调用super.init()。这个规则和Objective-C那种先调父类再初始化自己的习惯完全不同。很多新人在子类init里先写了super.init()然后把笑话属性赋初值结果编译器在你脸上甩错误类型没有初始化所有存储属性。正确顺序是class BaseView { let borderWidth: CGFloat init(width: CGFloat) { borderWidth width } } class SpecialView: BaseView { let needsShadow: Bool init(width: CGFloat, shadow: Bool) { needsShadow shadow super.init(width: width) } }这种两段式初始化的设计是为了保证在父类方法被调用之前子类的所有属性已经处于确定的状态。否则父类的init里可能去调用一个被子类重写的方法而子类那个方法依赖的存储属性还是空的运行期就直接信号化了。另外一个和super相关的坑是deinit。Swift的deinit并不是重写父类的deinit它没有override。你只需要在子类中写deinitSwift会自动保证先执行子类的deinit再执行父类的deinit。不用像init这样手动调用super.deinit()因为超级这个方法根本不存在。这里不需要调用super确实省心。2.4 访问控制open、public与final的关系这个可能是最容易忽略的内部知识。在模块外部也只有open类可以被继承open方法可以被重写public类能被引用但不能被继承public方法也不能被重写。很多人用public修饰类后发现自己SDK的用户没法继承他写的类就开始四处查这个明明public怎么还继承不了实际上就是因为缺少open。如果明确设计了这个类是要给别人继承的就写open如果只是在自己模块内部继承那么internal权限也够了没必要把内部设计暴露到外部接口中去。关于final有一个反向思维模式一个方法有含义加了final后曾经想继承它的子类要重用这个逻辑怎么办有两个方向要么把这个方法作为私有内部逻辑再把更上层的扩展点留在外面要么用协议组合绕开继承。我建议优先考虑第一点因为你越是通过final来收敛继承层次你类内部的结构就越容易表达出本意。3. 继承边上那些反复踩才产生的坑3.1 为什么Swift没有多继承反而救了你每次遇到多继承的话题我都有很多起死回生的记忆。曾在C中落入过菱形继承的泥潭那个Base保持了一个状态两个中间类各自持有一份最终派生类里出现了两块一模一样的基类数据。等你调用基类方法时根本不确定操作的是哪一份只能用virtual继承去小心翼翼消除二义性。Java和Swift都刻意避开了多继承但Swift引入了“协议AOP”的组合方案。理论上一个类可以遵守无数个协议也就可以从多个协议里“借用”到接口的约束。这样设计的好处是所有继承语义都明确发生在类层级中保持单一继承链。发现问题时只沿着一条直线走不需要处理横向交叉来减少各个接受度不确定性。但这也带来一个重要限制你没法从两个真正有实现的类中拼凑出“共享实现”。多继承缺失时只能把公共实现挪到协议扩展里或者在组合关系中设置delegate转发。我在项目中一般会做这样一个选择如果两个类真的都要有同一套行为且行为与状态有关那我宁可抽一个底层类把具体类变为共享这个底层类而不是让两个类互相继承彼此。3.2 继承与引用计数为什么重写dealloc或deinit时总出问题Swift里所有类都是引用类型ARC会自动管理引用计数。当你构造一个继承链时每个实例里都嵌入了父类的一些属性那ARC处理的是整条链而不是分别扫描的。常见坑是存储属性持有闭包闭包里又捕获self。比如父类定义了一个var onTap: (() - Void)? nil子类初始化后在闭包里写self.reloadData()这个闭包被父类属性持有而闭包又持有self形成循环引用那么哪怕你在deinit里清空监听只要闭包还保存着该实例根本没机会释放。这个坑在继承场景中最隐蔽的是如果子类重写了父类的方法而闭包里调用的正是这个被重写的方法那么在闭包执行时调到的可能是任意一层的实现而你又很难通过静态分析发现循环引用了谁。我写这类代码的原则是任何闭包捕获了self都立即标记为[weak self]不管这个闭包放在哪里这个原则比“看是否形成循环”更好落地。3.3 初始化器继承的秘密什么时候子类会自动继承父类指定初始化器很多Swift开发者知道子类默认不继承父类的初始化器但这里有个非常容易出问题的例外。如果子类自己没定义任何指定初始化器那么你实现的父类指定初始化器就会被自动继承。也就是说我在父类里写了三个init方法子类什么init都没写那么子类实际上也能用这三个init方法。一旦子类定义了自己的存储属性哪怕有默认值或者写了一个指定初始化器这个自动继承就会崩掉。这个机制经常让团队里的同事觉得“为什么我新建的子类不能调父类的init了”。避免方案很简单子类需要全新初始化逻辑时显式实现需要的init方法并调用super.init对应的参数不需要时把存储属性的默认值全部设置好再依赖自动继承。3.4 类型转换is、as、as?在继承链上的行为用is判断类型会按照实际动态类型来匹配这在多态中很自然。真正烦人的是你希望通过as?强转后缓存一些信息但对象继承了很长链中间有一次转型失败后面的逻辑忽然跳分支。例如一个UserSpecialView继承自UserBaseView是UserAdminView你判断if view is UserBaseView并处理base里的逻辑但当实际对象是UserSuperAdminView时这个判断依然成立导致你把所有base和superadmin都混为same逻辑而业务上两者可能完全不同。我的建议是宁可多写几个协议来处理那些规则确实不同但共性的分支也不要指望一个is判断能区分所有层级。写继承类时用as?转换必须对层次上的每一层做边界验证特别是配合了泛型之后。4. 并发安全继承让每个锁和隔离方案都不好使4.1 把可变状态放在基类等于给所有子类上了共享枷锁热词里有“swift并发安全”说明这是大家都非常关心的点。先亮观点在Swift的类继承中一旦基类有可变的存储属性比如读写计数器、当前状态值、缓存字典整条继承链上的对象都可能共享这份属性也就是说并发访问这类对象的属性会出现竞争条件。试想一下一个父类定义了var heartBeatCount 0两个子类对象并行地修改这个值由于类是引用类型他们指向同一内存两个线程同时对heartBeatCount做自增就必须加锁或者用原子操作否则读出来永远不对。我在写一个关于BT下载组件的模块时父类管理一个下载任务队列子类分别处理视频下载和音频下载。问题来了这两个子类对象是在不同线程中运行的它们共同操作父类里那个待处理队列。刚开始没加任何同步最后看日志大量重复下载同一资源才发现是队列里的元素被两个子类同时pop掉了。所以现在只要有人写含有可变属性的基类我就会立刻问他这些可变属性需要在哪些队列里访问能不能全部移动到actor隔离4.2 actor配合继承的痛点被隔离的属性和继承机制冲突Swift的actor是把属性隔离到自己的执行域中去避免并发访问。但你创建一个actor时它的继承规则依然适用也就是说领域上可以用class去继承actor的子类。注意Swift其实不允许actor用class去继承非actor类也不允许非actor类去继承actor类。所以actor的继承层级比你想象中的类继承还要死板和受限。这样一来你就没法在actor中间通过一个普通基类给所有继承者提供并发隔离属性因为子类的具体类型还是普通class时在编译期就会直接被拦死。解决方式依然是指向协议把需要并发保护的接口定义为协议使用actor隔离实现再用组合方式注入到需要它的类中。4.3 方法重写与异步调用边界子类重写父类方法时的并发陷阱这一节聊的是真正在生产环境里出现过的Bug。我们把一个基类的public方法设计成同步方法子类重写后却在一个异步线程里面改变一些共享状态外部调用者以为这是个同步操作拿到的返回值还是旧值。这种Bug特别隐晦因为它不会触发编译错误你只有在极端运行时序下才能发现两次调用返回不一致。我的处理思路非常简单先明确每个类的方法是同步还是异步在类注释和接口注释里直接写明线程归属例如该方法需要在主线程调用、别的方法在工作线程调用互不交叉。如果某个重写方法调用父类同名方法那么调用链上的线程归属也必须保持一致父类保证了什么子类必须保证兜住。可以采用的一个强约束模式是使用全局Actor定义执行域globalActor actor DataStoreActor { static let shared DataStoreActor() } DataStoreActor class BaseStore { var lastUpdated: Date Date() } class OrderStore: BaseStore { func isStale() async - Bool { let last await lastUpdated return Date().timeIntervalSince(last) 60 } }由于BaseStore被全局Actor隔离OrderStore也自动继承Actor隔离。但如果你只在方法层面加await没有把整个类标注到Actor中然后去重写数据属性还是会在并发环境掉链子。所以我会在所有服务类内部尽量用actor隔离状态把继承点控制在actor的“接口协议”上而不是直接用基类跨线程共享可变数据。5. Swift继承与其他语言之间的距离它到底吸收了哪些经验5.1 与C#的attribute继承之间声明式扩展替代重写热词里出现“c#继承attribute”说明很多人都关心继承机制能不能影响元数据和标记。Swift里有类似attribute的语法例如available、objc动态派发而Swift的属性包装器比如State、Published在使用时并不是父类和子类之间自动复制的。一个重要区别是C#的Attribute可以通过继承链传播表面上你写了一个挂在基类上的Attribute子类也会被标记为拥有这个特性。Swift中的attribute更偏向于编译期语言元数据不同attribute本身的语义决定了它是可被继承还是自己独立应用。例如objc和objcMembers就具有继承语义子类如果重写objc方法那么也自动成为Objective-C可调用方法的一部分而你自己封装的包装器就不会自动跨层级生效。所以写SDK时不要把“期望子类自动拷贝某些元数据”这种想法直接映射到Swift十有八九会失望。5.2 与JavaScript原型继承相比继承更严格但灵活性降低JavaScript的原型链可以在运行时动态修改甚至还能给构造函数实例新增方法而Swift的继承是在编译期定下来的。你在Swift里不能动态给一个类增加一份新的父类也不能随意修改某个类正在继承的父类。这个差异导致的问题是从JavaScript转过来的开发者会不自觉地期待“我可以后期把某个方法加进去”但Swift只允许你用extension加持在类外面却无法影响既定继承链上的重写决策。所以我会建议团队里的人继承在Swift里就是一次结构性承诺定下来就不能轻易反悔比JS里更沉重。写代码时要像签合同一样理解父类开放的重写点别期待之后可以通过运行时来“修补”。5.3 与Python ABC多继承相比类型约束更显性但抽象成本更高热词中有“python abc 多继承”可能触及了抽象基类。Swift里用协议来承担抽象接口定义的角色协议继承和类继承是两条不同轴。Swift协议可以继承多个协议类只能继承一个类这让我们可以把面切得很散。但代价是Swift的协议最大的能力是不同类实现同一协议在泛型约束下做静态多态。Python里的ABC允许你定义抽象方法并直接在运行时实例化让子类去补充方法没有编译期强制。Swift也有抽象类模拟但需要你用协议破路由技巧比如protocol Vehicle { func startEngine() } class BaseVehicle { func commonNoise() { print(rumbling) } } class Car: BaseVehicle, Vehicle { func startEngine() { print(Car engine starting) } }这里我实际上可以用BaseVehicle来共享实现又用Vehicle来做类型抽象。如果你在C或者Python里习惯了“一个抽象基类负责一切”在Swift里就要改为“一个协议负责接口一个类负责实现”。5.4 TypeScript的interface继承和class继承静态类型与运行时派发之间的错位对于来自TypeScript的开发者会发现interface可以extends多个接口但class只extends一个类。这一点和Swift很像TypeScript的interface更像是Swift协议的角色。不过TS的静态兼容类型是鸭子类型而Swift要求显式声明遵守协议。所以在Swift中你继承一个类时其实还引入了类的内部存储布局而类型系统会对init方法、泛型约束做额外检查。TS一般来说是结构类型系统而Swift名义类型系统加继承给热重构带来了更强约束但也确实能帮你在编译阶段抓住更多错误。6. 替代继承的方案选型协议、组合与泛型各自的取舍6.1 什么时候协议优先于继承我的经验是如果两个类需要的是一组行为接口而不是一组共享状态那么协议优先。例如“可以刷新页面”“可以处理登录失效”这类动作直接定义protocol然后让各无关类去遵守就好。但是协议的缺点是协议本身不能保存存储属性无法提供有状态实现的复用。你在protocol extension里可以写计算属性和方法默认实现但如果你要存状态那几个类都共享一份状态协议就做不到了。此时我就把注意点迁移到组合。6.2 组合模式怎么替代继承举个例子我们有个UploadService原先设计成BaseService的子类结果BaseService里有networkSession、localCache等一大堆状态。后来发现另一个DownloadService也想复用BaseService里的这些网络基础设施但是继承会强制存在“父子”关系但网络下载和上传之间根本不存在“is a”关系。改成组合后我把网络能力拆成一个SessionProvider对象UploadService和DownloadService都持有一个SessionProvider实例哪个需要调用就委托过去。这样每个服务类数据状态独立线程访问也更好隔离。这个模式有个很明朗的经验法则如果你打算复用父类的某些基础设施但业务方向上两个类是平行关系那基本就是组合的候选者。6.3 泛型约束与继承共享实现的最小冲突泛型可以对协议进行约束这一点很像抽象基类的作用。比如class BaseProcessor { func run() { print(Base run) } } class VideoProcessor: BaseProcessor { override func run() { print(Video run) } } class AudioProcessor: BaseProcessor { override func run() { print(Audio run) } } func processT: BaseProcessor(_ p: T) { p.run() }泛型约束允许你在编译期只知道T是BaseProcessor的子类就能传入。但如果方法内部需要按照具体类型做不同分支一个switch就宣告泛型解耦结束。泛型和多态的组合可以让我们把类型约束做在编译期把行为差异做在运行时这对一些高实时性模块很有用因为可以规避运行时类型转换开销。但你要是发现自己的“子类”数量多到要维护一张映射表往往说明继承层次设计的“顶点”可能有问题这时候更合适用协议把公共操作抽象出来再用不同实现类去遵守协议把条件分支拆成独立类型。7. 一些从长期维护项目中总结出来的“继承设计”清单牙疼的继承层次几乎都能提前用清单排除。以下几条是我经验里最值得写进团队规范的不要建立一个继承层级超过3层的类。超过之后每次改动都要在多个层面同时评估。每个父类的可重写方法应该控制在5个以内否则子类不得不重写一大片。父类中如果还有可变属性必须强制文档化线程访问权限。一个类被标记为open之前先反问自己我真允许整个SDK外部的人对这个类做扩展吗大多数情况下不加open不会错。协议继承与类继承可以并存但优先让协议描述抽象让类描述具体尽量把“抽象概念”放在协议里把“公共实现细节”放在父类里。把所有自动化测试放到父类可重写方法上跑一遍尤其是那些并发场景下的读写测试多跑几次就能提前发现很多隐性伪共享。我在实际项目中还会给子类写单元测试时同时把父类的基础测试套件也跑一遍。比如UrgencyBaseService子类我新建了一个测试Target直接复用父类TestData看是不是某些方法重写后原本状态返回的语义被破坏。这样一种“继承套缝补”的做法比代码走查更能抓出逻辑回归因为编译不会发现这类行为变了。当你面对继承问题时我要说的核心经验是继承不是一种“组合所有东西的方式”而是一种非常具体的行为共享机制。Swift把它口子收得很窄目的就是逼你多想想协议。只有当你明确知道状态必须共享、且这个共享发生在同一个类型体系内部时继承才是那个最正确的解。其余情况我建议你和各位一样先用组合方案写出来跑一天再回头看继承的依赖方向。如果组合能解决你又何必背着整条父类链的风控去编码呢。最后一个小技巧当你写好一个父类后尝试在子类里只重写一个方法然后跑一次全流程如果你发现自己为了完成这个需求必须重写三个以上的方法说明这个继承边界又劈歪了。此时请停下来把那些本不该属于同一个“业务切面”的方法移交给协议我靠着这个简单的判定法已经拆掉了好几个原本被判了直死刑的臃肿基类。
返回列表