第19章 属性、方法与下标:把接口设计得顺手

第十九章:属性、方法与下标:把接口设计得顺手

属性不只是“变量放在类型里”。它可以是算出来的、可以观察变化、可以延迟生成,甚至可以被属性包装器重新定义读写方式。方法和下标则决定使用者如何与你定义的类型互动。接口设计得好,调用处读起来像一句顺滑的话。

19.1 存储属性与计算属性

存储属性真的占一块存储;计算属性每次访问时运行代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct Circle {
    var radius: Double

    var area: Double {
        get { Double.pi * radius * radius }
        set { radius = (newValue / Double.pi).squareRoot() }
    }
}

var circle = Circle(radius: 2)
print(circle.area)
// prints: 12.566370614359172
circle.area = Double.pi * 9
print(circle.radius)
// prints: 3.0

Double.pi 是标准库替你存好的 π 常量(Float.pi 同理),不用自己背小数位。它是类型属性的典型例子:写在类型名后面,属于类型本身,而不是某个实例——这一节的后面会讲怎么定义自己的类型属性。

只读计算属性可以省略 get:

1
2
3
var diameter: Double {
    radius * 2
}

计算属性不能直接存储值。可读写的计算属性可以临时传入 inout,读写会分别调用它的 getter 和 setter;只读计算属性则不行。

上面这句话值得配一个例子,因为它解释了"为什么有的属性不能传 &":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import Foundation

struct Counter {
    private var _n = 0
    var n: Int {                    // 可读可写:get + set
        get { _n }
        set { _n = newValue }
    }
    var readOnlyN: Int { _n }       // 只读:只有 get
}

func bump(_ value: inout Int) { value += 1 }

var c = Counter()
bump(&c.n)          // ✅ 合法:写入会被送回 setter
print(c.n)
// prints: 1

// bump(&c.readOnlyN)  // ❌ 报错:cannot pass immutable value as inout argument

inout 的本质是"读出来、改一改、再写回去"。只读属性写不回去,所以编译器直接拦下——这比运行时才崩要好得多。

19.2 属性观察器:变化前后插一脚

willSet 在赋值前执行,didSet 在赋值后执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct StepCounter {
    var steps = 0 {
        willSet {
            print("将从 \(steps) 变为 \(newValue)")
        }
        didSet {
            print("已经从 \(oldValue) 变为 \(steps)")
        }
    }
}

var counter = StepCounter()
counter.steps = 5
// prints: 将从 0 变为 5
// prints: 已经从 0 变为 5

初始化器里首次赋值不会触发观察器。观察器适合记录日志、刷新缓存、通知依赖变化的轻量行为;不要在里面放入昂贵的隐式工作,否则每次赋值都会变慢。

三条容易被忽略的细节:

  • willSet 拿不到旧值,didSet 拿不到新值。 想同时看两边,就在 didSet 里读一次 self(那时已经是新值),旧值留给 oldValue。
  • lazy 属性也能挂观察器,只是它的第一次"赋值"发生在初始化那一刻,观察器不触发:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import Foundation

struct Panel {
    lazy var width: Int = 100 {
        didSet { print("宽度 \(oldValue) → \(width)") }
    }
}

var panel = Panel()
print(panel.width)
// prints: 100
panel.width = 240
// prints: 宽度 100 → 240
  • 类型属性上的观察器在 Swift 6 里需要隔离。 可变的 static var 属于全局共享可变状态,直接写会报错:
1
2
3
error: static property 'shared' is not concurrency-safe because it is nonisolated global shared mutable state
note: convert 'shared' to a 'let' constant to make 'Sendable' shared state immutable
note: add '@MainActor' to make static property 'shared' part of global actor 'MainActor'

这不是刁难,而是把第 30–32 章的并发规则提前搬到了门口:要么让它不可变(static let),要么明确它属于哪个隔离域:

1
2
3
4
5
6
7
8
import Foundation

@MainActor
struct SettingsStore {
    static var shared = 0 {
        didSet { print("shared: \(oldValue) → \(shared)") }
    }
}

19.3 lazy:第一次需要时才生成

有些属性算起来不便宜,而你可能一次都用不上。lazy 让它在第一次被读取时才动手:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct Report {
    var raw: String

    lazy var wordCount: Int = {
        print("计算一次")
        return raw.split(separator: " ").count
    }()
}

var report = Report(raw: "swift is fun")
print(report.wordCount)
// prints: 计算一次
// prints: 3
print(report.wordCount)
// prints: 3

lazy 只能用于 var。第一次读取后结果被保存,后续不会重复计算。适合昂贵且不一定用到的派生值,但要注意它在并发环境中访问时的线程安全问题。

19.4 类型属性:属于类型本身

static 表示属性属于类型,而不是某个实例:

1
2
3
4
5
struct AppInfo {
    static let name = "Prgms"
}
print(AppInfo.name)
// prints: Prgms

类中如果希望子类可以重写类型属性,使用 class:

1
2
3
4
5
6
7
8
9
class Base {
    class var label: String { "base" }
}

class Child: Base {
    override class var label: String { "child" }
}
print(Base.label, Child.label)
// prints: base child

结构体不能使用 class 关键字。

顺带把"类型成员"这一族凑齐,它们的关系可以用一张表记住:

写法属于谁能否被子类重写
static let x类型不能
static var x类型(Swift 6 需隔离)不能
class var x { }类型能(类专属)
final class var x { }类型不能
普通 var x实例能

⚠️ class 这个关键字在这里只表示"可以在子类里 override",和面向对象里的"类"没关系。结构体、枚举都不支持它,因为值类型没有继承。

