导读:引用类型和值类型是编程中的一个重要却很基础的知识点,网络上也有很多关于两者的讨论,文章深入分析了两者的不同以及在Swift中该如何选择使用的问题,推荐编程新兵及对引用类型和值类型有疑问的Swift使用者阅读。

(文章略长,精读大概需要半小时到1小时)



内容提纲


翻译内容分割线


我们将在这篇文章探讨Swift中引用类型和值类型的区别,介绍两者的概念和各自的优缺点,以及该如何使用。

引用类型

引用类型初始化后,无论是分配给变量还是常量,或是通过参数传递给函数,都将是同一个实例对象。

Object是一个典型的引用类型,一旦初始化完成,我们不管是将它作为一个值进行分配还是传递,我们都是分配或传递了一个原始对象的引用(实际上它就是内存中的一块区域),引用类型的分配我们一般叫它浅拷贝。

Swift中,用关键字class来定义objects


class PersonClass {
    var name: String
    init(name: String) {
        self.name = name
    }
}
var person = PersonClass(name: "John Doe")

值类型

值类型每次分配给变量/常量或者作为参数传递到函数时,都会重新创建(复制)一个新的实例。

所有的基本类型都是典型的值类型,常用的基本类型也是值类型的有:Int, Double, String, Array, Dictionary, Set。值类型每次初始化以后,当我们将它分配或者传递时,实际上是分配或传递了它的一个拷贝。

Swift中最常用的值类型是structs,enums和tuples,值类型的分配叫做深拷贝。

拷贝语意

我会用图片来展示一个实际的例子以描述它们在语法上的区别。假设我们有一个树的数据结构:


class Node<T: Comparable> {
    let value: T
    var left: Node?
    var right: Node?
    convenience init(value: T) { […] }
    init(value: T, left: Node?, right: Node?){ […] }

    func add(value: T) { […] }
}

我们创建一个二叉树的实例如下:


let binaryTree = Node(value: 8)
tree.add(2)
tree.add(13)

一个二叉树实例 现在,我们来看一下复制语意在行为上的区别。

浅拷贝(引用类型)

复制一个引用类型时,Swift编译器将会复制实例的一个引用。但不包含实例的属性。因此,当对一个引用类型进行多次复制时,每一份复制都将共享同一份数据。

二叉树浅拷贝

深拷贝(值类型)

当我们复制一个值类型时,Swift编译器将复制一个全新的实例,包括它的所有属性。整个过程会复制它所有的值类型属性。因此,当对一个值类型进行多次复制时,每次复制都会产生一个单独的、没有数据共享的新实例。

二叉树深拷贝

引用类型的问题:隐式的数据共享

为了说明引用类型的这种典型问题,我们先定义一个类用来表示2D平面上的一个点。


class PointClass {
    var x: Int = 0
    var y: Int = 0
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

现在,当我们实例化一个PointClass对象并将它分配给另一个变量时发生了什么?


var pointA = PointClass(x: 1, y: 3)
var pointB = pointA

因为 PointClass 是一个引用类型,最后一句的变量声明实际上是把分配给pointA的引用也分配给了pointB。我们可以用下面的图来形象的描述上述情形:

Reference type instances

在这种情形下,pointA和pointB 共享 同一个实例。因此,任何对pointA的改变也将反应到pointB上,反之亦然。这在很多情况下没什么问题,但它也会导致一些不太显而易见的bug。

让我们来看一个很普遍的隐式数据共享问题。假设实例化了一个 view controller 1,并分配了一个 Person 对象(Person是一个model)的实例给它。这时,用户进行操作,我们push了另一个 view controller 2 到上一个 view controller 1 的上面,并把分配了同一个Person的引用实例给 view controller 2。我们可以想象下图这样的特殊情景:

Assigning a reference type to more than one view controller

当两个 view controller 同时持有Person实例的用一个引用,如果我们在 view controlelr 2 中修改了Person的任何属性,将导致之前 view controller 1 中所持有Person的属性也同时被修改。因此,在view controller 2 中对数据模型的修改都将传递到 view controller 1 中。

回到我们最初那个例子,要避免数据隐式共享的问题,一个方法是创建一个实例真正的拷贝,以代替将变量pointA直接进行新变量的分配赋值,如下手动创建拷贝并分配给pointB:


var pointB = pointA.copy()

现在,pointB 有了它自己独立的引用,它和 pointA 之间不再共享数据。这个技巧可正常工作,但还是有一些缺点:

  • 不得不:

    – 继承NSObject并实现NSCopying

    – 实现一个新的Copyable协议

  • 每一次赋值都需要显示的调用 copy() 带来的额外开销

  • 很容易就忘记调用 copy()

值类型实例:没有隐式共享

当分配一个值类型时,编译器会自动创建(并返回)一个实例的拷贝。来看看发生了什么,我们用 struct (值类型) 的 PointStruct 来代替 class (引用类型) 的 Point 。


struct PointStruct {
    var x: Int = 0
    var y: Int = 0
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

现在,我们可以创建一个 PointStruct 的实例 , 并将它分配给另一个变量。


var pointA = PointStruct(x: 1, y: 3)
var pointB = pointA

因为 PointStruct 是值类型, 最后一行的声明会先创建一个PointA的拷贝,并将拷贝分配给了PointB。这就使得两个实例分配是安全并独立的。上述情形图示如下: Values type instances

我们看到 pointB 有它自己独立的引用,并且不会被 pointA 共享。这表明了使用值类型时,我们可以很容易的确保所有值类型的实例都是相互独立并且不会产生数据共享的。

从性能方面看,使用值类型不会产生巨大的开销:

低成本的拷贝:

  • 基础数据类型的复制花费恒定时间

  • 值类型(struct, enum, tuple)的复制花费恒定时间

可扩展的数据结构使用即写即拷

  • 拷贝包含一个固定数量的引用计数操作

  • 这项技术常用于许多标准库的类型:String, Array, Set, Dictionary, …

除了以上说的,值类型的另一个性能方面的优点是栈分配,它相对于堆分配(引用类型)有着更高的效率。这将使得访问更快但无法支持继承。

有一点需要注意的是,只有当 structs, enums, tuples 它们的所有属性也都是值类型时,它们才是真正的值类型。如果它们包含引用类型的属性,则依然会有前面所提到的隐式数据共享的问题。

看下面的 struct :


struct PersonView {
    let person: PersonStruct
    let view: UIView
}

我们预期的目标是创建一个容器,用来跟踪一个 person, 并用一个视图来显示这个person的所有的相关信息。我们确信上面的代码是合理的;我们用 let 定义了属性,对吗?但很不幸,事实并非如此。因为 view 是一个引用(UIView是引用类型),我们依然可以修改它的属性!但这会造成一些更微妙的bugs。为了展示这个问题,我们创建一个 PersonView 的实例并进行一份拷贝:


let personViewA =
    PersonView(person: PersonStruct(name: "John Doe"),
               view: UIView())
let personViewB = personViewA

由于 view 是引用类型,这将导致两个 PersonView 的实例共享了同一个 view 属性!所以当我们修改 view 的属性时,实际上最终修改的是 共享的 view 。通过下图可以更容易理解,再一次,我们又回到了隐式共享数据的问题讨论: Value type containing a reference type

引用类型,值类型和不可变性

不可变性: 一个实例的属性在创建之后不可更改。

不可变性是函数式编程中很重要的一个严格约束。使用值类型可确保我们所创建的实例的状态是确定的,定义后即不可改变。不可变的对象有着一些有趣的优缺点。

优点:

  • 不可变对象不会共享数据,因此,也不会在实例间共享状态。这就避免了不可预见的状态改变所带来的副作用。
  • 一个直接结果是对于前面的不可变对象Point来说是线程安全的。这意味着我们将不必担心竞争条件和线程同步。
  • 因为不可变对象保存其自身的状态,这将更容易的组织代码。

缺点:

  • 不可变性对机器模型来说不总是高效映射的。一个典型的例子是执行立即改变的算法(例如快速排序)。在保持原来性能的前提下,通过值类型来实现这些是不容易的。

Swift中,我们可以使用两个不同的单词来定义变量:

  • var : 定义可变实例
  • let : 定义不可变实例

上面两个关键字有着不同的行为,这取决于他们是用于引用还是值类型。

可变实例声明:var

引用类型 引用可以改变(mutable):你可以改变实例本身和它的引用。

值类型 实例可以改变(mutable): 你可以改变实例的属性。

不可变实例声明:let 引用类型 引用将保持不变(immutable): 你不能改变实例的引用,但可以改变实例它本身。

值类型 实例保持不变(不可变的):不能修改实例的属性,无论这个属性是用let还是var定义的。

引用类型和值类型该如何选择?

一个很常见的问题:“我该在什么时候用引用类型,什么时候用值类型?” 你能在网上看到很多这样的讨论。以下是我喜欢的一些例子:

作为一个基本的规则,我们每次创建引用类型都要继承自 NSObject 。这是一个使用 Cocoa SDK 常见的场景。对于引用类型和值类型的使用,苹果还提供了一些通用规则。我总结如下:

以下情况使用引用类型:

  • NSObject的子类必须是class类型
  • 使用 === (全等于)来比较实例
  • 需要创建可共享和改变的状态

以下情况使用值类型

  • 使用 == 来比较实例数据
  • 需要拷贝独立的状态
  • 代码会运行在跨多线程上 (避免显示同步)

有趣的是,Swift 的标准库大量依赖于值类型:

  • 基本数据类型(Int,Double,String…)是值类型
  • 标准集合类型(Array,Dictionary,Set…)也是值类型

通过查阅Swift标准库文档,可收集到有关上面讨论的准确数据:

  • Classes = 4
  • Structs = 103
  • Enums = 9

抛开上面的讨论,选择引用还是值类型真的应该取决于你要实现什么。一般说来,如果没有特殊的限制迫使你要选择引用类型,或者不确定哪个选择更合适时,可以先实现一个值类型的数据结构。如果以后需要,你可以相对简单的将它转化为一个引用类型。

总结

你可以从这里下载本文中的代码(一个playground文件)。 在这篇文章里,我们研究了引用类型和值类型的区别。然后深入探讨了隐式数据共享所带来的问题,以及如何用值类型代替引用类型以避免它们发生。

我们还介绍了不可变性的概念以及在Swift中它们是如何适用的。最后,我们回顾了一些用例中如何简单的在引用和值类型之间做选择。对于其他所有情况,最好是通过试验来找出最好的选择。