写点 Swift: 为什么你需要使用泛型而不是 protocol

欢迎大家关注我的微信公众号:小林居酒屋

TLDR

Swift 中,在没有类型抹除需求的前提下使用泛型会比直接使用 protocol 有更好的运行时性能表现。

protocol Foo { }
// ❌
struct Hello {
  let foo: Foo
}
// ⭕️
struct World<T: Foo> {
  let foo: T
}

SE-0335

Swift 5.6 又有一堆鲜滚热辣的 feature 出炉,其中又以 SE-0335 最为重要。

SE-0335 带来了一个全新的关键字 aswift是什么ny (注意不是 Any, AnyObject):

protocol Foo { }
struct Hello {
  let foo: any Foo
}
func foo(_: any Foo) { }

所有的 Existential typesany 这个关键字修饰。在 Swift 5.6 中,any 可以使用,不添加此关键字的 protoc二进制ol 对象会出现 warning. 在即(yao)将(yao)到(wu)来(qi)的 Swift 6.0 中,any 将被强制要求。

从实现 P二进制R-40282 来看,主要添加了编译期检查,没有发现编译后多态和重载的区别/运行时有什么较大的改变。

语言通常都是朝着更简洁更抽象的方向去迭代,但这个 p多态的条件roposal 的唯一工作就是给原本简洁明了的 protocol 对象强制塞上了一个新造的关键字,阿果你是不是工作量不饱和,还想让我们饱和!

但如果仔细看提案的 Motivation,里面详细阐述了动机:

The cost of using existential types should not be多态和重载的区别 hid容器苗den, a函数调用语句nd programmers should explicitly opt into these semantics.

Existential types 的运行时成本不可被忽略,通过显著提高直接使用 Existential types 的成本的方式,让开发者只有在必要时才使用。

Existential Type

Existential Type 在多态的作用类型系统中,描述了一个抽象类型的接口。这个名称源于 Existential Quantifi二进制亡者列车cation (存在量词, 数学符号 ∃ )这个概念。既存在一种特定swifter的类型,但在上下多态的作用文中并不能理解其的真正具体类型。例如:

T = ∃X { a: X; f: (X → int); }

这表示了一种类型 T,描述了一个模块接口,它表明了存在一种类型 X,有一个名为aX 类型的数据成员和一函数调用可以作为一个函数的形参个名为 f 的函数,该函数调用可以作为一个函数的形参函数接收一个相同类型的 X 的参数swift是什么并返回一个整数。X 的具体类型可以有不同的实现,如

intT = { a: int; f: (intint); }
floatT = { a: float; f: (floatint); }

在 Swift 中,这种概念由 protocol 对象进行具像化(在一些别的语言中,对象被限定为 class函数调用可以作为一个函数的形参一个实例,但 Swift 的对象概念非常广泛,可以是 struct, enum, class, protocol 或者一个 closures。这又是另外一个话题容器是什么了)。

SwifSwiftt 引入了 Existential Container, Protocol Witness Table, Value Witne函数调用是什么意思ss Tab二进制le 三种技术来函数调用语句解决 Reference type, Value type 的类型抹除和多态问题。

protocol runtime

当开发者存储或者传递一个 protocol 时,其遵循该二进制亡者列车 protocol 的特定真实类型在编译器时还无法确定,只有在运行时才能获取真实类型。那么我们需要一些额外的运行时手段来解决存储,调用约定函数,和复制销毁等行为。

内存布局

Swift 有两种二进制的运算规则形式的类型,值类型和引用类型。它们的内存布局是不一样的,一般来说,值类型会多态的概念直接保存在栈上,引用类型则保存在堆上。假设有一个 protocol,三函数调用可以出现在表达式中吗个内存布局各不相同的对象:

protocol Drawable { func draw() }
struct Point : Drawable {
   var x, y: Double
   func draw() { ... }
}
struct Line : Drawable {
   var x1, y1, x2, y2: Double
   func draw() { ... }
}
class SharedLine : Drawable {
   var x1, y1, x2, y2: Double
   func draw() { ... }
}

这三种类型内存布局分别是,2 * 8 字节的栈空间,4 * 8 字节的栈空间,8 字节栈空函数调用是什么意思间和 (2 + 4) * 8 字节的堆空间。显然,protocol 对象在存储和传递时,无法直接使用类型本身的内存布局。

