Swift API设计规范
今天开始一段新的学习旅程---- 翻译Swift英文技术文档,目的主要是为了学习英语,顺带学习技术,翻译不是逐字翻译,而是基于内容翻译,原则是把里面涉及到的关键点讲清楚;Let‘s go。
原文链接
1. 首先是Swift命名规范,这是很重要的。
我们不再需要给Swift类型加前缀了,这能帮助API更加清晰可读,而在C和OC中,我们必须使用前缀,因为每一个类是在全局的命名空间中, 除此之外,没有更好的方式去消除命名歧义; 因此,苹果开发者必须严格遵守这个前缀约定。
但是在swift模块系统中,我们可以通过在类型前增加模块名来消除命名歧义,所以在swift标准库中已经没有前缀了,你当然也可以从你的swift 代码中把前缀删除。
但是即使这样你同样需要小心。比如在两个swift模块中存在相同的类名,当你没有明确指定模块名时,或同时引用了两个模块,会产生混乱,编译器不知道你用的是哪个模块的类,这时可以在类名前加上模块名,比如 "模块名.类名"。
2. 类和结构体如何选择?
类是引用类型,意味着当你有一个类变量,它仅仅是引用了某个对象;这个对象持有了某些值;当你copy它,仅仅是copy它的引用,意味着当你通过这个引用改变一个值,其他引用这个相同对象的变量也同样受到了影响;所以他们都能看到这些修改,而结构体和枚举则不一样,他们是值类型,当你copy他们时,他们copy整个内容,那意味着当你修改它时,你只是修改唯一的那份拷贝。
在你的API中使用值类型能带来很多好处,如果使用值类型。你不需要关心这个值来自于哪里,不用考虑其他地方是否引用它,不用担心那些引用它的地方是否会在你背后偷偷修改它,值类型非常安全;因此你不需要做一份防御性的copy。
所以问题来了,难道我所有的类型都用结构体?
一般情况下,你应该尽量使用结构体,除非你有别的原因必须要使用类。
但是,类在swift中依然扮演着非常重要的角色,如果你需要通过引用计数去管理资源,那应该使用类。尽管你可能需要把类包装到结构体中。
如果某些资源需要存储和共享,那也应该使用类。
如果类型有id,并且id和值是分开的,那使用类更合适。
我们拿 RealityKit举例,RealityKit的API围绕着一组被叫做entities的东西, 它们出现在场景中,并集中存储在RealityKit引擎中,而且它们有id标识。当你通过修改一个对象的显示或移动它们来操作一个场景,你可以直接在引擎中操作对象,您可以将引用类型视为存储在RealityKit中的实际对象的句柄。所以这是对引用类型的完美诠释。
但是这些entities的属性,比如位置或方向信息,这些适合申明为值类型。
让我们看一下代码:
var material = SimpleMaterial(color: .blue, roghness: 0.3, isMetallic: true)
let largeBox = ModelEntity(mesh: .generateBox(size: 0.5), materials: [material])
let smallBox = ModelEntity(mesh: .generateBox(size: 0.3), materials: [material])
let anchor = AnchorEntity()
anchor.children.append(largeBox)
anchor.children.append(smallBox)
arView.scene.anchors.append(anchor)
smallBox.position.y += 0.6
largeBox.orientation = .init(angle: .pi/4, axis: [0,1,0])
我们先创建一个material,然后用它来创建两个箱子,然后把它们固定在场景中。然后我们能通过代码直接操作场景,比如移动smallbox的y坐标, 或旋转45度,我们通过这些引用类型去执行这些操作,我们直接操作场景,这非常直观。
假设想修改其中一个箱子为红色,在第三行增加一行代码
let largeBox = ModelEntity(mesh: .generateBox(size: 0.5), materials: [material])
material.tintColor = .red
你期望的结果是什么,两个都变成红色还是其中一个是红色?这就要看meterial是引用类型还是值类型。对于API设计来说,两种方式都是合理的,使用值类型的好处在于:当你创建material和使用material之间有很长的一段距离,中间可能存在很多代码,而且这些代码你可能太了解做了什么,当你修改它时,你都不知道之前是否修改过它,然后你可以结束修改不期望的那部分场景。
因此,RealityKit选择material为一个值类型;但是引用类型也有它的具体应用场景。
另一个要点,尽量不要在结构体中包含引用类型的属性,因为当这样做时,比如copy结构体,引用类型的属性只copy引用,这种copy让人很困惑,因为它没有完成内容的完整copy,如果非得这么做,尽量不要公开这个属性,而是提供计算属性,限制外部修改这个引用类型。
3. 关于协议和范型
值类型并不是什么新概念,在OC中也有值类型,那么swift中的值类型有什么不一样呢?
Swift的不同之处在于,它能够将协议应用于struct和Enums,而不仅仅是类。这意味着您可以使用泛型在各种类型之间共享代码。
当你想要在不同类型之间共享代码,不要觉得必须创建一个共享的基类,然后子类去继承基类,这样虽然可以,但是在Swift中,推荐使用协议。
你的代码应该从协议开始
在Swift API设计中,就像任何Swift设计一样,首先要用具体的类型来探索用例。当您发现自己在不同类型上重复使用多个函数时,要了解您希望共享的代码是什么。
然后使用泛型分解共享代码。
现在,这可能意味着要创建新的协议。但是首先,考虑从现有的协议中组合出您需要的内容。当你设计协议时,要确保它们是可组合的。
作为创建协议的替代方法,可以考虑创建泛型类型。
下面看一些例子。
假设我想创建一个几何API。我想对这个几何向量添加操作。我可能先为这个几何向量定义一个协议。在协议中定义我需要的操作,比如点积或者两个向量之间的距离,就像下面的代码:
protocol GeometricVector : SIMD {
static func dot(_ a: Self, _ b: Self) -> Scalar
var length: Scalar { get }
func distance(to other: Self) -> Scalar
}
现在,我需要存储向量的维数。所以我可以让我的几何向量继承SIMD协议。SIMD类型基本上就像一种同质元组,可以非常有效地一次对每个元素执行计算,非常适合做几何运算。
然后我们可以为这些操作提供默认实现,这样我们的默认实现可以共享给实现了该协议的任何类型,代码如下。
extension GeometricVector {
static func dot(_ a: Self, _ b: Self) -> Scalar {
(a * b).sum()
}
var length: Scalar {
Self.dot(self, self).squareRoot()
}
func distance(to other: Self) -> Scalar {
(self - other).length
}
}
然后我们给遵循协议的类型做一些约束。
extension SIMD2: GeometricVector where Scalar: FloatingPoint {}
extension SIMD3: GeometricVector where Scalar: FloatingPoint {}
extension SIMD4: GeometricVector where Scalar: FloatingPoint {}
那么,协议真的给了我们什么吗?如果我们后退一步,不在新协议上编写默认实现,而是直接在SIMD协议上编写具有相同约束的扩展,那么就完成了。在下面的代码中,我们自动为包含浮点数的所有SIMD类型提供了所需的所有功能。
extension SIMD where Scalar: FloatingPoint {
static func dot(_ a: Self, _ b: Self) -> Scalar {
(a * b).sum()
}
var length: Scalar {
Self.dot(self, self).squareRoot()
}
func distance(to other: Self) -> Scalar {
(self - other).length
}
}
有时,创建这个复杂的协议层次结构并将不同类型分类到这个层次结构中可能很有意思。但这种让人感到满足的方式并不总是必要的。 这种没有协议的更简单的基于扩展的方式对编译器来说更容易处理。
事实上,我们已经发现,在具有大量复杂协议类型的大型项目中,通过采用这种简化方法和减少协议的数量,我们可以显著提高这些应用程序的编译时间。
现在,这种扩展方法适用于少数助手方法。但当您设计一个更完整的API时,它确实遇到了可伸缩性问题。当我们考虑创建一个协议时,我们说过要定义几何向量,并使其继承SIMD,我们将使用它来存储数据。
但这是正确的吗?这是is-a的关系吗?我们真的可以说一个几何向量“是”SIMD类型吗?我的意思是,有些操作是有意义的。你可以对向量进行加减运算。但其他操作就没有意义。两个向量不能相乘。或者把数字1加到向量上;但是这些操作在所有SIMD类型上都可用。
所以如果我们要设计一个易于使用的API,那么我们可能会考虑另一个选择,那就是将is-a关系实现为has-a关系。也就是说,将SIMD值包装在泛型结构中, 这样设计可能更加合理,如下代码。
struct GeometricVector<Storage: SIMD> where Storage.Scalar: FloatingPoint {
typealias Scalar = Storage.Scalar
var value: Storage
init(_ value: Storage) { self.value = value }
}
所以我们创建了一个几何向量的结构体。我们使用SIMD泛型,这样它就可以处理任何浮点类型和任何不同的维数。
一旦我们完成了这些,我们就可以更细粒度地控制在我们的新类型上公开什么API。
所以我们可以定义两个向量的加法。但不是把一个数字加到一个向量上。或者我们可以定义一个向量乘以一个比例因子,而不是两个向量相乘,我们仍然可以使用泛型扩展。所以我们对点积和距离的实现和之前一样。如下代码:
extension GeometricVector {
static func + (a: Self, b: Self) -> Self {
Self(a.value + b.value)
}
static func - (a: Self, b: Self) -> Self {
Self(a.value - b.value)
}
static func * (a: Self, b: Self) -> Self {
Self(a.value * b)
}
}
extension GeometricVector {
static func dot(_ a: Self, _ b: Self) -> Scalar {
(a.value * b.value).sum()
}
var length: Scalar {
Self.dot(self, self).squareRoot()
}
func distance(to other: Self) -> Scalar {
(self - other).length
}
}
我们实际上已经在标准库中使用了这项技术。例如,我们只有一个SIMD协议。然后我们有泛型结构体,它代表SIMD类型的每个不同大小。注意这里没有SIMD2或SIMD3协议。
SIMD
因此,希望这篇文章能让您了解泛型类型是如何像协议一样强大和可扩展的。
协议依然很强大。
我们还可以为GeometricVector 添加乘法运算:
extension GeometricVector {
static func X<T>(_ a: Self, _ b: Self) -> Self where Storage == SIMD3<T> {
Self([
a.value.y*b.value.z - a.value.z*b.value.y,
a.value.y*z.value.x - a.value.x*b.value.z,
a.value.y*x.value.y - a.value.y*b.value.x,
])
}
}
上面的代码看起来有点丑陋,因为我们为了获得x,y,z, 我们间接地获取这些值,如果我们能把这里优化下就好了。现在,我们可以编写计算属性来简化这块代码, 但实际上Swift5.1中的一个新特性称为KeyPath,允许您编写一个下标操作。看下面的代码:
@dynamicMemberLookup
struct GeometricVector<Storage: SIMD> where Storage.Scalar: FloatingPoint {
var value: Storage
init(_ value: Value) { self.value = value }
subscript(dynamicMember keyPath: KeyPath<Storage, Scalar>) -> Scalar {
value[keyPath: keyPath]
}
}
首先我们标记GeometricVector为动态查找成员属性@dynamicMemberLookup, 然后,编译器会提示我们实现一个特殊的动态成员下标函数。
这个下标函数传入一个KeyPath,实现这个下标的效果是,任何可以通过这个KeyPath访问的属性,都会自动暴露为GeometricVector的计算属性。所以在我们的例子中,我们想要获取一个进入SIMD存储类型的KeyPath,并让它返回一个Scalar。然后我们使用那个KeyPath在value中检索所有属性值并返回。一旦我们这样做了,我们的GeometricVector自动地得到了SIMD的所有属性。所以代码会简化成下面:
extension GeometricVector {
static func X<T>(_ a: Self, _ b: Self) -> Self where Storage == SIMD3<T> {
Self([
a.y*b..z - a.z*b.y,
a.y*z.x - a.x*b.z,
a.y*x.y - a.y*b.x,
])
}
}
4. Property Wrappers 属性包装器
Swift致力于编写简介清晰并且富有表现力的API,对于代码复用同样如此,我们已经谈论了范型和协议,为你的函数或类型加入范型代码,这样可以被复用。
属性包装器是Swift5.1的新特性。 属性包装器背后的思想是有效地从您编写的计算属性中获得代码重用;比如像下面一堆代码:
public struct MyType {
var imageStorage: UIImage? = nil
public var image: UIImage {
mutating get {
if imageStorage == nil {
imageStorage = loadDefaultImage()
}
}
set {
imageStorage = newValue
}
}
}
上面的一大堆代码实际上可以简化成一行代码:
public lazy var image: UIImage = loadDefaultImage()
用一个lazy 就解决了,这明显更简洁并且更易理解。
但问题是这是一个特殊的例子。那么,让我们来看另一个例子:
public struct MyType {
var textStorage: String? = nil
public var text: String {
get {
guard let value = textStorage else {
fatalError("text has not yet been set!")
}
return value
}
set {
textStorage = newValue
}
}
}
上面的代码与lazy不一样,textStorage需要先设值,才能读取它,其中获取值的逻辑可能千变万化,如何用更通用的方式去解决它?
这就是属性包装器背后的思想:消除这种模版式的代码,获得更有表现力的api,使得代码更加简洁易懂,所以使用属性包装器就像这样:
public struct MyType {
@LateInitialized public var text: String
}
我们的想法很简单,通过一个自定义的修饰符包装一个属性的访问策略,消除复杂冗长重复的代码,使代码更加简洁易懂。
接下来就是如何自定义一个属性包装器,比如上面的代码改成下面:
@propertyWrapper
public struct LateInitialized<Value> {
private var textStorage: Value?
public init() {
storage = nil
}
public var text: Value {
get {
guard let value = textStorage else {
fatalError("text has not yet been set!")
}
return value
}
set {
textStorage = newValue
}
}
}
1、注明 @propertyWrapper
2、为了让代码更通用,我们用到了范型,这样不管什么类型的属性都适用。
3、把结构体的名字定义为 LateInitialized
就这么简单,然后我们就可以愉快地使用它。
另一个有趣的事是我们定义了一个不带参数的初始化器。
public init() {
storage = nil
}
这块代码可以不写,它的作用是,编译的时候会进行隐式初始化,就像这样:
@LateInitialized public var text: String 会被编译成:
var $text: LateInitialized<String> = LateInitialized<String>()
public var text: String {
get { $text.value }
set { $text.value = newValue }
}
编译器会翻译成两个独立的属性$text
和 text
. $text
用来存储数据,text
是计算属性,用来提供get set方法。
$text 通过我们定义的无参初始化函数进行隐式初始化,这些都是编译器为我们做好了 的。
OK:这非常优雅,我们可以对任何类型的任意数量的不同属性进行包装,这使它更简单,并具有更少的代码模版。
Ben已经谈论了值和引用类型的相关概念。
当你正在处理引用类型的数据和可变状态时,在某些时候,您会发现自己在进行防御性复制。
当然,我们可以手动那么做,但是为什么不为它构建一个属性包装器呢?所以,下面就是代码:
@propertyWrapper
public struct DefensiveCopying<Value: NSCopying> {
private var storage: Value
public init(initialValue value:Value) {
storage = value.copy() as! Value
}
public var value: Value {
get { storage }
set {
storage = newValue.copy() as! Value
}
}
上面的代码(防御性复制)一个有趣之处在于它提供了一个初始值初始化器。 这就像那个无参数初始化器。它不是必须的。但是当它存在时,它允许你为任何属性提供一个默认值。这个默认值会被输入到这个初始化器中。我们可以对初始化执行任何我们想要的策略。
让我们看看怎么使用它:
```swift
public struct MyType {
@DefensiveCopying public var path: UIBezierPath = UIBezierPath()
}
编译器会把这个转换成两个不同的属性:
// Compiler-synthesied code...
var $path: DefensiveCopying<UIBezierPath> = DefensiveCopying(initialValue: UIBezierPath())
public var path: UIBezierPath {
get { $path.value }
set { $path.value = newValue }
}
$path 通过已定义的初始化器进行初始化。
注意我们是如何初始化它的。我们将用户给定的初始值输入到初始值初始化器中,这样它就可以被防御性地复制。
在这个例子中,我们知道我们的默认值。它创建一个新对象。我们为什么要去复制那个对象?我们来优化一下:
我们可以扩展下这个包装器。我们给它增加一个无拷贝的初始化器。
public init(withoutCopying value:Value) {
storage = value
}
当我们想要使用它时,我们可以通过这个新的初始化器来初始化path。
@DefensiveCopying public var path: UIBezierPath
public init() {
$path = DefensiveCopying(withoutCopying: UIBezierPath())
}
我们在这里做的是调用withoutcopy初始化器进行初始化,这样我们就能避免额外的拷贝。
上面的代码实际上可以简写成:
public struct MyType {
@DefensiveCopying(withoutCopying: UIBezierPath())
public var path: UIBezierPath
}
因此,属性包装实际上是相当强大的。它们抽象了策略的概念来访问数据。因此,您可以决定如何存储数据。您可以决定如何访问您的数据。用户使用属性包装器所需要做的就是使用自定义属性语法来绑定到系统中。因此,在我们开发属性包装器的过程中,我们发现它们有很多不同的用途,都是围绕着这种数据访问的概念。