Swift引用类型 VS 值类型 (2/2)
本篇文章翻译自:Reference vs Value Types in Swift: Part 2/2
原作: Eric Cerney on November 10, 2015
欢迎来到swift引用类型和值类型的第二部分,也是本系列文章的最后一部分。在第一本,你已经探索了引用类型和值类型的区别,以及每种类型适用的场景。
第二部分将解决一个现实的问题,来更加细致地解释每种类型,还有向你展示每种类型更细微的特性。
tutorial 实践在Swift 1.2和 Swift 2.0
开始
首先,在Xcode中创建一个playground,选择File\New\Playground...把playground文件命名为ValueSemanticsPart2。你可以选择任意平台,因为本tutorial与平台无关,我们仅关注Swift语言本身。点击Next,选择一个合适的路径,保存playground,然后打开。
写时复制
第一部分中,只是向你展示了值类型,但是它内部原理是怎样的呢?
值类型实现了一个很棒的特征,即"写时复制":当赋值时,每一个引用都会指向同一个块内存地址。只有当一个引用的底层数据真的被修改,Swift才会去复制原先的实例,并做出修改。
为了展示这种机制是怎么工作的,在playground中,添加一个地址的基本实现:
struct Address {
var streetAddress: String
var city: String
var state: String
var postalCode: String
}
地址所用的属性来自于现实建筑的物理地址。这些属性都是String类型;为了简洁,我们省略了验证逻辑。
接下来,你需要创建几个变量存储相同的地址结构:
var test1 = Address(streetAddress: "1 King Way", city: "Kings Landing", state: "Westeros", postalCode: "12345")
var test2 = test1
var test3 = test2
为了弄清楚写时复制是怎么工作的,你需要检查每一个地址实例的内存地址。因为这些值Swift并不向你开放,所以这需要点hacker技术。
查看内存
在地址的实现下面添加代码:
struct AddressBits {
let underlyingPtr: UnsafeMutablePointer<Void>
let padding1: Int
let padding2: Int
let padding3: Int
let padding4: Int
let padding5: Int
}
这个结构会呈现你之前创建的地址对象的具体大小。你将要把这个类型和地址对象一同传给一个函数,这个函数会返回对象的内存地址。
这个UnsafeMutablePointer变量将存储内存地址,而padding变量仅仅是让这个结构匹配地址对象的大小。不用纠结这个结构的具体细节;它唯一的任务是填充正确的数量的比特位。
之前说到每一次赋值,Swift会创建原始数据的一个新的副本,并且引用它。为了弄清楚底层到底发生了什么,playground添加如下代码:
let bits1 = unsafeBitCast(test1, AddressBits.self)
let bits2 = unsafeBitCast(test2, AddressBits.self)
let bits3 = unsafeBitCast(test3, AddressBits.self)
bits1.underlyingPtr
bits2.underlyingPtr
bits3.underlyingPtr
上面的代码调用了unsafeBitCast(_:type:)方法,并传入你之前创建的AddressBits作为参数来存储内存地址,然后该函数打印underlyingPtr属性来查看内存地址。
你会看到:
whoa---每一个变量的都有相同的内存地址!这可能跟你认为的值类型的工作方式有所不同。很显然,有一些Swift魔法参与了进来。
触发写时复制
如果你改变一个变量的属性会发生什么?
test2.streetAddress = "test"
我们来看一下指针地址:
当你改变test2,Swift会创建一个新的,独一无二的拷贝,然后分配它回到原来的变量!你会注意到test1和test3同样还是指向相同的实例,因为他们没有发生改变。
聪明的内存
为了验证更加疯狂的结论,给test3赋值与test2相同的值。
test3.streetAddress = "test"
Swift发现test2和test3有相同的底层数据,然后就让他们指向相同的实例。
Swift不仅懒得拷贝数据,还智能地向相同的内存地址赋值相等的值类型数据。
写时复制总结
以上行为让Swift的值类型如此强大。Swift智能地引用相同的对象直到他们发生改变,这样一来就带来了内存使用的优化,还有CPU运算性能的明显提升。
但是性能提升还远不止这些。当两个值类型共享同一块内存地址时,Swift甚至都不依赖==比较,因为他们从定义上就是必须是相等的。像这样的小细节对于swift效率的提升有很大帮助。
所有的这些优化对于开发者都是不可见的。你可以在一个比较低级的层面认识引用类型带来的好处,但是你也不必担心两个值类型变量引用相同的实例(还是蛮震撼的,笔者之前也认为值类型肯定都是独一无二的,各占各坑)。非常简洁!!!
混合值和引用类型
之前说到,会有一个现实场景需要你做出决定,权衡该选用值类型还是引用类型。
引用类型包含值类型
引用类型包含值类型很普遍。
class Person { // 引用类型
var name: String // 值类型
var address: Address // 值类型
init(name: String, address: Address) {
self.name = name
self.address = address
}
}
这种混合类型在这个场景下是讲的通的。每一个类实例有它自己的值类型属性实例,他们并不共享这些属性。
但是当值类型包含引用类型,事情就开始变得乱糟糟了。下面的部分会看到。
值类型包含引用类型属性
在playground添加代码:
struct Bill {
let amount: Float
let billedTo: Person
}
每一个账单对象都是数据的独一无二的拷贝,但是billedTo:Person属性将会被大量的账单实例共有。
这会给你维护对象的值语意增加难度。例如,你怎么比较两个账单相等?毕竟值类型应该遵守Equatable
你可能会试着这么写:
//不用添加到playground
extension Bill: Equatable{}
func ==(lhs: Bill, rhs: Bill) -> Bool {
return lhs.amount == rhs.amount &&
rhs.billedTo === lhs.billedTo
}
使用操作符===来检查两个对象是否有相同的引用,这意味着两个值类型共用了数据。这很显然不是你想要的值语意(你想要的值语意是独一无二的,不能两个值类型还共享一些数据)。所以你该做些什么呢?
从混合类型中获得值语意
很显然把账单创建为结构体类型是有原因的,但是让它共享实例是违背初衷的。在playground添加代码:
let billAddress = Address(streetAddress: "1 King Way", city: "Kings Landing", state: "Westeros", postalCode: "12345")
let billPayer = Person(name: "Robbert", address: billAddress)
let bill = Bill(amount: 42.99, billedTo: billPayer)
let bill2 = bill
billPayer.name = "Bob"
bill.billedTo.name //Bob
bill2.billedTo.name //Bob
我们依次来看以下几点:
- 首先, 基于Adddress和name,创建一个Person实例。
- 然后,用默认构造器初始化一个新的Bill实例,之后通过赋值给一个新的常量,创建了一个副本。
- 最后,你改变了传进来的Person对象。这影响到本来应该独一无二的实例。
Hmm, 这显然不是你想要的。你可以在init(amount:billedTo:)方法中拷贝账单的引用。这样以来,你不得不写自定义的copy方法,因为Person类不是NSObject,也没有自己的copy方法。
在初始化方法中拷贝引用
struct Bill {
let amount: Float
let billedTo: Person
//由参数创建一个新的Person引用
init(amount: Float, billTo: Person) {
self.amount = amount
self.billedTo = Person(name: billedTo.name, address: billedTo.address)
}
}
这里增加了一个显式构造器。代替直接赋值billedTo,我们创建了一个新的跟传入参数相同的数据。调用者将不能够通过修改Person的原始版本,影响Bill。
看一下playground的打印输出,你可以检查一下每一个账单实例。你会看到即使是改变了传入的参数,每一个实例也会保持原有的值。
bill.billedTo.name //Robbert
bill2.billedTo.name //Robbert
这种设计存在一个问题,你可以从结构体外部访问到billedTo属性;那就意味着可以以一种不可预知的方式来修改结构体。
bill.billedTo.name = "Bob"
现在检查一下输出值;他们完全被外界修改了---就是上面淘气的代码。即使你的结构体是不可变的,但是任何能够访问到它的人都可以修改它的底层数据(显然我们要权限控制)。
写时复制计算属性
你可以让billedTo私有化,写时返回一个副本。
playground中移除测试代码:
//移除
/*
bill.billedTo.name = "Bob"
bill.billedTo.name
bill2.billedTo.name
*/
现在考虑下面账单的实现:
struct Bill {
let amount: Float
private var _billedTo: Person
var billedToForRead: Person {
return _billedTo
}
var billedToWrite: Person {
mutating get {
_billedTo = Person(name: _billedTo.name, address: _billedTo.address)
return _billedTo
}
}
init(amount: Float, billedTo: Person) {
self.amount = amount
_billedTo = Person(name: billedTo.name, address: billedTo.address)
}
}
我们来看发生了什么:
- 首先,你创建了一个私有属性引用Person对象。
- 然后,创建计算属性,为读操作返回私有属性。
- 最后,创建一个计算属性,它总是为写操作创建一个新的,独一无二的Person对象的拷贝。注意这个属性必须声明为mutating,因为它要改变结构体的底层数据。
如果你可以保证你的调用者会以你的意思使用你的结构体,这个方法可以解决你的问题。理想状态下,你的调用者总是使用billedTorRead从引用获取数据,使用billedToForWrite改变引用。
但是现实情况并非如此,不是吗?
保护Mutating方法
为了解决问题,你需要添加一些保护代码。你可以隐藏两个新的属性,让外部访问不到,然后创建方法让外部跟内部属性沟通交流。
struct Bill {
let amount: Float
private var _billedTo: Person
// 1
private var billedToForRead: Person {
return _billedTo
}
private var billedToWrite: Person {
mutating get {
_billedTo = Person(name: _billedTo.name, address: _billedTo.address)
return _billedTo
}
}
init(amount: Float, billedTo: Person) {
self.amount = amount
_billedTo = Person(name: billedTo.name, address: billedTo.address)
}
// 2
mutating func updateBilledToAddress(address: Address) {
billedToWrite.address = address
}
mutating func updateBilledToName(name: String) {
billedToWrite.name = name
}
}
我们来看看发生的一些改变:
- 你让两个计算属性都为private, 因此调用者不能直接访问属性。
- 你还添加个两个方法用新的name和address来改变Person引用。这让误用变得不可能,因为你隐藏底层属性billedTo。
用mutating声明方法意味着,只有当使用var,而不是let初始化Bill实例时,你可以调用该方法。这正是你所期待的值语意工作方式。
更加高效的写时复制
最后一件事情是提高你的代码效率。你目前每次写入都会拷贝引用类型Person。一个更好的方法是只有在有多个对象引用的时候,我们才拷贝数据。
替换billledToForWrite的实现为以下方式:
private var billedToWrite: Person {
mutating get {
if !isUniquelyReferencedNonObjC(&_billedTo) {
_billedTo = Person(name: _billedTo.name, address: _billedTo.address)
}
return _billedTo
}
}
isUniquelyReferencedNonObjC(_:)检查有无其他对象引用穿入的参数。如果没有其他对象共有引用,那么就没有必要拷贝,直接返回当前引用。这将会节省内存,在用值类型工作时,要学着模仿Swift自身的做法。
延伸阅读
你可以在这里下载本篇所有代码。
在本篇tutorial中,你了解到值类型和引用类型的一些特定的功能,你可以选择性的使用它们,来让你的代码以一种可预见的方式工作。你也了解到值类型通过懒拷贝数据来保证性能,和怎么避免在一个对象中同时使用值类型和引用类型的混乱状态。
希望你了解混合值类型和引用类型时,保持值语意一致是多么具有挑战性,甚至是在上面的简单场景也不简单。如果你已经发现,这个场景需要一点修改,那么你很棒。
本篇的这个实例致在保证一个账单引用一个人,但是也许你可以使用人的独一无二的ID,或者仅仅是姓名。更进一步,也行把Person设计成Class一开始就是错的。当你的项目需求发生改变时,那么你就要评估类型的事情了。
我希望你很享受这个系列文章;你可以利用你学到东西,在代码实践中调整使用值类型的方式,和避免代码混乱和混淆。
欢迎学习交流。