Swift 使用了 Existential Containers 来装载原本类型的 property 和其相关联的必要信息,以确保他们在运行时能有一个统一的内存布局。

Existential Container 是一个五个词大小的容器,前三个词为 value buffer. 第四个词存储真实类型所对应多态实现的三种形式 Value Witness Table 的指针, 第五个词是真实类型遵循当前 protocol 的函数表 protocol Witness Table 的指针

写点 Swift: 为什么你需要使用泛型而不是 protocol

如果 protocol 的真实类型为值类型,且其足够小能被装进三个词大多态和重载的区别小的容器中,则其会被直接装载到 value buffer 中容器苗

写点 Swift: 为什么你需要使用泛型而不是 protocol

如果真实类型的大小超过三个词,或者真实类型是引用类型,则会swiftly在堆上开辟空间存储,并将指针存储到 value buffer 中。(示意图函数调用中的参数太少中忽略了堆对象的 0x0 和 0x8 偏移的内容)

写点 Swift: 为什么你需要使用泛型而不是 protocol

伪代码如下:

protocol Foo { ... }
struct ExistentialContainer {
  var valueBuffer: (Int, Int, Int)
  var vwt: ValueWitnessTable
  var pwt: ProtocolWitnessTable
  init<T: Foo>(_ value: T) {
    self.vwt = T.vwt
    self.pwt = T.pwt
    if value is class { // reference type
      self.valueBuffer = (value.ptr, nil, nil)
    }
    if value is struct { // value type 
      if MemoryLayout<T>.size > 3 * MemoryLayout<Int>.size { // larger then 3 word
        let newValue = heapAlloc(value)
        self.valueBuffer = (value.ptr, nil, nil) // alloc heap object to storage large value
      } else {
        self.valueBuffer = value.copy() // copy the value to inline value buffer
      }
    }
  }
}

函数调用

一个遵从了特定 protocol 的类型需要包含其约定的所有函数,但由于无法在编译期确定类型,自然函数调用的方法也无法确二进制转换器定所调用的函数和其地址。这与 non-final class 是类似的,因此,protocol 对象也采用了和 non-final class 的 v-table 相似的技术手段,来解决约定函数地址的问题。每一个函二进制的运算规则数的地址都会按次序被保存二进制转化为十进制在一张表中,这被称为 pswiftlyrotocol Witness Table.

每个遵循了特定 protocol 的具体二进制类型,都会根据 protocol 生成对应的 protocol Witness Table. 对于同一个约定函数,它们的相对 proto函数调用可以出现在表达式中吗col Witness Table 开始地址的偏移都是一样的。那么对于调用方二进制转化为十进制而言,真实类型可以不被关注。调二进制亡者列车用时获取 protswift语言ocol Witness Table,根据编译时的方法偏移量从表中取swift是什么出方法的地址,跳到方法的地址执行。

这一段代码:

protocol Drawable { func draw() }
struct Point: Drawable { 
  func draw() { ... }
} 
func foo(_ d: Drawable) {
  d.draw()
}
foo(Point())

其汇编的伪代码如下

_main:
push       rbp
mov        rbp, rsp
mov        rdi, qword [_protocol_witness_table_for_Point]
call       _foo
pop        rbp
ret
_foo:
push       rbp
mov        rbp, rsp
call       [rdi + 0x8] # rdi is Drawable `protocol` Witness Table for Foo
pop        rbp
ret
_protocol_witness_table_for_Point:
dq         0x1230 
dq         0x1238 # Point.draw() address

实例管理

Existential Containers 采用 value buffer 的方容器所能容纳什么叫做容器的容积式装载原类型,来函数调用中的参数太少抹平各个类型内存布局的不同。因此 Existential Containers 也需要去管理这些不同类型的生命周期,其采用了与 protocol Witn二进制怎么算ess Table 一样的跳表机制,称为 Val容器ue Witness Table. 每一个具体的类型都有一个 Value Witness Table, 它提供了函数调用可以作为一个函数的形参如何容器对桌面的压强怎么算布置和管理该类型的信息和相关swift代码函数的地址,如初始化,赋值,复制,销毁,大小等。具体存储的信息可以参见 ValueWitness.def.

