Swift 中的值类型与引用类型使用指北

9,304 阅读19分钟

Swift 中的值类型与引用类型使用指北

在本文中,我们将探索值类型与引用类型语义的不同之处,在 Swift 中使用值类型的一些鲜明特征和关键的好处。然后我们会关注在设计程序时,何时使用值类型或者引用类型。

Swift 中的值类型和引用类型

Swift 是一种多范式的编程语言。它有类,这是构成面向对象编程的基石。类在 Swift 中可以定义属性和方法,指定构造器,符合协议,支持集成和多态。Swift 也是一种面向协议的编程语言,通过功能丰富的协议和结构体,可以在没有继承的情况下实现抽象和多态。在 Swift 中,函数是第一类型,它可以赋给变量,作为参数和返回值在多个函数之间传递。因此 Swift 也适用于函数式编程。

对于多数面向对象语言的开发者来说,Swift 中最大的不同就是结构体的丰富功能。除了继承以外,你在一个类里可以做什么,在结构体中同样可以做到。这就引发了问题 —— 何时并如何使用结构体和类。更通俗的说,问题是在 Swift 中何时并如何使用值类型和引用类型。

为了完整需要提醒一下,Swift 中的值类型并不仅仅只有结构体。枚举和元组也是值类型。同样地,引用类型并不只有类,函数也是引用类型。不过函数、枚举和元组在使用时更加特定化。Swift 在值类型和引用类型的争论中心都集中在结构体和类上。这是本文中的主要重点,所以在本文中术语值类型和引用类型可以和术语结构体和类相互转换。

现在让我们从一些基本原理开始,即值和引用语义的区别。

值与引用

使用值语义,变量和分配给变量的数据在逻辑上是统一的。由于变量存在于栈上,值类型在 Swift 中被称为栈分配。确切地说,所有的值类型实例并不一直在栈上。一些可能只存在于 CPU 寄存器中,另一些可能实际在堆上分配。从逻辑上讲,值类型的实例可以被认为是包含在被赋值的变量之中。在变量和值之间存在一对一的关系。变量所包有的值不能独立于变量进行操作。

另一方面,在使用引用语义时,变量和数据是不同的。引用类型的实例在堆中分配,变量只包含一个对存储数据的内存位置的引用。一个实例引用多个变量是可以的也是很常见的。任何这些引用都可以用来操作实例。

这会对将值或引用类型实例分配给新变量或传递给函数时发生一些影响。由于值类型实例只能拥有一个所有者,实例被复制,并将副本分配给新变量或传入某函数。每个副本都可以修改而互不影响。对于引用类型,只有引用被复制,并且新变量或函数获得对同一实例的新引用。如果使用任何引用修改引用类型实例,则会影响所有其他引用持有者,因为它们持有的都是对同一实例的引用。

我们来看看代码。

struct CatStruct {
    var name: String
}

let a = CatStruct(name: "Whiskers")
var b = a
b.name = "Fluffy"

print(a.name)   // Whiskers
print(b.name)   // Fluffy

我们定义了一个结构体表示一只猫,有一个 name 属性。我们创建一个 CatStruct 实例,把它赋给一个变量,然后把这个变量赋给一个新的变量,并用新变量改变 name 属性。由于结构体是值语义,赋值给新变量的行为会导致实例被复制,然后我们得到了两个不同名字的 CatStruct

现在,我们用类做同样的事:

class CatClass {
    init(name: String) {
        self.name = name
    }

    var name: String
}

let x = CatClass(name: "Whiskers")
let y = x
y.name = "Fluffy"

print(x.name)   // Fluffy
print(y.name)   // Fluffy

在这种情况下,用新变量改变 name 属性也会修改第一个变量的 name 属性。这是因为类是引用语义,赋值给新变量的行为不会创建一个新的实例,两个变量持有对同一个实例的引用,这导致隐式数据共享,这可能会对你如何并何时使用引用类型产生影响。

可变性的不同概念

为了理解可变性在值类型和引用类型之间的差异,我们必须要分清楚变量可变性实例可变性