19.5 实例方法、类型方法与 self

方法是挂在实例上的函数,self 则是“当前这个实例”的正式名字。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct Temperature {
    var celsius: Double

    func inFahrenheit() -> Double {
        celsius * 9 / 5 + 32
    }

    static func freezingPoint() -> Temperature {
        Temperature(celsius: 0)
    }
}
print(Temperature.freezingPoint().inFahrenheit())
// prints: 32.0

self 指当前实例。在值类型 mutating 方法里,self 可以被整体替换:

1
2
3
4
5
6
7
struct Name {
    var first: String

    mutating func uppercase() {
        self = Name(first: first.uppercased())
    }
}

这不会造成递归调用,因为右边先构造新值,再赋给 self。

方法签名也是接口的一部分

参数标签、默认值和返回值处理方式,直接决定调用处读起来顺不顺:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import Foundation

struct Wallet {
    private(set) var balance = 0

    @discardableResult
    mutating func deposit(_ amount: Int, note: String = "") -> Int {
        balance += amount
        return balance
    }
}

var wallet = Wallet()
wallet.deposit(100)                     // 忽略返回值,也不报"未使用结果"
print(wallet.deposit(50, note: "红包"))
// prints: 150
  • _ amount 去掉了参数标签,调用处写 deposit(100) 而不是 deposit(amount: 100);数值型参数通常这么写更自然。
  • note: String = "" 让可选信息不必每次都填。
  • @discardableResult 说明"返回值只是个附加信息,可以不要";没有它,编译器会对忽略返回值发出警告。这个标注其实是一种文档:它告诉使用者"放心忽略"。

19.6 下标:用方括号访问自定义容器

下标让你给自己的类型配上 [] 语法,像访问数组那样访问它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct Matrix {
    let rows: Int
    let columns: Int
    private var storage: [Int]

    init(rows: Int, columns: Int) {
        self.rows = rows
        self.columns = columns
        self.storage = Array(repeating: 0, count: rows * columns)
    }

    subscript(row: Int, column: Int) -> Int {
        get { storage[row * columns + column] }
        set { storage[row * columns + column] = newValue }
    }
}

var matrix = Matrix(rows: 2, columns: 2)
matrix[1, 1] = 42
print(matrix[1, 1])
// prints: 42

下标可以有多个参数,也可以只读:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct Bag<Element> {
    private var items: [Element] = []

    mutating func append(_ item: Element) { items.append(item) }

    subscript(index: Int) -> Element? {
        guard items.indices.contains(index) else { return nil }
        return items[index]
    }
}

如果下标可以修改,调用位置的实例必须是 var;只读下标则可以被 let 实例读取。

19.7 属性包装器:把读写规则装进类型

属性包装器用一个类型包住属性的读写逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@propertyWrapper
struct Clamped {
    var value: Int
    let range: ClosedRange<Int>

    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct Player {
    @Clamped(0...100) var health = 80
}

var player = Player()
player.health = 150
print(player.health)
// prints: 100
player.health = -5
print(player.health)
// prints: 0

包装器可以额外暴露 projectedValue,通常写作 $property:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@propertyWrapper
struct Logged<Value> {
    private var value: Value

    init(wrappedValue: Value) { value = wrappedValue }

    var wrappedValue: Value {
        get { value }
        set {
            print("写入 \(newValue)")
            value = newValue
        }
    }

    var projectedValue: String { "当前值:\(value)" }
}

struct Settings {
    @Logged var theme = "light"
}

var settings = Settings()
settings.theme = "dark"
// prints: 写入 dark
print(settings.$theme)
// prints: 当前值:dark

SwiftUI 的 @State、@Binding 等机制都建立在属性包装器之上。初学阶段不必手写复杂包装器,但要能读懂 $ 和 wrappedValue 的关系。

第 15B.6 节把属性包装器展开的 _property / property / $property 三个成员讲透了,还给了完整的自定义包装器例子,值得回头对读一遍——19.7 这里是"够用版",那里是"看穿版"。

19.8 本章小结

主题关键结论
计算属性每次读取运行代码,可以有 getter/setter
属性观察器willSet 看新值,didSet 可看旧值
lazy第一次访问时初始化,只能用于 var
类型属性static 属于类型,类中可用 class 支持重写
方法实例方法默认不能改值类型;mutating 可以
下标自定义方括号访问,可多参数、可读写
属性包装器用类型封装属性读写逻辑,$ 访问投影值
参数标签与默认值_ label、默认参数决定调用处的可读性
@discardableResult明确告诉使用者"返回值可以忽略"

19.9 本章易错点速查

易错点正确理解
把计算属性当存储属性它不占存储,每次访问都会执行
在初始化器里期待观察器触发初始化阶段首次赋值不触发
在 lazy 属性里访问未初始化的 self可能形成复杂依赖,要保持简单
以为 static 每个实例一份类型属性全局一份
下标越界不处理应主动检查或明确文档化崩溃约束
看到 $ 就以为是语法糖它通常来自属性包装器的 projectedValue
用只读计算属性当 inout写不回去;只有 get+set 的属性才能传 &
以为可变 static var 随便写Swift 6 下报并发安全错误;用 static let 或加隔离
在初始化器里等 didSet 触发首次赋值不触发观察器;lazy 的首次取值同样不触发

19.10 下章预告

对象如何诞生、如何确保创建到一半时仍然合法、如何优雅地失败、又如何释放?下一章专讲初始化与反初始化。它是类与结构体设计中规则最多、也最能体现 Swift 严谨性的部分。

最后修改 September 19, 2026: 更新 (3489033b1)