原文: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 中有许多值类型,例如 structenum 和元组(tuples)类型。你可能没有意识到 Objective-C 也在数字类型(如 NSInteger)乃至 C 结构(如 CGPoint)中运用值类型。

为了更好地了解两者之间的差异,最好从你可能在 Objective-C 中认识到的内容开端:引证类型。

引证类型

引证类型由同享实例组成,这些实例能够被多个变量传递和引证。最好用一个比如来说明这一点。

将以下内容增加到你的 Playground 中:

// 引证类型
class Dog {
    var wasFed = false
}

上面的类代表一只宠物狗以及该狗是否被喂食。经过增加以下内容创立 Dog 类的新实例:

let dog = Dog()

这仅仅指向内存中存储 dog 的方位。要增加另一个目标来保存对同一只狗的引证,请增加以下内容:

let puppy = dog

关于引证类型,let 意味着引证有必要坚持不变。换句话说,您无法更改常量引证的实例,但能够更改实例自身。

由于 dog 是对内存地址的引证,所以 puppy 指向内存中彻底相同的数据。经过将 wasFed 设置为 true 来喂食你的宠物:

puppy.wasFed = true

puppydog 都指向彻底相同的内存地址。

Swift 中的引证类型与值类型

因此,你会期望对其间一个的任何改动都会反映在另一个上。经过检查 playground 中的特点值来检查这是否正确:

dog.wasFed     // true
puppy.wasFed   // true

更改一个命名实例会影响另一个实例,由于它们都引证同一目标。这正是你在 Objective-C 中所期望的。

值类型

值类型的引证与引证类型彻底不同。你将经过一些简略的 Swift 源码来探究这一点。

将以下 Int 变量赋值和相应的操作增加到你的 Playground 中:

// 值类型
var a = 42
var b = a
b += 1
a // 42
b // 43

你期望 ab 等于多少?明显,a 等于 42,b 等于 43。假如你将它们声明为引证类型,则 ab 都将等于 43,由于两者都指向相同的内存地址。

关于任何其他值类型也是如此。在你的 Playground 中,完成以下 Cat 结构:

struct Cat {
    var wasFed = false
}
var cat = Cat()
var kitty = cat
kitty.wasFed = true
cat.wasFed // false
kitty.wasFed // true

这显现了引证类型和值类型之间的奇妙但重要的差异:设置 kittywasFed 特点对 cat 没有影响。 kitty 变量收到的是 cat 值的副本而不是引证。

Swift 中的引证类型与值类型

看来你的 cat 今晚饿了!:]

虽然将引证分配给变量要快得多,但副本简直相同廉价。仿制操作以恒定的 O(n) 时间运行,由于它们依据数据大小运用固定数量的引证计数操作。在本教程的后面部分,你将看到 Swift 中优化这些仿制操作的巧妙办法。

可变性

varlet 关于引证类型和值类型的功用不同。请注意,你运用 letdogpuppy 界说为常量,但你却能够更改 wasFed 特点。这怎么可能?

class Dog {
    var wasFed = false
}
let dog = Dog()
let puppy = dog
puppy.wasFed = true

关于引证类型,let 意味着引证有必要坚持不变。换句话说,你无法更改常量引证的实例,但能够更改实例自身。

关于值类型,let 意味着实例有必要坚持不变。实例的任何特点都不会更改,无论该特点是运用 let 仍是 var 声明的。

运用值类型操控可变性要容易得多。要运用引证类型完成相同的不行变性和可变性行为,你需求完成不行变和可变类变体,例如 NSStringNSMutableString

Swift 喜爱什么类型?

你可能会感到惊奇,Swift 规范库简直只运用值类型。在 Swift 规范库中快速搜索 Swift 1.2、2.0 和 3.0 中 enumstructclass 的公共实例的成果显现了值类型方向的偏差:

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

这包括 StringArrayDictionary 等类型,它们都是作为 Struct 完成的。

何时该运用哪个

既然你知道了这两种类型之间的差异,那么什么时候应该挑选一种而不是另一种呢?