我们上面已经知道,值类型实例和被赋值的变量在逻辑上是一致的。因此,如果变量是不可变的,那无论该实例是否有可变属性或者可变方法,变量都会忽略让实例不可变。只有当值类型的实例赋给一个可变变量时,实例的可变性才可以起作用。

对于引用类型,实例和被赋值的变量是不同的,因此他们的可变性也是不同的。当我们声明一个不可变的变量引用一个实例,我们能确定的是,这个变量的引用永远不会改变。即它总会指向同一个实例。实例的可变属性还是可以通过这个或者其他的引用改变。如果要让类实例不可变,必须保证它的所有存储属性都是不可变的。

在刚才的代码中,我们看到,可以声明 a 将第一个 CatStruct 实例作为 let 常量,因为它不会被修改。而 b 必须被声明为一个 var,因为我们修改了它的 name 属性和值。对于 CatClassxy 都被声明为 let 常量,然而我们能修改 name 属性。

定义为值类型的特征

为了能更好的理解什么时候以及如何使用值类型,我们需要看一下定义为值类型的一些特征:

  1. **基于属性的相等:**任何两个同类型值,其属性相等,都可以认为他们是相等的。考虑一个货币类型,它表示货币具有货币和金额属性。如果我们创建一个 5 美元的实例,它与任何其他 5 美元实例都相等。
  2. **淡化的标识及生明周期:**值类型没有固定的身份。它仅由其属性而定义。对于数字 2 或者 “Swift” 这种简单的值就是这种情况。对于复杂的值来说也是如此。值也没有需要保存状态变化的生命周期。它可以随时被创建、销毁或重建。代表 5 美元的货币实例,等于代表 5 美元的任何其他实例,无论这两个实例是何时或如何创建。
  3. **可替代性:**没有明确的标识和生命周期给了值类型可替代性,这意味着,如果两个实例相等,即它们通过了基于属性的相等测试,那么任何实例都可以被自由地替代。回到我们的货币类型例子,一旦我们创建了一个代表 5 美元的实例,程序可以根据情况自由的创建或放弃这个实例的副本。无论何时我们需要递交一个 5 美元的实例,这个 5 美元的实例是否是先前创建的那个已经无关紧要,我们要关心的是值的属性。

使用值类型的优点

1. 效率

引用类型在堆上分配,这比在栈上分配要昂贵的多。为了确保在引用类型不需要时内存被释放,需要保持一个对每个引用类型的所有活动的引用计数,并在没有引用时销毁实例。值类型没有这种开销,所以在创建和复制上很高效。值类型的复制是廉价的,因为值类型的实例在不变(constant)的时间被复制。

Swift 实现了内置的可扩展的数据结构,比如 StringArrayDictionary 等等。然而,这些并不能在栈上分配,因为他们的大小在编译时是不知道的。为了能有效地使用堆分配并且保有值语义,Swift 使用一种名为写时复制的优化技术。这意味着每个复制的实例都是逻辑意义上的副本,只有当复制的实例发生变化时才会在堆上创建实际的副本,在此之前,所有的逻辑副本都会指向相同的底层实例。因为更少的副本被创建,并且在创建的时候,涉及了固定数量的引用计数操作,所以提供了更好的性能。如果需要,这种性能优化还可以对自定义值类型使用。

2. 可预测的代码

使用引用类型时,持有对实例的引用的代码的任何部分都不能确定该实例包含的内容,因为可以使用任何其他引用来修改该实例包含的内容。由于值类型实例在复制时没有隐式数据共享,所以我们不需要考虑代码的某部分的行为会影响其他部分行为所造成的意外后果。而且,当我们看到一个变量声明为 let 常量并持有一个值类型的实例时,我们可以肯定,无论如何定义值类型,该值都不能被修改。这为代码的行为提供了强有力的守护以及细粒度的控制,让代码变的易于推理和预测。

有人可能会争辩说,可以编写代码,使得每次将引用类型实例交给新所有者时,都会创建一个副本。 但是这会导致很多防御性复制,这样效率会非常低,因为复制一个引用类型会带来很大的开销。如果正在复制的引用类型实例具有也是引用类型实例的属性,并且我们希望避免任何隐式数据共享,则每次都必须创建深度拷贝,这会使让性能更糟。我们也可以尝试通过使所有引用类型不可变来解决共享状态和可变性的问题。但是这仍然会涉及到很多低效率的复制,而且无法改变引用类型的状态会失去引用类型的用意。

