属性包装器 @propertyWrapper
本教程共 93 篇 · 第 57 篇 · 更新于 2026-08-08 · 约 8 分钟阅读
本节目标:学完你能用 @propertyWrapper 把多个属性的”存值逻辑”抽成一个可复用的组件,并理解 wrappedValue 与投射值 $。
当你发现好几个属性都在重复同一套存取规则——比如”数值不能超过 12""取值前要做线程检查""数据要存进数据库”——难道每个属性都手抄一遍?Swift 给出的答案是属性包装器(property wrapper)。它把”怎么存值”和”属性本身的定义”拆开,管理代码只写一次,然后贴到多个属性上复用。
1-1 包装器长什么样
属性包装器本质上是一个结构体、枚举或类,只要它里面有一个叫 wrappedValue 的属性。这个 wrappedValue 就是被包装属性真正读写的那层。
@propertyWrapper
struct TwelveOrLess {
private var number = 0
var wrappedValue: Int {
get { return number }
set { number = min(newValue, 12) }
}
}
@propertyWrapper 这个属性(attribute)告诉编译器:下面这是个包装器。里面的 number 是真正存值的地方,标了 private 表示只有包装器内部能直接碰它,外部只能通过 wrappedValue 的 getter/setter 访问。setter 里那句 min(newValue, 12) 就是核心规则:无论你写多大,存进去的都不会超过 12。
1-2 把包装器贴到属性上
有了包装器,给属性加功能就变成在前面写个 @ 加包装器名:
struct SmallRectangle {
@TwelveOrLess var height: Int
@TwelveOrLess var width: Int
}
var rect = SmallRectangle()
rect.height = 10
print(rect.height) // 10
rect.height = 24
print(rect.height) // 12
height 和 width 看不到任何 min 逻辑,但行为被 TwelveOrLess 兜住了。赋 24 时,包装器在 setter 里把它压成允许的最大值 12。这就是属性包装器的甜头:存取规则集中在一处,属性定义保持干净。
编译器在背后替你生成了存储包装器实例的代码,也生成了通过包装器访问属性的代码。换句话说,@TwelveOrLess var height 等价于你手写一个 _height 私有属性加一对 getter/setter 去转发 wrappedValue——只是语法糖帮你省了这些样板。
1-3 给包装的属性设初始值
上面的例子里,包装属性的初始值由包装器自己决定(默认 0)。很多场景你想在声明属性时直接给个初值,或者配置点参数。这就需要给包装器加初始化器。
@propertyWrapper
struct SmallNumber {
private var maximum: Int
private var number: Int
private(set) var projectedValue: Bool
var wrappedValue: Int {
get { return number }
set {
let clamped = newValue > maximum
number = min(newValue, maximum)
projectedValue = clamped
}
}
init() {
maximum = 12
number = 0
projectedValue = false
}
init(wrappedValue: Int) {
maximum = 12
number = min(wrappedValue, maximum)
projectedValue = wrappedValue > maximum
}
init(wrappedValue: Int, maximum: Int) {
self.maximum = maximum
number = min(wrappedValue, maximum)
projectedValue = wrappedValue > maximum
}
}
注意这三个初始化器的名字。init() 什么都不接;init(wrappedValue:) 接收被包装属性的值;init(wrappedValue:maximum:) 还能额外收一个上限参数。Swift 会根据你怎么写属性,自动挑对应的初始化器。
三种写法对应不同初始化方式:
struct ZeroRectangle {
@SmallNumber var height: Int
@SmallNumber var width: Int
} // 没给初值 → 用 init()
struct UnitRectangle {
@SmallNumber var height: Int = 1
@SmallNumber var width: Int = 1
} // 给了 = 1 → 用 init(wrappedValue:)
struct NarrowRectangle {
@SmallNumber(wrappedValue: 2, maximum: 5) var height: Int
@SmallNumber(wrappedValue: 3, maximum: 4) var width: Int
} // 带了参数 → 用 init(wrappedValue:maximum:)
规则很直观:属性没写初值就用 init();写了 = 值 就翻译成 init(wrappedValue:);在 @ 后面括号里传参数,就调对应的自定义初始化器。这是属性包装器里最灵活、也最容易被忽略的一点。
1-4 投射值 projectedValue
有时包装器除了”包住的值”,还想额外暴露点信息。比如 SmallNumber 在值被压小过时,你可能想知道”刚才那次赋值到底有没有被改”。这就需要投射值(projected value),它的名字和包装属性一样,但前面加美元符号 $。
struct SomeStructure {
@SmallNumber var someNumber: Int
}
var s = SomeStructure()
s.someNumber = 4
print(s.$someNumber) // false
s.someNumber = 55
print(s.$someNumber) // true
普通访问 s.someNumber 拿到的是被包住的值;访问 s.$someNumber 拿到的是投射值,也就是”上次赋值是否被截断了”。$ 开头是 Swift 的保留命名,你自己的属性不能用 $ 开头,所以投射值永远不会和你定义的属性撞名。
投射值可以是任何类型。上面只暴露一个布尔,你也能让它返回一个对象、甚至返回包装器自身(self),把整个实例的能力都暴露出去。它在包装器内部方法里访问时也能省略 self.,直接写 $height 这种形式,和普通属性一样用。
1-5 还能用在局部变量上
属性包装器不只能贴在类型的属性上,也能贴在函数内部的局部变量上:
func someFunction() {
@SmallNumber var myNumber: Int = 0
myNumber = 10 // 10
myNumber = 24 // 12
}
全局变量和函数内部的局部计算变量不能用包装器,但局部存储变量可以。这在某些临时需要统一约束的场景下挺方便。
1-6 什么时候该上包装器
当你发现同一段存取逻辑要出现在三个以上属性身上,就该考虑抽成包装器。它能把”规则”和”数据”解耦,改规则只改一处,属性声明保持清爽。常见的实际用途包括:给值设上下限、自动 trim 字符串前后空格、把属性同步到 UserDefaults、做线程安全检查等。不过也别滥用——如果一个属性有独一无二的存取逻辑,直接写计算属性或观察器反而更直白。包装器解决的是”重复”,不是”复杂”。
1-7 包装器与初始值的真实顺序
当你给被包装属性写 = 1 这种初值时,别以为那是普通赋值。Swift 实际把它翻译成对包装器 init(wrappedValue:) 的调用。只有完全没写初值、也没在 @ 后加参数时,才会走无参的 init()。这个顺序常被忽略,导致”为什么我的初值没生效”这类困惑。记住:属性右边的 = 值 永远进 wrappedValue: 参数,括号里的才是额外配置。
1-8 包装器适合解决哪类问题
包装器是横切多个属性的复用工具,最适合放”同一种存取规则出现在多处”的场景。典型例子:给所有用户输入做 trim、给所有数值设上下限、把偏好同步进 UserDefaults、给网络结果做缓存。反过来说,如果一条逻辑只服务一个属性,直接写计算属性或观察器更直白,没必要上包装器。判断标准就一句:同样的规则是否会被三个以上属性共享。是就抽出来,否就就地解决。抽象不足和过度抽象一样有害,包装器解决的是”重复”,不是”复杂”。
1-9 编译器替你生成了什么
@TwelveOrLess var height 这种写法,编译器会在背后生成一个隐藏的包装器实例(比如 _height),并把 height 的读写转发到它的 wrappedValue。也就是说,属性包装器只是语法糖,你完全可以不写 @、手动写私有属性和一对 getter/setter 达到同样效果。理解这点,你就不会把它想得太神秘:它把”样板转发代码”自动化了,让你写的类型更干净。当你在调试时想知道值到底被怎么处理,脑中把它还原成”私有属性加转发”就清楚了。