上一章我们刚刚学习了闭包,这是一个很难的内容,然而你还能回来继续学习Swift。说真的,这值得尊敬!
我有一些好消息要告诉你。首先,在接下来的几章里,你不仅可以不去想闭包,而且一旦你休息了一段时间,我们就会开始在真正的 iOS 项目中实践它们。因此,即使你还不完全清楚它们是如何工作的,或者为什么需要它们,一切都会变得明朗起来——坚持下去!
总之,今天的主题是结构体。结构体是 Swift 让我们从多个小类型中创建自己的数据类型的方法之一。例如,你可以把三个字符串和一个布尔值放在一起,然后说这代表了应用程序中的一个用户。事实上,Swift 本身的大多数类型都是以结构体的形式实现的,包括字符串、Int、布尔、数组等。
更重要的是,结构体在 SwiftUI 中极为常见,因为我们设计的每一块 UI 都是基于结构体构建的,内部有大量的结构体。结构体并不难学,但公平地说,在闭包之后,几乎所有东西都变得简单了!
今天我们将学习四个大内容,在这四个大内容中,你将认识自定义结构体、计算属性、属性观察器等。现在就让我们开始吧!
1. 如何创建你自己的结构体
通过 Swift 的结构体,我们可以创建自己的自定义复杂数据类型,并拥有自己的变量和函数。
一个简单的结构体是这样的:
struct Album {
let title: String
let artist: String
let year: Int
func printSummary() {
print("\(title) (\(year)) by \(artist)")
}
}
这样就创建了一个名为 “专辑 “的新类型,其中包含两个名为标题和艺术家的字符串常量,以及一个名为年份的整数常量。我还添加了一个简单的函数,用于总结三个常量的值。
注意到 Album 是如何以大写字母 A 开头的吗?这是 Swift 的标准,我们一直在使用它——想想 String、Int、Bool、Set 等等。在引用数据类型时,我们使用驼峰式大小写,即第一个字母大写;但在引用类型内部的内容时,例如变量或函数,我们使用驼峰式大小写,即第一个字母小写。请记住,在大多数情况下,这只是一个惯例,而不是规则,但坚持使用很有帮助。
此时,Album 就像 String 或 Int 一样——我们可以创建它们、赋值、复制它们等等。例如,我们可以创建几个相册,然后打印它们的一些值并调用它们的函数:
let red = Album(title: "Red", artist: "Taylor Swift", year: 2012)
let wings = Album(title: "Wings", artist: "BTS", year: 2016)
print(red.title)
print(wings.artist)
red.printSummary()
wings.printSummary()
请注意我们是如何像调用函数一样创建新相册的——我们只需按照定义的顺序为每个常量提供值即可。
正如你所看到的,red和wings来自同一个 Album 结构,但一旦我们创建了它们,它们就会像创建两个字符串一样分开。
当我们在每个结构体上调用 printSummary() 时,就可以看到这一点,因为该函数会引用标题、艺术家和年份。在这两种情况下,每个结构体都会打印出正确的值:red 打印出 “Red (2012) by Taylor Swift”,wings 打印出 “Wings (2016) by BTS”——Swift明白,当对 red 调用 printSummary() 时,它应该使用同样属于 red 的标题、艺术家和年份常量。
更有趣的地方在于,当你想让值发生变化时。例如,我们可以创建一个可以根据需要休假的 Employee 结构:
struct Employee {
let name: String
var vacationRemaining: Int
func takeVacation(days: Int) {
if vacationRemaining > days {
vacationRemaining -= days
print("I'm going on vacation!")
print("Days remaining: \(vacationRemaining)")
} else {
print("Oops! There aren't enough days remaining.")
}
}
}
然而,这实际上是行不通的——Swift 会拒绝构建代码。
要知道,如果我们使用 let 将雇员创建为常量,Swift 就会将雇员及其所有数据设为常量——我们可以正常调用函数,但这些函数不允许更改结构体的数据,因为我们将其设为常量了。
因此,Swift 让我们多做了一步:任何只读取数据的函数都没有问题,但任何改变结构体数据的函数都必须使用特殊的 mutating 关键字标记,就像下面这样:
mutating func takeVacation(days: Int) {
现在,我们的代码可以正常构建,但 Swift 会阻止我们在常量结构体中调用 takeVacation()。
在代码中,这是允许的:
var archer = Employee(name: "Sterling Archer", vacationRemaining: 14)
archer.takeVacation(days: 5)
print(archer.vacationRemaining)
但如果将 var archer 改为 let archer,你会发现 Swift 拒绝再次构建你的代码——我们试图在一个常量结构体上调用一个mutating函数,这是不允许的。
我们将在接下来的几章中详细探讨结构体,但首先我想给一些东西命名。
- 属于结构体的变量和常量称为属性(properties)。
- 属于结构体的函数称为方法(methods)。
- 当我们从结构体中创建常量或变量时,我们称其为实例(instance)——例如,你可能会为 Album 结构体创建十几个独特的实例。
- 当我们创建结构体实例时,我们会使用初始化器(initializer),如下所示: Album(title: “Wings”, artist: “BTS”, year: 2016)。
最后一个初始化器乍看起来可能有点奇怪,因为我们是把结构体当作函数来处理并传递参数。这就是所谓的 “语法糖(syntactic sugar)”–Swift 会在结构体内部默默创建一个名为 init() 的特殊函数,并使用结构体的所有属性作为参数。然后,它会自动将这两段代码视为相同的代码:
var archer1 = Employee(name: "Sterling Archer", vacationRemaining: 14)
var archer2 = Employee.init(name: "Sterling Archer", vacationRemaining: 14)
实际上,我们以前就依赖于这种行为。早在我们第一次学习 Double 时,我就解释过不能将 Int 和 Double 相加,而需要写这样的代码:
let a = 1
let b = 2.0
let c = Double(a) + b
现在你可以看到这里到底发生了什么: Swift 自己的 Double 类型是作为结构体实现的,它有一个接受整数的初始化函数。
Swift 生成初始化函数的方式非常智能,如果我们为属性赋值,它甚至会插入默认值。
例如,如果我们的结构体有以下两个属性:
let name: String
var vacationRemaining = 14
然后,Swift 会静默地生成一个初始化程序,将 vacationRemaining 的默认值设为 14,从而使这两个值都有效:
let kane = Employee(name: "Lana Kane")
let poovey = Employee(name: "Pam Poovey", vacationRemaining: 35)
提示:如果为常量属性指定默认值,该值将从初始化器中完全删除。如果要指定默认值,但又留有在需要时覆盖它的可能性,可以使用变量属性。
结构体与元组之间有何区别?
Swift 的 tuples 可以让我们在单个变量中存储多个不同名称的值,而 struct 也有类似的功能,那么它们有什么区别,什么时候应该选择其中一个而不是另一个呢?
刚开始学习时,区别很简单:tuple 实际上就是一个没有名字的结构体,就像匿名结构体一样。这意味着你可以将它定义为(name: String, age: Int, city: String),它将做与下面结构体相同的事情:
struct User {
var name: String
var age: Int
var city: String
}
但是,元组有一个问题:虽然元组非常适合一次性使用,尤其是当你想从一个函数中返回多个数据时,但反复使用元组会很烦人。
试想一下:如果你有几个处理用户信息的函数,你会愿意这样写吗?
func authenticate(_ user: User) { ... }
func showProfile(for user: User) { ... }
func signOut(_ user: User) { ... }
或者这样:
func authenticate(_ user: (name: String, age: Int, city: String)) { ... }
func showProfile(for user: (name: String, age: Int, city: String)) { ... }
func signOut(_ user: (name: String, age: Int, city: String)) { ... }
想一想在 User 结构体中添加一个新属性有多难(确实非常容易),而在元组中添加一个值又有多难?(非常困难,而且容易出错!)。
因此,当你想从函数中返回两个或多个任意值时,请使用元组,而当您想多次发送或接收某些固定数据时,请使用结构体。
函数与方法之间有何区别?
Swift 的函数可以让我们命名一段功能并重复运行它,而 Swift 的方法做的也是同样的事情,那么它们有什么区别呢?
老实说,唯一真正的区别在于,方法属于结构体、枚举和类等类型,而函数不属于。仅此而已,这就是唯一的区别。两者都可以接受任意数量的参数,包括可变参数,并且都可以返回值。它们如此相似,以至于 Swift 仍然使用 func 关键字来定义方法。
当然,与结构体等特定类型相关联意味着方法获得了一个重要的超级能力:它们可以引用该类型中的其他属性和方法,这意味着您可以为 User 类型编写一个 describe() 方法,打印用户的姓名、年龄和城市。
方法还有一个非常微妙的优点:方法可以避免命名空间污染。每当我们创建一个函数,该函数的名称就开始在我们的代码中产生意义——我们可以编写 wakeUp() 并让它做一些事情。因此,如果编写 100 个函数,就会有 100 个保留名;如果编写 1000 个函数,就会有 1000 个保留名。
这种情况很快就会变得一团糟,但通过将功能放到方法中,我们就限制了这些名称的使用范围——除非我们特别编写 someUser.wakeUp(),否则 wakeUp() 就不再是保留名称了。这就减少了所谓的污染,因为如果我们的大部分代码都放在方法中,就不会意外出现名称冲突。
为什么我们需要为某些方法标记mutating?
修改结构体的属性是可能的,但前提是该结构体是作为变量创建的。当然,在你的结构体内部,我们无法判断你使用的是可变结构体还是常量结构体,因此 Swift 提供了一个简单的解决方案:当结构体的方法试图更改任何属性时,你必须将其标记为突变(mutating)。
除了将方法标记为突变外,你不需要做其他任何事情,但这样做会给 Swift 足够的信息来阻止该方法用于常量结构体实例。
有两个重要细节你会发现很有用:
- 将方法标记为突变将阻止该方法在常量结构体上被调用,即使该方法本身实际上没有改变任何属性。如果你说它改变了什么,Swift 就会相信你!
- 未标记为突变的方法不能调用突变函数——你必须将它们都标记为突变。
2. 如何动态地计算属性值
结构体可以有两种属性:存储属性是一个变量或常量,用于保存结构体实例中的数据;计算属性则在每次访问时动态计算属性值。这意味着计算属性是存储属性和函数的混合体:它们的访问方式类似于存储属性,但工作方式类似于函数。
举个例子,以前我们有一个 Employee 结构,可以跟踪该员工还剩多少天假期。下面是一个简化版本:
struct Employee {
let name: String
var vacationRemaining: Int
}
var archer = Employee(name: "Sterling Archer", vacationRemaining: 14)
archer.vacationRemaining -= 5
print(archer.vacationRemaining)
archer.vacationRemaining -= 3
print(archer.vacationRemaining)
这虽然是一个微不足道的结构,但我们却失去了宝贵的信息——我们给这名员工分配了 14 天的假期,然后在他休假时减去这些天数,但这样我们就失去了他们最初获得的假期天数。
我们可以将其调整为使用计算属性,例如
struct Employee {
let name: String
var vacationAllocated = 14
var vacationTaken = 0
var vacationRemaining: Int {
vacationAllocated - vacationTaken
}
}
现在,我们不再直接为 vacationRemaining 赋值,而是通过从分配的假期中减去已使用的假期来计算它。
当我们读取 vacationRemaining 时,它看起来就像一个普通的存储属性:
var archer = Employee(name: "Sterling Archer", vacationAllocated: 14)
archer.vacationTaken += 4
print(archer.vacationRemaining)
archer.vacationTaken += 4
print(archer.vacationRemaining)
这是一个非常强大的功能:我们读取的是一个看起来像属性的东西,但在幕后,Swift 正在运行一些代码来计算它每次的值。
但我们无法写入它,因为我们还没有告诉 Swift 应该如何处理。为了解决这个问题,我们需要同时提供一个 getter 和一个 setter,它们分别是 “读取代码 “和 “写入代码 “的别致名称。
在本例中,getter 非常简单,因为它只是我们现有的代码。但 setter 就比较有趣了——如果你设置了某个员工的 vacationRemaining,那么你是希望他的 vacationAllocated 值增加还是减少呢?
我假定这两种情况中的第一种是正确的,在这种情况下,属性会是这样的:
var vacationRemaining: Int {
get {
vacationAllocated - vacationTaken
}
set {
vacationAllocated = vacationTaken + newValue
}
}
请注意 get 和 set 如何在读取或写入值时标记要运行的单个代码片段。更重要的是,请注意 newValue——这是 Swift 自动提供给我们的,它存储了用户试图为属性赋值的任何值。
有了 getter 和 setter,我们现在就可以修改 vacationRemaining 了:
var archer = Employee(name: "Sterling Archer", vacationAllocated: 14)
archer.vacationTaken += 4
archer.vacationRemaining = 5
print(archer.vacationAllocated)
SwiftUI 广泛使用计算属性——你将在创建的第一个项目中就看到它们!
什么时候应该使用计算属性或存储属性?
属性可以让我们将信息附加到结构体上,Swift 提供了两种不同的属性:存储属性和计算属性,前者是将值存储在内存中,以备日后使用,而后者则是每次调用时都会重新计算值。在幕后,计算属性实际上只是一个函数调用,碰巧属于你的结构体。
决定使用哪种属性,部分取决于属性值是否依赖于其他数据,部分也取决于性能。性能部分很简单:如果你经常在属性值未发生变化时读取属性,那么使用存储属性要比使用计算属性快得多。另一方面,如果属性很少被读取,甚至根本不会被读取,那么使用计算属性就可以省去计算其值并将其存储在某个地方的麻烦。
当涉及到依赖关系(即属性值是否依赖于其他属性的值)时,情况就会发生逆转:这正是计算属性的用武之地,因为你可以确保它们返回的值总是考虑到最新的程序状态。
懒惰属性(Lazy properties)有助于缓解很少读取存储属性的性能问题,而属性观察者(property observers)则可以缓解存储属性的依赖性问题——我们很快就会学习它们。
3. 如何在属性发生变化时采取行动?
Swift 允许我们创建属性观察者,它们是在属性发生变化时运行的特殊代码。它们有两种形式:一种是在属性刚发生变化时运行的 didSet 观察器,另一种是在属性发生变化之前运行的 willSet 观察器。
要了解为什么需要属性观察者,请想想这样的代码:
struct Game {
var score = 0
}
var game = Game()
game.score += 10
print("Score is now \(game.score)")
game.score -= 3
print("Score is now \(game.score)")
game.score += 1
这将创建一个 Game 结构,并修改其分数若干次。每次分数发生变化时,都会有一行 print() 跟随,这样我们就能跟踪分数的变化。但有一个错误:在最后,分数改变了,但没有被打印出来,这是一个错误。
有了属性观察器,我们就可以解决这个问题,使用 didSet 将 print() 调用直接附加到属性上,这样无论何时何地,只要它发生变化,我们就会运行一些代码。
下面是同样的示例,现在使用了属性观察器:
struct Game {
var score = 0 {
didSet {
print("Score is now \(score)")
}
}
}
var game = Game()
game.score += 10
game.score -= 3
game.score += 1
如果你需要,Swift 会在 didSet 中自动提供常量 oldValue,以防你需要根据改变的内容自定义功能。还有一个 willSet 变体,可以在属性发生变化之前运行一些代码,进而提供将分配的新值,以备你根据该值采取不同的操作。
我们可以使用一个代码示例来演示所有这些功能,示例将在值发生变化时打印信息,以便你可以看到代码运行时的流程:
struct App {
var contacts = [String]() {
willSet {
print("Current value is: \(contacts)")
print("New value will be: \(newValue)")
}
didSet {
print("There are now \(contacts.count) contacts.")
print("Old value was \(oldValue)")
}
}
}
var app = App()
app.contacts.append("Adrian E")
app.contacts.append("Allen W")
app.contacts.append("Ish S")
是的,向数组追加会同时触发 willSet 和 didSet,因此运行该代码时会打印大量文本。
在实践中,willSet 比 didSet 用得少得多,但你可能还是会时不时地看到它,所以知道它的存在很重要。无论你选择哪种方法,都请尽量避免在属性观察器中投入过多的工作——如果 game.score += 1 这样看似微不足道的事情触发了高强度的工作,就会经常让你措手不及,并导致各种性能问题。
什么时候你应该使用属性观察器?
Swift 的属性观察器让我们可以分别使用 willSet 和 didSet 来附加功能,以便在属性更改之前或之后运行。大多数情况下,属性观察器并不是必需的,只是很好用——我们可以正常更新属性,然后在代码中调用函数。那什么时候会用到属性观察器呢?
最重要的原因是方便:使用属性观察器意味着只要属性发生变化,你的功能就会被执行。当然,你也可以使用函数来做到这一点,但你会记得在改变属性的每一个地方都使用函数吗?
这就是函数方法的问题所在:每当属性发生变化时,你都要记得调用该函数,如果你忘记了,你的代码中就会出现神秘的错误。另一方面,如果你使用 didSet 将功能直接附加到属性上,它就会一直发生,而且你可以将确保这一点的工作移交给 Swift,这样你的大脑就可以专注于更有趣的问题。
有一个地方使用属性观察者是个坏主意,那就是如果你在其中添加了缓慢的工作。如果你有一个年龄为整数的 User 结构,你会希望年龄的变化立即发生,毕竟这只是一个数字。如果你附加了一个 didSet 属性观察器,该观察器会执行各种缓慢的工作,那么突然改变一个整数所需的时间可能会比你预期的要长,这会给你带来各种问题。
什么情况下应该使用 willSet 而不是 didSet?
willSet 和 didSet 都允许我们将观察器附加到属性上,这意味着当属性发生变化时,Swift 会运行一些代码,以便我们有机会对变化做出响应。问题是:你是想在属性发生变化之前知道,还是在属性发生变化之后知道?
答案很简单:大多数情况下,你都会使用 didSet,因为我们希望在发生变化后采取行动,以便更新用户界面、保存更改等。这并不意味着 willSet 没有用处,只是在实际应用中,它远不如同类产品受欢迎。
最常使用 willSet 的情况是,当你需要在更改之前知道程序的状态时。例如,SwiftUI 会在某些地方使用 willSet 来处理动画,这样它就能在更改前获取用户界面的快照。当它同时拥有 “更改前 “和 “更改后 “的快照时,就可以将两者进行比较,从而查看用户界面中需要更新的所有部分。
4. 如何创建自定义初始化器
初始化器是专门用于准备新结构体实例的方法。你已经看到 Swift 如何根据我们在结构体中放置的属性为我们默默生成初始化器,但你也可以创建自己的初始化器,只要遵循一条黄金法则:初始化器结束时,所有属性都必须有一个值。
让我们从 Swift 默认的结构体初始化器开始:
struct Player {
let name: String
let number: Int
}
let player = Player(name: "Megan R", number: 15)
它通过为两个属性提供值来创建一个新的Player实例。Swift 将此称为成员初始化器(memberwise initializer),也就是按照定义的顺序接受每个属性的初始化器。
正如我所说,这种代码是可行的,因为 Swift 会默默生成一个接受这两个值的初始化器,但我们也可以编写自己的初始化器来做同样的事情。这里唯一的问题是,你必须小心区分输入的参数名和分配的属性名。
如下所示:
struct Player {
let name: String
let number: Int
init(name: String, number: Int) {
self.name = name
self.number = number
}
}
这与我们之前的代码工作原理相同,只是现在初始化器归我们所有,因此我们可以根据需要在这里添加额外的功能。
不过,有几件事希望大家注意:
- 没有 func 关键字。没错,就语法而言,这看起来像一个函数,但 Swift 对初始化器的处理方式比较特殊。
- 尽管这会创建一个新的Player实例,但初始化器从来没有明确的返回类型——它们总是返回其所属的数据类型。
- 我使用 self 将参数赋值给属性,以明确我们的意思是 “将 name 参数赋值给我的 name 属性”。
最后一点尤为重要,因为如果没有 self,我们就会有 name = name,而这是说不通的——我们是将属性赋值给参数,还是将参数赋值给它自己,还是其他什么?通过写 self.name,我们可以明确我们指的是 “属于我当前实例的 name 属性”,而不是其他任何东西。
当然,我们的自定义初始化器并不需要像 Swift 提供的默认成员初始化器那样工作。例如,我们可以说你必须提供球员姓名,但球衣号码是随机的:
struct Player {
let name: String
let number: Int
init(name: String) {
self.name = name
number = Int.random(in: 1...99)
}
}
let player = Player(name: "Megan R")
print(player.number)
请记住一条黄金法则:初始化器结束时,所有属性都必须有一个值。如果我们没有在初始化器中为 number 赋值,Swift 就会拒绝构建我们的代码。
重要提示:虽然你可以在初始化器中调用结构体的其他方法,但不能在为所有属性赋值之前这样做——Swift 需要在确定一切安全后再做其他事情。
如果需要,你可以为结构体添加多个初始化器,还可以利用外部参数名称和默认值等功能。但是,一旦尼实现了自己的自定义初始化器,你就将无法访问 Swift 生成的成员初始化器,除非你采取额外措施保留它。这并非偶然:如果你使用了自定义初始化器,Swift 会有效地认为这是因为你使用了某种特殊方法来初始化属性,这意味着默认初始化器不再可用。
Swift 的成员初始化器如何工作?
默认情况下,所有 Swift 结构都会获得一个合成的成员初始化器,这意味着我们会自动获得一个初始化器,接受结构的每个属性值。这个初始化器让结构体变得易于使用,但 Swift 还做了两件让它变得特别聪明的事情。
首先,如果你的任何属性都有默认值,那么它们就会作为默认参数值加入初始化器中。因此,如果我创建了这样一个结构体:
struct User {
var name: String
var yearsActive = 0
}
然后,我可以通过以下两种方式之一来创建它:
struct Employee {
var name: String
var yearsActive = 0
}
let roslin = Employee(name: "Laura Roslin")
let adama = Employee(name: "William Adama", yearsActive: 45)
这使得它们更容易创建,因为你可以只填写需要的部分。
Swift 的第二个聪明之处在于,如果你创建了自己的初始化器,就可以移除成员初始化器。
例如,如果我有一个创建匿名雇员的自定义初始化器,它看起来就会像这样:
struct Employee {
var name: String
var yearsActive = 0
init() {
self.name = "Anonymous"
print("Creating an anonymous employee…")
}
}
这样一来,我就不能再依赖成员初始化器了,因此也就不能再使用这种方法了:
let roslin = Employee(name: "Laura Roslin")
这并非偶然,而是我们有意为之的:我们创建了自己的初始化器,如果 Swift 保留其成员初始化器,那么它可能会丢失我们在初始化器中所做的重要工作。
因此,一旦你为结构体添加了自定义初始化器,默认的成员初始化器就会消失。如果你想保留它,请将自定义初始化器移至扩展中,就像这样:
struct Employee {
var name: String
var yearsActive = 0
}
extension Employee {
init() {
self.name = "Anonymous"
print("Creating an anonymous employee…")
}
}
// creating a named employee now works
let roslin = Employee(name: "Laura Roslin")
// as does creating an anonymous employee
let anon = Employee()
什么时候在方法中使用self?
在方法内部,Swift 允许我们使用 self 来引用结构体的当前实例,但一般来说,除非你特别需要区分你的意思,否则你不希望使用 self。
到目前为止,使用 self 的最常见原因是在初始化器中,在初始化器中,你可能希望参数名称与类型的属性名称相匹配,就像下面这样:
struct Student {
var name: String
var bestFriend: String
init(name: String, bestFriend: String) {
print("Enrolling \(name) in class…")
self.name = name
self.bestFriend = bestFriend
}
}
当然,你不一定非要这样做,但在参数名上添加某种前缀会显得有点笨拙:
struct Student {
var name: String
var bestFriend: String
init(name studentName: String, bestFriend studentBestFriend: String) {
print("Enrolling \(studentName) in class…")
name = studentName
bestFriend = studentBestFriend
}
}
除了初始化器之外,使用 self 的主要原因是我们在闭包中,Swift 要求使用 self,这样我们就能清楚地了解正在发生什么。只有在从属于类的闭包内部访问 self 时才需要这样做,如果不添加,Swift 将拒绝构建你的代码。
好了,结构体的Part 1到此为止,下一章我们继续。