类与结构体非常相似,因为我们使用它们来创建带有属性和方法的新数据类型。然而,类引入了一个新的、重要的、复杂的特性,即继承——使一个类建立在另一个类的基础上的能力。
毫无疑问,这是一个强大的功能,当你开始构建真正的 iOS 应用程序时,你将无法避免使用类。但请记住,一定要保持代码的简洁:一项功能的存在并不意味着你一定需要使用它。
SwiftUI 在用户界面设计中广泛使用了结构体。那么,它在数据方面也广泛使用了类:当你在屏幕上显示来自某个对象的数据时,或者当你在布局之间传递数据时,你通常会使用类。
今天我们一起来学习类的内容,有六大部份,内容有点多,让我们一一来看看。
如何创建自己的类
Swift 使用结构体来存储大多数数据类型,包括字符串、Int、Double 和数组,但还有一种创建自定义数据类型的方法,称为类。这些类与结构体有许多共同点,但在关键的地方有所不同。
首先,类和结构体的共同点包括:
- 你可以创建并命名它们。
- 可以添加属性和方法,包括属性观察者和访问控制。
- 你可以创建自定义初始化器,以便随心所欲地配置新实例。
然而,类与结构体在五个关键点上有所不同:
- 你可以让一个类建立在另一个类的功能基础上,获得它的所有属性和方法作为起点。如果你想有选择性地覆盖某些方法,也可以这样做。
- 基于第一点,Swift 不会自动为类生成成员初始化器。这意味着你要么需要编写自己的初始化器,要么为所有属性分配默认值。
- 当你复制一个类的实例时,两个副本共享相同的数据——如果你更改其中一个副本,另一个副本也会随之更改。
- 在销毁类实例的最终副本时,Swift 可以选择运行一个名为去初始化器(deinitializer)的特殊函数。
- 即使你将一个类设为常量,只要其属性是变量,你仍然可以改变它们。
从表面上看,这些可能看起来相当随意,而且你很可能会想,既然我们已经有了结构体,为什么还需要类呢?
其实,SwiftUI 广泛使用类,主要是为了第 3 点:类的所有副本共享相同的数据。这意味着应用程序的许多部分都可以共享相同的信息,因此,如果用户在一个屏幕上更改了姓名,所有其他屏幕都会自动更新以反映该更改。
其他要点也很重要,但用途各不相同:
- 在苹果较早的 UI 框架 UIKit 中,基于一个类构建另一个类的能力非常重要,但在 SwiftUI 应用程序中却不常见。在 UIKit 中,长类层次结构很常见,类 A 基于类 B 构建,类 B 基于类 C 构建,类 C 基于类 D 构建,等等。
- 缺乏成员初始化器是一件令人讨厌的事,但希望你能明白,由于一个类可以基于另一个类,因此实现起来会很棘手——如果类 C 增加了一个额外的属性,就会破坏 C、B 和 A 的所有初始化器。
- 改变常量类的变量与类的多重拷贝行为有关:常量类意味着我们不能改变拷贝指向的容器,但如果属性是可变的,我们仍然可以改变容器内的数据。这一点与结构体不同,在结构体中,每个副本都是唯一的,并拥有自己的数据。
- 由于一个类的实例可以在多个地方被引用,因此知道最终副本何时被销毁就变得非常重要。这就是去初始化器的作用所在:它允许我们在最后一个副本消失时清理我们分配的任何特殊资源。
在结束之前,让我们来看看创建和使用类的一小段代码:
class Game {
var score = 0 {
didSet {
print("Score is now \(score)")
}
}
}
var newGame = Game()
newGame.score += 10
是的,它与结构体的唯一区别是,它是用 class 而不是 struct 创建的,其他都是一样的。这可能会让类显得多余,但请相信我:它们的所有五个不同点都很重要。
在接下来的内容中,我们将详细学习类和结构体的五种不同之处,但现在最重要的是:结构体很重要,类也很重要——在使用 SwiftUI 时,你将同时需要这两种类型。
为什么 Swift 有类和结构体?
类和结构体使 Swift 开发人员能够创建带有属性和方法的自定义复杂类型,但它们有五个重要的不同点:
- 类没有合成的成员初始化器。
- 一个类可以建立在另一个类之上(”继承自”另一个类),从而获得其属性和方法。
- 结构体的副本总是唯一的,而类的副本实际上指向相同的共享数据。 类有去初始化器,即在类的实例被销毁时调用的方法,但结构体没有。
- 常量类中的变量属性可以自由修改,但常量结构体中的变量属性不能修改。
我们很快会详细了解这些差异,但重点是它们的存在和重要性。在可能的情况下,大多数 Swift 开发人员更倾向于使用结构体而不是类,这意味着当你选择类而不是结构体时,是因为你希望获得上述行为之一。
为什么 Swift 类没有成员初始化器?
Swift 的结构体有很多有用的功能,其中之一就是它们自带一个合成的成员初始化器,让我们只需指定结构体的属性就能创建新的结构体实例。然而,Swift 的类却没有这个功能,这让人很恼火,但它们为什么没有呢?
主要原因是类具有继承性,这将使成员初始化器难以使用。想想看:如果我创建了一个类,而你继承了这个类,然后在我的类中添加了一些属性,那么你的代码就会崩溃——你依赖于我的成员初始化器的所有地方都会突然失效。
因此,Swift 有一个简单的解决方案:类的作者必须手工编写自己的初始化器,而不是自动生成成员初始化器。这样,你就可以随心所欲地添加属性,而不会影响你的类的初始化器,也不会影响继承自你的类的其他人。而且,当你决定更改初始化器时,也是你自己的决定,因此你完全知道这样做对任何继承类的影响。
如何让一个类继承另一个类
Swift 允许我们在现有类的基础上创建类,这个过程被称为继承(inheritance)。当一个类从另一个类(它的 “父类 “或 “超类”)继承功能时,Swift 会让新类(”子类”)访问父类的属性和方法,这样我们就可以对新类的行为方式进行微小的添加或修改。
要使一个类继承自另一个类,请在子类名称后面写一个冒号,然后添加父类的名称。例如,下面是一个只有一个属性和一个初始化器的 Employee 类:
class Employee {
let hours: Int
init(hours: Int) {
self.hours = hours
}
}
我们可以创建 Employee 的两个子类,每个子类都将获得 hours 属性和初始化器:
class Developer: Employee {
func work() {
print("I'm writing code for \(hours) hours.")
}
}
class Manager: Employee {
func work() {
print("I'm going to meetings for \(hours) hours.")
}
}
请注意,这两个子类可以直接引用hours——就好像它们自己添加了该属性,只是我们不必一直重复自己的工作。
这些类都继承自 Employee,但每个类都添加了自己的自定义属性。因此,如果我们分别创建一个实例并调用 work(),就会得到不同的结果:
let robert = Developer(hours: 8)
let joseph = Manager(hours: 10)
robert.work()
joseph.work()
除了共享属性,还可以共享方法,然后可以从子类中调用这些方法。举个例子,在 Employee 类中添加以下内容:
func printSummary() {
print("I work \(hours) hours a day.")
}
因为 Developer 继承自 Employee,所以我们可以立即在 Developer 实例上调用 printSummary(),就像这样:
let novall = Developer(hours: 8)
novall.printSummary()
当你想改变一个继承的方法时,情况就会变得复杂一些。例如,我们刚把 printSummary() 放进 Employee 中,但可能其中一个子类想要稍微不同的行为。
这时,Swift 会强制执行一条简单的规则:如果子类想要更改父类的方法,就必须在子类的版本中使用覆盖。这样做有两个目的:
- 如果你试图在不使用覆盖的情况下更改方法,Swift 将拒绝构建你的代码。这可以防止你不小心覆盖了一个方法。
- 如果你使用了覆盖,但你的方法实际上并没有覆盖父类中的某些内容,Swift 将拒绝构建你的代码,因为你可能犯了一个错误。
因此,如果我们希望开发人员拥有一个独一无二的 printSummary() 方法,我们可以将其添加到Developer类中:
override func printSummary() {
print("I'm a developer who will sometimes work \(hours) hours a day, but other times spend hours arguing about whether code should be indented using tabs or spaces.")
}
Swift 对于方法覆盖的工作方式很聪明:如果父类有一个不带参数的 work() 方法,而子类有一个接受字符串来指定工作位置的 work() 方法,这就不需要override,因为你并没有替换父类的方法。
提示:如果确定类不支持继承,可以将其标记为 final。这意味着类本身可以从其他事物继承,但不能被用来继承——任何子类都不能使用最终类作为父类。
什么时候需要重写方法?
在 Swift 中,任何继承自父类的子类都可以覆盖父类的方法,有时还可以覆盖父类的属性,这意味着它们可以用自己的方法替换父类的方法实现。
这种程度的自定义非常重要,让我们的开发生活变得更轻松。想想看:如果有人设计了一个出色的类,而你又想使用它,但它又不太合适,那么你只需重写它的一部分行为,而不必自己重写整个类,这不是很好吗?
当然,这正是方法覆盖的用武之地:你可以保留你想要的所有行为,只需在自定义子类中更改一两个小部分即可。
在苹果公司最初的 iOS 用户界面框架 UIKit 中,这种方法被大量使用。例如,考虑一些内置应用程序,如 “设置 “和 “信息”。这两个应用程序都以行的形式显示信息: “设置”有”常规”、”控制中心”、”显示和亮度 “等行,而 “信息 “则有单独的行来显示你与不同人的每次对话。在 UIKit 中,这些行被称为tables,它们有一些共同的行为:你可以滚动浏览所有行,可以点击行选择其中一行,行的右侧有灰色的小箭头,等等。
这些行列表非常常见,因此苹果为我们提供了使用它们的现有代码,其中包含了所有这些内置的标准行为。当然,有些部分实际上需要更改,例如列表有多少行以及列表中包含哪些内容。因此,Swift 开发人员会创建 Apple 表格的子类,并覆盖他们想要更改的部分,这样他们就能获得所有内置功能以及大量的灵活性和控制权。
Swift 让我们在覆盖函数之前使用override关键字,这确实很有帮助:
- 如果在不需要时使用(因为父类没有声明相同的方法),就会出现错误。这可以防止你输入错误的内容,如参数名或类型,还可以在父类更改方法而你没有跟上时防止覆盖失败。
- 如果你在需要的时候没有使用它,那么你也会得到一个错误信息。这样可以避免意外更改父类的行为。
哪些类应声明为 final?
最终类是不能被继承的类,这意味着你的代码的用户不可能添加功能或更改已有的功能。这不是默认情况:你必须在类中添加 final 关键字,以选择这种行为。
请记住,任何子类化你的类的人都可以覆盖你的属性,也许还可以覆盖你的方法,这将为他们提供难以置信的力量。如果你做了他们不喜欢的事情,他们可以直接替换掉。他们可能仍然会调用你原来的方法,但也可能不会。
这可能会带来一些问题:也许你的类做了一些非常重要的事情,不能被替换掉;也许你有客户签订了支持合同,你不想让他们破坏你的代码工作方式。
在 Swift 出现之前,苹果自己的大部分代码都是用早期的 Objective-C 语言编写的。Objective-C 对最终类的支持并不完善,因此苹果通常会在其网站上发布大型警告。例如,有一个非常重要的类叫做 AVPlayerViewController,它是用来播放电影的,其文档页面上有一个大大的黄色警告,上面写着:”不支持子类化 AVPlayerViewController 并重写其方法,否则会导致未定义的行为”。我们不知道为什么,只知道不应该这么做。还有一个类叫 Timer,用于处理定时事件(如闹钟),那里的警告更简单:”请勿子类化 Timer”。
在 Swift 中,final 类曾经比非 final 类性能更强,因为我们提供了更多关于代码如何运行的信息,Swift 会利用这些信息进行优化。
这种情况已经不是第一次出现了,但即使在今天,我想很多人还是会本能地将自己的类声明为 final,意思是 “除非我特别允许,否则你不应该从这个类中进行子类化”。
如何为类添加初始化器
Swift 中的类初始化器比结构体初始化器复杂得多,但只要稍加挑选,我们就能专注于真正重要的部分:如果子类有任何自定义初始化器,那么它必须在设置完自己的属性(如果有的话)后始终调用父类的初始化器。
正如之前所说,Swift 不会自动为类生成成员初始化器。无论是否发生继承,这一点都适用——它永远不会为你自动生成成员初始化器。因此,你要么需要编写自己的初始化器,要么为类的所有属性提供默认值。
让我们从定义一个新类开始:
class Vehicle {
let isElectric: Bool
init(isElectric: Bool) {
self.isElectric = isElectric
}
}
它有一个布尔属性,还有一个初始化器,用于设置该属性的值。记住,这里使用 self 表明我们将 isElectric 参数赋值给了同名的属性。
现在,假设我们想创建一个继承自 Vehicle 的 Car 类,一开始可以这样写:
class Car: Vehicle {
let isConvertible: Bool
init(isConvertible: Bool) {
self.isConvertible = isConvertible
}
}
但 Swift 会拒绝构建该代码:我们已经说过,车辆类需要知道它是否是电动车,但我们并没有为此提供一个值。 Swift 希望我们做的是为 Car 提供一个包含 isElectric 和 isConvertible 的初始化器,但我们不能自己存储 isElectric,而是需要将其传递给超类——我们需要让超类运行自己的初始化器。 下面就是这个过程:
class Car: Vehicle {
let isConvertible: Bool
init(isElectric: Bool, isConvertible: Bool) {
self.isConvertible = isConvertible
super.init(isElectric: isElectric)
}
}
super 是 Swift 自动提供给我们的另一个值,与 self 类似:它允许我们调用属于父类的方法,例如父类的初始化器。如果你愿意,也可以在其他方法中使用它;它并不局限于初始化器。 现在,我们在两个类中都有了一个有效的初始化器,我们可以像这样创建 Car 的实例:
let teslaX = Car(isElectric: true, isConvertible: false)
提示:如果子类没有自己的初始化程序,它将自动继承父类的初始化程序。
如何复制类
在 Swift 中,类实例的所有副本共享相同的数据,这意味着你对其中一个副本所做的任何更改都会自动更改其他副本。出现这种情况是因为类在 Swift 中是引用类型,这意味着类的所有副本都会引用相同的底层数据。
要了解这一点,请试试这个简单的类:
class User {
var username = "Anonymous"
}
该类只有一个属性,但由于它存储在一个类中,因此将在该类的所有副本中共享。
因此,我们可以创建该类的一个实例:
var user1 = User()
然后,我们可以复制 user1 并更改用户名值:
var user2 = user1
user2.username = "Taylor"
希望你能明白这一点!现在,我们已经更改了副本的用户名属性,然后可以打印出每个不同副本的相同属性:
print(user1.username)
print(user2.username)
……而这两个实例都将打印 “Taylor”(泰勒),即使我们只更改了其中一个实例,另一个实例也发生了变化。
这看似是一个错误,但实际上是一项功能,而且是一项非常重要的功能,因为它允许我们在应用程序的各个部分共享通用数据。正如你所看到的,SwiftUI 在很大程度上依赖于类来获取数据,特别是因为它们可以非常容易地共享。
相比之下,结构体不能在副本中共享数据,这意味着如果我们在代码中将类 User 更改为结构体 User,就会得到不同的结果:会打印出 “Anonymous”(匿名),然后是 “Taylor”(泰勒),因为更改副本并不会同时调整原始副本。
如果要创建一个类实例的唯一副本(有时称为深度副本),就需要安全地创建一个新实例并复制所有数据。
在我们的例子中,这很简单:
class User {
var username = "Anonymous"
func copy() -> User {
let user = User()
user.username = username
return user
}
}
现在,我们可以安全地调用 copy() 来获取一个具有相同起始数据的对象,但今后的任何更改都不会影响原始数据。
为什么一个类的副本要共享数据?
Swift 有一个特点一开始确实让人困惑,那就是类和结构体在复制时的行为有何不同:同一个类的副本共享它们的底层数据,这意味着改变一个副本会改变所有副本;而结构体始终有自己独特的数据,改变一个副本不会影响其他副本。
这种区别的专业术语是 “值类型(value types)与引用类型(reference types)”。结构体是值类型,这意味着它们可以保存简单的值,如数字 5 或字符串 “hello”。不管你的结构体有多少属性或方法,它仍然被认为是一个简单的值,比如一个数字。另一方面,类是引用类型,这意味着它们引用其他地方的值。
对于值类型来说,这一点很容易理解,不言自明。例如,请看这段代码:
var message = "Welcome"
var greeting = message
greeting = "Hello"
代码运行时,message仍将设置为 “Welcome”,但greeting将设置为 “Hello”。这似乎显而易见且符合一般逻辑,这就是结构体的行为方式:它们的值完全包含在变量中,不会以某种方式与其他值共享。这意味着它们的所有数据都直接存储在每个变量中,所以当你复制它时,你会得到所有数据的深度拷贝。
相比之下,引用类型就像指向某些数据的路标。如果我们创建了一个类的实例,它会占用 iPhone 上的一些内存,而存储实例的变量实际上只是指向对象所在实际内存的路标。如果你复制了一个对象,就会得到一个新的路标,但它仍然指向原始对象所在的内存。这就是为什么改变一个类的一个实例会改变所有实例的原因:对象的所有副本都是指向同一块内存的路标。
在 Swift 开发中,这种差异的重要性怎么估计都不为过。之前我们提到 Swift 开发人员更喜欢使用结构体来创建自定义类型,而这种复制行为正是其中一个重要原因。想象一下,如果你有一个大型应用程序,并希望在不同地方共享一个 User 对象,那么如果其中一个地方更改了你的用户,会发生什么情况?如果你使用的是类,那么使用你的用户的所有其他地方的数据都会在不知不觉中发生变化,最终可能会出现问题。但如果使用的是结构体,应用程序的每个部分都有自己的数据副本,不会被意外更改。
与编程中的许多事情一样,你所做的选择应有助于传达你的一些推理。在本例中,使用类而不是结构体传递了一个强烈的信息,即你希望以某种方式共享数据,而不是拥有大量不同的副本。
如何为类创建去初始化程序
Swift 的类可以选择使用去初始化器(deinitializer),它有点像初始化器的反义词,在对象被销毁时调用,而不是在创建时调用。
这有几个小问题:
- 与初始化器一样,去初始化器也不能使用 func,因为它们比较特殊。
- 去初始化器从不接受参数或返回数据,因此在编写时甚至不使用括号。
- 当类实例的最终副本被销毁时,去初始化器将自动被调用。例如,这可能意味着它是在一个函数内部创建的,而该函数现在正在结束。
- 我们从不直接调用去初始化器,而是由系统自动处理。
- 结构体没有去初始化器,因为无法复制它们。
究竟何时调用去初始化器取决于你正在做什么,但实际上它归结于一个叫做 scope 的概念。作用域或多或少意味着 “信息可用的上下文”,你已经看过很多例子了:
- 如果你在函数内部创建了一个变量,你就不能从函数外部访问它。
- 如果在 if 条件中创建变量,那么在条件之外就无法使用该变量。
- 如果在 for 循环(包括循环变量本身)中创建变量,则无法在循环之外使用该变量。
纵观全局,你会发现每一个变量都使用大括号来创建它们的作用域:条件、循环和函数都创建了局部作用域。
当一个值退出作用域时,我们指的是它创建时的上下文正在消失。对于结构体来说,这意味着数据被销毁,但对于类来说,这意味着只有底层数据的一个副本会消失——其他地方可能还有其他副本。但是,当最后一份副本消失时,也就是指向类实例的最后一个常量或变量被销毁时,底层数据也会被销毁,它所使用的内存会返回系统。
为了演示这一点,我们可以创建一个类,使用初始化器和去初始化器在创建和销毁时打印一条信息:
class User {
let id: Int
init(id: Int) {
self.id = id
print("User \(id): I'm alive!")
}
deinit {
print("User \(id): I'm dead!")
}
}
现在,我们可以使用循环快速创建和销毁用户实例——如果我们在循环内创建了一个用户实例,当循环迭代结束时,它将被销毁:
for i in 1...3 {
let user = User(id: i)
print("User \(user.id): I'm in control!")
}
当代码运行时,你会看到它分别创建和销毁了每个用户,其中一个用户甚至在另一个用户创建之前就被完全销毁了。
请记住,只有在类实例的最后一个引用被销毁时,才会调用去初始化器。这可能是你藏起来的变量或常量,也可能是你存储在数组中的东西。
例如,如果我们在创建 User 实例时添加了它们,那么只有在数组被清除时它们才会被销毁:
var users = [User]()
for i in 1...3 {
let user = User(id: i)
print("User \(user.id): I'm in control!")
users.append(user)
}
print("Loop is finished!")
users.removeAll()
print("Array is clear!")
为什么类有去初始化器,而结构体没有?
类的一个微小但重要的特性是,它们可以有一个去初始化函数——与 init() 相对应,在类实例被销毁时运行。结构体没有去初始化函数,这意味着我们无法知道结构体何时被销毁。
去初始化器的工作就是告诉我们类实例何时被销毁。对于结构体来说,这一点相当简单:当结构体的所有者不再存在时,结构体就会被销毁。因此,如果我们在一个方法中创建了一个结构体,并且该方法结束了,那么结构体就被销毁了。
然而,类具有复杂的复制行为,这意味着程序的不同部分可能存在多个类的副本。所有副本都指向相同的底层数据,因此现在很难判断实际的类实例何时销毁,即指向它的最终变量何时消失。
在幕后,Swift 会执行一种称为自动引用计数(ARC)的功能。ARC 会跟踪每个类实例的副本数量:每复制一个类实例,Swift 就会在其引用计数上加 1;每销毁一个副本,Swift 就会在其引用计数上减 1。当引用计数为 0 时,意味着没有人再引用该类,Swift 将调用去初始化器并销毁对象。
因此,结构体没有去初始化器的原因很简单,因为它们不需要去初始化器:每个结构体都有自己的数据副本,因此在销毁时不需要发生任何特殊情况。
你可以将去初始化器放在代码的任何地方,但代码应该读起来像句子,这让我觉得我的类应该读起来像章节。因此,去初始化器应该放在最后,它是类的 “结尾”。
如何在类内使用变量
Swift 类的工作原理有点像路标:我们拥有的每个类实例副本实际上都是指向相同底层数据的路标。这主要是因为改变一个副本会改变所有其他副本,但这也与类如何处理变量属性有关。
这一小段代码演示了事情是如何运作的:
class User {
var name = "Paul"
}
let user = User()
user.name = "Taylor"
print(user.name)
这样就创建了一个常量 User 实例,但随后又对其进行了更改,即更改了常量值。这很糟糕,对吗?
但它根本没有改变常量值。是的,类中的数据发生了变化,但类实例本身——我们创建的对象——并没有变化,事实上也无法改变,因为我们将其设置为常量。
试想一下:我们创建了一个指向用户的恒定标识点,但我们删除了该用户的姓名标签,并写入了一个不同的姓名。这个用户并没有改变——他仍然存在——但其内部数据的一部分发生了改变。
现在,如果我们使用 let 将 name 属性设为常量,那么它就不会被更改——我们有了一个指向用户的常量路标,但我们用永久性墨水写下了他们的名字,这样它就不会被擦除。
相比之下,如果我们把用户实例和用户名都设为属性变量,会发生什么呢?现在,我们可以更改属性,但如果我们愿意,也可以改成一个全新的用户实例。继续用路标做比喻,这就好比把路标转向完全不同的人。
用这段代码试试看:
class User {
var name = "Paul"
}
var user = User()
user.name = "Taylor"
user = User()
print(user.name)
这最终会打印出 “Paul”,因为即使我们将名字改为 “Taylor”,我们也会用一个新的用户对象覆盖整个用户对象,将其重置为 “Paul”。
最后一种变体是拥有一个可变实例和常量属性,这意味着我们可以创建一个新的用户,但一旦创建完成,我们就无法更改其属性。
因此,我们有四种选择:
- 恒定实例,恒定属性——指向同一个用户的路标,用户名始终不变。
- 恒定实例,可变属性——指路牌始终指向同一个用户,但其名称可以改变。
- 可变实例,常量属性——指路牌可以指向不同的用户,但其名称永远不会改变。
- 可变实例,可变属性——指路牌可以指向不同的用户,而且这些用户的名称也可以改变。
这看起来可能非常令人困惑,甚至有些迂腐。不过,由于类实例的共享方式,它的作用非常重要。
假设你得到了一个 User 实例。你的实例是常量,但其中的属性被声明为变量。这不仅告诉你,如果你想改变该属性,你就可以改变它,更重要的是告诉你,该属性有可能在其他地方被改变——你的这个类可能是从其他地方复制过来的,由于该属性是变量,这意味着其他代码部分可能会出其不意地改变它。
当你看到常量属性时,这意味着你可以确保当前代码或程序的任何其他部分都不会改变它,但一旦你要处理的是变量属性(无论类实例本身是否常量),就意味着数据有可能在你的脚下发生变化。
这一点与结构体不同,因为恒定的结构体即使属性是可变的,也不能改变其属性。希望你现在能明白为什么会发生这种情况:结构体没有路标,它们直接保存数据。这就意味着,如果你试图改变结构体内部的值,你也会隐式地改变结构体本身,而这是不可能的,因为结构体是常量。
这样做的一个好处是,类在使用改变数据的方法时不需要使用 mutating 关键字。这个关键字对于结构体来说非常重要,因为常量结构体无论如何创建,其属性都不能改变,所以当 Swift 看到我们在一个常量结构体实例上调用一个突变方法时,它就知道这是不允许的。
对于类来说,实例本身是如何创建的不再重要——决定属性是否可以修改的唯一因素是属性本身是否创建为常量。Swift 可以通过查看创建属性的方式来了解这一点,因此不再需要对方法进行特殊标记。
为什么常量类中的变量属性可以更改?
结构体和类之间一个微小但重要的区别是它们处理属性可变性的方式:
- 可变类可以更改可变属性
- 常量类可更改变量属性
- 可变结构体可以更改可变属性
- 常量结构体不能更改变量属性
原因在于类和结构体之间的根本区别:一个指向内存中的某些数据,而另一个是一个值,如数字 5。
请看下面的代码:
var number = 5
number = 6
我们不能简单地将数字 5 定义为 6,因为这样做没有意义,会破坏我们对数学的一切认知。相反,这段代码删除了分配给数字的现有值,取而代之的是数字 6。
这就是 Swift 中结构体的工作原理:当我们改变结构体的某个属性时,实际上是在改变整个结构体。当然,Swift 可以在幕后进行一些优化,这样每次我们只更改其中一部分时,它就不会真正丢弃整个值,但从我们的角度来看,它就是这样处理的。
因此,如果更改结构体的一部分实际上意味着销毁并重新创建整个结构体,那么希望你能明白为什么常量结构体不允许更改其变量属性——这意味着销毁并重新创建本应是常量的东西,而这是不可能的。
类不是这样工作的:你可以改变其属性的任何部分,而不必销毁和重新创建值。因此,常量类可以自由更改其可变属性。
总结
类并不像结构体那样常用,但它们在共享数据方面有着不可估量的作用,如果你选择学习苹果较早的 UIKit 框架,你会发现自己会大量使用它们。
让我们回顾一下所学内容:
- 类与结构体有很多共同点,包括都能拥有属性和方法,但类与结构体有五大不同之处。
- 首先,类可以从其他类继承,这意味着类可以访问父类的属性和方法。如果需要,你可以选择覆盖子类中的方法,或者将一个类标记为最终类,以阻止其他人子类化它。
- 其次,Swift 不会为类生成成员初始化器,因此你需要自己动手。如果一个子类有自己的初始化器,它必须在某个时刻调用父类的初始化器。
- 第三,如果你创建了一个类实例,然后复制了它,那么所有这些副本都会指向同一个实例。这意味着改变其中一个副本中的某些数据会改变所有副本。
- 第四,类可以有去初始化器,当一个实例的最后一个副本被销毁时,去初始化器就会运行。
- 最后,无论实例本身是否作为变量创建,类实例内部的变量属性都可以更改。