`_read` Accessors in Swift: What They Actually Buy You (and What They Don’t)
There is a persistent piece of folklore in Swift performance circles: that swapping a computed property’s get for a _read accessor will cut ARC traffic and make your hot loop faster. The second half of that sentence is sometimes true. The first half is not what the language record actually says, and if you go looking for a retain/release reduction you may spend an afternoon chasing a win that was never promised.
What the Swift evolution documents do describe is copy avoidance. That distinction matters, because it tells you when to reach for a borrowing accessor and when to leave get alone.
The problem get creates
A computed property with get and set looks like it mutates in place. It does not. As SE-0474 lays out, an expression like wordFrequencies["swift", default: 0] += 1 is really three operations: a get that returns a copy, a mutation performed on that temporary, and a set that writes the temporary back over the original.
For a String or an Array, that copy is cheap in the common case—until it isn’t. These types use copy-on-write: the copy shares the backing buffer, and the next mutation checks whether the buffer is uniquely referenced. Because the getter handed out a second reference, it usually isn’t, so the mutation duplicates the buffer first. The proposal is blunt about the consequence: appending through such a property in a loop turns a linear operation into a quadratic one. That is the performance pitfall a borrowing accessor exists to remove.
What _read is, precisely
_read is the underscored spelling of a yield-once coroutine accessor. It has been available in that unofficial form since Swift 5.0, and the pitch that formalizes it describes the underscored _modify and _read accessors as allowing copies to be avoided when changing and viewing a value’s fields.
SE-0474 is the accepted proposal that gives these accessors a supported spelling: yielding borrow for the read side and yielding mutate for the modify side. The proposal states that these accessors enable values to be accessed and modified without requiring a copy, and that this is essential for noncopyable types and often desirable for performance even with copyable types. At the time of the proposal, the feature was only partially available on the compiler’s main branch behind the -enable-experimental-feature CoroutineAccessors flag.
So the honest framing is: _read is the old, unsupported spelling of a feature whose supported form is still experimental. Treat it as an implementation detail you can isolate, not a public API you sprinkle through a codebase.
How the mechanism differs from get
A get body behaves like an ordinary method. It runs, produces a value, and hands ownership of that value to the caller. A yielding accessor is a coroutine. It borrows self, runs until it reaches yield, suspends, and lends the value to the caller for the duration of the access. When the caller is done, the coroutine resumes after the yield and finishes.
The caller borrows; it does not own. That is the whole trick. No second reference to the backing buffer is created, so the copy-on-write check sees a unique buffer and mutates in place.
A sketch of the shape, using the underscored spelling:
struct Buffer {
private var storage: [UInt8] = []
var bytes: [UInt8] {
get { storage }
_read { yield storage }
}
}
Reading through bytes now lends storage instead of returning a copy. The same idea applies to subscripts, which is why the standard library’s Span subscript uses a _read accessor—it needs to yield a borrowed element rather than produce one.
A decision framework, not a blanket rule
The temptation is to treat borrowing accessors as the “correct” default. Resist it. Here is the order I would work through.
Start with get. If the property returns a copyable value and you have not profiled a copy-related hotspot, plain get is the lower-risk choice. It has no coroutine semantics, no exclusivity subtleties during suspension, and no underscored spelling to migrate later.
Reach for a borrowing accessor when profiling shows copies. The signal is specific: a computed property on a hot path where the getter’s returned copy forces a copy-on-write duplication, or where the copy itself is expensive. The proposal’s own example—appending through a property in a loop—is the canonical case. If your Instruments trace does not show that pattern, you probably do not have the problem this feature solves.
Reach for it unconditionally when the value is noncopyable. This is the one case where the argument is about correctness, not speed. For a ~Copyable property, get cannot be implemented at all: the getter borrows self but then tries to transfer ownership of the field to the caller, and since the field cannot be copied, there is nothing to transfer. A borrowing accessor is the only way to expose inspection of such a field without consuming the container.
The ARC question, handled honestly
Here is where the folklore and the record diverge. The proposals describe copy avoidance. They do not describe retain/release counts, and I have not found a primary source that quantifies an ARC-traffic reduction from _read. Avoiding a copy of a reference-counted buffer will often avoid some retain/release traffic as a side effect—that is a reasonable hypothesis—but it is a hypothesis you should verify rather than assume.
If ARC traffic is your actual concern, measure it directly. Build a microbenchmark that mutates through a computed property in a loop, run it once with get/set and once with a borrowing accessor, and inspect retain/release events and buffer duplication in Instruments rather than trusting the intuition. If the win is real, it will show up. If it does not, you have saved yourself a migration.
Constraints worth knowing before you commit
Borrowing accessors are not free of rules. A few that bite in practice:
- Yield-once. The coroutine yields exactly once per access. You cannot loop over multiple yields in a single accessor body.
- Exclusivity during suspension. A
yielding mutateaccessor holds exclusive access toselffor the duration of the access, including while suspended. Code that tries to touch the same storage from inside the caller’s access will trap. - Interaction with
consume. SE-0366 specifies that theconsumeoperator cannot be applied to a binding that has accessors attached, and it lists_readamong them. If you are mixing ownership transfer with borrowing accessors, check that combination first. - Unstable spelling.
_readis underscored and unsupported. The supported spelling is still behind a flag. Isolate the accessor behind a small wrapper type so that migrating toyielding borrowlater is mechanical rather than a scavenger hunt.
A short checklist
Before you add a borrowing accessor to production code, run through this:
- Profile first. Confirm the hotspot is a copy or a copy-on-write duplication, not something else.
- Isolate the accessor. Keep the underscored spelling in one place so the migration to the supported form is a single edit.
- Compare
getagainst the borrowing accessor on the same benchmark, and look at retain/release events and buffer duplication rather than guessing. - Check the yield-once and exclusivity rules against how callers actually use the property.
- If the value is noncopyable, stop—the borrowing accessor is not an optimization, it is the only option.
The feature is real and useful. The framing around ARC traffic is the part that needs evidence. Treat copy avoidance as the documented benefit, measure the rest, and you will get the wins without the folklore.
FAQ
Is _read safe to use in shipping code?
It is underscored, which in Swift convention signals an unsupported implementation detail. The supported spelling, yielding borrow, was still behind an experimental flag when SE-0474 was written. If you use _read, isolate it so a future migration is contained.
Does _read eliminate all copies?
No. It avoids the copy that a get would produce when lending a value to the caller. Copies elsewhere in your code—explicit ones, or ones introduced by other access patterns—are unaffected.
Can I use both get and _read on the same property?
SE-0474 states that a property providing a yielding borrow cannot also provide a get. Plan for one or the other.
When is a borrowing accessor mandatory rather than optional?
When the property’s type is noncopyable. A get would need to transfer ownership of the field to the caller, which is impossible without a copy, so the getter cannot be implemented.
Where can I read the primary sources?
SE-0474, Yielding accessors, is the accepted proposal and the best starting point. The Swift forums pitch thread for modify and read accessors documents the history of the underscored spellings. SE-0366 covers the consume operator and its interaction with accessors.