通过解决实际问题,了解Swift中引用和值类型之间的微妙但重要的差异。
如果您一直在跟踪最近的WWDC大会,您可能已经注意到在swift中重新思考代码架构。从Objective-c到Swift时,开发人员注意到的最大差异之一是对 值类型 的偏爱要超过 引用类型。
在本教程中,您将学习到:
- 值与引用类型的主要概念
- 两者之间的差异
- 如何选择使用哪个类型
当您了解每种类型的主要概念时,您将解决现实中的问题。此外,您将学习更多高级的概念并发现两者微妙但重要的观点。
不论您是来自Objective-c背景,还是您对Swift更精通,您一定会学到一些关于Swift输入输出的细节。
入门
首先,新建一个playground,在Xcode选择File->New->Playground…并命名playground为ReferenceTypes。
注:您可以选择任何平台,因为本教程是与平台无关的,只关注Swift语言方面。
点击 下一步,选择一个方便的位置保存playground,点击 创建并打开它。
引用类型与值类型
那么两者类型的核心区别是什么?简单一句话来概括就是引用类型共享同一个数据拷贝,而值类型保持其唯一的数据拷贝。
Swift将引用类型表示为类,这和Objective-c类似,所有从NSObject继承的内容都存储为引用类型。
在Swift中有许多值类型,例如结构体、枚举、元组。您可能不知道Objective-c也有用值类型,表示字面量的像NSInteger甚至C结构如CGPoint。
为了更好的理解两者的差异,最好是先从认识Objective-c的引用类型开始。
引用类型
引用类型是一个可以由多个变量传递并引用的共享实例。用一个例子能很好的说明这一点。
添加下面这段代码到你的playground中:
1 | // Reference Types: |
上面这个类表示表示🐶是否被喂养,添加下面这段代码来创建你的🐶的实例:
1 | let dog = Dog() |
这是指向存储🐶的内存地址。添加如下代码去添加另外一个对象来持有相同🐶实例的引用:
1 | let puppy = dog |
因为dog对象是一个内存地址的引用,puppy对象指向内存中相同的数据。通过设置变量wasFed值为true:
1 | puppy.wasFed = true |
puppy 和 dog 两个对象指向同一个内存地址。
因此你期望修改其中一个的值另一个值也会被更改。通过在playground中观察属性的值来检查判断是否正确:
1 | dog.wasFed // true |
更改一个实例会影响另一个因为他们引用同一个对象,这正是你在Objective-c所期望的。
#值类型
值类型和引用类型完全的不同,您将使用一些简单的Swift源语言来探索它。
将以下的Int变量和相应的操作添加到playground中去:
1 |
|
您觉得变量a和b相当吗?很显然a等于42,而b等于43。如果您将他们声明为引用类型的话,a和b都将等于43,因为他们指向相同的内存地址。
对于任何其他值类型的也是如此。在你的playground中实现下面的Cat 结构体:
1 | struct Cat { |
这里展示了引用和值类型微妙但重要的差异:设置kitty的wasFed变量并没有影响到cat,kitty变量接受一个cat的值拷贝而不是引用。
好像你的猫今晚要饿肚子了^_^
虽然为变量分配引用要快的多,拷贝也相当的廉价,拷贝操作时间复杂度O(n),因为它们使用基于数据大小的固定数量的引用计数操作,在稍后的教程中你将看到在Swift中一些巧妙的方法对这些拷贝操作进行优化处理。
易变性
var 和 let 定义对引用类型与值类型有功能上有差异,注意你定义的dog和puppy是用let定义的常量。然后你还是能够修改wasFed属性的值,为什么可以呢?
对于引用类型,let 意味着引用必须要保持不变,换句话说就是你不能改变引用实例,但你能修改实例本身。
对于值类型,let 意味着着实例也必须保持不变,不论属性是被声明为let或var都不能被修改。
这样值类型就变得更容易控制。要实现引用类型实现相同的不变性和可变性行为,您需要实现不可变和可变的类变体,例如NSString和NSMutableString。
Swift喜欢什么类型?
你可能会感到惊讶,Swift标准库几乎只是用值类型,通过Swift标准库快速搜索Swift 1.2,2.0和3.0中的枚举,结构和类的公共实例的结果显示了值类型方向的偏差:
Swift 1.2:
- struct: 81
- enum: 8
- class: 3
Swift 2.0:
- struct: 87
- enum: 8
- class: 4
Swift 3.0:
- struct: 124
- enum: 19
- class: 3
这些包括的类型像String,Array和Dictionary,都是用struct来实现的。
如何用何时用
现在你知道了两者的差异,什么时候应该选择另一个呢?
有一种情况让你别无选择,很多Cocoa接口都是NSObject的子类,这样就迫使你只能使用类。除此之外,您可以在如何选择的情况下使用Apple的Swift博客中的案例? 决定是使用struct或enum值类型还是类引用类型。 您将在以下部分中仔细研究这些案例。
何时用值类型
这里有三种情形使用值类型是最佳之选
在比较实例数据==意义的时候用值类型
我知道你在想什么。当然,你想每个对象去被比较,对吗?当是,你需要考虑这些数据是否能被比较。思考下面Point的实现:
1 |
|
这是否意味着具有完全相同的x和y成员的两个变量是相等的?
1 |
|
没错,很明显你应该认为两个Point有相同的内部值他们就是相等的。具体存储在内存哪里无关紧要,你关心的是他们的值。
确保你的Point能够被比较,你需要去遵循Equatable协议,这是所有值类型的良好实践。这个协议定义了一个必须实现方法为了比较两个对象实例。
这意味着==操作必须具有以下特性:
- 反射的:x == x is true
- 对称的:if x == y then y == x
- 可传递的:if x == y and y == z then x == z
这个例子为你的Point实现的了==操作:
1 |
|
当副本具有独立状态时候使用值类型。
再稍微考虑下Point实例,思考下面两个Shape实例,他们两个中心点的初始值为相同的Points
1 |
|
如果你修改shapes其中之一中心点的值会发生什么呢?
1 |
|
每个shape都拥有自己的Point副本,所以你能够独立于其他的shape保持自身状态。你能够想象所有的shape都共享同一个中心点Point副本吗?
当你想创建一个共享,可变状态时候使用引用类型
有时候你想存储一块数据作为单例可以被多人使用和修改。
具有共享可变状态的对象的常见示例是共享银行帐户。你可以实现一些基本的表示账户和个人(账户持有人),如下所示:
1 |
|
如果任何联名账户持有人向账户添加资金,则与账户关联的所有借记卡应反映新的余额:
1 |
|
因为Account是一个类,每个Person都持有账号的引用,并且所有内容都保持同步。
还是无法决定?
如果你还是不确定哪种机制适用你的场景,默认使用值类型,你可以随时很轻易的迁移成类。
虽然Swift几乎只使用值类型,但令人难以置信的是当在Objective-c中的情况确实截然相反的。
作为新Swift范例下的编码架构师,您需要对如何使用数据进行一些初步规划。 您可以使用值类型或引用类型解决几乎任何情况。 但是,错误地使用它们可能会导致大量错误和令人困惑的代码。
任何情况下,当新要求出现时候常识和自愿去改变你的架构师最好的方法。挑战自己去遵循Swift模型。您将能产出比你预期更漂亮的代码!
混合值类型和引用类型
你通常会遇见这种情况,引用类型需要包含值类型反之亦然。这很容易使对象的预期语义复杂化。
要查看其中一些复杂的情况,以下是每个方案的例子。
引用类型包含值类型属性
引用类型包含值类型是很常见的。举一个例子一个Person类有些身份事项,存储一个Address结构体等事项。
来看看这个怎么样,将下面基本的addres实现替换你的playground中的内容:
1 |
|
在这个例子中,Address的所有属性来标识一个现实世界中建筑的唯一物理地址。所有属性都是String类型的值类型,为了简单起见将忽略验证逻辑。
接下来,添加下面的代码到你playground的底部
1 |
|
这种情况下使用混合类型是十分有意思的,每个类实体有自己的值类型属性实体不被共享。这样就避免了不同人共享和意外的修改其他人的地址。
为了验证此行为,添加如下代码到你的playground末尾:
1 |
|
这边是你添加的内容:
- 首先,你创建两个来自相同Address实体的Person对象。
- 下一步,你修改其中一个Person对象的的地址。
- 最后,你验证两个地址是不同的。虽然每个对象创建使用的是相同的地址,修改其中一个并不会影响到另一个。
接下来你将发现值类型包含引用类型会更复杂一些。
值类型包含引用类型属性
上一个实例如此简单,为什么反过来就变得更困难了呢?
添加如下代码到你的playground中去验证值类型包含引用类型:
1 |
|
每个Bill备份是唯一的内容备份,但所有的Bill实体将共享这个billedTo的Person对象。这在保持对象的值语义上增加了很多的复杂性。对于实体来说如何比较两个Bill对象,是根据他值类型应该遵循Equatable协议?
你能够试着添加如下代码(但不要加到你的playground中):
1 |
|
使用 === 特性操作来检查两个对象是否拥有相同的引用,这意味着两个值类型共享数据,这正是遵循值语义时你不想要的。
所以你改如何处理?
从混合类型中得到值语义
你出于某种原因创建了一个依赖共享实体的Bill结构体,这意味着不是完全独立的拷贝,这违背了值类型的主要目的。
为了更好理解这个问题,添加下面代码到playground的底部:
1 |
|
这边是你按照注释编号依次执行的:
- 首先,创建了一个基于Address和名字的Person类。
- 下一步你实例化了一个默认初始的Bill结构体并创建了它的一个副本
- 最后,你改变了传递过去的Person对象,反过来同时也影响了所谓的唯一实体。
哦不对,这不是你想要的。修改其中一个账单的用户另一个账单的用户也改变了。根据值语义,你期望一个账单属于Bob的而另一个属于Robert才对。
这里,你可以创建Bill的唯一拷贝引用通过 init(amount:billedTo:). 因为Person不属于NSObject类他没有自己的方法,我们必须自己实现copy方法。
在初始化的时候拷贝引用
添加如下代码到你的Bill的实现类底部:
1 |
|
这里添加了一个构造器,你可以通过传递进来的name和address来创建一个新的Person实例,而不是简单给billedTo赋值。因此,调用者将无法通过编辑原来的Person副本来影响Bill结构体中billedTo属性。
看看playground底部打印的两行并查看每个Bill实体的值,你将发现在修改传递过去的参数的Person值,他们任然保持原来的值不变。
1 | bill.billedTo.name // "Robert" |
这个设计最大的一个问题是你能够从外部去访问这个结构体的billedTo属性,这意味着外部实体能够通过意想不到的方式去修改他。
在打印输出上方添加如下代码:
1 | bill.billedTo.name = "Bob" |
现在检查打印的值,你将发现外部实体修改了他们的值,正是由于你上面这句代码导致的:
1 | bill.billedTo.name = "Bob" |
这里的问题是,即使你的结构体是不可变的,任何一个有权限的人都可以修改他底层数据。
使用 copy-on-write 计算属性
原生Swift值类型实现了一个名叫 copy-on-write 的强大特性。当进行赋值时,每个引用指针指向同一个内存地址。只有当其中一个引用修改底部数据的时候Swift才会拷贝原始实体并进行修改。
你可以通过创建一个私有的写入是返回的一个拷贝的billedTo属性。
移除下面这些代码:
1 | // Remove these lines: |
现在使用下面这些代码来实现当前的Bill结构体:
1 | struct Bill { |
以下是新的实现内容:
- 你创建了一个私有变量 _billedTo 持有Person对象的引用。
- 下一步你创建了一个计算属性 billedToForRead 来发回这个私有私有变量用于读取操作。
- 最后,你创建了一个计算属性 billedToForWrite 它将总是创建一个新的唯一的Person类副本用于写操作。注意这个属性被声明为mutating,因为结构体中修改内部值是需要这样声明的。
如果你能保证调用者你能按照你的意愿去使用这些结构体,这个方式能够解决你的问题。理想的状态下,调用者将总是用billedToForRead从你的引用中获取数据,用billedToForWrite修改引用数据。
当是现实真的能如此完美吗?
防御型的修改方法
你将必须添加一些防御型的代码,为了解决这个问题,你应该将这两个属性私有化并创建方法来进行交互。
按照如下代码替换Bill的实现:
1 |
|
这里几点是以上代码做的调整:
- 你将属性私有化,调用者将无法直接进行访问。
- 你添加了 updateBilledToAddress 和 updateBilledToName 两个方法来进行对新的地址和名字的Person类的引用修改。这样的方式保证调用者能够正确的更新billedTo,因为你对外部隐藏的属性。当你使用var替换let来构建Bill对象,用mutating声明这些方法意味着你能够在这里面调用他们。这样的行为和你预期的效果是一致的。
更高效的Copy-on-Write
最后对你的代码进行一些优化。你当前每次都会对Person的引用类型进行拷贝操作,而更好的方式是仅仅当多个对象持有这个引用的时候才进行拷贝操作。
使用如下代码替换billedToForWrite方法的实现:
1 | private var billedToForWrite: Person { |
isKnownUniquelyReferenced(_:)检查是否只有一个对象持有对传入参数的引用。如果没有其他对象共享这个引用,我们就不需要进行拷贝而是直接返回当前的引用即可。他将节省一份副本,并模拟了Swift对值类型的处理。
看如下操作,按如下进行billedToForWrite方法的修改:
1 | private var billedToForWrite: Person { |
这边你仅仅添加了log打印使得你能够看到是否进行拷贝或者没进行拷贝。
在playground末尾添加如下Bill对象进行测试:
1 | var myBill = Bill(amount: 99.99, billedTo: billPayer) |
下一步,在playground末尾添加如下代码使用updateBilledToName(_:)方法进行更新操作。
1 | myBill.updateBilledToName(name: "Eric") // Not making a copy of _billedTo |
因为myBill是唯一的引用,所以不会进行拷贝操作,你能够在debug窗口区域看到打印结果:
注:你实际看到了打印了两次结果,这是因为playground的侧边结果栏是动态对每行进行解析提供预览。这个结果一个是来自通过调用updateBilledToName(_:)中调用billedToForWrite属性得到的,另一个是来自侧边结果栏显示Person对象得到的。
现在在调用updateBilledToName方法之前加入如下myBill的定义去触发拷贝操作:
1 | var billCopy = myBill |
你现在将在debugger窗口看到myBill确实在修改值之前进行_billedTo的拷贝操作。
你将看到一个额外的一个不匹配的打印在playground的侧边结果栏中。那是因为在修改值之前updateBilledToName(_:)创建了一个唯一的副本。当playground再次获取这个属性时候,没有其他对象共享这个引用拷贝,所以不会创建新的拷贝,亲。
结论:高效的价值语义是引用类型和值类型的结合。
从这里我们学到了什么?
在这篇教程中,你学到了值与引用这两种类型的功能差异,你能够利用这些功能来保证你的代码安全的运行。你也学到了如何使用copy-on-write实现值类型,保证只有当需要拷贝的时候才进行拷贝操作。最后你还学到如何避免在一个对象中使用值类型和引用类型产生的混淆。
真心希望,在混合值与引用类型的练习中告诉你如何去保持你语义始终如一,虽然是一个简单的场景。如果你发现你正遇见这样的场景,对他进行一些重新的设计将是不错的选择。
教程中的这个例子强调确保Bill能够持有对Person的引用,但你也能够使用Person的唯一ID或者是简单的name。更进一步说,可能一开始设计Person作为一个类就是一个错误。这些事情都是在你项目需求发现变化时候需要进行评估的。
我希望你能喜欢这篇教程。你能够使用在这里学到的内容去修改值类型的方法,来避免代码混淆。