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

我们依次来看以下几点:

  1. 首先, 基于Adddress和name,创建一个Person实例。
  2. 然后,用默认构造器初始化一个新的Bill实例,之后通过赋值给一个新的常量,创建了一个副本。
  3. 最后,你改变了传进来的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)
    }
}

我们来看发生了什么:

  1. 首先,你创建了一个私有属性引用Person对象。
  2. 然后,创建计算属性,为读操作返回私有属性。
  3. 最后,创建一个计算属性,它总是为写操作创建一个新的,独一无二的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
    }
}

我们来看看发生的一些改变:

  1. 你让两个计算属性都为private, 因此调用者不能直接访问属性。
  2. 你还添加个两个方法用新的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一开始就是错的。当你的项目需求发生改变时,那么你就要评估类型的事情了。
我希望你很享受这个系列文章;你可以利用你学到东西,在代码实践中调整使用值类型的方式,和避免代码混乱和混淆。
欢迎学习交流。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容