Swift引用类型 VS 值类型 (2/2)

作者: matrix_lab | 来源:发表于2016-08-19 02:19 被阅读67次

    本篇文章翻译自: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一开始就是错的。当你的项目需求发生改变时,那么你就要评估类型的事情了。
    我希望你很享受这个系列文章;你可以利用你学到东西,在代码实践中调整使用值类型的方式,和避免代码混乱和混淆。
    欢迎学习交流。

    相关文章

      网友评论

        本文标题:Swift引用类型 VS 值类型 (2/2)

        本文链接:https://www.haomeiwen.com/subject/tatbsttx.html