假设我们有一个函数,swiftly其参数为一个 protoc容器的容积一定比它的体积小ol 对象,并在其中对这个对象进行修改 (mutating),最后再消费这个对函数调用可以作为独立的语句存在象。

protocol Changeable { mutating func change() }
struct Hello {
  mutating func change() { ... }
}
func foo(_ value: Changeable) {
  var copiedValue = value
  copiedValue.change()
  print(copiedValue)
}

那么这会经历以下几个步骤。

  1. 在本地保存 Existential C多态实现的三种形式ontainer.
  2. 通过 Value Witne多态javass Table 获取原类型对应的大小,开辟临时栈空间。二进制
  3. 通过 Value Witness Table 获取原类型的 copy 函数,复制一份原类型。
  4. 对复制后的对象进行修改。
  5. 消费复制后的对象。
  6. 销毁复制后的对象,并归还栈空间。

伪代码如下:

func foo(_ value: ExistentialContainer) {
  // Stored existential container
  let valueBuffer = value.valueBuffer
  let vwt = value.vwt
  let pwt = value.pwt
  // alloc stack space for this functions
	let size = value.vwt.size
	addRSP(size) 
  // Keep a local copy
  let initializeWithCopy = vwt.initializeWithCopy
	var localValue = initializeWithCopy(valueBuffer)
  // call change() function
	let change = pwt.change
  change(&localValue) // passed self as parameters
  print(localValue)
  // destroy localValue
 	let destroy = vwt.destroy
  destroy(&localValue)
  // move RSP to raw state
  resetRSP()
}

从上文伪容器对桌面的压强怎么算代码中我们可以发现,在运行时直多态的条件接使用 pr二进制转八进制otocol 对象相比直接使函数调用可以出现在表达式中吗用固定类型swift语言和静态函数而言,每一步都会二进制计算器有一个额外的消耗。但如果需要多态性,这些代价可能都是你所需要付出的。而且相比直接使用 class 来实现多态性,还省去了引用计数的管理。

泛型

那么,我们有没有一种方式,既拥有多态性,又能减少运行时的动态消耗呢?

写点 Swift: 为什么你需要使用泛型而不是 protocol

可以通过泛型这种更静态的多态来解决运行时的损耗。

对于泛型函数或泛型结构,编译器会编译生成一个swift系统通用实现。与直接使用 protocol 对象相比,虽不再使用 Existential Container, 但其内存布局,函数调用和实例管理都十分类似,运行时的动态消耗和性能表现自然也是相似的,有一定的损耗。真正能减少运行时动态消耗的是编译器的泛型特化的优化。

对于下面的例子 fooswift代码(_:), Swift 编译时在当前 c函数调用可以作为独立的语句存在ontext 下将泛型 D 绑定至 f函数调用语句oo 中

protocol Drawable { func draw() }
func foo<D: Drawable>(_ value: D)
struct Point: Drawable { }
foo(Point()) // Is foo<Point>(Point())

当开发者去存储或者传递 protocol 对象时,swifter总是容器英文以一个具体类型起始,如果根据这个 context,将整条调用链中所有未确定类型替换多态的条件成当前具体类型,这便是泛型的类型调用链替换。

对于下面的例子 foo(_:)

protocol Drawable { func draw() }
func foo<D: Drawable>(_ value: D)
struct Point: Drawable { }
struct Line: Drawable { } 
foo(Point())
foo(Line())
// generic specialization <Point> of foo<D>(_:D)
func foo(_ value: Point)
// generic specialization <Line> of foo<D>(_:D)
func foo(_ value: Line)

除了生成通用版本的二进制外,还会生成入二进制转换器参为 PointLine 特化版本的 foo(_:),并使用其来替代调用链中 foo(_:) 通用版本。此时 protocol 对象中内存布局,函数调用和实例管理这几个需要运行时决议的部分,已全部可以在编译时确定。

泛型特化后与 protocol 对象的运行时对比

泛型特化多态实现的三种形式后的函数和结构,其优化行为与静态函数和固定结构一样。 这里以两个类型 PointLine 为例子,对比泛型特化后与直接使用 proto函数调用语句col 对象。下列所有情况都基于 64 位系统。

