Swift — 泛型(Generics)
[TOC]
本文将介绍泛型的一些用法、关联类型、where
语句,以及对泛型函数的原理的探索。
泛型代码让你能根据自定义的需求,编写出适用于任意类型的、灵活可复用的函数及类型。你可以避免编写重复的代码,而是用一种清晰抽象的方式来表达代码的意图。
泛型是Swift最强大的特性之一,很多Swift标准库是基于泛型代码构建的。可能你没意识到,我们常用的Array
和Dictionary
都是泛型集合,里面可以放Int
类型的数据,也可以存放String
类型,字典也一样。
1. 泛型解决的问题
1.1 简单应用 & 类型参数
泛型主要用于解决代码的抽象能力和代码的复用性。
我们看个例子,有一个交换两个Int
值的函数:
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
let temporaryA = a
a = b
b = temporaryA
}
这个函数可以交换两个Int
值,如果我们想交换两个Double
值,或者String
,就得重新定义两个函数,如果使用泛型就显得很简单了:
func swapTwoValues(_ a: inout T, _ b: inout T) {
let temporaryA = a
a = b
b = temporaryA
}
这两个函数体内容相同,只是第一行不同,对于泛型函数的函数名后面跟着占位类型名(T
),并用尖括号括起来(
)。这个尖括号告诉Swift那个T
是swapTwoValues(_:_:)
函数定义内的一个占位类型名称,因此Swift不会去查找名为T
的实际类型。
在这个函数中泛型就是类型参数,类型参数指定并命名一个占位类型,并且紧随在函数名后面,使用一对尖括号括起来(例如
)。一旦一个类型参数被指定,我们可以用它来定义函数的参数类型或返回值类型,还可以用作函数主体中的注释类型。如果需要提供多个类型参数,将它们都写在尖括号中,用逗号分开。
关于类型参数的命名我们使用大写字符开头的驼峰命名法,对于有意义的例如Dictionary
中的Key
和Value
及数组Array
中的Element
,这能告诉阅读代码的人这些参数类型与泛型或函数之间的关系。然而,当它们之间没有有意义的关系时,通常使用单个字符来表示,例如T
、U
、V
等。
1.2 泛型类型
Swift允许自定义泛型类型,这些自定义类、结构体和枚举可以适用于任意类型,类似于Array
和Dictionary
。
举个例子,这里我们通过一个栈来举例泛型类型。
struct Stack {
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
}
栈作为数据结构中的经典,相信大家都很熟悉,在iOS开发中UINavigationController
是一个经典的栈的应用,为了使栈中可以压栈出栈各种类型,我们使用泛型来实现,这也是泛型类型。
1.3 泛型扩展
当泛型类型需要扩展的时候,我们并不需要提供类型参数列表作为定义的一部分。原始类型定义中声明的类型参数列表在扩展中可以直接使用,并且这些来下原始类型中的参数名称会被用作原始定义中类型参数的引用。
下面我们就通过对上面的例子中的Stack
进行扩展,为他添加一个名为topItem
的只读计算属性,它将返回当前栈顶元素且不会将其从栈中移除:
extension Stack {
var topItem: Element? {
return items.isEmpty ? nil : items[items.count - 1]
}
}
1.4 类型约束
在上面的例子中swapTwoValues(_:_:)
函数和 Stack
适用于任意类型。不过,如果能对泛型函数或泛型类型中添加特定的类型约束,这将在某些情况下非常有用。类型约束知道类型参数必须继承自指定类、遵循特定的协议或协议组合。
例如Swift中的Dictionary
类型对字典键的类型做了必须是可哈希(hashable)的。
1.4.1 类型约束语法
在一个类型参数后面放置一个类名或者协议名,并用冒号进行分隔,来定义类型约束:
func someFunction(someT: T, someU: U) {
// 这里是泛型函数的函数体部分
}
比如上面这段代码第一个类型参数T
必须是SomeClass
子类;第二个类型参数U
必须符合SomeProtocol
协议。
1.4.2 类型约束应用
举个例子,我们想要知道某个值在数组中的索引,数组中存储值的类型是不确定的,所以使用泛型能够更具有通用性。
func findIndex(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
如果写成上面这样是编译不过的:
这是为什么呢?因为不是所有的Swift类型都可以用等式符(==
)进行比较。例如我们自定义的类或结构体来描述复杂的数据模型,对于这个类或结构体而言,Swift无法明确“相等”意味着什么。正因如此,这部分代码无法保证适用于任意类型T
。
对于这个问题的解决还是很简单的,Swift标准库定义了一个Equatable
协议,该协议要求任何遵循该协议的类型必须实现等式符(==
)及不等符(!=
),从而能对该类型的任意两个值进行比较。所有的Swift
标准类型自动支持Equatable
协议。关于Equatable
的更多信息请参考我的另一篇文章Swift - Equatable & Comparable
所以修改后的代码如下:
func findIndex(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
1.5 关联类型
定义一个协议时,声明一个或多个关联类型作为协议定义的一部分将会非常有用。关联类型为协议中的某个类型提供了一个占位符名称,其代表的实际类型在协议被遵循时才会被指定。关联类型通过associatedtype
关键字来指定。
1.5.1 关联类型实践
下面我们定义一个容器(Container
)的协议(类似于数组),该协议定义了一个关联类型Item
:
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
该协议类似于一个数组Array
:
- 必须可以通过
append(_:)
方法添加一个新元素到容器里。 - 必须可以通过
count
属性获取容器中元素的数量,并返回一个Int
值。 - 必须可以通过索引值类型为
Int
的下标检索到容器中的每一个元素。
对于遵循该协议的类型都可以使用以上的功能,但是如何实现这些功能还需遵循该协议的类型去实现。
此处我们先使用一个IntStack
作为示例:
struct IntStack: Container {
// IntStack 的原始实现部分
var items = [Int]()
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
// Container 协议的实现部分
typealias Item = Int
mutating func append(_ item: Int) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Int {
return items[i]
}
}
在IntStack
结构体中实现了Container
协议,并指定Item
为Int
。其实不指定Item
的类型也是可以的,因为IntStack
符合Container
协议的所有要求,Swift只需通过方法中的类型推断出Item
的类型。
当然我们也可以让我们的泛型Stack
结构体遵循Container
协议:
struct Stack: Container {
// Stack 的原始实现部分
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
// Container 协议的实现部分
mutating func append(_ item: Element) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Element {
return items[i]
}
}
此处我们并没有像上面那样指定Item
的类型,占位类型参数Element
被用作append(_:)
方法的item参数和下标的返回类型。Swift可以推断出Element
的类型即是Item的类型。
1.5.2 扩展现有类型来指定关联类型
我们说Container
类似数组,是因为Swift的Array
类型已经提供了Container
中的功能,且都符合Container
协议的要求,也就意味着我们只需要声明Array
遵循Container
协议,就可以扩展Array
,我们可以通过一个空的扩展来实现这一点:
extension Array: Container {}
关于Item
类型的推断也是一样的,那么这样用的好处是什么呢?定义了这个扩展后,你可以将任意Array
当做Container
来使用。
1.5.3 给关联类型添加约束
我们可以在协议里给关联类型添加约束来要求遵循的类型满足约束,例如:
protocol Container {
associatedtype Item: Equatable
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
对于遵循Container
协议的类型中,Item
类型也必须遵循Equatable
协议。
1.5.4 在关联类型约束里使用协议
协议可以作为它自身的要求出现。例如,有一个协议细化了Container
协议,添加了一个suffix(_:)
方法。suffix(_:)
方法犯规容器中从后往前给定数量的元素,并把它们存储在一个Suffix
类型的实例里。
protocol SuffixableContainer: Container {
associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
func suffix(_ size: Int) -> Suffix
}
这个看起来就很精妙了,在这个协议里,Suffix
也是一个关联类型,Suffix
拥有两个约束:首先它必须遵循SuffixableContainer
协议(也就是当前定义的协议),以及它的Item
类型必须和Container
里的Item
类型相同。Item
的约束是一个where
分句,在下面会详细介绍。
这里说的有点绕,举个例子吧,我们扩展一下泛型类型Stack
:
extension Stack: SuffixableContainer {
func suffix(_ size: Int) -> Stack {
var result = Stack()
for index in (count-size)..()
stackOfInts.append(10)
stackOfInts.append(20)
stackOfInts.append(30)
let suffix = stackOfInts.suffix(2)
for item in suffix.items {
print(item)
}
20
30
再看看这个例子就很清晰了吧。其实,遵循SuffixableContainer
的类型可以拥有一个与它自己不同的Suffix
类型,也就是说suffix(_:)
可以返回不同的类型:
extension IntStack: SuffixableContainer {
func suffix(_ size: Int) -> Stack {
var result = Stack()
for index in (count-size)..。
}
这次我们给非泛型的IntStack
类型提供了扩展,它也遵循了SuffixableContainer
协议,使用Stack
作为suffix(_:)
方法的返回值类型。
1.6 where 语句
类型约束可以让我们为泛型函数、下标、类型的类型参数定义一些强制要求。对关联类型添加约束也是非常有用的。我们可以通过定义一个泛型where
子句来实现。
通过泛型where
子句让关联类型遵从某个特定的协议,以及某个特定的类型参数和关联类型必须类型相同。
我们可以通过将where
关键字紧跟在类型参数列表后面来定义where
子句,where
子句后跟一个或者多个针对关联类型的约束,以及一个或多个类型参数和关联类型间的相对关系。我们可以在函数体或者类型的大括号之前添加where
子句。
举个例子,检查两个容器中是否具有相同的元素:
func allItemsMatch
(_ someContainer: C1, _ anotherContainer: C2) -> Bool
where C1.Item == C2.Item, C1.Item: Equatable {
// 检查两个容器含有相同数量的元素
if someContainer.count != anotherContainer.count {
return false
}
// 检查每一对元素是否相等
for i in 0..
此处通过where
子句标注了:
-
C1
和C2
内的元素类型必须相同 - 并且元素必须遵循
Equatable
协议
1.7 具有泛型where子句的扩展
我们也可以使用泛型where
子句作为扩展的一部分。
extension Stack where Element: Equatable {
func isTop(_ item: Element) -> Bool {
guard let topItem = items.last else {
return false
}
return topItem == item
}
}
此处我们想要判断某个元素是不是栈顶元素,如果需要比较就要遵循Equatable
协议,此时就可以使用where
2. 泛型的原理探索
首先来看个例子:
func testGenerics(_ value: T) -> T {
let tmp = value
return tmp
}
class Person {
var name: String = "xiaohei"
var age: Int = 18
}
// 传入Int
testGenerics(10)
// 传入元组
testGenerics((10,20))
// 传入实例对象
testGenerics(Person())
我们定义了一个泛型函数,这里面可以传入Int
值、元组、实例对象等。在函数体内,对于tmp
的赋值到底需不需要在堆上开辟内存?泛型是如何区分不同的参数类型的呢?对于不同类型又是怎么做内存管理的呢?
2.1 初步探索
下面我们查看一下SIL
代码:
从sil
代码中并不能看出底层是如何进行处理的,下面我们就看一下IR
代码:
调用时传的第三个参数都是metadata
从IR
代码中我们可以看到:
- 这里面传入了一个
metadata
参数 - 从
metadata
获取到valuewitness
- 从而也就可以获取到
size
(大小)、alignment
(对齐方式)、stride
(步长)、destory
、copy
等
2.2 源码探索
这样我们就能够明白了,对于传入的不同类型都可以通过metadata
来进行类型的确定和内存的管理。下面我们通过源码(Swift 5.3.1)来验证一下:
我们首先搜索一下valueWitness
,可以在metadata.h
中找到:
我们可以看到getValueWitnesses
函数,以及size
、alignment
、stride
的获取。
下面我们看一下ValueWitnessTable
的源码:
template struct TargetValueWitnessTable;
using ValueWitnessTable = TargetValueWitnessTable;
首先我们可以看到ValueWitnessTable
是TargetValueWitnessTable
的别名
TargetValueWitnessTable 源码:
/// A value-witness table. A value witness table is built around
/// the requirements of some specific type. The information in
/// a value-witness table is intended to be sufficient to lay out
/// and manipulate values of an arbitrary type.
template struct TargetValueWitnessTable {
// For the meaning of all of these witnesses, consult the comments
// on their associated typedefs, above.
#define WANT_ONLY_REQUIRED_VALUE_WITNESSES
#define VALUE_WITNESS(LOWER_ID, UPPER_ID) \
typename TargetValueWitnessTypes::LOWER_ID LOWER_ID;
#define FUNCTION_VALUE_WITNESS(LOWER_ID, UPPER_ID, RET, PARAMS) \
typename TargetValueWitnessTypes::LOWER_ID LOWER_ID;
#include "swift/ABI/ValueWitness.def"
using StoredSize = typename Runtime::StoredSize;
/// Is the external type layout of this type incomplete?
bool isIncomplete() const {
return flags.isIncomplete();
}
/// Would values of a type with the given layout requirements be
/// allocated inline?
static bool isValueInline(bool isBitwiseTakable, StoredSize size,
StoredSize alignment) {
return (isBitwiseTakable && size <= sizeof(TargetValueBuffer) &&
alignment <= alignof(TargetValueBuffer));
}
/// Are values of this type allocated inline?
bool isValueInline() const {
return flags.isInlineStorage();
}
/// Is this type POD?
bool isPOD() const {
return flags.isPOD();
}
/// Is this type bitwise-takable?
bool isBitwiseTakable() const {
return flags.isBitwiseTakable();
}
/// Return the size of this type. Unlike in C, this has not been
/// padded up to the alignment; that value is maintained as
/// 'stride'.
StoredSize getSize() const {
return size;
}
/// Return the stride of this type. This is the size rounded up to
/// be a multiple of the alignment.
StoredSize getStride() const {
return stride;
}
/// Return the alignment required by this type, in bytes.
StoredSize getAlignment() const {
return flags.getAlignment();
}
/// The alignment mask of this type. An offset may be rounded up to
/// the required alignment by adding this mask and masking by its
/// bit-negation.
///
/// For example, if the type needs to be 8-byte aligned, the value
/// of this witness is 0x7.
StoredSize getAlignmentMask() const {
return flags.getAlignmentMask();
}
/// The number of extra inhabitants, that is, bit patterns that do not form
/// valid values of the type, in this type's binary representation.
unsigned getNumExtraInhabitants() const {
return extraInhabitantCount;
}
/// Assert that this value witness table is an enum value witness table
/// and return it as such.
///
/// This has an awful name because it's supposed to be internal to
/// this file. Code outside this file should use LLVM's cast/dyn_cast.
/// We don't want to use those here because we need to avoid accidentally
/// introducing ABI dependencies on LLVM structures.
const struct EnumValueWitnessTable *_asEVWT() const;
/// Get the type layout record within this value witness table.
const TypeLayout *getTypeLayout() const {
return reinterpret_cast(&size);
}
/// Check whether this metadata is complete.
bool checkIsComplete() const;
/// "Publish" the layout of this type to other threads. All other stores
/// to the value witness table (including its extended header) should have
/// happened before this is called.
void publishLayout(const TypeLayout &layout);
};
在源码中我们可以看到很多flags
相关的取值,对于size
、stride
也有单独的定义,这里就不在继续深入探索了,感兴趣的可以去源码中跳转看一看,这些基本来自ValueWitness.def
文件,属于Swift ABI
,在metadata.h
也有相关声明。
所以说泛型类型使用VWT
进行内存管理,VWT
有编译器生成,其存储了 该类型的size
、alignment
以及对该类型的基本内存操作。
当泛型类型进行内存操作的时候,比如说拷贝,最终会调用对应泛型类型的VWT
中的基本内存操作。泛型类型不同,其对应的操作也不同。
当我们查看destroy
或者retain
方法的时候就会知道:
- 对于一个值类型,它的copy和move操作会进行内存拷贝,
destroy
操作不进行任何处理 - 对于一个引用类型,它的copy操作会对引用计数加1;move操作会拷贝指针,而不会更新引用计数;destroy操作会对引用计数减1
2.3 泛型传入一个函数
如果我们传入的是一个函数呢?举个例子:
// 泛型传入一个函数
func makeIncrement() -> (Int) -> Int{
var runningTotal = 10
return {
runningTotal += $0
return runningTotal
}
}
func testGenerics(_ value: T) {}
var m = makeIncrement()
testGenerics(m)
查看IR
代码:
这里我们看到还是将函数进行包装,这点可以参考Swift 闭包,最后调用的时候也会生成一个函数的metadata
作为第三个参数传入metadata for (Swift.Int) -> Swift.Int
这里我们还能够看到一个wift.function
在IR
代码中查找可以发现这是个结构体:%swift.function = type { i8*, %swift.refcounted* }
。
在后续的代码中我们可以看到通过这个结构体的包装,最后作为参数在调用的时候传递。
下面我们就仿写一下传入函数的底层代码把,细节还是参考的代码:
struct HeapObject {
var type: UnsafeRawPointer
var refCount1: UInt32
var refCount2: UInt32
}
struct FunctionData {
var ptr: UnsafeRawPointer
var captureValue: UnsafePointer
}
struct Box {
var refCounted: HeapObject
var value: T
}
struct GenData {
var ref: HeapObject
var function: FunctionData
}
func testGenerics(_ value: T) {
let ptr = UnsafeMutablePointer.allocate(capacity: 1)
ptr.initialize(to: value)
let ctx = ptr.withMemoryRebound(to: FunctionData>>.self, capacity: 1){
$0.pointee.captureValue.pointee.function.captureValue
}
print(ctx.pointee.value)
}
func makeIncrement() -> (Int) -> Int{
var runningTotal = 10
return {
runningTotal += $0
return runningTotal
}
}
var m = makeIncrement()
testGenerics(m)
10
所以当一个函数作为泛型参数传递的时候,会做一层包装,意味着并不会直接将函数值等传给泛型函数,这样做一层抽象就可以解决函数作为泛型参数时对类型的确定以及内存管理。
3. 总结
至此我们对Swift泛型的分析就告一段落了,现在总结一下
- 泛型是Swift最强大的特性之一
- 泛型可以增强代码的抽象能力,提升代码的复用性
- 泛型可以作为类型,成为泛型类型,我们也可以扩展泛型类型
- 如果一个泛型类型遵循了某个协议,则在使用的时候,要求具体的类型也必须遵守该协议
- 在定义协议的时候可以使用关联类型
associatedtype
给协议中用到的类型起一个占位符名称 - 我们还可以使用
where
语句对泛型进行约束 - 泛型使用
metadata
确定具体类型 - 使用中的
metadata``VWT
进行具体类型的内存管理 -
VWT
由编译器生成,里面存储了size、alignment
以及针对该类型的基本内存操作 - 对于值类型的内存管理
- 基本类型的
copy
和move
操作会进行内存拷贝 -
destroy
操作则不进行任何操作
- 基本类型的
- 对于引用类型的内存管理
-
copy
操作会调用retain
使引用计数+1 -
move
操作会拷贝指针,而不更新引用计数 -
destroy
操作会对引用计数-1
-
- 如果函数(闭包)作为泛型的参数,在传递过程中会做一层包装,也就是不会直接将函数的函数值和
type
给泛型函数,而是做一层抽象,这样就可以对不同的函数传递做到统一
4. 参考文档
Swift Generics
SwiftGG 泛型