返回首页 Objective-C 期刊

第二十四期-音频

结构体和值类型

如果你曾经使用过 Objective-C 或者像 Ruby,Python,JavaScript 这样的语言,可能会觉得 Swift 里的结构体就像外星人一样奇异。类是面向对象编程语言中传统的结构单元。的确,和结构体相比,Swift 的类支持实现继承,(受限的)反射,析构函数和多所有者。

既然类比结构体强大这么多,为什么还要使用结构体?正是因为它的使用范围受限,使得结构体在构建代码块 (blocks) 的时候非常灵活。在本文中,你将会学习到结构体和其他的值类型是如何大幅提高代码的清晰度、灵活性和可读性的。

值类型和引用类型

结构体是值类型的,而类是引用类型的,这一行为上的细微区别造就了架构上的无限可能。

值类型的实例,不管是在赋值或是作为函数参数的时候,都是被复制的。数字,字符串,数组,字典,枚举,元组和结构体都是值类型。比如:

var a = "Hello"
var b = a
b.extend(", world")
println("a: \(a); b: \(b)") // a: Hello; b: Hello, world

引用类型的实例 (主要是类) 可以有多个所有者。将一个引用赋值给一个新的变量或者传递给一个函数的时候,它们都指向同一个实例。这是你熟悉的对象的行为。比如:

var a = UIView()
var b = a
b.alpha = 0.5
println("a: \(a.alpha); b: \(b.alpha)") // a: 0.5; b: 0.5

这两种类型的区别看起来似乎不大,但是选择值类型还是选择引用类型会给你的系统架构带来很大的差异。

培养我们的直觉

既然我们已经知道了值类型和引用类型行为上的区别,现在让我们讨论一下使用上的区别。

Swift 将来除了对象可能还会有其他的引用类型,但是就这次讨论,我们只将对象作为引用类型的范例。

我们在代码中引用对象和我们在现实生活中引用对象是一样的。编程书籍经常使用一个现实世界的隐喻来教授人们面向对象编程:你可以创建一个 Dog 类,然后将它实例化来定义 fido (译注:狗的名字)。如果你将 fido 在系统的不同部分之间传递,它们谈论的仍然是同一个 fido。这是有意义的,因为如果你的确有一只叫 Fido 的狗,无论何时你谈到它时,你将会使用它的名字进行信息传输 —— 而不是传输狗本身。你可能依赖于其他人知道 Fido 是谁。当你使用对象的时候,你是在系统内传递着实例的名字

值就像数据一样。如果你向别人发出了一张费用开销表,你发出的不是一个代表那个信息的标签 —— 你是在传递信息本身。消息接收者可以在不和任何人交流的情况下,计算总和,或者把费用写下来供日后查阅。如果消息接收者打印了费用表并且修改了它们,这也没有修改你自己的那张表。

一个值可以是一个数字,也许代表一个价格,或是一个类似字符串的描述。它可以是枚举中的一个选项:这次的花费是因为一顿晚餐,还是旅行,还是材料?在指定的位置中还能包括一些其他的值,比如一个代表经度和纬度的 CLLocationCoordinate2D 结构体。或者它可以是一些其他值的列表等等。

Fido 可能在自己的地盘里来回跑叫。它也许会有特殊的行为使它区别于其他的狗。他可能会同其他的狗建立关系。你不能把 Fido 换成其他的狗 —— 你的孩子们会发现的!但是一张费用开销表是独立的。那些字符串和数字不会做任何事情。它们不会背着你私下改变,不管你用多少种不同的方式在第一列写入了一个6,它永远只会是一个6

这就是值类型的伟大之处。

值类型的优势

Objective-C 和 C 具有值类型,但是 Swift 允许你在以前不能使用的场景下使用它们。比如,泛型系统的抽象特性可以让泛型类型在值和引用类型间互换。数组既可以存储 Int 也能存储 UIView。Swift 中的枚举的表现更是大放异彩,因为它们现在可以携带某些值和方法了。结构体可以遵守协议和指定的方法。

Swift 增强了对值类型的支持,这提供了一个巨大的机会:值类型成为了使代码简单的一个非常灵活的工具。你可以使用它们将孤立的、可预见组件从臃肿的类中抽离出来。默认情况下,值类型被强制使用或者至少说被鼓励使用在属性上,来使得工作更清晰。

在这部分,我会描述一些鼓励使用值类型特性的情形。值得注意的是,你也可以让对象包含这些特性,但是语言本身决定了你没必要去那么做。如果你在代码里看到了一个对象,你不会期待它出现这些特性;然而,如果你看到了一个值类型,那么对这些特性的期望就是合理的。诚然,不是所有的值类型都有这些属性 —— 我们稍后会讨论这个 —— 但是这是合理的概括。

值类型是稳定的

总的来说,值类型不具有行为。它是非常稳定的。它保存数据并暴露使用这些数据进行计算的方法。其中的一些方法可能会使值类型本身发生改变,但是控制流却还是严格地受控于该实例的唯一所有者。

