下次发生崩溃时,请按照以下说明正确解决问题:单击线程中的 objc_exception_throw
,然后在调试区域键入po $arg1
以获取错误的可读版本。如果你使用异常断点,你甚至可以在那里添加po $arg1
命令,这样它将自动输出。
-- Natasha Murashev (@natashatherobot), 作者和演说家
有什么区别?(What's the difference?)
理解和利用引用类型和值类型之间的差异对于任何认真的Swift开发人员来说都是一项至关重要的技能。这不是“有用的知识”,也不是“知道就好”,而是至关重要的——我不是随便说说。
在接下来的几章中,我将详细介绍引用和值,以便你可以自己学习,但首先我想解释它们之间的区别。我遇到过太多的开发人员,他们要么不理解,要么不在乎——这两者都是错误的——所以这是一个明智的起点。
我是《星球大战》的超级粉丝,你可能还记得第一部《星球大战》电影(后来被命名为《新希望》)的结尾是对死星的攻击。叛军飞行员之一韦奇·安地列斯——飞往攻击死星,但遭到破坏,不得不飞回基地。另一名叛军飞行员卢克·天行者也在攻击死星,但他使用原力拯救了这一天,至少在下一部电影之前是这样。
为了说明引用类型和值类型之间的区别,我想用简单的 Swift 代码重新创建这个电影场景。虽然还不完整,但你可以跟着做:
// create a target
var target = Target()
// set its location to be the Death Star
target.location = "Death Star"
// tell Luke to attack the Death Star
luke.target = target
// oh no – Wedge is hit! We need to
// tell him to fly home
target.location = "Rebel Base"
wedge.target = target
// report back to base
print(luke.target.location)
print(wedge.target.location)
现在的问题是:当这些飞行员返回基地时,他们会说什么?答案是,“视情况而定。” 它所依赖的是——你猜对了——引用类型和值类型之间的区别。
你看,如果Target
是一个类,在 Swift 中是一个引用类型,它应该是这样的:
class Target {
var location = ""
}
如果Target
是结构体,也就是 Swift 中的值类型,它会是这样的:
struct Target {
var location = ""
}
代码几乎相同,只有一个词不同。但结果是相差巨大的:如果Target
是一个结构体,那么卢克会瞄准死亡之星,而韦奇会瞄准叛军基地,但是如果Target
是一个类,那么卢克和韦奇都会瞄准叛军基地——这会给叛军和一些深深困惑的电影迷们带来非常不愉快的结局。
这种行为的原因是,当你在多个位置分配引用类型时,所有这些位置都指向相同的数据块。
因此,尽管卢克有一个target
属性,韦奇也有一个target
属性,但它们都指向Target
类的同一个实例——改变一个意味着另一个也会改变。
另一方面,值类型总是只有一个所有者。当你在多个位置分配值类型时,所有这些位置都指向该值的单个副本。
当代码luke.target = target
运行时,卢克获得了自己的Target
实例的唯一副本。所以,当这变成“叛军基地”时,他不在乎——他继续攻击死星。
Swift 是一种积极的值类型导向的语言,这意味着它的大多数数据类型都是值类型,而不是引用类型。布尔值、整数、字符串、元组和枚举都是值类型。甚至数组和字典也是值类型,所以下面的代码将打印 3 而不是 4 :
var a = [1, 2, 3]
var b = a
a.append(4)
print(b.count)
值类型比引用类型简单,这不是坏事。当你给定一个要使用的值时,你可以确保它的值不会意外更改,因为你有自己惟一的副本。你还可以轻松地比较值类型,它们是如何得到值的并不重要——只要两个值看起来相同,它们就是相同的。例如,下面的代码将打印 “Equal ” ,即使 a 和 b 的创建方式非常不同:
let a = [1, 2, 3]
let b = Array(1...3)
if a == b { print("Equal") }
简而言之,引用类型在赋值时共享,因此可以有多个所有者,而值类型在赋值时复制,因此只有一个所有者。
闭包是引用类型(Closures are references)
这可能会有点烧脑,所以如果你很少复制闭包,那么可以完全跳过这一章。
我已经说过,布尔类型、整型、字符串类型、数组、字典、结构体等等都是 Swift 中的值类型。类是引用类型,闭包也是。对于简单的闭包,这并不重要。例如,下面的代码在printGreeting
中存储了一个简单的闭包,调用它,将它分配给copyGreeting
,然后再调用:
let printGreeting = { print("Hello!") }
printGreeting()
let copyGreeting = printGreeting
copyGreeting()
在该代码中,闭包可以是值类型,它不会影响输出。当闭包捕获值时,事情就变得复杂了,因为捕获的值在指向相同闭包的任何变量之间共享。
给你一个实际的例子,下面的代码是一个createIncrementer()
函数,它不接受任何参数并返回一个闭包:
func createIncrementer() -> () -> Void {
var counter = 0
return {
counter += 1
print(counter)
}
}
在createIncrementer()
函数中有一个变量counter
,它的初始值为0
。因为该变量是在返回的闭包中使用的,所以它将被捕获。所以,我们可以用这样的代码来执行这个函数:
let incrementer = createIncrementer()
incrementer()
incrementer()
第一行调用createIncrementer()
函数并将其返回的闭包存储在incrementer
变量中。下面的两行调用incrementer()
两次,触发了闭包,因此你将看到 1
和2
被打印出来—计数器如预期的那样向上增加。
现在重点是:因为闭包是引用类型,如果我们创建另一个incrementer
引用,它们将共享相同的闭包,因此也将共享相同的计数器变量。例如:
let incrementer = createIncrementer()
incrementer()
incrementer()
let incrementerCopy = incrementer
incrementerCopy()
incrementer()
当代码运行时,你将看到1
、2
、3
、4
被打印出来,因为incrementer
和incrementerCopy
都指向完全相同的闭包,因此也指向相同的捕获值。
重复一遍:如果你不经常使用闭包,那么这不太可能是一个问题,如果你不使用闭包来捕获值,那么你就是安全的。否则,请小心操作:在不引入闭包捕获的情况下,使用引用类型可能会非常困难。
为什么使用结构体?(Why use structs?)
如果你要在类或结构体(即引用类型或值类型)之间进行选择,那么选择结构体是有充分理由的。这种比较适用于所有引用类型或值类型,但最常见的是当涉及到类或结构体时如何选择,这就是我将在这里讲的内容。
我已经提到过值类型是复制而不是共享,这意味着如果你的代码有三个不同的东西指向同一个结构,它们每个都有自己的副本 - 他们没有机会踩到对方的脚趾。 一旦你理解了这种逐个复制的行为,你就会开始意识到它带来了另一套好处。
如果你使用的应用程序比较复杂,那么值类型的最大好处之一就是它们本质上是线程安全的。也就是说,如果你正在处理一个或多个后台线程,则不可能导致值类型的竞争条件。为了避免混淆,在解释原因之前,我先简要解释一下原因。
首先,竞态条件:这是一个常见的 bug 类型的名称,它是由并行运行的两段代码引起的,它们完成的顺序会影响程序的状态。例如,假设我开车去你家给你 1000 美元,你的一个朋友也开车去你家索要 200 美元。我先到,然后把钱给你。朋友几分钟后到了,你给了她 200 美元——一切都很好。或者,你的朋友可能会比我开得快,然后先到那里,在这种情况下,他们要求 200 美元,而你却没有从我这里得到任何钱——突然之间,你就有麻烦了。
在软件中,这可能意味着尝试在收到结果之前对其进行操作,或者尝试处理尚未创建的对象。无论如何,结果都是不好的:你的软件的行为不一致,这取决于首先发生的操作,这使得问题很难发现和解决。
第二,线程安全:这是一个术语,它意味着代码的编写方式是多线程可以使用数据结构,而不会相互影响。如果线程 A 修改了线程 B 正在使用的东西,那么这就不是线程安全的。
值类型本质上是线程安全的,因为它们不是在线程之间共享的——每个线程都将获得自己的数据副本,并且可以在不影响其他线程的情况下尽可能多地操作该副本。每个副本都独立于其他副本,因此基于数据的竞态条件将消失。
消除(或至少显著减少)线程问题是很好的,但是值类型有一个更大的好处:它们大大减少了代码中存在的关系的数量。举个例子,常见的情况是 Core Data 应用程序在 app delegate 中设置数据库,然后在视图控制器之间传递对数据库或单个对象的引用。每次你在视图控制器之间传递一个对象时,你都在双向地连接它们——子视图控制器可以以任何方式修改那个对象,而所有这些变化都会无声地出现在父视图控制器中。
因此,应用程序中的对象关系可能不太像树,而更像意大利面工厂中的爆炸——联系无处不在,任何人所做的更改都可以在应用程序中显示和传播,这一切变得很难解释。也就是说,很难说“这段代码是如何工作的?”当你的物体被缠住的时候。
当你使用值类型时,这就不是问题了: 一旦将结构体从一个视图控制器传递到另一个视图控制器,子控制器就有了自己的独立副本 -- 这里没有形成任何隐含的关系,子控制器也不可能搞乱自己和父控制器的值。
选择结构体而不是类的最后一个原因是:它们带有成员初始化。这意味着编译器自动生成一个初始化器,该初始化器为结构的每个属性提供默认值。例如:
struct Person {
var name: String
var age: Int
var favoriteIceCream: String
}
let taylor = Person(name: "Taylor Swift", age: 26, favoriteIceCream: "Chocolate")
我知道这微不足道,但它非常实用,使结构更容易使用。
为什么使用类类型?(Why use classes?)
使用类而不是结构有一些很好的理由,尽管它们中至少有一个会回来咬你一口。引用类型有点像命名或句柄,如果你是一个老派的开发人员,甚至是指针:当你传递它们时,每个人都指向相同的值。这意味着你可以创建一个资源,如数据库连接,并在应用程序中的许多对象之间共享它,而无需创建新的连接。
巧妙地说,类的这种共享特性是支持和反对使用值类型而不是引用类型的主要原因。原因之一是灵活性和性能:在短期内,很容易传递一个共享对象,并让每个人根据需要修改它。 这很糟糕,而且很脆弱,但它确实很容易编写代码。事实上,我建议这种方法是许多开发人员的默认方法,特别是那些来自 Objective-C 背景的开发人员。
这种方法也可能运行得非常快,因为你不需要每次将对象传递到其他地方时都创建对象的副本。相反,你只创建一个对象,并将其传递到需要它的地方——只要你的代码是线程安全的,对象甚至可以跨线程使用,而不用担心太多。
对象提供给你的一个结构体所没有的特性是继承:获取现有类并在其基础上构建的能力。多年来,继承一直是软件开发中的一个基本概念,并且现在已经深深地扎根于我们的行业中,很容易就可以追溯到一两代开发人员。它允许你使用现有的类,无论大小,并以任何你想要的方式构建它。你可能会添加一些小的调整,或大量的更改。有些语言(幸运的是不是 Swift !) 允许你从多个类中继承——这在智力上非常有趣,但很少使用,而且从来没有必要。
最后,使用类的一个主要原因是 Cocoa Touch 本身是使用类编写的:UIKit 、SpriteKit 、MapKit、 Core Location 等等都是面向对象的,NSCoding 和 NSCopying 等协议也是如此。如果你正在编写需要使用 Cocoa 或 Cocoa Touch 的 Swift 代码,比如将自定义数据类型保存到 NSUserDefaults ,那么你可能在某个时候需要类类型。
在结构和类之间选择(Choosing between structs and classes)
在结构或类之间进行选择取决于你想要引用类型还是值类型行为。每种方法的一些优点实际上可能是缺点,这取决于你的上下文,因此你需要阅读下面的建议并自己决定。
首先,线程安全和不受竞争条件限制的好处是使用值类型的主要原因。很容易想象我们是一个非常聪明的程序员,能够在睡梦中处理多线程,但事实是,我们的大脑并不是这样工作的:计算机实际上可以同时执行两项、四项、八项或更多复杂的任务,这对于普通人来说极其困难,更不用说调试了。
Joel Spolsky 就这个主题写了一篇优秀的文章,叫做 “The Duct Tape Programmer ”,他在文章中讨论了聪明的程序员采用简单解决方案的原则 – the equivalent duct tape and WD-40 。在这篇文章中,他引用了多线程作为复杂代码的一个例子,并说:“The Duct Tape Programmer 很好地理解的一个原则是,任何一种稍微复杂的编码技术都会毁了你的项目。”
你也不应该低估简化你的应用程序中的关系的价值。正如我已经提到的,每次你将一个对象从一个地方传递到另一个地方,你都在含蓄地创建一个关系,在未来的任何时候都可能适得其反。如果你只做过一两次这样的操作,那么跟踪它可能并不困难,但是你有多少次看到对象被传递了几十次的情况呢?如果你曾经使用过 Core Data ,你就会知道它与简单的关系建模截然相反。
对象允许你在应用的不同部分共享数据,但你真的需要吗?就绝对性能而言,它通常更有效,但是它会在你的体系结构中创建非常复杂的关系。有些值类型——特别是内置到 Swift 本身的值类型——具有一种称为写时复制(copy on write) 的优化,使得传递它们和传递对象一样高效,因为除非你试图更改值,否则 Swift 不会复制值。遗憾的是,这种优化并没有运用到你自定义的结构,所以你要么自己编写代码,要么(稍微)降低性能。
类的另一个主要优势是能够通过继承另一个类来创建新类。这是一个功能强大的特性,具有经过验证的跟踪记录,而且还有一个额外的好处,即数百万开发人员了解该技术并广泛使用它。但是,尽管继承功能强大,它也有自己的问题:如果你在设计一个聪明,有用的和清晰的架构,从 A 到 B 到 C 甚至是 D,如果你后来改变了主意,会发生什么 -- 例如,试着移除 B 或者把 E 放到 B 所在的位置? 答案是,它变得非常混乱。
虽然继承在任何人的工具包中仍然是一个有价值的工具——特别是在构建 Cocoa 和 Cocoa Touch 时——但是一种更新、更简单的方法正在迅速流行起来:面向协议开发。在这里,你可以水平地而不是垂直地添加单个功能块,这允许你随时更改你的想法,而不会造成任何问题。Swift 强大的扩展类型和扩展能力使得继承远不如以前有用:我们通过功能组合而不是层次继承来生成强大的数据类型。
同样,你的选择很大程度上取决于你的使用场景。然而,基于以上,我想提供一些总结点,以帮助指导你。
首先,我建议你尽可能选择结构而不是类。如果没有继承就无法生存,或者迫切需要共享数据所有权,那么就使用类,但是结构应该始终是你的默认起点。我喜欢在自己的代码中使用结构,但在本书中,我偶尔使用类,因为我试图覆盖所有的基础。
其次,如果必须使用类,请将其声明为final
。这将立即带给你性能提升,而且它也应该是你的默认设置:除非你特别认为,“是的,这个类可以安全地被其他类子类化”,否则允许它发生是一个错误。不要低估健壮子类的复杂性!
第三,尽可能地将结构体或类的属性声明为常量。不变性——数据无法被更改——多亏了let
这个关键字,它被完美地融入了 Swift ,这是一个值得坚持的好习惯。
第四,如果你发现自己无论如何都是从一个类开始的,这只是其他编程语言遗留下来的问题吗? Objective-C 开发人员几乎所有的事情都使用类,因此类可能是一个难以改变的习惯。如果你像写 Objective-C 一样写 Swift 的时候,那你将错过一半乐趣和一半的效率。对于这种新语言,我建议你在回到使用类之前对值类型进行彻底的了解。
结合类和结构(Mixing classes and structs)
一旦理解了值类型和引用类型,在类和结构体之间进行选择就容易得多——至少在开始时是这样。但是随着应用程序的增长,你可能会发现你的情况变得不那么黑白分明了:你的应用程序可能 95% 的时间都在处理值类型,但是如果你使用引用类型,那么可能只需要一两次,一切就会变得简单得多。
所有这些都不会丢失:记住我所说的关于两者相对优势和劣势的所有内容,如果你确信这是正确的解决方案,那么有一种方法可以在多个地方共享值类型。这种技术被称为 boxing ——不是那种有力的、汗流浃背的拳击,而是“把东西放在盒子里”。这种方法将值类型封装在引用类型中,以便更容易地共享它,这种方法在 c# 和 Java 等语言中很常见。
注意:我不会继续重复警告:共享而不是复制值会增加程序的复杂性;请将其视为已读!
我想给你举一个实际的例子,让你自己看看它是如何工作的。首先,这里是我们的 Person
结构:
struct Person {
var name: String
var age: Int
var favoriteIceCream: String
}
let taylor = Person(name: "Taylor Swift", age: 26, favoriteIceCream: "Chocolate")
如果我们想在多个对象之间共享taylor
这个结构体,我们需要像这样创建一个PersonBox
类:
final class PersonBox {
var person: Person
init(person: Person) {
self.person = person
}
}
let box = PersonBox(person: taylor)
它是封装Person
结构体的类容器,作为引用类型,将被共享而不是复制。
最后,让我们创建一个TestContainer
类,它模拟应用程序的某些部分,例如不同的视图控制器:
final class TestContainer {
var box: PersonBox!
}
let container1 = TestContainer()
let container2 = TestContainer()
container1.box = box
container2.box = box
这段代码创建两个容器,每个容器指向同一个PersonBox
对象,这意味着它们指向同一个Person
结构体。为了证明这一点,我们可以编写如下代码:
print(container1.box.person.name)
print(container2.box.person.name)
box.person.name = "Not Taylor"
print(container1.box.person.name)
print(container2.box.person.name)
这将打印 “Taylor Swift ”两次,然后打印 “Not Taylor ”两次,以证明更改一个容器中的值会更改另一个容器中的值。
如果你打算广泛使用 boxing
和 unboxing
(警告,我希望你可以自己写这个!),你可能需要考虑创建这样的通用Box
类:
final class Box<T> {
var value: T
init(value: T) {
self.value = value
} }
final class TestContainer {
var box: Box<Person>!
}
这样就可以共享其他类型的结构,而不必创建许多不同的Box
类。
不可否认的是,这种方法稍微削弱了值类型的能力和安全性,但至少它明确了哪些情况提供了值类型的安全性,哪些情况没有——你在声明“这部分是显式共享的”,而不是隐式共享所有内容。
还有一件事你应该知道,如果你有使用 Objective-C 的背景:如果你面临重要的引用计数性能问题,boxing
和 unboxing
可能会很有帮助。Swift 与现代的 Objective-C 一样,使用了一个称为自动引用计数(ARC)的系统,它可以跟踪一个对象被引用的次数。当该计数达到 0 时,对象将自动销毁。
Swift 的结构体不进行引用计数,因为它们始终是唯一引用的。但是,如果结构体包含一个对象作为它的属性之一,则该对象将被引用计数。
对于小的东西,这不是问题,但是如果你的结构有很多对象作为属性。假设有 10 个引用类型的属性,然后你的结构体每次被复制时 ARC 都需要做 10 次增加引用计数的操作。在这种情况下,将结构体装箱到包装对象中可以极大地简化工作,因为 ARC 只需要操作包装对象的引用计数,而不需要操作所有单独的属性。
不可变性(Immutability)
值和引用类型在处理不可变性的方式上有所不同,但我在这里将其分开,因为这是一个非常细微的差异,很容易混淆。
让我们回顾一下:我喜欢 Swift 的一点是,它积极地关注不变性。也就是说,很容易说“不要让这个值改变”。这不仅意味着在编写代码时鼓励你使用let
而不是var
,而且 Swift 编译器将扫描你的代码,并在发现可以将变量转换为常量的位置时发出警告。这和 Objective-C 非常不同,在Objective-C中,可变性只作为类名的一部分强制执行 NSString 有一个NSMutableString 对应项,NSArray 有一个 NSMutableArray 对应项,等等。
不变性和值类型似乎是密切相关的。毕竟,如果一个值只是一个值,它怎么能改变呢? 整型是 Swift 中的值类型,你不能确切地说 “嘿,我在改变数字 5,所以现在 5 实际上等于 6 ”。但是当你开始比较类和结构时,不变性就更加复杂了,我想解释一下原因。
考虑以下代码:
struct PersonStruct {
var name: String
var age: Int
}
var taylor = PersonStruct(name: "Taylor Swift", age: 26)
taylor.name = "Justin Bieber"
当代码运行时,taylor
实例的最终名称值是 “Justin Bieber ”,这对于任何参加演唱会的人来说都将是一个巨大的惊喜!
如果我们只修改一行代码,结果会非常不同:
let taylor = PersonStruct(name: "Taylor Swift", age: 26)
taylor.name = "Justin Bieber"
只有一行不同,代码甚至无法编译,因为不允许更改name
属性。即使name
和 age
属性被标记为变量,taylor
结构被标记为常量,因此 Swift 不允许它的任何部分更改。
这就是事情变得有点复杂的地方,但请认真对待:这真的很重要。考虑以下代码:
final class PersonClass {
var name: String
var age: Int
init(name: String, age: Int) {
self.name = name
self.age = age
}
}
let taylor = PersonClass(name: "Taylor Swift", age: 26)
taylor.name = "Justin Bieber"
这个例子使用的是 PersonClass
类而不是结构体,但是它将 taylor
实例保留为常量。现在代码进行编译,这与它是一个结构体时非常不同。
当 taylor
是一个常量结构体时,你不能改变它的值或属性。当它是一个常量对象时,你不能改变它的值,但你可以改变它的属性,只要它们没有被单独标记为常量。这意味着使用结构允许你比使用类更强地执行不变性,这有助于进一步简化代码。
下面的代码显示了所有可能的选项——我已经注释掉了不能工作的代码:
// variable struct: changing property and changing value OK
var taylor1 = PersonStruct(name: "Taylor Swift", age: 26)
taylor1.name = "Justin Bieber"
taylor1 = PersonStruct(name: "Justin Bieber", age: 22)
// constant struct: changing property or value not allowed
let taylor2 = PersonStruct(name: "Taylor Swift", age: 26)
//taylor2.name = "Justin Bieber"
//taylor2 = PersonStruct(name: "Justin Bieber", age: 22)
// variable object: changing property and reference OK
var taylor3 = PersonClass(name: "Taylor Swift", age: 26)
taylor3.name = "Justin Bieber"
taylor3 = PersonClass(name: "Justin Bieber", age: 22)
// constant object: changing property OK, changing reference not allowed
let taylor4 = PersonClass(name: "Taylor Swift", age: 26)
taylor4.name = "Justin Bieber"
//taylor4 = PersonClass(name: "Justin Bieber", age: 22)
正如你所看到的,在使用常量的地方会发生差异:常量对象不能更改为指向新对象,但是你可以更改它的任何属性,而常量结构是完全固定的。
在处理集合(如数组)时,易变性是最常见的问题。这些是 Swift 中的值类型(万岁!),但它们也可以是可变的——如果你将它们声明为 var ,你可以自由地添加和删除元素。