protocol Drawable { func draw() }
struct Point: Drawable { 
  var x, y: Double
}
struct Line: Drawable { 
  var x1, y1, x2, y2: Double
}

内存布局

以 A,B 两种类型为例子,都存储一个 Point 和一个 Line 实例:

struct A {
  var first, second: Drawable
}
struct B<First: Drawable, Second: Drawable> {
  var first: Frist
  var second: Second
}
A(Point(), Line())
B(Point(), Line())

A 在栈上存函数调用可以作为一个函数的形参储了两个 Existential Container 和在堆上的一个 Line 空间swift是什么组织缩写,总共 2 * 5 * 8 + (2 + 4) * 8 字节。

写点 Swift: 为什么你需要使用泛型而不是 protocol

B 直接在栈上存储了 Point 和 Line,总共 (2 + 4) * 8 字多态的条件节。

写点 Swift: 为什么你需要使用泛型而不是 protocol

在这个 case 下,内存结构的变化除了可以节省内存空间外,还可以通过减少单次复制成本和堆空间开辟次数来提高速度。

栈空间占用由函数调用可以作为一个函数的形参原来 (2 * 5 * 8) 字节下降到了 (6 * 8) 字节, 堆空间原来 (6 * 8) 字节下降至 0 字节。单个结构体节省了 40% 的栈空间和 100% 的堆空间。

结构内存占用下降和无需开辟堆空间后,单次复制的成本显然会随之降低(在 Swift 中函数调用,值复制的操作十分常见,也容器中有某种酒精含量的酒精溶液可以通过 copy on write 来解决频繁复制的问题)。

在调用函数时,按照 Swift 的调用约定,结构体在小于一定大小的情况下,可以直接使用约定寄存器传递参数,而超过容器技术一定尺寸的结构体则需要开辟堆空间,传递一个指针作为参数。

函数调用/实例管理

上文提到,调用 protocol 对象的swift翻译函数,需要在运行时swift是什么意思啊进行查表动态swift是什么组织缩写派发。而被泛型特化的函数,由于有了更充分的 cont多态的作用ext,编译器直接会将上下文中使用动态派发调用的函数函数调用可以出现在表达式中吗替换成静态派发的函数。

在汇编角度则是多态实现的三种形式

call [rbx + 0x8]

转换成了

call _Line_Draw

但单单从动态派发转换成静态派发,其性能提升是有限的(减少了一次查表的操作),性能提升的大部分,在于编译器对静态函数swift国际结算系统的优化。

动态派发的 protocol 对象函数,在编译期的优化是函数调用可以作为一个函数的形参极其有限的,如内联等优化就几乎没有。泛型特化后的静态函数,编译器有了更多的 context 和机会对其进行优化,如裁减特化函数中的死分支容器所能容纳什么叫做容器的容积(如在某种特定类型下确定不会执二进制行的分支)swift是什么组织缩写,删除原函数泛型通用版本,内联展开,完全展开后删除原函数等操作。

这也是使用泛型后,虽然单个函数特化出了多个不同的版本,但包体积可能没有较大变化的原因。

protocol 对象对实例的管理会需要依靠 Value Witness Table. 其优化方容器所能容纳什么叫做容器的容积式与函数派发相近,实例生命周期的操作大部分情况下都会被内联展开,如值类型被回收时会直接移动栈顶指针,无需再调swiftlydestory 函数。

总结

Swift is a Protocol-O二进制换成十进制算法riented Programming La二进制转八进制nguage多态兔兔.

Swift 使用 protocol 来解决了无继承或引用语义的多态性。由于其简洁隐蔽多态与其他类型完全一致的语法,开发者们非常容易在代码中直接存储或传递一个 protocol, 但它的成本并不是可忽略的。

提案 SE-0335 通过关键字 any 的方式,将以前隐藏在代码中的 protocol 高亮出来,在语言层面解决了 protocol 过于隐蔽容器所能容纳什么叫做容器的容积的问题。而开发者所要所要做的,除了根据 warning (5.6), error (6.0) 无脑填上 any 外,可能还需要多一步的思考,是否可以通过使用更多的泛型来替代 protocol 对象,和善用函数调用栈 where 关键字限定,来减少运行时的损二进制八进制十进制十六进制转换耗。

发表评论

提供最优质的资源集合

立即查看 了解详情