这太好了!这下更容易思考被唯一所有者直接调用才会执行的代码了。

相比之下,一个对象可能将它自己注册为一个定时器的 target。它可能会接收到来自系统的事件。这样的交互意味着引用类型需要有多个拥有者。因为值类型只能有一个所有者并且没有析构函数,所以我们也不容易写出会对自己产生副作用影响的值类型。

值类型是孤立的

一个典型的值类型对任何外部组件的行为都没有隐式的依赖。一眼看上去,与引用类型和其未知个数的所有者之间的交互相比,值类型和它的唯一所有者之间的交互要简单多了。它是孤立的。

如果你正在获取一个可变实例的引用,那么你对该实例的所有其他所有者都产生了隐式依赖:它们可能在任何时刻背着你偷偷改变它。

值类型是可交换的

因为每次将值类型赋给一个新变量的时候,该值类型都是被复制的,所以,所有的这些副本都是可交换的。

你可以安全地存储传递给你的值,然后在将来就像使用值一样使用它们。人们区分该实例和其他实例的唯一依据就是实例所包含的数据。可交换还意味着不管一个给定的值是如何进行构造的,我们通过 == 进行比较的话,同样的值在任何情形下都是相等的。

所以如果你使用值类型同系统里的组件进行通信,你可以很容易地改变你的组件图。你有没有一个视图用来描绘触摸采样的序列?你不用触及视图代码,只通过一个触摸采样序列的组件,就可以补偿触摸延迟,依据前一次的采样,追加用户手指将要移动位置的预测,然后返回一个新的序列。你可以自信地将另一个新的组件的输出传给视图 —— 因为视图分辨不出区别。

为值类型编写单元测试不需要花哨的模拟 (mocking) 框架。你可以直接从应用程序中的活的实例中构造出无分别的值。上面提到的触摸预测组件很容易进行单元测试:可预测的值类型输入,可预测的值类型输出等,它们都不会产生副作用。

这是巨大的优势。在以对象行为主导的传统架构中,你必须要测试与正被测试的对象的交互以及与系统的其他部分之间的交互。那通常意味着笨拙的模拟,或者为了建立那样的关系而添加了大量的设置代码。值类型是孤立的,稳定的和可交换的,所以你可以直接地构建一个值,调用一个方法,然后检查输出。更简单的测试,更大的覆盖范围意味着代码更容易修改。

不是所有的值类型都有这些特性

虽然值类型的结构鼓励这些特性,但是你也可以使值类型违反这些特性。

包含不是由所有者调用而执行的代码的值类型,通常是不可预测的,并且通常情况下应该是要避免使用的。比如:一个结构体的构造函数可能调用 dispatch_after 来安排一些工作。但是将该结构体的一个实例传递给函数的时候,因为进行了一次复制,就会不经意地重复做这件事情。值类型应该是稳定的。

包含引用的值类型通常都不是孤立的,并且应该避免使用它们:它们携带了对那个对象的所有其他所有者的依赖。这些值类型也不是易交换的,因为外部引用可能以复杂的方式与系统的其他部分相联系。

对象们的对象

我当然不是建议使用稳定的值类型来构建所有的事情。

更精确地讲,对象也是有用的,因为它们不包含我上面所说的属性。一个对象在系统中扮演着实体的角色。它有身份,具有行为,通常也是独立的。

那种行为通常复杂并且不容易思考,但是其中一些细节通常可以由简单的值和孤立地函数调用表现出来。那些细节不会和对象的复杂的行为交织在一起。通过将它们分离,对象的行为就会变得清晰。

可以将对象看成是一个薄的、命令式的层,它位于可预测的、纯值类型的层之上。

对象维护通过值来定义的状态,但是那些值其实是独立于对象被设定和操作的。值层 (value layer) 实际上没有状态;它仅仅用来表示和变换数据。那些数据作为状态可能有 (也可能没有) 高层的意味,这取决于使用值的上下文。

对象就像 I/O 和网络一样会有副作用,但是数据、计算和重要的决策最后都驱使这些副作用存在于值类型层。对象就像薄膜,通过这一层薄膜,将那些纯净的、可预测的结果引入副作用的不纯净的领域。

对象可以和其他对象通信,但是通常它们发送的是值,而不是引用,除非它们确实想要和外部不可或缺的层创建一个持久的连接。

值类型的总结

值类型能够使你构建非常清晰,简单,更容易测试的典型架构。

值类型与外部状态通常没有依赖或者只有很少的依赖,所以当你思考它们的时候,你只需要考虑很少的一部分。

值类型是内在可组合的和可重用的,因为它们是可交换的。

最后,一个值类型层允许你从应用程序稳定的业务逻辑中独立出活跃的行为元素。代码越稳定,你的系统会变得越容易测试和修改。

参考文献