3. 线程安全

值类型实例可以在多线程环境中使用,而不用担心一个线程正在改变另一个线程实例的状态。由于没有竞态条件和死锁,所以没有必要实现同步机制。使用值类型编写多线程的代码变得更简单、更安全、更高效。

4. 无内存泄漏

Swift 使用自动引用计数,并在没有引用的情况下,释放引用类型实例。这解决了正常事件过程中的内存泄漏问题。不过,通过强循环引用仍会内存泄漏,即当两个类实例彼此强引用互相阻止彼此的释放。当一个类与一个闭包(在 Swift 中也是引用类型)彼此强引用也会发生相同的情况。由于值类型没有引用,所以内存泄漏的问题也就不存在。

5. 易于测试

因为引用类型的生命周期会保有状态,所以在对引用类型进行单元测试时,经常使用模拟框架来观察各种方法被调用时对测试对象的状态和行为的影响。而且由于引用类型实例的行为会随状态的变化而改变,通常需要设置代码来保证测试对象处于正确的状态。对值类型而言,要关心的全部是值类型的属性。所以我们需要做的,就是创建一个新的值,这个值的属性和期望的值属性相同。

用值类型和引用类型设计程序

值类型和引用类型不应该被看作是相互竞争的。他们不同的语义和行为,让他们适用于不同的情景。我们的目的是理解并运用值和引用语义,让他们以最能满足应用目标的方式结合起来。

1. 使用引用类型模拟具有标识的实体

几乎所有现实世界领域都有在生命周期里保持着标识和状态的实体。这些实体应该使用类来建模。

考虑有一个使用员工类型来代表员工的薪酬应用。简单地,假设只存储员工的姓和名。可能有两个或者更多的员工实例的姓名相同,但是这并不能让他们相等,因为在现实世界中,这些实例代表着不同的员工。

如果把一个员工类实例赋给一个新的变量或者把它传到一个函数里,新的引用会指向相同的实例。这是我们可以确定的。例如,如果我们在应用的某个模块中使用一个引用来记录员工的工时,那么当应用另一个模块计算每月工资时,它使用的都是具有正确工时的同一个实例。同样,如果在某个位置更新员工的地址,那么我们对员工的所有引用都会更新为正确的地址,因为他们是对同一实例的引用。

如果尝试使用结构体来模拟员工的话会导致错误并且前后矛盾,因为每次把员工实例赋给一个变量或者传给一个函数时,它会被复制。程序中不同的部分会以它们各自的实例结束,并且其中某部分状态改变并不会在其他部分体现出来。

2. 用值类型来封装状态和暴露行为

虽然有标识和生命周期的实体需要用类来建模,但是需要用值类型来封装它们的状态,表示相关的业务并且暴露行为。

继续以员工类型为例。假设要保留每个员工的个人数据,工资绩效信息。我们可以创建个人信息工资绩效值类型,将状态、业务规则和行为这些元素联系在一起。这可以让类不那么臃肿,因为它只负责维护标识,而它包含的值类型实例会处理该状态的各种元素和相关行为。

这也非常符合单一原则。例如,相比于员工类型不得不实现一些方法来暴露各种层面的行为,客户代码只对员工的绩效感兴趣,所以交给绩效实例来处理。因为处理的是值类型,我们无需担心隐式数据共享与客户端背后变化,而对员工实例的状态产生影响。

这种方式也更加适用于多线程。表示引用类型实例状态的各种元素的值类型实例副本,可以自由地切换到不同线程上的进程,而不需要同步。这可以提高性能,并提高应用交互的响应。

3. 上下文的重要性

要注意的是,有时值类型和引用类型的选择是由上下文驱动的。应用开发不是绝对意义上的对现实世界的建模练习,而是建模问题的具体方面,以满足给定的用例。因此,要判断在应用程序的上下文中使用值语义还是引用语义,具体取决于实体在相关领域问题中扮演的角色。