有一种状况让你别无挑选。许多 Cocoa API 需求 NSObject 子类,这迫使你运用 class。除此之外,你还能够运用 Apple Swift 博客中 “怎么挑选?” 中的事例。决议是运用 structenum 类型仍是 class 引证类型。你将在以下部分中仔细研究这些事例。

何时运用值类型

在以下三种状况里,运用值类型是最佳挑选。

1️⃣ 将实例数据与 == 进行比较时运用值类型是有意义的。

You want every object to be comparable, right? But, you need to consider whether thedatashould be comparable.

我知道你在想什么。当然!你期望每个目标都具有可比性,对吧?可是,你需求考虑数据是否具有可比性。考虑以下一个 Point 的完成:

struct Point: CustomStringConvertible {
  var x: Float
  var y: Float
  var description: String {
    return "{x: (x), y: (y)}"
  }
}

这是否意味着具有彻底相同的 xy 成员的两个变量持平?

let point1 = Point(x: 2, y: 3)
let point2 = Point(x: 2, y: 3)

是的。很明显,你应该将具有相同内部值的两个 Point 实例视为持平。这些值的存储方位并不重要。你关怀的是值自身。

为了使你的 Point 具有可比性,你需求遵从 Equatable 协议,这关于一切值类型来说都是很好的做法。该协议仅界说一个函数,你有必要完成该函数才干比较目标的两个实例。

