Swift的设计旨在弥补C++的零成本抽象与Objective-C的动态灵活性之间的差距。早期版本在很大程度上依赖于类继承和虚拟方法表,但在Swift 2.0中引入协议导向编程后,需要一个更细致的调度模型。编译器团队选择了一种混合方法,其中协议要求(在协议主体中声明的方法)利用见证表进行运行时多态,而仅在扩展中定义的方法则静态解析。这个设计决定源于支持追溯建模和值类型的需求,同时不牺牲静态调度的性能特征。
开发者经常假设在协议扩展中提供的方法实现会创建一个“默认”行为,使符合类型能够多态地重写。然而,Swift根据引用的编译时类型而不是实例的运行时类型静态调度扩展方法。当使用存在箱(any Protocol)时,编译时类型是存在容器本身,导致调用解析为扩展的实现,而忽略了具体类型中的任何重写。这创造了隐秘的错误,其中子类或结构中的自定义实现在异构集合中被静默绕过。
为了实现真正的动态多态,方法必须在协议声明中声明为协议要求。这迫使编译器为该方法分配一个见证表条目,使运行时能够通过类型的见证表查找正确的实现。对于多态性不必要的性能关键算法,方法应保留在扩展中,以允许编译器内联或进行其他静态优化。**Swift 5.6+**引入了显式any关键字语法,以使存在类型擦除更为明显,提醒我们类型信息已丢失,静态调度默认使用扩展。
protocol Drawable { func draw() // 要求:通过见证表进行动态调度 } extension Drawable { func draw() { print("默认") } func render() { print("静态渲染") } // 扩展:仅静态调度 } struct Circle: Drawable { func draw() { print("圆") } func render() { print("圆形渲染") } } let shape: any Drawable = Circle() shape.draw() // 打印"圆"(动态调度) shape.render() // 打印"静态渲染"(静态调度 - 忽略Circle的版本!)
我们正在开发一个矢量图形引擎,其中各种形状符合RenderCommand协议。我们最初在协议扩展中单独添加了一个generatePreview()方法,以为所有形状提供默认的光栅化缩略图。像BezierCurve和Polygon这样的具体类型实现了自己的优化generatePreview()方法,利用它们特定的几何属性进行清晰的渲染。当我们将这些形状存储在[any RenderCommand]数组中以处理渲染管道时,我们发现对每个元素调用generatePreview()产生的都是相同的模糊默认图像,而不是自定义的高质量预览。
我们考虑了三种不同的解决方案。首先,我们可以将generatePreview()移入RenderCommand协议声明中作为正式要求。这种方法将保证通过见证表进行动态调度,确保在运行时正确的方法解析。然而,这将迫使每个形状类型在其符合性中显式声明该方法,虽然我们可以通过在不需要自定义的类型的扩展中保留默认实现来减轻样板代码。
其次,我们可以重构我们的管道,使用类似func process<T: RenderCommand>(commands: [T])的函数签名,而不是使用存在的[any RenderCommand]。这将保留对正确实现的静态调度,因为Swift在编译时对泛型进行单态化,保留类型信息。缺点是,我们不能再将异构形状类型(混合BezierCurve和Polygon)存储在同一个数组中,除非实现类型擦除包装器,这将显著增加代码复杂性。
第三,我们可以实现访问者模式,以手动将方法调用路由到适当的具体类型。这将避免完全修改协议定义,同时仍然实现多态行为。然而,这个解决方案引入了大量的样板代码,并在每当系统中添加新形状类型时创建了维护负担。
我们最终选择了第一个解决方案,因为协议是我们模块内部的,明确的多态行为对渲染引擎的正确性至关重要。添加要求对我们的二进制大小影响微小,而见证表间接的轻微开销与渲染计算相比是感觉不到的。在实施这一变更后,预览生成正确利用了每个形状的优化实现,消除了用户界面的视觉伪影。
为什么子类不能重写仅在协议扩展中定义的方法?
当一个方法仅在协议扩展中定义而不是在协议本身中声明时,Swift不会为其分配见证表条目。调度根据引用类型在编译时静态解析。如果一个类符合协议并定义了一个具有相同签名的方法,它会创建一个新的、无关的方法,这会遮蔽扩展方法,而不是重写它。这意味着通过协议存在(any Protocol)访问时,始终调用协议扩展的实现,忽略类的版本。要实现多态行为,方法必须在协议声明中声明以成为具有动态调度的要求。
使用some(不透明结果类型)而不是any如何影响协议扩展方法的调度?
使用some Drawable时,具体类型在编译时已知,因为Swift对泛型进行了单态化。当在不透明类型上调用扩展方法时,编译器可以静态地调度到具体类型的实现,因为类型信息在幕后得以保留,即使对调用者而言是隐藏的。相反,any Drawable是一个擦除具体类型的存在箱,迫使编译器对非要求的方法使用协议扩展的默认实现。关键的区别在于,some保留了静态多态性,允许编译器内联或直接绑定到正确的方法,而any仅对要求强制运行时vtable查找,其他一切则默认切换到扩展。
将扩展方法转换为协议要求的二进制大小和性能影响是什么?
将扩展方法转换为协议要求会增加一个条目到协议的见证表,这在64位架构中每个符合的大约增加8字节的二进制大小。每个符合类型现在必须填充该条目在其见证表中,给每种类型增加了少量内存开销。从性能方面来看,要求涉及通过见证表的间接调用开销(一个额外的指针解引用和跳转),而扩展方法可以被内联或直接调用而没有任何开销。然而,要求内联的损失通常会被CPU的分支预测器所抵消,而正确的多态行为的好处通常超过了在大多数应用代码中间接调用的纳秒级成本。