想一想前面介绍的 CatStructCatClass 类型。我们更愿意使用哪一种模型来模拟宠物猫呢?由于实例将代表一只真正的猫,所以应该使用一个类。例如,当我们把猫交给兽医来打疫苗时,我们不希望兽医给一只猫的副本打疫苗,如果使用一个结构体,就会发生这样的事情。但是,如果我们正在设计一个处理宠物猫的饮食习惯的应用,那么就应该使用结构体来处理一般意义上的猫,而不是寻找一只特定标识的猫。对于这样的应用,我们的 CatStruct 不会拥有 name 属性,但可能有消耗食物类型,每天的服务数量等的属性。

不久前,我们使用货币类型作为一个值为模型的概念的绝佳例子。在银行,金融或其他应用的情况下,我们只关心货币的属性,即货币的多少和种类。但是,如果我们正在建立一个实物货币的印刷,分配和最终处理的应用,我们就需要将每个纸币视为具有唯一标识和生命周期的实体。

相同地,对于为轮胎制造商开发的应用程序来说,每个轮胎都可能是一个具有唯一标识和生命周期的实体,用于销售点以追踪退货,保修索赔等。但是,对制造汽车的公司而言,他们也许不想看轮胎的属性来跟踪哪辆车使用哪个轮胎,尽管他们可以看到他们制造的汽车具有独特的标识和生命周期。

4. 基于属性相等的测试

值类型没有固定的标识来区分它是否是那个类型实例。唯一比较它们的方式就是比较它们的属性。事实上,基于属性相等性的概念在值类型中是非常基本的,所以决定一个特定的类型是值类型还是引用类型,它可以作为一个指引。如果一个类型的两个实例不能仅使用基于属性的相等来比较的话,那我们就要处理一些元素的标识,这通常意味着他们是引用类型,或者它们可以用值和引用语义区分。

实际上,这意味着要比较任何两个实例是否相等都要使用 == 运算符。因此,所有的值类型都必须符合 Equatable 协议。

5. 结合值类型和引用类型

如上面提到过的,把引用类型的属性封装为值类型的实例,以达到封装状态,表示业务规则并且暴露行为的目的是非常可取的。这些值类型可以高效传递,而不用担心意外后果,如线程安全性等。但是,值类型应该保存引用类型的实例吗?这通常应该避免,因为在值类型上使用引用类型属性会引入堆分配,引用计数和隐式数据共享,影响值类型的性能和其他优点。事实上,它会导致值类型失去其基于属性的平等,淡化标识和可替代性的特点。因此,重要的是要遵守规则,不能以损害两者完整性的方式来结合值与引用语义。

有很多方式描述了值类型和引用类型是如何在实际应用中工作的。如 Andy Matuschak这篇文章中所说的:把对象看作是可预测的纯净的值层之上的一个轻薄的必要的层。在 Andy 的文章的参考文献部分是 Gary Bernhardt这次演讲,一种使用他称之为的函数性核心和命令式外壳来构建系统的方法。函数核心由纯粹的值,特定领域逻辑和业务规则组成。很容易得出,这套系统有利于并发并且易于测试,因为它通过命令式外壳与外部依赖隔离,因此保留了状态并连接到用户界面,持久化机制,网络等等。

Swift 标准库与 Cocoa 框架

Swift 的标准库主要由值类型组成。所有的内建基本类型和集合都是用结构体实现的。构成 Cocoa 框架的部分主要由类构成。有些地方需要类的原因是,类对于 MVC,用户界面元素,网络连接,文件处理等等是很恰当的方式。

但是 Cocoa 在 Foundation 框架里也有很多类是值类型的,不过作为引用类型而存在,因为他们是用 Objective-C 来编写的。这就是 Swift 标准覆盖的地方,为越来越多的 Objective-C 引用类型提供了值类型的桥接。更多桥接类型和 Swift 与 Cocoa 框架之间交互的细节,可以看看苹果开发者网站上的这一页

结论

Swift 提供了强大而高效的值类型,让我们的代码更加高效,可预测而且线程安全。这就需要理解值和引用语义之间的差异,才能以最能满足应用程序目标的方式来结合值类型和引用类型。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