这意味着 == 运算符有必要具有以下特征:

  • 自反(Reflexive)x == x 为真;
  • 对称(Symmetric:假如 x == yy == x
  • **传递性(Transitive):**假如 x == yy == zx == z

以下是你的 Point== 完成示例:

extension Point: Equatable { }
		func ==(lhs: Point, rhs: Point) -> Bool {
		    return lhs.x == rhs.x && lhs.y == rhs.y
}

2️⃣ 当副本应具有独立状况时,请运用值类型

再进一步考虑 Point 示例,考虑以下两个 Shape 实例,其间心为两个初始等效的 Point

struct Shape {
  var center: Point
}
let initialPoint = Point(x: 0, y: 0)
let circle = Shape(center: initialPoint)
var square = Shape(center: initialPoint)

假如改动其间一个形状的中心点会发生什么?

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️⃣ 当你想要创立同享的可变状况时,请运用引证类型。

有时你期望将一段数据存储为单个实例,以便多个运用者能够拜访和更改。

具有同享、可变状况的目标的一个常见示例是同享银行帐户。你能够完成帐户和个人(帐户持有人)的根本表明,如下所示:

class Account {
  var balance = 0.0
}
class Person {
  let account: Account
  init(_ account: Account) {
    self.account = account
  }
}

假如任何联名账户持有人向该账户增加资金,则与该账户关联的一切借记卡应反映新余额:

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 中的引证类型与值类型

但请考虑一下,Swift 简直只运用值类型,当你考虑到 Objective-C 中彻底相反的状况时,这是令人难以置信的。

作为新 Swift 范式下的编码架构师,你需求对怎么运用数据进行一些初步规划。您能够运用值类型或引证类型解决简直任何状况。但是,不正确地运用它们可能会导致很多过错和令人困惑的代码。

在一切状况下,常识和在呈现新需求时改动架构的志愿是最好的办法。挑战自己,遵从 Swift 模型。你可能会生成一些比你预期更好的代码!

你能够经过单击“下载资料”按钮在教程的顶部或底部下载此 Playground 的完好版别。

混合值和引证类型

你经常会遇到引证类型需求包括值类型的状况,反之亦然。这很容易使目标的预期语义变得复杂。

要了解其间一些复杂状况,下面是每种状况的示例。

包括值类型特点的引证类型

引证类型包括值类型是很常见的。一个比如是一个 Person 类,其间 identity 很重要,它存储一个 Address 结构,其间 equality 很重要。

要检查其外观,请将 Playground 的内容替换为以下 Address 的根本完成:

struct Address {
  var streetAddress: String
  var city: String
  var state: String
  var postalCode: String
}

在此示例中,Address 的一切特点一起构成实际国际中建筑物的仅有物理地址。特点都是 String 表明的值类型;为了简略起见,验证逻辑已被省略。

接下来,将以下代码增加到 Playground 的底部:

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 的末尾:

// 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

这是你增加的内容:

  1. 首要,你从同一个 Address 实例创立了两个新的 Person 目标。
  2. 接下来,你修正了一个人的地址。
  3. 最终,你承认这两个地址不同。即使每个目标都是运用相同的地址创立的,更改一个目标也不会影响另一个目标。

而当值类型包括引证类型时,作业就会变得紊乱,正如你接下来将要探讨的那样。

包括引证类型特点的值类型

前面的比如中的作业就十分简略了。相反的状况怎么会困难得多?

将以下代码增加到你的 Playground 以演示包括引证类型的值类型:

struct Bill {
  let amount: Float
  let billedTo: Person
}

Bill 的每个副本都是数据的仅有副本,但许多 Bill 实例将同享 billedTo Person 目标。这增加了保护目标的值语义的相当多的复杂性。例如,由于值类型应该是 Equatable,因此怎么比较两个 Bill 目标?

你能够尝试以下操作(但不要将其增加到你的 Playground 中!):

extension Bill: Equatable { }
func ==(lhs: Bill, rhs: Bill) -> Bool {
  return lhs.amount == rhs.amount && lhs.billedTo === rhs.billedTo
}

运用恒等运算符 === 检查两个目标是否具有彻底相同的引证,这意味着两个值类型同享数据。这正是遵从值语义时你不想要的。

所以,你能够做什么?

从混合类型获取值语义

你出于某种原因将 Bill 创立为 struct,并使其依赖于同享实例意味着你的结构体不是彻底仅有的副本。这违反了值类型的大部分目的!

为了更好地了解这个问题,请将以下代码增加到 Playground 的底部:

// 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"

顺次检查每个编号的注释,这就是你所做的:

  1. 首要,你依据 Address 和称号创立了一个新的 Person 实例。
  2. 接下来,你运用默许初始值设定实例化了一个新的 Bill 实例,并经过将其分配给一个新常量来创立一个副本。
  3. 最终,你改动了传入的 Person 目标,这反过来又影响了所谓的仅有实例。

糟糕!这不是你想要的。改动一项的 Person 实例就会改动另一个。由于值语义的原因,你会期望一个是 Bob,另一个是 Robert

在这儿,你能够让 Billinit(amount:billedTo:) 中仿制一个新的仅有引证。不过,你有必要编写自己的 copy 办法,由于 Person 不是 NSObject 而且没有自己的版别。

初始化时仿制引证

Bill 完成的底部增加以下内容:

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 实例的值。你将看到,即使在改动传入参数之后,每个值仍保存其原始值:

bill.billedTo.name    // "Robert"
bill2.billedTo.name   // "Robert"

这种规划的一个大问题是你能够从结构外部拜访 billedTo。这意味着外部实体可能会以意想不到的办法改动它。

将以下内容增加到 Playground 的底部,就在打印输出行的上方:

bill.billedTo.name = "Bob"

现在检查打印输出值。你应该看到外部实体现已改动了它们——这是你上面的流氓代码:

// 即使 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 止境的测验线:

// Remove these lines:
/*
bill.billedTo.name = "Bob"
bill.billedTo.name
bill2.billedTo.name
*/

现在,将 Bill 的当时完成替换为以下代码:

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)
  }
}

以下是这个新施行的状况:

  1. 你创立了一个私有变量 _billedTo 来保存对 Person 目标的引证。
  2. 接下来,你创立了一个核算特点 billedToForRead 以回来读取操作的私有变量。
  3. 最终,你创立了一个核算特点 billedToForWrite,它将一直为写入操作创立一个新的、仅有的 Person 副本。请注意,此特点还有必要声明为 mutating,由于它会更改结构的根底值。

