原文:Reference vs. Value Types in Swift
Value and Reference Types - Swift Blog
Swift --- struct与class的差异_swift struct与class的区别-CSDN博客
通过解决实际问题,了解 Swift 中引用类型和值类型之间微妙但重要的差异。
如果你一直关注最近 WWDC 的会议,你可能已经注意到重新思考 Swift 中的代码架构的真正重点。开发人员注意到,从 Objective-C 转向 Swift 时,最大的区别之一是更倾向于使用值类型而不是引用类型。
在本教程中,你将学习:
- 值类型和引用类型的关键概念;
- 两种类型之间的差异;
- 如何选择;
当你了解每种类型的主要概念时,你将解决现实世界的问题。此外,你将学习更高级的概念,并发现有关这两种类型的一些微妙但重要的点。
无论你是否有 Objective-C 背景,还是更精通 Swift,你都一定会了解 Swift 中输入的细节。
开始
首先,创建一个新的 playground。在 Xcode 中,选择 File ‣ New ‣ Playground... 并将 Playground 命名为 ReferenceTypes。
你可以选择任何平台,因为本教程与平台无关,并且仅关注 Swift 语言。
单击 "下一步",选择一个方便的位置来保存 Playground,然后单击 "创建" 将其打开。
引用类型与值类型
那么,这两种类型的关键区别是什么?简单而粗暴的解释是:引用类型共享其数据的单个副本,而值类型保留其数据的唯一副本。
内存分配:
struct
类型的内存分配在栈上,class
类型的内存分配在堆上。
Swift 将引用类型表示为 class
。这类似于 Objective-C,从 NSObject
继承的所有内容都存储为引用类型。
Swift 中有许多值类型,例如 struct
、enum
和元组(tuples)类型。你可能没有意识到 Objective-C 也在数字类型(如 NSInteger
)甚至 C 结构(如 CGPoint
)中使用值类型。
为了更好地理解两者之间的区别,最好从你可能在 Objective-C 中认识到的内容开始:引用类型。
引用类型
引用类型由共享实例组成,这些实例可以被多个变量传递和引用。最好用一个例子来说明这一点。
将以下内容添加到你的 Playground 中:
swift
// 引用类型
class Dog {
var wasFed = false
}
上面的类代表一只宠物狗以及该狗是否被喂食。通过添加以下内容创建 Dog
类的新实例:
swift
let dog = Dog()
这只是指向内存中存储 dog
的位置。要添加另一个对象来保存对同一只狗的引用,请添加以下内容:
swift
let puppy = dog
对于引用类型,let
意味着引用必须保持不变。换句话说,您无法更改常量引用的实例,但可以更改实例本身。
因为 dog
是对内存地址的引用,所以 puppy
指向内存中完全相同的数据。通过将 wasFed
设置为 true
来喂养你的宠物:
swift
puppy.wasFed = true
puppy
和 dog
都指向完全相同的内存地址。
因此,你会期望对其中一个的任何改变都会反映在另一个上。通过查看 playground 中的属性值来检查这是否正确:
swift
dog.wasFed // true
puppy.wasFed // true
更改一个命名实例会影响另一个实例,因为它们都引用同一对象。这正是你在 Objective-C 中所期望的。
值类型
值类型的引用与引用类型完全不同。你将通过一些简单的 Swift 源码来探索这一点。
将以下 Int
变量赋值和相应的操作添加到你的 Playground 中:
swift
// 值类型
var a = 42
var b = a
b += 1
a // 42
b // 43
你期望 a
和 b
等于多少?显然,a
等于 42,b
等于 43。如果你将它们声明为引用类型,则 a
和 b
都将等于 43,因为两者都指向相同的内存地址。
对于任何其他值类型也是如此。在你的 Playground 中,实现以下 Cat
结构:
swift
struct Cat {
var wasFed = false
}
var cat = Cat()
var kitty = cat
kitty.wasFed = true
cat.wasFed // false
kitty.wasFed // true
这显示了引用类型和值类型之间的微妙但重要的区别:设置 kitty
的 wasFed
属性对 cat
没有影响。 kitty
变量收到的是 cat
值的副本而不是引用。
看来你的 cat
今晚饿了!:]
尽管将引用分配给变量要快得多,但副本几乎同样便宜。复制操作以恒定的 O(n) 时间运行,因为它们根据数据大小使用固定数量的引用计数操作。在本教程的后面部分,你将看到 Swift 中优化这些复制操作的巧妙方法。
可变性
var
和 let
对于引用类型和值类型的功能不同。请注意,你使用 let
将 dog
和 puppy
定义为常量,但你却可以更改 wasFed
属性。这怎么可能?
swift
class Dog {
var wasFed = false
}
let dog = Dog()
let puppy = dog
puppy.wasFed = true
对于引用类型,let
意味着引用必须保持不变。换句话说,你无法更改常量引用的实例,但可以更改实例本身。
对于值类型,let
意味着实例必须保持不变 。实例的任何属性都不会更改,无论该属性是使用 let
还是 var
声明的。
使用值类型控制可变性要容易得多。要使用引用类型实现相同的不可变性和可变性行为,你需要实现不可变和可变类变体,例如 NSString
和 NSMutableString
。
Swift 喜欢什么类型?
你可能会感到惊讶,Swift 标准库几乎只使用值类型。在 Swift 标准库中快速搜索 Swift 1.2、2.0 和 3.0 中 enum
、struct
和 class
的公共实例的结果显示了值类型方向的偏差:
Swift 1.2
- Struct: 81
- enum: 8
- class: 3
Swift 2.0
- Struct: 87
- enum: 8
- class: 4
Swift 3.0
- Struct: 124
- enum: 19
- class: 3
这包括 String
、Array
和 Dictionary
等类型,它们都是作为 Struct
实现的。
何时该使用哪个
既然你知道了这两种类型之间的区别,那么什么时候应该选择一种而不是另一种呢?
有一种情况让你别无选择。许多 Cocoa API 需要 NSObject
子类,这迫使你使用 class
。除此之外,你还可以使用 Apple Swift 博客中 "如何选择?" 中的案例。决定是使用 struct
或 enum
类型还是 class
引用类型。你将在以下部分中仔细研究这些案例。
何时使用值类型
在以下三种情况里,使用值类型是最佳选择。
1️⃣ 将实例数据与 ==
进行比较时使用值类型是有意义的。
You want every object to be comparable, right? But, you need to consider whether the data should be comparable.
我知道你在想什么。当然!你希望每个对象都具有可比性,对吧?但是,你需要考虑数据是否具有可比性。考虑以下一个 Point
的实现:
swift
struct Point: CustomStringConvertible {
var x: Float
var y: Float
var description: String {
return "{x: \(x), y: \(y)}"
}
}
这是否意味着具有完全相同的 x
和 y
成员的两个变量相等?
swift
let point1 = Point(x: 2, y: 3)
let point2 = Point(x: 2, y: 3)
是的。很明显,你应该将具有相同内部值的两个 Point
实例视为相等。这些值的存储位置并不重要。你关心的是值本身。
为了使你的 Point
具有可比性,你需要遵循 Equatable
协议,这对于所有值类型来说都是很好的做法。该协议仅定义一个函数,你必须实现该函数才能比较对象的两个实例。
这意味着 ==
运算符必须具有以下特征:
- 自反(Reflexive) :
x == x
为真; - 对称(Symmetric) :如果
x == y
则y == x
; - **传递性(Transitive ):**如果
x == y
且y == z
则x == z
;
以下是你的 Point
的 ==
实现示例:
swift
extension Point: Equatable { }
func ==(lhs: Point, rhs: Point) -> Bool {
return lhs.x == rhs.x && lhs.y == rhs.y
}
2️⃣ 当副本应具有独立状态时,请使用值类型
再进一步考虑 Point
示例,考虑以下两个 Shape
实例,其中心为两个初始等效的 Point
:
swift
struct Shape {
var center: Point
}
let initialPoint = Point(x: 0, y: 0)
let circle = Shape(center: initialPoint)
var square = Shape(center: initialPoint)
如果改变其中一个形状的中心点会发生什么?
swift
square.center.x = 5 // {x: 5.0, y: 0.0}
circle.center // {x: 0.0, y: 0.0}
每个 Shape
都需要其自己的 Point
副本,以便你可以独立于其他形状来维护其状态。你能想象共享同一个中心点的所有形状的混乱吗?
3️⃣ 当代码将跨越多个线程使用数据时,请使用值类型
值类型允许你获取唯一的、拷贝后的数据实例,你可以相信应用程序的其他部分(例如另一个线程)不会更改该实例。在多线程环境中,这非常有用,并且可以防止极其难以调试的令人讨厌的错误。
为了使数据可以从多个线程访问并且在线程之间相等,你需要使用引用类型并实现加锁 - 这不是一件容易实现的任务!
如果线程可以唯一地拥有数据,则使用值类型可以避免潜在的冲突,因为数据的每个所有者都拥有唯一的副本而不是共享引用。
何时使用引用类型
尽管值类型在许多情况下都是可行的,但引用类型在许多情况下仍然有用。
1️⃣ 将实例的唯一标识符与 ===
进行比较时使用引用类型是有意义的。
===
检查两个对象是否完全相同,直到存储数据的内存地址。
用现实世界的术语来说,请考虑以下情况:如果你的室友将你的一张 20 美元钞票与另一张合法的 20 美元钞票交换,你并不真正关心,因为你只关心该物品的价值。
然而,如果有人窃取了《大宪章》并在其位置创建了该文件的相同羊皮纸副本,那将非常重要,因为该文件的固有身份根本不一样,在这种情况下,身份很重要。
在决定是否使用引用类型时,你可以使用相同的思维过程。你很少真正关心数据的固有身份(即内存位置)。你通常只关心比较数据值。
2️⃣ 当你想要创建共享的可变状态时,请使用引用类型。
有时你希望将一段数据存储为单个实例,以便多个使用者可以访问和更改。
具有共享、可变状态的对象的一个常见示例是共享银行帐户。你可以实现帐户和个人(帐户持有人)的基本表示,如下所示:
swift
class Account {
var balance = 0.0
}
class Person {
let account: Account
init(_ account: Account) {
self.account = account
}
}
如果任何联名账户持有人向该账户添加资金,则与该账户关联的所有借记卡应反映新余额:
swift
let account = Account()
let person1 = Person(account)
let person2 = Person(account)
person2.account.balance += 100.0
person1.account.balance // 100
person2.account.balance // 100
由于 Account
是一个类,因此每个 Person
都拥有对该帐户的引用,并且所有内容都保持同步。
还没有决定吗?
如果你不太确定哪种机制适用于你的情况,请默认使用值类型。你随时可以轻松地在稍后转换为 class
。
但请考虑一下,Swift 几乎只使用值类型,当你考虑到 Objective-C 中完全相反的情况时,这是令人难以置信的。
作为新 Swift 范式下的编码架构师,你需要对如何使用数据进行一些初步规划。您可以使用值类型或引用类型解决几乎任何情况。然而,不正确地使用它们可能会导致大量错误和令人困惑的代码。
在所有情况下,常识和在出现新需求时改变架构的意愿是最好的方法。挑战自己,遵循 Swift 模型。你可能会生成一些比你预期更好的代码!
你可以通过单击"下载材料"按钮在教程的顶部或底部下载此 Playground 的完整版本。
混合值和引用类型
你经常会遇到引用类型需要包含值类型的情况,反之亦然。这很容易使对象的预期语义变得复杂。
要了解其中一些复杂情况,下面是每种情况的示例。
包含值类型属性的引用类型
引用类型包含值类型是很常见的。一个例子是一个 Person
类,其中 identity
很重要,它存储一个 Address
结构,其中 equality
很重要。
要查看其外观,请将 Playground 的内容替换为以下 Address
的基本实现:
swift
struct Address {
var streetAddress: String
var city: String
var state: String
var postalCode: String
}
在此示例中,Address
的所有属性共同构成现实世界中建筑物的唯一物理地址。属性都是 String
表示的值类型;为了简单起见,验证逻辑已被省略。
接下来,将以下代码添加到 Playground 的底部:
swift
class Person { // Reference type
var name: String // Value type
var address: Address // Value type
init(name: String, address: Address) {
self.name = name
self.address = address
}
}
在这种情况下,这种类型的混合非常有意义。每个 class
实例都有自己的不共享的值类型属性实例。不存在两个不同的人共享并意外更改另一个人的地址的风险。
要验证此行为,请将以下内容添加到 Playground 的末尾:
swift
// 1
let kingsLanding = Address(
streetAddress: "1 King Way",
city: "Kings Landing",
state: "Westeros",
postalCode: "12345")
let madKing = Person(name: "Aerys", address: kingsLanding)
let kingSlayer = Person(name: "Jaime", address: kingsLanding)
// 2
kingSlayer.address.streetAddress = "1 King Way Apt. 1"
// 3
madKing.address.streetAddress // 1 King Way
kingSlayer.address.streetAddress // 1 King Way Apt. 1
这是你添加的内容:
- 首先,你从同一个
Address
实例创建了两个新的Person
对象。 - 接下来,你修改了一个人的地址。
- 最后,你确认这两个地址不同。即使每个对象都是使用相同的地址创建的,更改一个对象也不会影响另一个对象。
而当值类型包含引用类型时,事情就会变得混乱,正如你接下来将要探讨的那样。
包含引用类型属性的值类型
前面的例子中的事情就非常简单了。相反的情况怎么会困难得多?
将以下代码添加到你的 Playground
以演示包含引用类型的值类型:
swift
struct Bill {
let amount: Float
let billedTo: Person
}
Bill
的每个副本都是数据的唯一副本,但许多 Bill
实例将共享 billedTo
Person
对象。这增加了维护对象的值语义的相当多的复杂性。例如,由于值类型应该是 Equatable
,因此如何比较两个 Bill
对象?
你可以尝试以下操作(但不要将其添加到你的 Playground
中!):
swift
extension Bill: Equatable { }
func ==(lhs: Bill, rhs: Bill) -> Bool {
return lhs.amount == rhs.amount && lhs.billedTo === rhs.billedTo
}
使用恒等运算符 ===
检查两个对象是否具有完全相同的引用,这意味着两个值类型共享数据。这正是遵循值语义时你不想要的。
所以,你可以做什么?
从混合类型获取值语义
你出于某种原因将 Bill
创建为 struct
,并使其依赖于共享实例意味着你的结构体不是完全唯一的副本。这违背了值类型的大部分目的!
为了更好地理解这个问题,请将以下代码添加到 Playground 的底部:
swift
// 1
let billPayer = Person(name: "Robert", address: kingsLanding)
// 2
let bill = Bill(amount: 42.99, billedTo: billPayer)
let bill2 = bill
// 3
billPayer.name = "Bob"
// Inspect values
bill.billedTo.name // "Bob"
bill2.billedTo.name // "Bob"
依次查看每个编号的注释,这就是你所做的:
- 首先,你根据
Address
和名称创建了一个新的Person
实例。 - 接下来,你使用默认初始值设定实例化了一个新的
Bill
实例,并通过将其分配给一个新常量来创建一个副本。 - 最后,你改变了传入的
Person
对象,这反过来又影响了所谓的唯一实例。
糟糕!这不是你想要的。改变一项的 Person
实例就会改变另一个。由于值语义的原因,你会期望一个是 Bob
,另一个是 Robert
。
在这里,你可以让 Bill
在 init(amount:billedTo:)
中复制一个新的唯一引用。不过,你必须编写自己的 copy
方法,因为 Person
不是 NSObject
并且没有自己的版本。
在初始化时复制引用
在 Bill
实现的底部添加以下内容:
swift
init(amount: Float, billedTo: Person) {
self.amount = amount
// Create a new Person reference from the parameter
// 从参数中创建一个新 Person 的引用
self.billedTo = Person(name: billedTo.name, address: billedTo.address)
}
你在此处添加的只是一个显式初始化程序。你不是简单地分配 billedTo
,而是使用传入的名称和地址创建一个新的 Person
实例。因此,调用者将无法通过编辑 Person
的原始副本来影响 Bill
。
查看 Playground 底部的两条打印输出行,并检查每个 Bill 实例的值。你将看到,即使在改变传入参数之后,每个值仍保留其原始值:
swift
bill.billedTo.name // "Robert"
bill2.billedTo.name // "Robert"
这种设计的一个大问题是你可以从结构外部访问 billedTo
。这意味着外部实体可能会以意想不到的方式改变它。
将以下内容添加到 Playground 的底部,就在打印输出行的上方:
swift
bill.billedTo.name = "Bob"
现在检查打印输出值。你应该看到外部实体已经改变了它们------这是你上面的流氓代码:
swift
// 即使 bill 被声明为 let 类型,仍可改变其底层值!
bill.billedTo.name = "Bob"
// Inspect values
bill.billedTo.name // "Bob"
bill2.billedTo.name // "Bob"
这里的问题是,即使你的结构是不可变的,任何有权访问它的人都可以改变其底层数据。
使用写入时复制(Copy-on-Write)计算属性
原生 Swift 值类型实现了一个很棒的功能,称为写入时复制(Copy-on-Write)。分配后,每个引用都指向相同的内存地址。只有当其中一个引用修改了底层数据时,Swift 才会真正复制原始实例并进行修改。
你可以通过将 billedTo
设置为 private
并仅在写入时返回副本来应用此技术。
拆除 Playground 尽头的测试线:
swift
// Remove these lines:
/*
bill.billedTo.name = "Bob"
bill.billedTo.name
bill2.billedTo.name
*/
现在,将 Bill
的当前实现替换为以下代码:
swift
struct Bill {
let amount: Float
private var _billedTo: Person // 1.私有变量
// 2.计算属性,读取时返回私有变量
var billedToForRead: Person {
return _billedTo
}
// 3.计算属性,写入时创建一个新副本
var billedToForWrite: 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)
}
}
以下是这个新实施的情况:
- 你创建了一个私有变量
_billedTo
来保存对Person
对象的引用。 - 接下来,你创建了一个计算属性
billedToForRead
以返回读取操作的私有变量。 - 最后,你创建了一个计算属性
billedToForWrite
,它将始终为写入操作创建一个新的、唯一的Person
副本。请注意,此属性还必须声明为mutating
,因为它会更改结构的基础值。
如果你可以保证调用者将完全按照你的意图使用你的结构,那么这种方法将解决你的问题。在完美的世界中,你的调用者将始终使用 billedToForRead
从你的引用获取数据,并使用 billedToForWrite
对引用进行更改。
但这不是世界运转的方式,不是吗? :]
防御性变异方法
你必须在此处添加一些防御性代码。为了解决这个问题,你可以从外部隐藏这两个新属性,并创建方法来与它们正确交互。
将 Bill
的实施替换为以下内容:
swift
struct Bill {
let amount: Float
private var _billedTo: Person
// 1
private var billedToForRead: Person {
return _billedTo
}
private var billedToForWrite: 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) {
billedToForWrite.address = address
}
mutating func updateBilledToName(name: String) {
billedToForWrite.name = name
}
// ... Methods to read billedToForRead data
}
这是你上面更改的内容:
**将这些方法声明为 mutating
意味着你只能在使用 var
而不是 let
实例化 Bill
对象时调用它们。**这种行为正是你在使用值语义时所期望的。
- 你将两个计算属性设为私有,以便调用者无法直接访问这些属性。
- 你添加了
updateBilledToAddress
和updateBilledToName
以使用新地址或名称更改Person
引用。这种方法使得其他人不可能错误地更新billedTo
,因为你隐藏了基础属性。
更高效的写时复制
最后要做的事情是提高代码的效率。当前,每次写入时都会复制引用类型 Person
。更好的方法是仅在多个对象持有对数据的引用时才复制数据。
将 billedToForWrite
的实现替换为以下内容:
swift
private var billedToForWrite: Person {
mutating get {
if !isKnownUniquelyReferenced(&_billedTo) {
_billedTo = Person(name: _billedTo.name, address: _billedTo.address)
}
return _billedTo
}
}
isKnownUniquelyReferenced(_:)
检查是否没有其他对象持有对传入参数的引用。如果没有其他对象共享该引用,则无需复制并返回当前引用。这将为你节省一份副本,并且它模仿了 Swift 本身在处理值类型时所做的操作。
要查看此操作的实际效果,请修改 billedToForWrite
以匹配以下内容:
swift
private var billedToForWrite: Person {
mutating get {
if !isKnownUniquelyReferenced(&_billedTo) {
print("Making a copy of _billedTo")
_billedTo = Person(name: _billedTo.name, address: _billedTo.address)
} else {
print("Not making a copy of _billedTo")
}
return _billedTo
}
}
在这里,你刚刚添加了日志记录,以便你可以查看何时创建或未创建副本。
在 Playground 的底部,添加以下 Bill
对象进行测试:
swift
var myBill = Bill(amount: 99.99, billedTo: billPayer)
由于 myBill
是唯一引用的,因此不会进行任何复制。你可以通过查看调试区域来验证这一点:
你实际上会看到两次打印结果。这是因为 Playground 的结果侧边栏会动态解析每行上的对象,以便为你提供预览。这会导致从
updateBilledToName(_:)
访问billedToForWrite
一次,并从结果侧边栏访问另一次访问以显示Person
对象。
现在,在 myBill
的定义下方和对 updateBilledToName
的调用上方添加以下内容以触发复制:
swift
var billCopy = myBill
现在,你将在调试器中看到 myBill
实际上在更改 _billedTo
的值之前复制了它!
你会看到 Playground 结果侧边栏的额外打印,但这次它不匹配。这是因为 updateBilledToName(_:)
在改变其值之前创建了一个唯一的副本。当 Playground 再次访问此属性时,现在没有其他对象共享对该副本的引用,因此它不会创建新副本。甜美。 :]
现在你已经拥有了:高效的值语义以及引用和值类型的组合!
你可以通过单击"下载材料"按钮在教程的顶部或底部下载此 Playground 的完整版本。
何去何从
在本教程中,你了解到值类型和引用类型都有一些非常具体的功能,你可以利用这些功能使代码以可预测的方式工作。你还了解了**写时复制(Copy-on-Write)**如何通过仅在需要时复制数据来保持值类型的性能。最后,你学习了如何避免在一个对象中组合值类型和引用类型的混乱。
希望这个混合值和引用类型的练习能够向你展示,即使在简单的场景中,保持语义一致是多么具有挑战性。如果你发现自己处于这种情况,这是一个好兆头,有些东西需要重新设计。
本教程中的示例重点是确保 Bill
可以保存对 Person
的引用,但你可以使用 Person
的唯一 ID 或简单的名称作为替代方案。更进一步来说,也许 Person
作为一个类的整个设计从一开始就是错误的!随着项目需求的变化,你必须评估这些类型的事物。
我希望你喜欢本教程。你可以使用在这里学到的知识来修改处理值类型的方式并避免混乱的代码。
如果你有任何意见或问题,请加入下面的论坛讨论!