Lesson 22 / 24
Property Wrappers
Measures that the wrappedValue getter of a property wrapper runs on every access, that the initial value is set up once, and that a cached wrapper turns the moment into first use.
Published Updated
In this lesson
- Writes a property wrapper with wrappedValue and projectedValue and reaches the projected value with the $ prefix
- Reads from the measurement output that the wrappedValue getter runs on every access
- Measures that the initial value given to a wrapper is evaluated once while the property is set up
- Shows that in a cached wrapper the evaluation moment is written in the body of the wrapper
Contents
In the previous lesson a closure was a box that took a value into itself and stored it. A property wrapper (@propertyWrapper) builds a similar box for the property itself: how the value of the property is stored, and what happens when it is read or written, moves into a separate type, and the property is marked with the name of that type. The only required member of a wrapper type is wrappedValue; this member runs when the property is read and when it is written. The optional projectedValue member offers a second value that is reached by putting $ in front of the property name.
This lesson writes a wrapper and measures three different moments of it: the moment the getter runs, the moment the initial value is evaluated, and the moment that changes when a cache is added. Most of the measurements use a Clamped wrapper, a small type that squeezes a value into a range.
// Probe.swift — records when an expression is evaluated
final class Probe {
let label: String
private(set) var evaluations = 0
private(set) var uses = 0
private var evaluatedBeforeUse = false
init(_ label: String) {
self.label = label
}
func eval<T>(_ value: T) -> T {
evaluations += 1
if uses == 0 { evaluatedBeforeUse = true }
return value
}
func use<T>(_ access: () -> T) -> T {
uses += 1
return access()
}
var moment: String {
if evaluations == 0 { return "never" }
if evaluations > 1 { return "on-every-use" }
return evaluatedBeforeUse ? "immediately" : "on-first-use"
}
func report() {
print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
}
}
func tally(_ probes: [Probe]) {
for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
let count = probes.filter { $0.moment == moment }.count
print("\(moment): \(count)")
}
}
// Clamped.swift — squeezes a value into a range
@propertyWrapper
struct Clamped {
private var current: Int
let range: ClosedRange<Int>
let owner: Probe
init(wrappedValue: Int, _ range: ClosedRange<Int>, owner: Probe) {
self.range = range
self.owner = owner
self.current = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Int {
get { owner.eval(current) }
set { current = min(max(newValue, range.lowerBound), range.upperBound) }
}
var projectedValue: Bool { current == range.upperBound }
}
Writing the Wrapper
The Clamped wrapper holds an Int and squeezes the assigned value into the given range. The body of the wrappedValue getter calls owner.eval before it returns the value; every run of the getter is recorded this way. projectedValue is a Bool that says whether the value is at the upper bound, and it does not call eval when it is read, because what is measured is wrappedValue.
The Getter Runs on Every Access
When the wrapper is used, the property is written as @Clamped(0...10, owner: reads) var level = 5: the values in parentheses go to the init method of the wrapper, and = 5 goes to the wrappedValue parameter. The first measurement reads level twice and assigns a value outside the range in between. In the last line, $level reads the projected value.
// main.swift — the wrappedValue getter runs on every access
let reads = Probe("wrapper-read")
do {
@Clamped(0...10, owner: reads) var level = 5
print("level: \(reads.use { level })")
level = 42
print("level: \(reads.use { level })")
print("at upper bound: \($level)")
}
reads.report()
level: 5 level: 10 at upper bound: true wrapper-read: 2 evaluations, 2 uses -> on-every-use
The first read gives 5 and the second gives 10: the assigned 42 was squeezed to the upper bound of the range. $level reads projectedValue, and because the value is at the upper bound it writes true. wrapper-read records two uses and two evaluations; because the getter runs on every access, its moment is on-every-use. The $level read did not raise the record, because it calls a different member. This lesson’s moment is on every use.
The Initial Value Is Set Up Once
The = 5 written on the property is evaluated not on every run of the getter but once, while the property is set up. The second measurement wraps the initial value in start.eval(5) and reads the property twice. The getter’s own eval call writes to a different Probe, quiet, so start keeps the record of the initial value only.
// main.swift — the initial value is evaluated once while the property is set up
let start = Probe("initial-value")
let quiet = Probe("quiet-read")
do {
@Clamped(0...10, owner: quiet) var level = start.eval(5)
print("level: \(start.use { level }) \(start.use { level })")
}
start.report()
level: 5 5 initial-value: 1 evaluations, 2 uses -> immediately
Both reads give 5, and initial-value records a single evaluation. Because this evaluation happened before the first use call, its moment is immediately. The getter ran twice, but it did not evaluate the initial expression again: the init method of the wrapper took the value of the expression once and wrote it to current.
// Cached.swift — computes the value on first read and stores it
@propertyWrapper
final class Cached {
private var memo: Int?
private let make: () -> Int
init(wrappedValue make: @autoclosure @escaping () -> Int) {
self.make = make
}
var wrappedValue: Int {
if let memo { return memo }
let result = make()
memo = result
return result
}
}
The Setter Runs on Every Assignment Too
wrappedValue works in both directions: get runs on a read, and set runs on an assignment. The initial value, on the other hand, does not pass through set; the init method stores it directly. This difference can be measured separately on the write side. The Counted wrapper calls owner.eval in the set body and applies no limit. The measurement starts the property at 0 and then increases it three times.
// Counted.swift — records every assignment
@propertyWrapper
struct Counted {
private var current: Int
let owner: Probe
init(wrappedValue: Int, owner: Probe) {
self.current = wrappedValue
self.owner = owner
}
var wrappedValue: Int {
get { current }
set { current = owner.eval(newValue) }
}
}
// main.swift — set runs on every assignment, the initial value skips set
let writes = Probe("wrapper-write")
do {
@Counted(owner: writes) var score = 0
for amount in 1...3 {
_ = writes.use { score += amount }
}
print("score: \(score)")
}
writes.report()
score: 6 wrapper-write: 3 evaluations, 3 uses -> on-every-use
The score is 6, because 1, 2 and 3 were added. wrapper-write records three uses and three evaluations: the initial = 0 assignment did not enter the evaluation count, only the three increases did. The read and write sides follow the same rule: a member of the wrapper runs on every access, while the initial value is taken once in the init method.
The Projected Value
Clamped offers two things: the property itself through wrappedValue, and an extra piece of information carried beside it through projectedValue. The extra information is reached by putting $ in front of the property name. This $ read does not call the wrappedValue getter; it is a separate member. The measurement builds two identical wrappers with separate Probe objects and assigns both a value outside the range. It reads one through $ and the other directly.
// main.swift — a $ read does not call the wrappedValue getter
let projected = Probe("projected-value")
let plain = Probe("direct-read")
do {
@Clamped(0...10, owner: projected) var left = 5
@Clamped(0...10, owner: plain) var right = 5
left = 50
right = 50
print("at upper bound: \(projected.use { $left }) \(projected.use { $left })")
print("value: \(plain.use { right }) \(plain.use { right })")
}
projected.report()
plain.report()
at upper bound: true true value: 10 10 projected-value: 0 evaluations, 2 uses -> never direct-read: 2 evaluations, 2 uses -> on-every-use
Both properties were assigned 50, and both were squeezed to 10. The $left read gives true: the value is at the upper bound. But this read does not go through the wrappedValue getter. projected-value records zero evaluations against two uses and says never, because there is no eval in the body of projectedValue and current is compared directly. direct-read records two evaluations in the same two uses and says on-every-use. A $ read does not run the wrappedValue getter; it is a separate member: the $ spelling asks the wrapper a question that is independent of the side effects of the wrappedValue getter. The moment question is therefore asked separately for each access: left and right are the same wrapper type, but which getter runs depends on how the access is written.
Wrapper Arguments Are Evaluated Once
The expressions in parentheses in @Clamped(0...10, owner: quiet) go to the init method of the wrapper. The compiler turns the line @Clamped(...) var level = 5, roughly, into a call Clamped(wrappedValue: 5, 0...10, owner: quiet) that runs once while the property is set up. This measurement tests both the property form and a hand-written init call. The upper bound of the range and the initial value pass through Probe.
// main.swift — wrapper arguments are evaluated once while the property is set up
let ceiling = Probe("range-argument")
let explicit = Probe("explicit-init")
let quiet = Probe("quiet")
do {
@Clamped(0...ceiling.eval(10), owner: quiet) var level = 5
print("level: \(ceiling.use { level }) \(ceiling.use { level })")
let wrapper = Clamped(wrappedValue: explicit.eval(5), 0...10, owner: quiet)
print("explicit: \(explicit.use { wrapper.wrappedValue }) \(explicit.use { wrapper.wrappedValue })")
}
ceiling.report()
explicit.report()
level: 5 5 explicit: 5 5 range-argument: 1 evaluations, 2 uses -> immediately explicit-init: 1 evaluations, 2 uses -> immediately
On both paths the counter says the same thing. range-argument records a single evaluation, and because that evaluation happened before the first use call, its moment is immediately. The two reads did not recompute the upper bound of the range: ceiling.eval(10) ran once while the property was set up, and the ClosedRange value stayed inside the wrapper. The hand-written call Clamped(wrappedValue: explicit.eval(5), ...) gives the same result: explicit-init records a single evaluation and says immediately. The wrapper syntax introduces no new evaluation rule; it is a short way of writing an init call, and the arguments of the call are evaluated once at the moment of the call, as always. How many times the getter runs and how many times the argument is evaluated are two separate numbers: the first rises with every access, and the second is fixed once while the property is set up. This distinction requires choosing, from the start, which number is being looked at when a wrapper is read.
Composing Wrappers
More than one wrapper can be written on a property. The one on the left is outer, and the one on the right is inner. The Traced below is a generic wrapper; it stores the value as it is and calls owner.eval on every read. The measurement puts it outside Clamped. It reads the property twice and looks at how many times the getter of each layer runs.
// Traced.swift — a generic wrapper that records every read
@propertyWrapper
struct Traced<Value> {
private var current: Value
let owner: Probe
init(wrappedValue: Value, owner: Probe) {
self.current = wrappedValue
self.owner = owner
}
var wrappedValue: Value {
get { owner.eval(current) }
set { current = newValue }
}
}
// main.swift — two wrappers stacked
let outer = Probe("outer-wrapper")
let inner = Probe("inner-wrapper")
do {
@Traced(owner: outer) @Clamped(0...10, owner: inner) var level = 50
print("read: \(outer.use { inner.use { level } }) \(outer.use { inner.use { level } })")
level = 3
print("after write: \(outer.use { inner.use { level } })")
}
outer.report()
inner.report()
read: 10 10 after write: 3 outer-wrapper: 4 evaluations, 3 uses -> on-every-use inner-wrapper: 3 evaluations, 3 uses -> on-every-use
The first two reads give 10: the initial value was 50, and the innermost Clamped had squeezed it to 10 inside init. After the assignment level = 3, the read gives 3. The counters are worth noting: the getter of the inner wrapper runs three times in three reads. The getter of the outer wrapper runs four times: three reads and the assignment itself. The assignment calls the outer getter one more time, because the outer wrapper first reads its value and then changes it on the inner Clamped. Composition therefore counts the number of runs of each of the two layers separately. Both Probe objects say on-every-use. Adding a layer adds a chain of get and set to every read and write. When a wrapper whose getter has side effects is placed in a composition, this extra work has to be taken into account. In this measurement the outer getter ran four times and the inner getter three times; compositions with more layers were not measured.
The Bounding Measurement
That the initial value is immediately is a result of how Clamped is written, not of the wrapper syntax. The Cached wrapper below turns the expression given to the init method into a closure with @autoclosure and does not call it until wrappedValue is first read. The result is stored in a property; later reads return the stored value. The same @ sign and the same = expression spelling, but the evaluation moment has changed.
// main.swift — a cached wrapper turns the moment into first use
let lazyValue = Probe("cached-wrapper")
do {
@Cached var total = lazyValue.eval(7)
print("total: \(lazyValue.use { total }) \(lazyValue.use { total })")
}
lazyValue.report()
total: 7 7 cached-wrapper: 1 evaluations, 2 uses -> on-first-use
Both reads give 7. cached-wrapper records a single evaluation, but this time the evaluation happened inside the first use call, so its moment is on-first-use. The @Cached spelling looked like the @Clamped spelling; what makes the difference is whether the expression is used at once in the init method of the wrapper or held back in a closure. The moment is written in the body of the wrapper, not in the @ syntax.
MA4. Both measured wrappers hold an Int value. That generic wrappers and wrappers used as class properties follow the same rules was not measured in this lesson. Use as a local variable is a design choice.
Summary
- A property wrapper is marked with
@propertyWrapper;wrappedValueruns when the property is read and written, andprojectedValueis read with the$prefix. - The
wrappedValuegetter runs on every access; an assignment is carried out by the rule of the wrapper, for example by squeezing into a range. - The initial value written on the property is evaluated once while the property is set up, in a wrapper such as
Clamped. Cached, written with@autoclosureand a stored result, turns the same spelling into a single evaluation on first use; the moment is in the body of the wrapper.- This lesson’s moment: on every use. The
wrappedValuegetter runs on every access.
Next Step
In this lesson a wrapper moved the value rule of a property into a separate type. Result builders apply the same idea to a list of expressions: instead of being evaluated one by one, the lines in a block are given to methods of a builder type such as buildBlock, and that type decides which line runs at which moment. The next lesson writes a result builder of its own and measures that the expressions of the branch that is not chosen are never evaluated.
To keep your progress and take notes, Log in
My notes
Log in to take notes.