存储属性与计算属性
本教程共 93 篇 · 第 56 篇 · 更新于 2026-08-08 · 约 9 分钟阅读
本节目标:学完你能分清哪些属性是真正存值的、哪些是按需算出来的,并会用 lazy 延迟加载和 willSet/didSet 监听变化。
属性(property)是类、结构体、枚举里用来存数据或提供数据的成员。Swift 把属性分成两大流派:存储属性(stored property)和计算属性(computed property)。前者真把值存下来,后者不存,每次访问都现算。再配上惰性加载和属性观察器,你就能精细控制数据的产生与变化。
1-1 存储属性:真正占内存的那位
存储属性就是作为实例一部分、被实实在在地存起来的常量或变量。用 let 声明的是常量存储属性,用 var 声明的是变量存储属性。它只能在类和结构体里出现,枚举不行——因为枚举的每个值是 case,不是字段。
struct FixedLengthRange {
var firstValue: Int
let length: Int
}
var range = FixedLengthRange(firstValue: 0, length: 3)
range.firstValue = 6
这里 firstValue 是变量存储属性,可以改;length 是常量存储属性,创建时就固定,之后改它编译器会报错。你可以给存储属性在定义时直接赋默认值,比如 var count = 0,这样每次创建实例时它都有个起点。
1-2 常量结构体实例的怪现象
一个小坑值得单独拎出来说。如果你把一个结构体实例赋值给 let 常量,哪怕它的属性声明为 var,你也改不了:
let fixedRange = FixedLengthRange(firstValue: 0, length: 4)
fixedRange.firstValue = 6 // 报错
原因还是值类型:结构体实例被标记为 let,意味着整份数据都是只读的,里面所有属性自然也跟着锁死。类就没有这个限制——类是引用类型,let 只锁引用不锁对象内部,所以类的可变属性照样能改。这点再次印证了类与结构体的根本差异。
1-3 惰性存储属性 lazy
有些属性的初始值算起来很贵,或者依赖外部条件,等实例建好之后才可能用得上。这种时候就该用 lazy,让它”第一次被访问时才真正创建”。
class DataImporter {
var filename = "data.txt"
}
class DataManager {
lazy var importer = DataImporter()
var data: [String] = []
}
let manager = DataManager()
manager.data.append("一些数据")
// 到这里 importer 还没创建
print(manager.importer.filename) // 这一刻才创建
DataManager 管数据,DataImporter 负责从文件读数据,假设读文件很耗时。很多情况下 DataManager 全程用不到导入功能,那就没必要一建对象就把文件读进来。标了 lazy 后,只有真的访问 manager.importer 时,那个 DataImporter 实例才会被创建。这能省下不少不必要的开销。
有两个硬性规定要记住。第一,lazy 属性必须声明为 var,不能是 let,因为它的初始值可能在初始化完成后才确定。第二,lazy 不是线程安全的:如果多个线程同时第一次访问它,不能保证只初始化一次。单线程或确定只在一处访问时再用它最安心。
1-4 计算属性:不存值,现算现给
计算属性自己不保存数据,它提供 getter(读取时算)和可选的 setter(写入时反过来改别的属性)。类、结构体、枚举都能定义计算属性。
struct Point {
var x = 0.0, y = 0.0
}
struct Size {
var width = 0.0, height = 0.0
}
struct Rect {
var origin = Point()
var size = Size()
var center: Point {
get {
let centerX = origin.x + size.width / 2
let centerY = origin.y + size.height / 2
return Point(x: centerX, y: centerY)
}
set(newCenter) {
origin.x = newCenter.x - size.width / 2
origin.y = newCenter.y - size.height / 2
}
}
}
一个矩形的中心点永远可以由原点和尺寸推出来,没必要单独存一份。上面 center 就是计算属性:读它时 getter 现算坐标;给它赋新值时 setter 反过来改 origin,从而移动整个矩形。对外部来说,square.center = ... 用起来和存储属性一模一样,但背后完全是计算。
1-5 getter 和 setter 的简写
当 setter 不自己起名时,新值默认叫 newValue,可以省略参数名:
var center: Point {
get {
Point(x: origin.x + size.width / 2,
y: origin.y + size.height / 2)
}
set {
origin.x = newValue.x - size.width / 2
origin.y = newValue.y - size.height / 2
}
}
如果 getter 整个函数体只有一行表达式,连 return 都能省。这种简写和第 28 章函数里”隐式返回”的规则一致。写起来更短,但第一次见容易愣,记住这是语法糖就好。
1-6 只读计算属性
只有 getter、没有 setter 的计算属性叫只读计算属性。它能被读取,但不能被赋值。声明时连 get 关键字也能省,直接写计算逻辑:
struct Cuboid {
var width = 0.0, height = 0.0, depth = 0.0
var volume: Double {
return width * height * depth
}
}
一个长方体的体积由长宽高决定,你没法”设定一个体积”而不指定具体用哪组长宽高,所以把它做成只读最合理。要改 volume 没有意义,系统也不允许。记住:计算属性(包括只读)一律用 var 声明,因为值会变,只有真正一成不变的才用 let。
1-7 属性观察器 willSet 与 didSet
如果你想盯着某个存储属性,在它每次被赋值时做点反应,就用属性观察器。它有两个钩子:willSet 在值被存进去之前调用,didSet 在存完之后立刻调用。
class StepCounter {
var totalSteps: Int = 0 {
willSet(newTotal) {
print("即将设为 \(newTotal)")
}
didSet {
if totalSteps > oldValue {
print("新增了 \(totalSteps - oldValue) 步")
}
}
}
}
let counter = StepCounter()
counter.totalSteps = 200 // 即将设为 200 / 新增了 200 步
willSet 能拿到即将写入的新值(这里自定义名叫 newTotal,不写则默认叫 newValue);didSet 能拿到被覆盖掉的旧值(默认叫 oldValue)。观察器有个细节:哪怕新值和旧值一样,它照样触发。这一点和”等价判断”无关,只认”发生了赋值动作”。
观察器能用在你自己定义的存储属性上,也能加在子类从父类继承来的属性上(靠重写实现,见第 62 章)。但你不能给继承来的常量存储属性、或只读计算属性加观察器——它们的值根本没法被你重新赋,加了也没意义。另外,如果你已经在 setter 里自定义逻辑,就不需要再额外套观察器,直接在 setter 里写反应代码即可,两者选其一。
1-8 这几样怎么选
一句话总结:数据需要被原样保存,用存储属性;数据能从别的属性推导出来,用计算属性;某个属性初始化很重、又不一定用得到,标 lazy;想在属性变化时自动执行副作用,挂 willSet/didSet。它们不是互斥的,一个设计良好的类型往往是几种组合着用。把这套分清,后面学属性包装器和继承里的属性重写都会轻松很多。
1-9 存储、计算、惰性的内存账
很多初学者以为计算属性”不占内存”,这个直觉对了一半。计算属性本身确实不存值,每次访问都现算,所以它不占用实例里那份”存值”的空间,但 getter 里的逻辑每次都会跑一遍。如果计算很重又频繁读,就会反复花算力。存储属性则正相反,值在实例里占着内存,读起来快,但每个实例都各自存一份。惰性属性把”创建”推迟到第一次用,省下的是初始化开销,不是读取开销。
把这三笔账合起来看:默认能用存储就用存储,简单直观;能从别处推导的改成计算,保证单一真相;初始化昂贵的再用 lazy 兜住。还有个易混点:只读计算属性虽不能赋值,但它每次返回的可能是一个新对象,所以”只读”指的是不能写,不代表”返回同一个东西”。理解存值和算值的成本差异,你才能在性能和代码清晰之间做对取舍。