第12天 类、继承
起初,类看起来与结构体非常相似,因为我们都用它们来创建具有属性和方法的新数据类型。然而,类引入了一个新的、重要且复杂的特性 —— 继承,即一个类可以在另一个类的基础上进行构建。
毫无疑问,这是一个强大的特性,而且当你开始构建真正的 iOS 应用时,你无法避免使用类。但请记住,要让你的代码保持简洁:仅仅因为某个特性存在,并不意味着你就必须要去使用它。正如马丁・福勒所写:“任何傻瓜都能写出计算机能理解的代码,但优秀的程序员会写出人类能理解的代码。”
我已经说过,SwiftUI 在其 UI 设计中大量使用结构体。而在数据处理方面,它则大量使用类:当你在屏幕上显示某个对象的数据,或者在不同布局之间传递数据时,通常都会用到类。
我还应该补充一点:如果你以前使用过 UIKit,你会发现这是一个显著的转变 —— 在 UIKit 中,我们通常用类来进行 UI 设计,用结构体来处理数据。所以,如果你觉得或许可以偶尔跳过一些内容,那我很抱歉地告诉你不行:这些内容都是必须掌握的。
今天你需要完成六个教程,你会接触到类、继承、析构器等等。 看完每个视频并完成任何你想做的额外阅读后,会有一些简短的测试来帮助你确认自己是否理解了所教的内容。
12.1 如何创建自己的类
作者:Paul Hudson 2021 年 11 月 8 日 已针对 Xcode 16.4 更新
Swift 使用结构体来存储其大多数数据类型,包括 String、Int、Double 和 Array,但还有另一种创建自定义数据类型的方式,称为类。类与结构体有很多共同之处,但在一些关键地方有所不同。
首先,类和结构体的共同之处包括:
- 你可以创建并命名它们。
- 你可以添加属性和方法,包括属性观察器和访问控制。
- 你可以创建自定义初始化器,以按照自己的意愿配置新实例。
然而,类与结构体在五个关键方面存在差异:
- 你可以让一个类基于另一个类的功能构建,以此为起点获得其所有属性和方法。如果你想有选择地重写某些方法,也可以做到。
- 由于第一点,Swift 不会为类自动生成成员初始化器。这意味着你要么需要自己编写初始化器,要么为所有属性分配默认值。
- 当你复制一个类的实例时,两个副本共享相同的数据 —— 如果你更改其中一个副本,另一个也会随之改变。
- 当类实例的最后一个副本被销毁时,Swift 可以选择性地运行一个名为析构器的特殊函数。
- 即使你创建了一个常量类,只要其属性是变量,你仍然可以修改这些属性。
表面上看,这些差异可能显得相当随意,你很可能会想,既然我们已经有了结构体,为什么还需要类呢?
不过,SwiftUI 大量使用类,主要是因为第三点:类的所有副本都共享相同的数据。这意味着你的应用程序的许多部分可以共享相同的信息,因此如果用户在一个屏幕上更改了他们的名字,所有其他屏幕都会自动更新以反映这一变化。
其他几点也很重要,但用途各不相同:
- 能够基于一个类构建另一个类在苹果较旧的 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 同时拥有类和结构体?
作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新
类和结构体让 Swift 开发者能够创建具有属性和方法的自定义复杂类型,但它们有五个重要区别:
- 类没有自动合成的成员初始化器。
- 一个类可以基于(“继承自”)另一个类,获得其属性和方法。
- 结构体的副本始终是唯一的,而类的副本实际上指向相同的共享数据。
- 类有析构器,即当类的实例被销毁时调用的方法,而结构体没有。
- 常量类中的可变属性可以自由修改,而常量结构体中的可变属性则不能。
我很快会更详细地解释这些区别,但关键是它们确实存在且很重要。大多数 Swift 开发者在可能的情况下更喜欢使用结构体而不是类,这意味着当你选择类而不是结构体时,是因为你需要上述某种行为。
【可选阅读】为什么 Swift 类没有成员初始化器?
作者:Paul Hudson 2021 年 11 月 8 日 已针对 Xcode 16.4 更新
Swift 结构体的众多有用特性之一是它们带有自动合成的成员初始化器,让我们只需指定其属性就能创建结构体的新实例。然而,Swift 的类没有这个特性,这有点烦人 —— 但为什么它们没有呢?
主要原因是类具有继承性,这会使成员初始化器难以使用。想想看:如果我创建了一个你继承的类,之后又向我的类中添加了一些属性,你的代码就会出错 —— 所有依赖我的成员初始化器的地方都会突然无法工作。
所以,Swift 有一个简单的解决方案:类的作者必须手动编写自己的初始化器,而不是自动生成成员初始化器。这样,你可以随意添加属性而不会影响类的初始化器,也不会影响从你的类继承的其他类。而且当你确实决定修改初始化器时,是你自己主动去做的,因此完全清楚这对任何继承类可能产生的影响。
【练习题】创建你自己的类
问题 1/12:这段代码定义了一个有效的类 —— 对还是错?
class Painting {
var title: String
var artist: String
var paintType: String
func init(title: String, artist: String, paintType: String) {
self.title = title
self.artist = artist
self.paintType = paintType
}
}问题 2/12:这段代码定义了一个有效的类 —— 对还是错?
class BoardGame {
var name: String
var minimumPlayers = 1
var maximumPlayers = 4
init(name: String) {
self.name = name
}
}问题 3/12:这段代码定义了一个有效的类 —— 对还是错?
struct Sandwich {
var name: String
var fillings: [String]
}
let blt = Sandwich(name: "BLT", fillings: ["Bacon", "Lettuce", "Tomato"])问题 4/12:这段代码定义了一个有效的类 —— 对还是错?
class TIE {
var name: String
var speed: Int
init(name: String, speed: Int) {
self.name = name
self.speed = speed
}
}
let fighter = TIE(name: "TIE Fighter", speed: 50)
let interceptor = TIE(name: "TIE Interceptor", speed: 70)问题 5/12:这段代码定义了一个有效的类 —— 对还是错?
class VideoGame {
var hero: String
var enemy: String
init(heroName: String, enemyName: String) {
self.hero = heroName
self.enemy = enemyName
}
}
let monkeyIsland = VideoGame(heroName: "Guybrush Threepwood", enemyName: "LeChuck")问题 6/12:这段代码定义了一个有效的类 —— 对还是错?
class Image {
var filename: String
var isAnimated: Bool
init(filename: String, isAnimated: Bool) {
filename = filename
isAnimated = isAnimated
}
}问题 7/12:这段代码定义了一个有效的类 —— 对还是错?
class ThemePark {
var entryPrice: Int
var rides: [String]
init(rides: [String]) {
self.rides = rides
self.entryPrice = rides.count * 2
}
}问题 8/12:这段代码定义了一个有效的类 —— 对还是错?
struct Poll {
var question: String
var option1: String
var option2: String
var votes: [Int]
}
let question = "Jet black or rose gold?"
let poll = Poll(question: question, option1: "Jet black", option2: "Rose gold", votes: [0, 0, 0, 0, 1, 0, 1])问题 9/12:这段代码定义了一个有效的类 —— 对还是错?
class Empty { }
let nothing = Empty()问题 10/12:这段代码定义了一个有效的类 —— 对还是错?
class Attendee {
var badgeNumber = 0
var name = "Anonymous"
var company = "Unknown"
init(badge: Int) {
self.badgeNumber = badgeNumber
}
}问题 11/12:这段代码定义了一个有效的类 —— 对还是错?
class Podcast {
var hosts: [String]
init(hosts: [String]) {
self.hosts = hosts
}
}问题 12/12:这段代码定义了一个有效的类 —— 对还是错?
class Singer {
var name: String
var favoriteSong: String
init(name: String, song: String) {
self.name = name
self.song = song
}
}
let taylor = Singer(name: "Taylor Swift", song: "Blank Space")12.2 如何让一个类继承另一个类
作者:Paul Hudson 2022 年 9 月 12 日 已针对 Xcode 16.4 更新
Swift 允许我们通过基于现有类来创建新类,这个过程被称为继承。当一个类从另一个类(它的 “父类” 或 “超类”)继承功能时,Swift 会让这个新类(“子类”)能够访问父类的属性和方法,使我们能够进行少量的添加或修改,以定制新类的行为。
要让一个类继承另一个类,在子类名称后面加一个冒号,然后加上父类的名称。例如,下面是一个带有一个属性和一个初始化器的Employee类:
class Employee {
let hours: Int
init(hours: Int) {
self.hours = hours
}
}我们可以创建Employee的两个子类,每个子类都会获得hours属性和初始化器:
class Developer: Employee {
func work() {
print("我要写\(hours)小时的代码。")
}
}
class Manager: Employee {
func work() {
print("我要开\(hours)小时的会。")
}
}注意这两个子类如何直接引用hours—— 就好像它们自己添加了那个属性一样,只是我们不必重复编写代码。
这些类都继承自Employee,但每个类都添加了自己的定制内容。所以,如果我们创建每个类的实例并调用work()方法,会得到不同的结果:
let robert = Developer(hours: 8)
let joseph = Manager(hours: 10)
robert.work()
joseph.work()除了共享属性,还可以共享方法,子类可以调用这些方法。例如,尝试向Employee类添加以下内容:
func printSummary() {
print("我一天工作\(hours)小时。")
}因为Developer继承自Employee,我们可以立即开始在Developer的实例上调用printSummary(),像这样:
let novall = Developer(hours: 8)
novall.printSummary()当你想修改继承来的方法时,情况会稍微复杂一些。例如,我们刚刚在Employee中添加了printSummary(),但也许某个子类希望有 slightly 不同的行为。
这时 Swift 有一个简单的规则:如果子类想修改父类的方法,必须在子类的方法版本中使用override。这有两个作用:
- 如果你试图修改一个方法却不使用
override,Swift 会拒绝编译你的代码。这能防止你意外地重写方法。 - 如果你使用了
override,但你的方法实际上并没有重写父类中的任何方法,Swift 也会拒绝编译你的代码,因为你可能犯了错误。
所以,如果我们想让开发者有一个独特的printSummary()方法,可以向Developer类添加这个:
override func printSummary() {
print("我是一名开发者,有时一天工作\(hours)小时,但其他时候会花很多时间争论代码应该用制表符还是空格缩进。")
}Swift 对方法重写的处理很智能:如果你的父类有一个不带参数的work()方法,而子类有一个接受字符串(用于指定工作地点)的work()方法,这不需要override,因为你并没有替换父类的方法。
提示: 如果你确定你的类不应该支持继承,可以将其标记为final。这意味着该类本身可以从其他类继承,但不能被用作继承的父类 —— 任何子类都不能将 final 类作为其父类。
【可选阅读】什么时候需要重写方法?
作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新
在 Swift 中,任何继承自父类的类都可以重写父类的方法,有时还可以重写属性,这意味着它们可以用自己的实现替换父类的方法实现。
这种定制化程度非常重要,也让我们开发者的工作更轻松。想想看:如果有人设计了一个很棒的类你想使用,但它并不完全符合你的需求,仅仅重写其中一部分行为,而不是自己重写整个类,不是很好吗?
当然很好,这正是方法重写的用途:你可以保留所有你想要的行为,只在自定义子类中修改一两个小部分。
在 UIKit(苹果为 iOS 开发的原始用户界面框架)中,这种方法被大量使用。例如,考虑一些内置应用,如 “设置” 和 “信息”。这两个应用都以行的形式展示信息:“设置” 有 “通用”、“控制中心”、“显示与亮度” 等行,“信息” 则为与不同人进行的每个对话都有单独的行。在 UIKit 中,这些被称为表格,它们有一些共同的行为:你可以滚动所有行,可以点击行来选择,行的右侧有小灰色箭头等等。
这些行列表非常常见,所以苹果为我们提供了现成的代码,其中内置了所有这些标准行为。当然,有些部分确实需要改变,比如列表有多少行以及行内有什么内容。所以,Swift 开发者会创建苹果表格的子类,并重写他们想要改变的部分,这样他们就获得了所有内置功能,同时又有很大的灵活性和控制权。
Swift 要求我们在重写函数之前使用override关键字,这非常有帮助:
- 如果你在不需要的时候使用了它(因为父类没有声明相同的方法),你会得到一个错误。这可以防止你输入错误,比如参数名称或类型,也可以防止如果父类更改了方法而你没有跟进时,你的重写失败。
- 如果你在需要的时候没有使用它,你也会得到一个错误。这可以防止你意外地改变父类的行为。
【可选阅读】哪些类应该声明为 final?
作者:Paul Hudson 2024 年 4 月 11 日 已针对 Xcode 16.4 更新
final 类是不能被继承的类,这意味着你的代码用户无法添加功能或改变它们已有的功能。这不是默认行为:你必须通过向类添加final关键字来选择这种行为。
记住,任何子类化你的类的人都可以重写你的属性,也许还有你的方法,这给了他们极大的权力。如果你做了他们不喜欢的事情,砰—— 他们可以直接替换掉。他们可能会在自己的替换代码中调用你原来的方法,但也可能不会。
这可能会有问题:也许你的类做了一些非常重要的事情,不能被替换,或者也许你有签订支持合同的客户,你不希望他们破坏你的代码运行方式。
苹果自己的很多代码是在 Swift 出现之前用一种更早的语言 Objective-C 编写的。Objective-C 对final类的支持不是很好,所以苹果通常会在他们的网站上给出大量警告。例如,有一个非常重要的类叫做AVPlayerViewController,用于播放电影,它的文档页面有一个大大的黄色警告说:“不支持子类化 AVPlayerViewController 并重写其方法,这会导致未定义的行为。” 我们不知道为什么,只知道我们不应该这样做。还有一个叫做Timer的类,用于处理定时事件(如闹钟),那里的警告更简单:“不要子类化 Timer”。
在 Swift 中,过去 final 类比非 final 类性能更好,因为我们提供了更多关于代码运行方式的信息,Swift 会利用这些信息进行一些优化。
虽然这种情况已经有一段时间不成立了,但即使在今天,我认为很多人还是本能地将他们的类声明为 final,意思是 “除非我特别允许,否则你不应该从这个类子类化”。我当然经常这样做,因为这是我帮助人们理解我的代码工作方式的另一种方式。
12.3 如何为类添加初始化器
作者:Paul Hudson 2021 年 10 月 12 日 已针对 Xcode 16.4 更新
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 会拒绝编译这段代码:我们已经声明Vehicle类需要知道它是否是电动的,但我们没有为其提供值。
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)提示: 如果子类没有任何自己的初始化器,它会自动继承父类的初始化器。
【练习题】类继承
问题 1/12:这段代码展示了有效的类继承 —— 对还是错?
class Vehicle {
var wheels: Int
init(wheels: Int) {
self.wheels = wheels
}
}
class Truck: Vehicle {
var goodsCapacity: Int
init(wheels: Int, goodsCapacity: Int) {
self.goodsCapacity = goodsCapacity
super.init()
}
}问题 2/12:这段代码展示了有效的类继承 —— 对还是错?
class Student {
var name: String
}
class UniversityStudent: Student {
var annualFees: Int
init(name: String, annualFees: Int) {
self.annualFees = annualFees
super.init(name: name)
}
}问题 3/12:这段代码展示了有效的类继承 —— 对还是错?
class Handbag {
var price: Int
init(price: Int) {
self.price = price
}
}
class DesignerHandbag: Handbag {
var brand: String
init(brand: String, price: Int) {
self.brand = brand
super.init(price: price)
}
}问题 4/12:这段代码展示了有效的类继承 —— 对还是错?
class Product {
var name: String
init(name: String) {
self.name = name
}
}
class Book: Product {
var isbn: String
init(name: String, isbn: String) {
self.isbn = isbn
super.init(name: name)
}
}问题 5/12:这段代码展示了有效的类继承 —— 对还是错?
class Computer {
var cpu: String
var ramGB: Int
init(cpu: String, ramGB: Int) {
cpu = cpu
ramGB = ramGB
}
}
class Laptop: Computer {
var screenInches: Int
init(screenInches: Int, cpu: String, ramGB: Int) {
self.screenInches = screenInches
super.init(cpu: cpu, ramGB: ramGB)
}
}问题 6/12:这段代码展示了有效的类继承 —— 对还是错?
class Bicycle {
var color: String
init(color: String) {
self.color = color
}
}
class MountainBike: Bicycle {
var tireThickness: Double
init(color: String, tireThickness: Double) {
self.tireThickness = tireThickness
super.init(color: color)
}
}问题 7/12:这段代码展示了有效的类继承 —— 对还是错?
class SmartPhone {
var price: Int
init(price: Int) {
self.price = price
}
}
class SmartPhone: SmartPhone {
var features: [String]
init(features: [String]) {
self.features = features
super.init(price: features.count * 50)
}
}问题 8/12:这段代码展示了有效的类继承 —— 对还是错?
class Dog {
var breed: String
var isPedigree: Bool
init(breed: String, isPedigree: Bool) {
self.breed = breed
self.isPedigree = isPedigree
}
}
class Poodle {
var name: String
init(name: String) {
self.name = name
super.init(breed: "Poodle", isPedigree: true)
}
}问题 9/12:这段代码展示了有效的类继承 —— 对还是错?
class Instrument {
var name: String
init(name: String) {
self.name = name
}
}
class Piano: Instrument {
var isElectric: Bool
init(isElectric: Bool) {
self.isElectric = isElectric
super.init(name: "Piano")
}
}问题 10/12:这段代码展示了有效的类继承 —— 对还是错?
class Printer {
var cost: Int
init(cost: Int) {
self.cost = cost
}
}
class LaserPrinter: Printer {
var model: String
init(model: String, cost: Int) {
self.model = model
super.init(cost: cost)
}
}问题 11/12:这段代码展示了有效的类继承 —— 对还是错?
class Food {
var name: String
var nutritionRating: Int
super init(name: String, nutritionRating: Int) {
self.name = name
self.nutritionRating = nutritionRating
}
}
class Pizza: Food {
init() {
super.init(name: "Pizza", nutritionRating: 3)
}
}问题 12/12:这段代码展示了有效的类继承 —— 对还是错?
class Shape {
var sides: Int
init(sides: Int) {
self.sides = sides
}
}
class Rectangle: Shape {
var color: String
init(color: String) {
self.color = color
super.init(sides: 4)
}
}12.4 如何复制类
作者:Paul Hudson 2021 年 10 月 12 日 已更新至 Xcode 16.4
在 Swift 中,类实例的所有副本都共享相同的数据,这意味着你对一个副本所做的任何更改都会自动改变其他副本。这是因为类在 Swift 中是引用类型,这意味着类的所有副本都引用同一个底层数据池。
为了实际了解这一点,试试这个简单的类:
class User {
var username = "Anonymous"
}它只有一个属性,但由于它存储在类中,所以会在类的所有副本之间共享。
因此,我们可以创建该类的一个实例:
var user1 = User()然后我们可以复制 user1 并更改 username 的值:
var user2 = user1
user2.username = "Taylor"我希望你能明白这会导致什么结果!现在我们已经更改了副本的 username 属性,然后我们可以从每个不同的副本中打印出相同的属性:
print(user1.username)
print(user2.username)…… 结果将是两个都打印出 “Taylor”—— 尽管我们只更改了其中一个实例,另一个也发生了变化。
这可能看起来像一个 bug,但实际上它是一个特性 —— 而且是一个非常重要的特性,因为它允许我们在应用程序的所有部分共享公共数据。你会发现,SwiftUI 在很大程度上依赖类来处理其数据,特别是因为类可以很容易地被共享。
相比之下,结构体不会在副本之间共享它们的数据,这意味着如果我们在代码中将 class User 改为 struct User,我们会得到不同的结果:它会先打印 “Anonymous”,然后打印 “Taylor”,因为更改副本不会同时修改原始数据。
如果你想创建类实例的唯一副本 —— 有时称为深拷贝—— 你需要处理创建新实例并安全地复制所有数据。
在我们的例子中,这很简单:
class User {
var username = "Anonymous"
func copy() -> User {
let user = User()
user.username = username
return user
}
}现在我们可以安全地调用 copy() 来获取一个具有相同初始数据的对象,但未来的任何更改都不会影响原始对象。
【可选阅读】为什么类的副本会共享它们的数据?
作者:Paul Hudson 2021 年 11 月 8 日 已更新至 Xcode 16.4
Swift 的一个起初确实令人困惑的特性是,当类和结构体被复制时,它们的行为有所不同:同一个类的副本共享其底层数据,这意味着更改一个会改变所有;而结构体始终有自己独特的数据,更改一个副本不会影响其他副本。
这种区别的技术术语是 “值类型与引用类型”。结构体是值类型,这意味着它们持有简单的值,例如数字 5 或字符串 “hello”。无论你的结构体有多少属性或方法,它仍然被视为一个像数字一样的简单值。另一方面,类是引用类型,这意味着它们引用其他地方的值。
对于值类型,这很容易理解,显而易见。例如,看这段代码:
var message = "Welcome"
var greeting = message
greeting = "Hello"当这段代码运行时,message 仍然会被设置为 “Welcome”,但 greeting 会被设置为 “Hello”。正如克里斯・艾德霍夫所说,“这太自然了,看起来就像在陈述显而易见的事情。但这就是结构体的行为:它们的值完全包含在其变量中,而不是以某种方式与其他值共享。这意味着它们的所有数据都直接存储在每个变量中,所以当你复制它时,你会得到所有数据的深拷贝。
相比之下,理解引用类型的最佳方式是把它想象成一个指向某些数据的路标。如果我们创建一个类的实例,它会占用 iPhone 上的一些内存,而存储该实例的变量实际上只是一个指向对象所在实际内存的路标。如果你复制该对象,你会得到一个新的路标,但它仍然指向原始对象所在的内存。这就是为什么更改类的一个实例会改变所有实例:对象的所有副本都是指向同一块内存的路标。
这种差异在 Swift 开发中的重要性怎么强调都不为过。之前我提到过,Swift 开发者更喜欢使用结构体作为他们的自定义类型,这种复制行为是一个重要原因。想象一下,如果你有一个大型应用程序,并想在不同的地方共享一个 User 对象 —— 如果其中一个地方更改了你的用户,会发生什么?如果你使用的是类,所有其他使用你的用户的地方的数据都会在不知情的情况下被更改,你可能最终会遇到问题。但如果你使用的是结构体,应用程序的每个部分都有自己的数据副本,不会被意外更改。
就像编程中的许多事情一样,你所做的选择应该有助于传达你的一些推理。在这种情况下,使用类而不是结构体传递了一个强烈的信息,即你希望以某种方式共享数据,而不是有许多不同的副本。
【练习题】复制对象
问题 1/12:这段代码会打印出相同的输出两次 —— 对还是错?
struct GalacticaCrew {
var isCylon = false
}
var starbuck = GalacticaCrew()
var tyrol = starbuck
tyrol.isCylon = true
print(starbuck.isCylon)
print(tyrol.isCylon)问题 2/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Statue {
var sculptor = "Unknown"
}
var venusDeMilo = Statue()
venusDeMilo.sculptor = "Alexandros of Antioch"
var david = Statue()
david.sculptor = "Michaelangelo"
print(venusDeMilo.sculptor)
print(david.sculptor)问题 3/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Starship {
var maxWarp = 9.0
}
var voyager = Starship()
voyager.maxWarp = 9.975
var enterprise = voyager
enterprise.maxWarp = 9.6
print(voyager.maxWarp)
print(enterprise.maxWarp)问题 4/12:这段代码会打印出相同的输出两次 —— 对还是错?
struct Calculator {
var currentTotal = 0
}
var baseModel = Calculator()
var casio = baseModel
var texas = baseModel
casio.currentTotal = 556
texas.currentTotal = 384
print(casio.currentTotal)
print(texas.currentTotal)问题 5/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Author {
var name = "Anonymous"
}
var dickens = Author()
dickens.name = "Charles Dickens"
var austen = dickens
austen.name = "Jane Austen"
print(dickens.name)
print(austen.name)问题 6/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Hater {
var isHating = true
}
var hater1 = Hater()
var hater2 = hater1
hater1.isHating = false
print(hater1.isHating)
print(hater2.isHating)问题 7/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Hospital {
var onCallStaff = [String]()
}
var londonCentral = Hospital()
var londonWest = londonCentral
londonCentral.onCallStaff.append("Dr Harlan")
londonWest.onCallStaff.append("Dr Haskins")
print(londonCentral.onCallStaff.count)
print(londonWest.onCallStaff.count)问题 8/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Ewok {
var fluffinessPercentage = 100
}
var chirpa = Ewok()
var wicket = Ewok()
chirpa.fluffinessPercentage = 90
print(wicket.fluffinessPercentage)
print(chirpa.fluffinessPercentage)问题 9/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Queen {
var isMotherOfDragons = false
}
var elizabeth = Queen()
var daenerys = Queen()
daenerys.isMotherOfDragons = true
print(elizabeth.isMotherOfDragons)
print(daenerys.isMotherOfDragons)问题 10/12:这段代码会打印出相同的输出两次 —— 对还是错?
class BasketballPlayer {
var height = 200.0
}
var lebron = BasketballPlayer()
lebron.height = 203.0
var curry = BasketballPlayer()
curry.height = 190
print(lebron.height)
print(curry.height)问题 11/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Magazine {
var pageCount = 132
}
var example = Magazine()
var wired = example
wired.pageCount = 164
var vogue = example
vogue.pageCount = 128
print(wired.pageCount)
print(vogue.pageCount)问题 12/12:这段代码会打印出相同的输出两次 —— 对还是错?
class Hairdresser {
var clients = [String]()
}
var tim = Hairdresser()
tim.clients.append("Jess")
var dave = tim
dave.clients.append("Sam")
print(tim.clients.count)
print(dave.clients.count)12.5 如何为类创建析构器
作者:Paul Hudson 2021 年 10 月 15 日 已针对 Xcode 16.4 更新
Swift 的类可以选择性地拥有一个析构器,它有点像初始化器的对立面,因为它在对象被销毁时而不是被创建时被调用。
这有几个小的限制:
- 就像初始化器一样,析构器不使用
func关键字 —— 它们是特殊的。 - 析构器永远不能带参数或返回数据,因此甚至不用括号来编写。
- 当类实例的最后一个副本被销毁时,析构器会自动被调用。例如,这可能意味着它是在一个即将结束的函数内部创建的。
- 我们从不直接调用析构器;它们由系统自动处理。
- 结构体没有析构器,因为你不能复制它们。
析构器的确切调用时机取决于你所做的操作,但实际上这归结于一个叫做作用域的概念。作用域大致意味着 “信息可用的上下文”,你已经见过很多例子了:
- 如果你在函数内部创建一个变量,你不能从函数外部访问它。
- 如果你在
if条件内部创建一个变量,那个变量在条件外部不可用。 - 如果你在
for循环内部创建一个变量,包括循环变量本身,你不能在循环外部使用它。
从大的方面看,你会发现这些都使用大括号来创建它们的作用域:条件语句、循环和函数都创建了局部作用域。
当一个值离开作用域时,意味着它被创建时所在的上下文正在消失。对于结构体来说,这意味着数据正在被销毁,但对于类来说,这只意味着底层数据的一个副本正在消失 —— 可能在其他地方还有其他副本。但是当最后一个副本消失时 —— 当指向类实例的最后一个常量或变量被销毁时 —— 底层数据也会被销毁,它所使用的内存会返回给系统。
为了演示这一点,我们可以创建一个类,在创建和销毁时打印消息,使用初始化器和析构器:
class User {
let id: Int
init(id: Int) {
self.id = id
print("用户 \(id):我活着!")
}
deinit {
print("用户 \(id):我死了!")
}
}现在我们可以使用循环快速创建和销毁该类的实例 —— 如果我们在循环内部创建一个User实例,它会在循环迭代结束时被销毁:
for i in 1...3 {
let user = User(id: i)
print("用户 \(user.id):我在控制中!")
}运行这段代码时,你会看到它逐个创建和销毁每个用户,一个用户完全销毁后才会创建另一个。
记住,只有当指向类实例的最后一个引用被销毁时,析构器才会被调用。这可能是你存放的一个变量或常量,或者你可能把某些东西存储在数组中。
例如,如果我们在创建User实例时将它们添加到数组中,那么只有当数组被清空时,它们才会被销毁:
var users = [User]()
for i in 1...3 {
let user = User(id: i)
print("用户 \(user.id):我在控制中!")
users.append(user)
}
print("循环结束了!")
users.removeAll()
print("数组清空了!")【可选阅读】为什么类有析构器而结构体没有?
作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新
类的一个小但重要的特性是它们可以有一个析构器函数—— 与init()相对应,在类实例被销毁时运行。结构体没有析构器,这意味着我们无法知道它们何时被销毁。
析构器的作用是告诉我们类实例何时被销毁。对于结构体来说,这相当简单:当拥有结构体的对象不再存在时,结构体就会被销毁。所以,如果我们在一个方法内部创建一个结构体,当方法结束时,结构体就会被销毁。
然而,类具有复杂的复制行为,这意味着类的多个副本可以存在于程序的各个部分。所有的副本都指向相同的底层数据,因此现在更难判断实际的类实例何时被销毁 —— 当指向它的最后一个变量消失时。
在幕后,Swift 执行一种叫做自动引用计数(ARC)的操作。ARC 跟踪每个类实例的副本数量:每次你复制一个类实例,Swift 就会将其引用计数加 1,每次一个副本被销毁,Swift 就会将其引用计数减 1。当计数达到 0 时,意味着没有人再引用这个类了,Swift 会调用它的析构器并销毁该对象。
所以,结构体没有析构器的简单原因是它们不需要:每个结构体都有自己的数据副本,所以当它被销毁时不需要发生任何特殊操作。
你可以把析构器放在代码中的任何地方,但我喜欢安妮・卡哈兰的这句话:“代码应该读起来像句子,这让我觉得我的类应该读起来像章节。所以析构器放在最后,它是类的~结束~!”
【练习题】析构器
问题 1/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
struct Olympics {
func deinit() {
print("现在举行闭幕式。")
}
}问题 2/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class MarketingFlyer {
deinit() {
print("你要直接进回收站了。")
}
}问题 3/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class Job {
deinit() {
print("我辞职了!")
}
}问题 4/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class IceCream {
deinit() {
print("没有冰淇淋了 :(")
}
}问题 5/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
struct Fairytale {
deinit {
print("从此他们过上了幸福的生活。")
}
}问题 6/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class PhoneCall {
func deinit {
print("再见!")
}
}问题 7/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class Lightsaber {
deinit {
print("嘶嘶!")
}
}问题 8/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class Election {
init() {
print("获胜者是……")
}
}问题 9/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class DisneyMovie {
deinit {
print("别担心,一年后还会有另一部。")
}
}问题 10/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class MagicSpell {
deinit {
print("干得好,赫敏!")
}
}问题 11/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class Meal {
dealloc {
print("请把账单送过来。")
}
}问题 12/12:当该类型的实例被销毁时,这段代码会打印一条消息 —— 对还是错?
class Firefly {
deinit {
print("我仍然对它被取消感到恼火。")
}
}12.6 如何处理类中的变量
作者:Paul Hudson 2021 年 10 月 12 日 已针对 Xcode 16.4 更新
Swift 中的类有点像路标:我们拥有的每个类实例的副本实际上都是指向相同底层数据的路标。这一点之所以重要,主要是因为更改一个副本会导致所有其他副本都发生变化,不过它在类处理可变属性的方式上也同样重要。
下面这个简短的代码示例展示了其工作原理:
class User {
var name = "Paul"
}
let user = User()
user.name = "Taylor"
print(user.name)这里创建了一个常量User实例,然后对其进行了修改 —— 修改了这个常量的值。这看起来不太对,对吧?
但实际上,它根本没有修改常量的值。是的,类内部的数据发生了变化,但类实例本身 —— 我们创建的这个对象 —— 并没有改变,而且实际上也不能被改变,因为我们将它定义为了常量。
可以这样理解:我们创建了一个指向用户的常量路标,但我们擦掉了这个用户的姓名标签,写上了另一个名字。这个用户本身并没有变 —— 这个人依然存在 —— 但他们内部数据的一部分确实发生了改变。
如果我们用let将name属性定义为常量,那么它就不能被修改了 —— 我们有一个指向用户的常量路标,而且他们的名字是用永久墨水写的,无法擦掉。
相反,如果我们既将user实例定义为变量,又将name属性定义为变量,会发生什么呢?这时我们不仅可以修改name属性,还可以根据需要将user改为一个全新的User实例。用路标来类比的话,就像是转动路标,让它指向一个完全不同的人。
试试下面这段代码:
class User {
var name = "Paul"
}
var user = User()
user.name = "Taylor"
user = User()
print(user.name)这段代码最终会打印出 “Paul”,因为尽管我们把name改成了 “Taylor”,但之后我们用一个新的User实例覆盖了整个user对象,使其重置回了 “Paul”。
最后一种情况是变量实例和常量属性的组合,这意味着我们可以根据需要创建一个新的User,但一旦创建完成,就不能修改其属性了。
因此,我们总共有四种情况:
- 常量实例,常量属性 —— 路标始终指向同一个用户,而这个用户的名字也始终不变。
- 常量实例,变量属性 —— 路标始终指向同一个用户,但这个用户的名字可以改变。
- 变量实例,常量属性 —— 路标可以指向不同的用户,但这些用户的名字永远不变。
- 变量实例,变量属性 —— 路标可以指向不同的用户,而且这些用户的名字也可以改变。
这可能看起来非常令人困惑,甚至有些咬文嚼字。然而,由于类实例的共享方式,这一点有着重要的意义。
假设你得到了一个User实例。你的实例是常量,但其中的属性被声明为变量。这不仅告诉你可以修改该属性,更重要的是,它告诉你该属性有可能在其他地方被修改 —— 你拥有的这个类可能是从其他地方复制过来的,而且因为这个属性是变量,这意味着代码的其他部分可能会意外地修改它。
当你看到常量属性时,这意味着你可以确定无论是你当前的代码还是程序的其他任何部分都不能修改它。但一旦涉及到变量属性,无论类实例本身是常量还是变量,都存在数据可能在你不知情的情况下被修改的可能性。
这与结构体不同,因为常量结构体即使其属性被定义为变量,也不能修改这些属性。希望你现在能明白为什么会这样:结构体没有路标这一特性,它们直接持有数据。这意味着如果你尝试修改结构体内部的值,你实际上也是在隐式地修改结构体本身,而由于结构体是常量,这是不允许的。
这带来的一个好处是,类中修改数据的方法不需要使用mutating关键字。这个关键字对结构体来说非常重要,因为常量结构体无论以何种方式创建,都不能修改其属性。所以当 Swift 看到我们在常量结构体实例上调用mutating方法时,它知道这是不允许的。
对于类来说,实例本身的创建方式不再重要 —— 决定一个属性是否可以被修改的唯一因素是该属性本身是否被定义为常量。Swift 只需查看你定义属性的方式就能知道这一点,因此不再需要专门标记方法。
【可选阅读】为什么常量类中的变量属性可以被修改?
作者:Paul Hudson 2020 年 5 月 28 日 已针对 Xcode 16.4 更新
结构体和类之间一个虽小但很重要的区别在于它们处理属性可变性的方式:
- 变量类可以修改其变量属性
- 常量类可以修改其变量属性
- 变量结构体可以修改其变量属性
- 常量结构体不能修改其变量属性
造成这种差异的原因在于类和结构体的根本区别:一个指向内存中的某些数据,而另一个本身就是一个值,就像数字 5 一样。
考虑下面这样的代码:
var number = 5
number = 6我们不能简单地把数字 5 定义为 6,因为这在逻辑上说不通 —— 这会颠覆我们对数学的所有认知。相反,这段代码会移除赋给number的现有值,然后给它赋上数字 6。
这就是 Swift 中结构体的工作方式:当我们修改结构体的一个属性时,实际上是在修改整个结构体。当然,在幕后 Swift 会进行一些优化,这样我们每次只修改结构体的一部分时,就不必真的丢弃整个值,但从我们的角度来看,其处理方式就是如此。
因此,如果修改结构体的一部分实际上意味着销毁并重新创建整个结构体,那么你应该能理解为什么常量结构体不允许修改其变量属性了 —— 这意味着要销毁并重新创建一个本应是常量的东西,而这是不可能的。
类的工作方式则不同:你可以修改类的任何属性部分,而不必销毁并重新创建整个值。因此,常量类可以随意修改其变量属性。
【练习题】可变性
问题 1/12:这段代码是有效的 Swift 代码 —— 对还是错?
class Pizza {
private var toppings = [String]()
func add(topping: String) {
toppings.append(topping)
}
}
var pizza = Pizza()
pizza.add(topping: "Mushrooms")问题 2/12:这段代码是有效的 Swift 代码 —— 对还是错?
class School {
let students = 200
func expel(student: String, for reason: String) {
print("\(student) has been expelled for \(reason).")
students -= 1
}
}
let school = School()
school.expel(student: "Jason", for: "coding during class")问题 3/12:这段代码是有效的 Swift 代码 —— 对还是错?
class SewingMachine {
var itemsMade = 0
mutating func makeBag(count: Int) {
itemsMade += count
}
}
var machine = SewingMachine()
machine.makeBag(count: 1)问题 4/12:这段代码是有效的 Swift 代码 —— 对还是错?
struct Park {
var numberOfFlowers = 1000
func plantFlowers() {
numberOfFlowers += 50
}
}
let park = Park()
park.plantFlowers()问题 5/12:这段代码是有效的 Swift 代码 —— 对还是错?
struct Piano {
var untunedKeys = 3
func tune() {
if untunedKeys > 0 {
untunedKeys -= 1
}
}
}
var piano = Piano()
piano.tune()问题 6/12:这段代码是有效的 Swift 代码 —— 对还是错?
class Beach {
var lifeguards = 10
mutating func addLifeguards(count: Int) {
lifeguards += count
}
}
var beach = Beach()
beach.addLifeguards(count: 2)问题 7/12:这段代码是有效的 Swift 代码 —— 对还是错?
struct Kindergarten {
var numberOfScreamingKids = 30
mutating func handOutIceCream() {
numberOfScreamingKids = 0
}
}
let kindergarten = Kindergarten()
kindergarten.handOutIceCream()问题 8/12:这段代码是有效的 Swift 代码 —— 对还是错?
class Light {
var onState = false
func toggle() {
if onState {
onState = false
} else {
onState = true
}
print("Click")
}
}
let light = Light()
light.toggle()问题 9/12:这段代码是有效的 Swift 代码 —— 对还是错?
struct Code {
var numberOfBugs = 100
mutating func fixBug() {
numberOfBugs += 3
}
}
var code = Code()
code.fixBug()问题 10/12:这段代码是有效的 Swift 代码 —— 对还是错?
class Phasers {
var energyLevel = 100
func firePhasers() {
if energyLevel > 10 {
print("Firing!")
energyLevel -= 10
}
}
}
var phasers = Phasers()
phasers.firePhasers()问题 11/12:这段代码是有效的 Swift 代码 —— 对还是错?
class Sun {
var isNova = false
func goNova() {
isNova = true
}
}
let sun = Sun()
sun.goNova()问题 12/12:这段代码是有效的 Swift 代码 —— 对还是错?
struct Barbecue {
var charcoalBricks = 20
mutating func addBricks(_ number: Int) {
charcoalBricks += number
}
}
var barbecue = Barbecue()
barbecue.addBricks(4)12.7 总结:类
作者:Paul Hudson 2021 年 10 月 12 日 已针对 Xcode 16.4 更新
类不像结构体那样常用,但它们在数据共享方面有着不可替代的作用,而且如果你打算学习苹果较旧的 UIKit 框架,会发现自己会大量使用类。
让我们回顾一下所学内容:
- 类与结构体有很多共同点,包括都可以有属性和方法,但类和结构体之间有五个关键区别。
- 第一,类可以从其他类继承,这意味着它们可以访问父类的属性和方法。你可以有选择地在子类中重写方法,或者将一个类标记为
final以阻止其他类继承它。 - 第二,Swift 不会为类生成成员初始化器,所以你需要自己编写。如果子类有自己的初始化器,它必须在某个时候调用父类的初始化器。
- 第三,如果你创建了一个类的实例,然后对其进行复制,所有这些副本都指向同一个实例。这意味着修改其中一个副本中的某些数据,所有副本都会发生变化。
- 第四,类可以有析构器,当一个实例的最后一个副本被销毁时,析构器会运行。
- 最后,类实例中的可变属性可以被修改,无论该实例本身是否是作为变量创建的。
12.8 检查点 7
作者:Paul Hudson 2021 年 10 月 12 日 已针对 Xcode 16.4 更新
既然你已经了解了类的工作原理,以及同样重要的 —— 类与结构体的区别,现在是时候通过一个小挑战来检验你的学习进度了。
你的挑战是:创建一个动物的类层次结构,最顶层是Animal(动物),然后Dog(狗)和Cat(猫)作为其子类,接着Corgi(柯基犬)和Poodle(贵宾犬)作为Dog的子类,Persian(波斯猫)和Lion(狮子)作为Cat的子类。
还有更多要求:
Animal类应该有一个legs整数属性,用于记录动物有多少条腿。Dog类应该有一个speak()方法,该方法打印一个通用的狗吠字符串,但每个子类应打印略有不同的内容。Cat类应该有一个对应的speak()方法,同样每个子类应打印不同的内容。Cat类应该有一个isTame布尔属性,通过初始化器提供。
过一会儿我会给出一些提示,但首先建议你自己尝试一下。
还在看吗?好吧,这里有一些提示:
- 这里需要七个独立的类,其中只有一个没有父类。
- 要让一个类继承另一个类,可以这样写:
class SomeClass: OtherClass。 - 可以使用
override关键字让子类的speak()方法与其父类不同。 - 我们所有的子类都有四条腿,但你仍然需要确保在
Cat的初始化器中将该数据传递给Animal类。