第4章 Swift 类型系统与抽象
本章导读
上一章的类型都是"裸"的 Int、String。这一章把数据和行为打包成自己的类型:结构体、类、枚举,再用协议定义"能力"、用泛型写出"适用于多种类型"的代码。这是 Swift 区别于脚本语言的核心,也是你看懂 SwiftUI 源码(到处是 some View、协议、泛型)的前提。重点吃透四件事:值类型和引用类型到底差在哪、协议怎么当类型用、some 和 any 有什么区别、throws 和 Result 怎么选。
4.1 结构体与类:值类型与引用类型、身份、选择依据
Swift 有两种自定义复合类型:struct 和 class。它们都能装属性和方法,关键差异在语义。
结构体是值类型:赋值、传参时发生拷贝。下面这段代码直接说明:
struct Habit {
var name: String
var completedCount: Int
}
var morningWalk = Habit(name: "走路", completedCount: 3)
var copied = morningWalk // 值类型:拷一份
copied.completedCount += 1
print(morningWalk.completedCount) // 还是 3,原对象没被动
print(copied.completedCount) // 4
类是引用类型:赋值只是多一个指针指向同一块堆内存:
final class HabitStore {
var completedCount = 0
}
let storeA = HabitStore()
let storeB = storeA // 引用:两个名字指向同一个对象
storeB.completedCount += 1
print(storeA.completedCount) // 1!storeA 也被改了
注意上面 storeB 用 let 声明,却能改属性——因为 let 锁的是"这个指针不指向别处",不是"对象内容不可变"。
身份(identity):引用类型有"是不是同一个对象"的概念,用 === 比较:storeA === storeB 为 true。值类型没有身份,两个值相等就是相等(==,需要自己实现 Equatable)。
怎么选? 记住一个倾向:SwiftUI 的 View 全是 struct,你的数据模型值小、语义简单时优先 struct。只有在这些情况才用 class:
- 需要跨多处共享同一个可变对象(多个界面引用同一个"账户");
- 需要 OC 互操作;
- 需要析构函数
deinit做清理。
警告:新手最爱犯的错,是把"数据模型"随手写成 class,然后两个界面各自改自己那份拷贝却改不动、或改了一处另一处莫名其妙跟着变。反过来,值类型拷贝在大数组上有写时复制(Copy-on-Write)兜底,性能通常不是问题。
4.2 属性:存储/计算属性、属性观察器、延迟属性
存储属性就是上面 Habit.name 这种,占内存存值。
计算属性不存值,每次访问时用其他属性算出来:
struct Habit {
var name: String
var completedThisWeek: Int
var completionRate: Double {
// 只读计算属性,省略 get {}
Double(completedThisWeek) / 7.0
}
}
属性观察器在值被设入时回调,最常用 didSet:
class HabitTracker {
var count: Int = 0 {
didSet {
print("计数从 \(oldValue) 变成 \(count)")
}
}
}
let t = HabitTracker()
t.count = 5 // 打印:计数从 0 变成 5
延迟属性 lazy:第一次访问时才初始化,适合构造昂贵或依赖其他对象的资源:
final class ReportGenerator {
lazy var bigCache: [String] = {
// 第一次用到 bigCache 时才跑这段
(0..<10000).map { "item-\($0)" }
}()
}
4.3 枚举:关联值、原始值、枚举方法
Swift 枚举比 C 的整型枚举强得多——每个 case 可以带关联值,这是实现"带数据的状态机"的利器。
enum NetworkStatus {
case loading
case success(data: [String]) // 关联值:成功时带一组数据
case failure(message: String) // 失败时带错误描述
}
var status = NetworkStatus.loading
status = .success(data: ["喝水", "走路"])
// 用 switch 拆解关联值
switch status {
case .loading:
print("加载中")
case .success(let data):
print("拿到 \(data.count) 条")
case .failure(let msg):
print("失败:\(msg)")
}
原始值是给 case 一个固定的底层表示(String / Int),用于序列化:
enum HabitIcon: String {
case water = "💧"
case walk = "🚶"
case meditate = "🧘"
}
HabitIcon.walk.rawValue // "🚶"
HabitIcon(rawValue: "💧") // 可失败构造器,返回 HabitIcon?
枚举也能挂方法:
enum Device {
case iPhone, iPad, mac
var screenCategory: String {
switch self {
case .iPhone: return "手机"
case .iPad: return "平板"
case .mac: return "电脑"
}
}
}
Device.iPad.screenCategory // "平板"
提示:用"带关联值的枚举"表达状态,比"一个 class 里一堆可选属性表示各种情况"干净得多。比如"加载成功的数据""加载失败的原因""加载中"这三种互斥状态,强行用结构体表示就是三个可选字段,逻辑上永远说不清。枚举把互斥关系写死在类型里。
4.4 方法、下标与扩展
方法就是写在类型里的函数。值类型的 mutating 方法才能改自身属性:
struct Counter {
var value = 0
mutating func increment() {
value += 1 // struct 方法默认不能改属性,必须 mutating
}
}
**下标(subscript)**让类型像数组一样用 [] 访问:
struct WeekLog {
var days: [Int] = Array(repeating: 0, count: 7)
subscript(index: Int) -> Int {
get { days[index] }
set { days[index] = newValue }
}
}
var log = WeekLog()
log[0] = 5
print(log[0]) // 5
扩展(extension):给已有的类型(哪怕是系统类型,如 String、Int)加方法,不改原类型定义:
extension Int {
var isEven: Bool { self % 2 == 0 }
}
4.isEven // true
SwiftUI 里 Button、View 的大量便利初始化器都是通过 extension 提供的——这是苹果组织代码的核心手法。
4.5 协议:定义、遵循、协议作为类型、协议扩展、组合
**协议(protocol)**定义一组能力契约:谁遵守它,谁就必须实现这些方法/属性。
protocol Loggable {
var logLine: String { get }
func writeLog()
}
// 让结构体遵守协议
struct Habit: Loggable {
var name: String
var logLine: String { "习惯:\(name)" }
func writeLog() { print(logLine) }
}
协议作为类型:函数参数可以是"任何遵守这个协议的东西",这是多态的写法:
func record(_ item: Loggable) {
item.writeLog() // 不关心 item 具体是 Habit 还是别的
}
协议扩展给协议提供默认实现——这是 Swift 协议编程的精髓:
extension Loggable {
func writeLog() {
print("[默认实现] \(logLine)") // 不自己实现的类型自动用这个
}
}
协议组合:要求一个类型同时遵守多个协议:
func configure<T: Loggable & Equatable>(_ item: T) { ... }
4.6 泛型:泛型函数/类型、约束、some 与 any
泛型让一份代码适配多种类型,又不丢类型信息。先看一个"什么类型都能打印"的函数:
func printArray<T>(_ array: [T]) {
for item in array {
print(item)
}
}
printArray([1, 2, 3])
printArray(["喝水", "走路"])
<T> 是占位类型,调用时由实参推断成 Int 或 String。类型约束要求 T 必须遵守某协议:
func totalMax<T: Comparable>(_ a: T, _ b: T) -> T {
a > b ? a : b
}
some 与 any 的区别是 SwiftUI 代码里最高频的语法,必须分清:
some View(不透明类型):对外隐藏具体类型,但编译器内部知道它是同一个具体类型。你写var body: some View,body 实际上是VStack<Text>这种具体类型,只是不写出来。它保证"类型一致、性能好"。any View(存在类型):一个盒子,里面装着"任意遵守 View 的类型",编译期不知道具体是哪个。代价是要间接寻址、且不能保证内部类型一致。
// 这是合法的:body 内部是同一个具体类型
struct CardView: View {
var body: some View {
Text("习惯卡")
}
}
// 数组里 heterogeneous 的视图才需要 any
let views: [any View] = [CardView(), AnotherView()]
注意:日常写 SwiftUI 99% 的时候用
some View。只有在"要装一个数组、元素是不同具体类型"这种确实需要抹掉类型的场景,才用any。Swift 6.0 起,裸写let v: View = ...这种协议类型会报警告,必须写成any View——这是为了让"抹类型"这个开销显式化。
4.7 错误处理:throws / try / do-catch、Result
可抛错的函数用 throws 标注,调用方必须用 try 处理:
enum LoadError: Error {
case fileMissing
case decodeFailed
}
func loadHabit(named name: String) throws -> String {
guard !name.isEmpty else {
throw LoadError.fileMissing // 主动抛错
}
return "记录:\(name)"
}
调用方式:
do {
let record = try loadHabit(named: "喝水")
print(record)
} catch LoadError.fileMissing {
print("文件不存在")
} catch {
print("其他错误:\(error)") // catch 未匹配分支时 error 自动绑定
}
两个缩写:
// try? 把成功值变成可选,失败得 nil
let record = try? loadHabit(named: "")
// record 是 String?,失败即 nil
// try! 强制不抛错(确定不会错时用,否则崩溃)
let must = try! loadHabit(named: "走路")
Result 类型把"成功值或错误"打包成一个值,适合闭包回调、或异步把结果存起来稍后处理:
func loadAsync(named name: String, completion: (Result<String, LoadError>) -> Void) {
if name.isEmpty {
completion(.failure(.fileMissing))
} else {
completion(.success("记录:\(name)"))
}
}
loadAsync(named: "") { result in
switch result {
case .success(let s): print(s)
case .failure(let e): print("出错:\(e)")
}
}
throws 和 Result 怎么选?新代码优先用 throws + async/await(第 5 章),错误沿着调用栈清晰传播;Result 主要在老式 completion handler 闭包 API 里见到,或当你需要"把一次可能失败的操作包成一个值到处传递"时用。
4.8 常见问题与排错
Q1:struct 的方法改属性报错 "mutating still violates mutation"。
值类型方法要改自身属性,函数前加 mutating。注意 let 声明的 struct 即使方法标了 mutating 也调不了。
Q2:class 属性明明用 let 声明,却被别的引用改了。
let 对 class 只锁指针不锁内容。要真正不可变,把属性也设 let,或用 struct。
Q3:Equatable 怎么让自己的类型支持 ==?
struct 里所有存储属性都遵守 Equatable 时,编译器自动合成 ==;class 需要自己实现 static func ==。通常直接声明 struct Habit: Equatable {} 就够。
Q4:写 var body: View 报 "protocol 'View' can only be used as a generic constraint"。
Swift 6 要求写成 some View(不透明类型)或 any View(存在类型)。SwiftUI 视图一律用 some View。
Q5:调用 throws 函数不写 try 报 "call can throw, but it is not marked with try"。
所有 throws 函数的调用点必须加 try(或 try? / try!)。这是强制你显式处理错误。
4.9 本章小结
- struct 是值类型,赋值即拷贝、没有身份;class 是引用类型,多个名字指向同一对象,用
===判断身份。优先 struct,需要共享/OC 互操作/析构时才用 class。 - 计算属性不存值、实时算;
didSet监听赋值;lazy第一次访问才初始化;值类型改自身要mutating。 - 枚举的 case 能带关联值,配合 switch 做状态机;原始值用于和字符串/整数互转;枚举可挂方法和计算属性。
subscript让类型支持[];extension给已有类型(含系统类型)加方法,是 SwiftUI 组织代码的核心手法。- 协议定义能力契约,可作函数参数实现多态;协议扩展提供默认实现;
ProtocolA & ProtocolB做组合约束。 - 泛型用占位类型
<T>一份代码适配多类型,T: Comparable加约束;some保留具体类型信息、any抹成存在盒子,SwiftUI 一律用some View。 throws函数调用必须try,用do-catch分支处理;try?得可选值、try!强解;Result把成功/错误打包成值,新代码优先 throws + async/await。- 循环引用、
weak self等引用类型内存问题在第 5 章并发与第 24 章性能里深入。
4.10 练习
- (动手题)把第 3 章的
Habit升级成 struct,含属性name: String、targetPerWeek: Int、doneThisWeek: Int,加一个计算属性completionRatio: Double(done/target)。完成标准:构造一个 target=7、done=3 的习惯,completionRatio ≈ 0.428。 - (动手题)定义带关联值的枚举
SyncState:idle、syncing(progress: Double)、synced(count: Int)、failed(error: String);写一个函数用 switch 打印每种状态。完成标准:四种 case 都能正确打印,带关联值的能解出 progress/count。 - (概念题)写一段 10 行以内的代码对比:两个 struct 实例赋值后改其中一个、两个 class 实例赋值后改其中一个,分别打印原对象的值。完成标准:注释说清"为什么一个不变一个跟着变"。
- (动手题)写一个泛型函数
pairs<T>(_ a: T, _ b: T) -> (T, T),要求 T 遵守Equatable;再扩展String加一个属性isBlank: Bool(全是空白字符时为 true)。完成标准:pairs(3, 4)返回元组," ".isBlank == true。 - (动手题)写一个可抛错函数
register(name: String) throws -> String:name 为空时抛RegisterError.emptyName;否则返回欢迎语。用 do-catch 调用两次(一次空名、一次正常)。完成标准:错误被 catch 分支打印,正常路径打印欢迎语。