假如你能够保证调用者将彻底按照你的意图运用你的结构,那么这种办法将解决你的问题。在完美的国际中,你的调用者将一直运用 billedToForRead 从你的引证获取数据,并运用 billedToForWrite 对引证进行更改。

但这不是国际运转的办法,不是吗? :]

防御性变异办法

你有必要在此处增加一些防御性代码。为了解决这个问题,你能够从外部躲藏这两个新特点,并创立办法来与它们正确交互。

Bill 的施行替换为以下内容:

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 目标时调用它们。**这种行为正是你在运用值语义时所期望的。

  1. 你将两个核算特点设为私有,以便调用者无法直接拜访这些特点。
  2. 你增加了 updateBilledToAddressupdateBilledToName 以运用新地址或称号更改 Person 引证。这种办法使得其他人不行能过错地更新 billedTo,由于你躲藏了根底特点。

更高效的写时仿制

最终要做的作业是提高代码的功率。当时,每次写入时都会仿制引证类型 Person。更好的办法是仅在多个目标持有对数据的引证时才仿制数据

billedToForWrite 的完成替换为以下内容:

private var billedToForWrite: Person {
  mutating get {
    if !isKnownUniquelyReferenced(&_billedTo) {
      _billedTo = Person(name: _billedTo.name, address: _billedTo.address)
    }
    return _billedTo
  }
}

isKnownUniquelyReferenced(_:) 检查是否没有其他目标持有对传入参数的引证。假如没有其他目标同享该引证,则无需仿制并回来当时引证。这将为你节约一份副本,而且它仿照了 Swift 自身在处理值类型时所做的操作。

要检查此操作的实际效果,请修正 billedToForWrite 以匹配以下内容:

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 目标进行测验:

var myBill = Bill(amount: 99.99, billedTo: billPayer)

由于 myBill 是仅有引证的,因此不会进行任何仿制。你能够经过检查调试区域来验证这一点:

Swift 中的引证类型与值类型

你实际上会看到两次打印成果。这是由于 Playground 的成果侧边栏会动态解析每行上的目标,以便为你供给预览。这会导致从 updateBilledToName(_:) 拜访 billedToForWrite 一次,并从成果侧边栏拜访另一次拜访以显现 Person 目标。

现在,在 myBill 的界说下方和对 updateBilledToName 的调用上方增加以下内容以触发仿制:

var billCopy = myBill

现在,你将在调试器中看到 myBill 实际上在更改 _billedTo 的值之前仿制了它!

Swift 中的引证类型与值类型

你会看到 Playground 成果侧边栏的额外打印,但这次它不匹配。这是由于 updateBilledToName(_:) 在改动其值之前创立了一个仅有的副本。当 Playground 再次拜访此特点时,现在没有其他目标同享对该副本的引证,因此它不会创立新副本。香甜。 :]

现在你现已具有了:高效的值语义以及引证和值类型的组合!

你能够经过单击“下载资料”按钮在教程的顶部或底部下载此 Playground 的完好版别。

何去何从

在本教程中,你了解到值类型和引证类型都有一些十分详细的功用,你能够使用这些功用使代码以可预测的办法作业。你还了解了**写时仿制(Copy-on-Write)**怎么经过仅在需求时仿制数据来坚持值类型的功能。最终,你学习了怎么防止在一个目标中组合值类型和引证类型的紊乱。

期望这个混合值和引证类型的操练能够向你展示,即使在简略的场景中,坚持语义一致是多么具有挑战性。假如你发现自己处于这种状况,这是一个好征兆,有些东西需求从头规划。

本教程中的示例要点是确保 Bill 能够保存对 Person 的引证,但你能够运用 Person 的仅有 ID 或简略的称号作为代替计划。更进一步来说,也许 Person 作为一个类的整个规划从一开端就是过错的!跟着项目需求的改变,你有必要评估这些类型的事物。

我期望你喜爱本教程。你能够运用在这儿学到的常识来修正处理值类型的办法并防止紊乱的代码。

假如你有任何意见或问题,请加入下面的论坛评论!