苹果应用开发实战四:Swift 类型系统与抽象

0 阅读10分钟

第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 本章小结

  1. struct 是值类型,赋值即拷贝、没有身份;class 是引用类型,多个名字指向同一对象,用 === 判断身份。优先 struct,需要共享/OC 互操作/析构时才用 class。
  2. 计算属性不存值、实时算;didSet 监听赋值;lazy 第一次访问才初始化;值类型改自身要 mutating。
  3. 枚举的 case 能带关联值,配合 switch 做状态机;原始值用于和字符串/整数互转;枚举可挂方法和计算属性。
  4. subscript 让类型支持 [];extension 给已有类型(含系统类型)加方法,是 SwiftUI 组织代码的核心手法。
  5. 协议定义能力契约,可作函数参数实现多态;协议扩展提供默认实现;ProtocolA & ProtocolB 做组合约束。
  6. 泛型用占位类型 <T> 一份代码适配多类型,T: Comparable 加约束;some 保留具体类型信息、any 抹成存在盒子,SwiftUI 一律用 some View。
  7. throws 函数调用必须 try,用 do-catch 分支处理;try? 得可选值、try! 强解;Result 把成功/错误打包成值,新代码优先 throws + async/await。
  8. 循环引用、weak self 等引用类型内存问题在第 5 章并发与第 24 章性能里深入。

4.10 练习

  1. (动手题)把第 3 章的 Habit 升级成 struct,含属性 name: String、targetPerWeek: Int、doneThisWeek: Int,加一个计算属性 completionRatio: Double(done/target)。完成标准:构造一个 target=7、done=3 的习惯,completionRatio ≈ 0.428。
  2. (动手题)定义带关联值的枚举 SyncState:idle、syncing(progress: Double)、synced(count: Int)、failed(error: String);写一个函数用 switch 打印每种状态。完成标准:四种 case 都能正确打印,带关联值的能解出 progress/count。
  3. (概念题)写一段 10 行以内的代码对比:两个 struct 实例赋值后改其中一个、两个 class 实例赋值后改其中一个,分别打印原对象的值。完成标准:注释说清"为什么一个不变一个跟着变"。
  4. (动手题)写一个泛型函数 pairs<T>(_ a: T, _ b: T) -> (T, T),要求 T 遵守 Equatable;再扩展 String 加一个属性 isBlank: Bool(全是空白字符时为 true)。完成标准:pairs(3, 4) 返回元组," ".isBlank == true。
  5. (动手题)写一个可抛错函数 register(name: String) throws -> String:name 为空时抛 RegisterError.emptyName;否则返回欢迎语。用 do-catch 调用两次(一次空名、一次正常)。完成标准:错误被 catch 分支打印,正常路径打印欢迎语。