[Pitch] borrow/mutate accessors, & immutable borrow, and mutating in signatures

Hi everyone,

I'd like to share a pitch that grew out of a real performance optimization effort where I was trying to eliminate unnecessary copies in hot loops on Copyable value types.

This is my first pitch, so I appreciate any feedback, especially if I've misunderstood constraints or prior art in the ownership model.


Introduction

SE-0474 introduced yielding borrow and yielding mutate accessors. SE-0507 then accepted the non-yielding borrow and mutate accessors built on that foundation. Current release toolchains do not accept the syntax proposed above.

This pitch builds on that direction. It proposes & at call sites as an explicit (mutable and immutable) borrow signal, and it extends the ownership model so mutable borrows can be expressed explicitly in function signatures as well as in accessors.

This pitch grows out of a real optimization effort on a Swift codebase that processes large tree structures and exclusion rules. The hottest stage sped up by about 2x without any algorithmic change, mostly by removing retain/release traffic and implicit copies in hot loops. The missing language feature was a lightweight way to say:

"Borrow this value from storage here; do not copy it."

Today the only reliable escape hatch is often to make the type ~Copyable, which is much broader than the problem being solved.

This pitch proposes three additions:

  1. & at the call site as an explicit borrow
  2. borrow and mutate accessors as the only surface accessor names, with optional yield when coroutine behavior is needed
  3. mutating as the ownership keyword for mutable borrows in function signatures

This pitch is about value semantics and value types. Reference types remain out of scope for now: this pitch does not propose borrow or mutate for ARC-managed reference types.


Motivation

The call-site performance gap

SE-0474 addressed copies for computed properties and subscripts. A gap remains at ordinary function call sites:

for i in myArray.indices {
    visit(myArray[i])
}

Even if visit(_:) is declared borrowing, subscript access semantics may still introduce copies. In the motivating case behind this pitch, that ownership churn was a major part of the runtime cost. Instruments revealed that more than half of the time spent in that hot loop was spent on retain / release and copies.

What is missing is a way to say, at the call site, "borrow this value from storage here."

The ~Copyable solution

Making a type ~Copyable to prevent unwanted copies is a heavy tool. It does work, but it is much broader than the local problem being solved.

The first issue is that the copy problem does not just disappear when one leaf type becomes ~Copyable. The copy boundary tends to move outward. In the motivating optimization work, the first hot copies were on inner wildcard-pattern components. After making those ~Copyable, the next hotspot was the array element wrapper that contained them. After fixing that, the next hotspot was the outer rule wrapper. In other words, ~Copyable solved the copies, but only by forcing more and more of the surrounding representation to become move-only too.

The second issue is that ordinary containers stop working in exactly the places where performance-sensitive code often wants them. A type that was previously [Rule] or [String: Rule] can no longer be expressed directly. You need ~Copyable-compatible storage such as UniqueArray<Rule>, and if you need keyed lookup you may need a custom map as well. That is not just a local change to one function or one loop. It changes the shape of the surrounding data structures.

The third issue is that this propagates into API boundaries that are not really about ownership at all. Helper functions, cached plans, wrapper structs, async captures, and storage used to pass data across phases all need to be revisited. In the motivating codebase, this even forced introducing shared wrappers and custom storage helpers purely to accommodate the ownership model.

The fourth issue is practical compiler/tooling friction. Once the model becomes sufficiently move-only, you are more likely to run into rough edges in the language and toolchain, including places where straightforward code patterns stop compiling cleanly or trigger compiler crashes. Thankfully the latter issue I encountered was fixed in Swift 6.3.0.

In the motivating codebase, the hot path walked exclusion rules and tree nodes that were perfectly reasonable Copyable value types. The problem was not that those values should never be copyable. The problem was that certain call sites in tight loops should have been able to say "borrow this from storage here" without forcing a whole ownership redesign.

The exclusion rules in SE-0474 and SE-0507

SE-0474 makes get and yielding borrow mutually exclusive because a plain read such as foo.bar has no syntax that tells the compiler whether the caller wants a copied value or a borrowed one. SE-0507 has a similar restriction: borrow and get cannot coexist on the same declaration.

This pitch uses & as that signal. Plain uses continue to mean copy semantics. &foo.bar means borrow.

This is an intentional divergence from both proposals. Their exclusion rule follows from the absence of a call-site marker. The main claim of this pitch is that once & exists as that marker, get and borrow, and likewise set and mutate, no longer need to be mutually exclusive.

Swift already uses & at call sites as the marker for "give the callee access to this value through its storage here". The exact access mode is already context-dependent today. With inout on Copyable types, that means the existing writeback model. With inout on ~Copyable types, it already lines up much more closely with mutable borrowing because copying is impossible. This pitch extends that same pattern: & still marks storage access, and the callee's ownership annotation determines whether that access is immutable borrow, mutable borrow, or the existing inout model.

Other languages such as C and Rust also associate & with borrowing or reference formation. Reusing it here preserves continuity with existing Swift call-site syntax. A distinct prefix operator for immutable borrow remains an interesting alternative and is discussed at the end.

Mutable borrow in function signatures

There is currently no way to spell "mutable borrow" in a function signature for a Copyable type.

inout is not that spelling. For Copyable types it is the existing writeback model. For ~Copyable types it lines up with mutable borrow because copying is impossible. Existing public APIs in the standard library also do not expose _read and _modify as an additive answer here.

mutating fills that gap. It is the explicit API-level spelling for mutable borrow, paired with mutate and selected by &.


Proposed Solution

1. borrow and mutate in accessors

This pitch introduces borrow and mutate as the source-language accessor pair:

var value: MyType {
    borrow { &storage }
    mutate { &storage }
}

The longer spelling is also valid:

var value: MyType {
    borrow {
        return &storage
    }
    mutate {
        return &storage
    }
}

Within these bodies, &place and return &place are contextual syntax for exposing borrowed access to that storage. This syntax is only valid inside borrow and mutate bodies. This is consistent with SE-0519's proposed Borrow<T> and Inout<T> discussed later.

At the syntax level, this pitch uses borrow and mutate as the accessor spellings. When coroutine behavior is desired, yield is used inside those bodies. The surface syntax does not introduce separate yielding borrow or yielding mutate accessor spellings.

This follows the same progressive disclosure of complexity direction as the accessor work in SE-0474 and SE-0507: the common case stays simple, and the more complex coroutine mental model appears only when needed in the body.

When post-access cleanup is needed, yield can still be used:

var value: MyType {
    mutate {
        logAccess()
        yield &storage
        cleanup(&storage)
    }
}

This keeps the coroutine feature available without making it the default mental model for every accessor body.

Deviation from SE-0507

SE-0507 uses return storage without & inside borrow bodies. This pitch proposes return &storage instead. The issue is not just cosmetic. Once a declaration may contain get, set, borrow, and mutate together, plain return storage and yield storage no longer make the storage-vs-copy distinction visible at the point where the value is being exposed. In get, return storage means produce an ordinary value result.

// Syntax without & that may be confusing
var value: Foo {
    get {
        return _value
    }
    borrow {
        return _value
    }
}

In borrow and mutate, the reader instead needs to understand that the body is exposing the underlying storage. Requiring & at that point makes the borrow semantics explicit where they happen, instead of relying on the reader to remember which accessor body they are currently inside.

// Pitched syntax
var value: Foo {
    get {
        return _value
    }
    borrow {
        return &_value
    }
}

2. mutating as the parameter keyword for mutable borrows

The existing keywords borrowing and consuming are retained. This pitch proposes mutating as the spelling for a mutable-borrow parameter:

func read(_ x: borrowing MyType)      // immutable borrow
func update(_ x: mutating MyType)     // mutable borrow
func take(_ x: consuming MyType)      // move / consume

For methods:

borrowing func example()    // immutable borrow of self
mutating func example()     // existing spelling
consuming func example()    // consumes self

We extend the same mutate / mutating terminology accepted in SE-0507 into function signatures, so mutable borrows have an explicit spelling that pairs with mutate and & at the call site.

This does reuse an existing spelling in a new position, so it should be evaluated carefully on readability grounds. The reason to prefer it here is alignment with the terminology already accepted for accessors.

3. get and borrow/mutate accessors can coexist thanks to &

The purpose of & is to let one declaration support both copy-based access and borrow-based access without ambiguity.

For a property or subscript that provides all four accessors:

struct Wrapper {
    private var storage: Int = 0

    var value: Int {
        get { storage }
        set { storage = newValue }
        borrow { &storage }
        mutate { &storage }
    }
}

the call-site syntax determines which kind of access is being requested:

  • plain use requests the ordinary value path
  • & requests a borrowed path

In detail:

func read(_ value: borrowing Int) {}
func update(_ value: mutating Int) {}

var wrapper = Wrapper()

read(wrapper.value)       // get
read(&wrapper.value)      // borrow
update(&wrapper.value)    // mutate

let x = wrapper.value     // get
wrapper.value = 1         // set

Existing inout calls remain source-compatible:

func visitCopyable(_ value: inout Int) {}
visitCopyable(&wrapper.value)       // existing inout model (get + set)

func visitNonCopyable(_ value: inout OpenFile) {}
visitNonCopyable(&file)    // existing inout model (mutable borrow)

The full quad of accessors can coexist on a single declaration:

subscript(index: Int) -> Element { get set borrow mutate }

That could make additive evolution more practical for Array, Dictionary, and similar types while preserving stable source and stable ABI, but that part would need a much more thorough analysis than this pitch provides.

Type checking and overload resolution otherwise proceed as they do today. The compiler first finds the declarations that match the call-site syntax, including the presence or absence of & and any ownership annotations on the parameter. If more than one declaration matches, the call is ambiguous and is rejected.

func use(_ x: borrowing Int) {}
func use(_ x: mutating Int) {}

use(&value)   // error: ambiguous use of 'use'

Likewise, there is no preference rule between inout and mutating:

func use(_ x: inout Int) {}
func use(_ x: mutating Int) {}

use(&value)   // error: ambiguous use of 'use'

Interestingly, & can now disambiguate borrowing from consuming, because consuming only matches the plain use(value) form.

If the callee's parameter has no ownership annotation, passing & is an error:

func visit(_ x: Foo) {}

visit(&foo)
// error: cannot pass a borrowed value to a function that may copy that argument

This pitch does not change the existing lifetime, escaping, or concurrency rules around borrowed access. An &-formed borrow remains non-escaping and subject to the same restrictions around suspension points, actor isolation, and exclusivity that already apply to borrowed accesses. In particular, this pitch does not by itself make it legal to pass &x into an async or actor-crossing use that would allow the borrow to outlive its valid scope.

4. borrow and mutate in protocol requirements

Protocol computed property and subscript requirements today support only get and set. This pitch extends that:

protocol Example {
    var value: Foo { get set borrow mutate }
    subscript(index: Int) -> Foo { get set borrow mutate }
}

The witness rules should be:

  • a borrow requirement may be witnessed by a stored property, a borrow accessor, or a legacy _read accessor
  • a mutate requirement may be witnessed by a stored property, a mutate accessor, or a legacy _modify accessor
  • computed get / set accessors should not witness borrow / mutate requirements by introducing copies through temporaries

That keeps the model capability-based while still allowing legacy underscore accessors to participate in conformance. If a protocol requires both borrow and mutate, then both capabilities must still be satisfied independently.

5. Relationship to inout

For Copyable types, this pitch does not require any downgrade in the implementation strategy for existing inout uses. Existing inout code remains source-compatible, and the compiler may continue to optimize those accesses as it does today. The role of mutating is to provide an explicit mutable-borrow parameter contract and call-site selection mechanism, not merely a new spelling for current inout behavior.

For example:

func update(_ x: inout SomeCopyableType) {}
update(&array[i])      // existing inout model (get + set)

func update(_ x: mutating SomeCopyableType) {}
update(&array[i])      // explicit mutable borrow under this pitch

This distinction matters for compatibility. Suppose we have:

// Library code
struct Wrapper {
    private var storage: Int = 0

    var value: Int {
        get { storage }
        set {
            print("set")
            storage = newValue
        }
    }
}

// User code
func update(_ x: inout Int) { x += 1 }

Today, update(&wrapper.value) goes through the existing inout writeback path. If a later version of the library adds:

mutate { &storage }

and inout were allowed to start using that mutate accessor automatically, the same code will stop calling set, willSet and didSet. That would be a breaking change, and dissuade library authors from adding mutate accessor to public types, which is exactly what this pitch is trying to avoid. mutate therefore needs to stay tied to mutating, not to existing inout.

For ~Copyable types, existing inout and mutating behavior already lines up more closely with mutable borrowing because copying is impossible.

The main migration issue is accessor-based. Existing _modify accessors participate in today's inout model, while mutate in this pitch is selected by mutating, with different semantics.


Source compatibility

Adding borrow and mutate to an existing property or subscript is source-compatible because current call sites do not use & in those positions. Plain reads still resolve to get, and assignments still resolve to set.

Extending & to borrowing and mutating parameters is also additive. Those uses are compile errors today, so this pitch only makes previously-invalid syntax meaningful.

Existing conformances to existing protocol requirements are unaffected. If a protocol later adds borrow or mutate requirements, conforming types must provide the corresponding capability explicitly, either directly with borrow / mutate, or via underscore _read / _modify. No synthesis from get should occur for computed properties: a synthesized borrow that copied through a temporary would go against the intent of writing & in the first place.


Relationship with SE-0474, SE-0507, and SE-0519

SE-0474 introduced coroutine accessors where yield is mandatory.

SE-0507 accepted borrow and mutate as the surface direction for this feature area. This pitch continues in that direction by extending the model to call sites and function signatures: an explicit call-site syntax to request the borrow path when get is also present, and an ownership spelling for mutable borrows in ordinary function signatures.

This pitch also tries to simplify the source-language model. At the language level, developers only write borrow and mutate. yield remains available inside those bodies when coroutine behavior is needed. That source-language simplification does not preclude the compiler from emitting yielding entry points and more specialized non-yielding entry points when appropriate.

SE-0507 prohibits borrow and get from coexisting on the same declaration because there is no call-site syntax to disambiguate them. This pitch intentionally diverges there. It proposes & as that syntax, which addresses the stated concern and make coexistence possible.

SE-0519 introduces Borrow<T> and Inout<T> as first-class non-Escapable reference types for shared and exclusive access respectively. The & syntax proposed here is consistent with that direction: a borrow accessor body producing &storage can be understood as yielding a Borrow<T> into the caller's scope, and a mutate body producing &storage as yielding an Inout<T>. Likewise, &x at a call site to a borrowing parameter forms a Borrow<T>, and &x to a mutating parameter forms an Inout<T>. This pitch does not depend on SE-0519, but the two designs reinforce each other.


Migration from _read and _modify

Existing _read and _modify accessors continue to work as they do in release toolchains. This pitch does not remove them. New code should prefer borrow and mutate.

This pitch keeps the declaration model simpler by recognizing two accessor families rather than allowing mixed declarations:

  • the underscore family: { _read _modify }
  • the unified family: { get set borrow mutate }

Declarations can use either family, and declare a subset of accessors of the family. Interoperability happens at use sites and in requirement satisfaction, not by mixing underscore and unified accessors inside the same declaration.

Today, code like this is valid:

struct Wrapper {
    private var storage: Int = 0

    var value: Int {
        _read { yield storage }
        _modify { yield &storage }
    }
}

func update(_ value: inout Int) {}

var wrapper = Wrapper()
update(&wrapper.value)

That example is important because it shows what is non-obvious about migration: _read and _modify do not have the same relationship to inout, but they do line up naturally with the borrowing and mutating capabilities proposed here.

The migration story is actually asymmetric:

  • _read and borrow should both work for borrowing arguments.
  • migrating from _read to borrow shoudn't cause breakage.
  • _modify and mutate should both work for mutating arguments.
  • The asymmetry is specifically with inout: _modify participates with inout arguments, while mutate in this pitch does not, and works with mutating only.

For ABI compatibility with callers that expect a _read accessor, the compiler could synthesize a _read from borrow.

Something to note: under this pitch, adding get alongside an existing borrow is source-breaking for callers that were previously relying on the borrow path through plain syntax. For example:

func example(_ x: borrowing Foo) {}

example(wrapper.value)

If wrapper.value previously exposed only borrow, this call would use the borrow accessor. Once get is added, the same source code resolves through get instead, and the caller must be updated to example(&wrapper.value) to preserve borrow semantics.

_modify and mutate should similarly both work for mutating arguments. The difference only appears with inout. Replacing _modify with mutate while leaving existing inout function signatures and & call sites untouched change how the accessor is selected. The old code was participating in today's inout model, which interacts with get and set. The new mutate accessor is selected by mutating only. That is why this step still needs coordinated migration when existing inout behavior must be preserved.

That also argues for keeping migration as a family-level choice at the declaration site. Underscore declarations stay in the { _read _modify } family. New declarations use the { get set borrow mutate } family. Compatibility between the two is handled by call-site and witness-matching rules rather than by allowing mixed declarations such as { borrow _modify }.


Implementation and ABI considerations

This pitch is intended to build on the implementation and ABI model introduced for accessors in SE-0474 and SE-0507, rather than defining a separate accessor runtime model.

At the language level, the model is intentionally simpler: developers write borrow and mutate, and may use yield inside those bodies when coroutine behavior is needed.

That source-level simplification does not preclude a more canonical ABI model underneath. In particular, for public accessors, a yielding ABI is the natural canonical form because it allows an implementation to grow post-access cleanup later without breaking ABI. A non-yielding implementation can then be understood as a specialization of that canonical accessor when no yield is used in the body.

This pitch does not require that specialized non-yielding ABI to exist, and it does not rely on it for correctness. The proposal works even if the implementation always lowers public borrow and mutate accessors to yielding implementation. The specialized, non-yielding versions can be seen as an optimization opportunity, not as part of the semantic contract of the feature.

The new surface area proposed here is call-site selection via & and an explicit mutable-borrow parameter spelling. Compatibility with underscore _read and _modify entry points may require synthesized compatibility entry points or other implementation work. This pitch does not depend on inventing a wholly separate ABI concept.


Summary of new syntax

Syntax Meaning
func f(_ x: mutating T) Function parameter spelled as mutable borrow
read(&x) where read(_ x: borrowing T) Immutably borrow x at the call site
update(&x) where update(_ x: mutating T) Mutably borrow x at the call site
borrow { &storage } Borrow accessor, simple form
mutate { &storage } Mutate accessor, simple form
borrow { yield &storage; logAccess() } Borrow accessor, yielding form
mutate { yield &storage; cleanup() } Mutate accessor, yielding form
protocol P { var value: T { get set borrow mutate } } Protocol requirement with all four accessors

Compatibility goals

  • inout remains valid and source-compatible
  • borrowing, consuming, and mutating remain valid
  • _read and _modify continue to work
  • stable source code does not break when borrow and mutate are added to existing APIs

Drawbacks

This pitch reuses mutating in two different positions with related but not identical semantics.

SE-0507 accepted mutate as the accessor spelling. Following the existing borrow / borrowing pattern, the natural paired parameter keyword is therefore mutating.

  • On methods, mutating func example() already exists and remains the spelling for a method that takes self through Swift's existing model. For Copyable types, that means inout-style get + set semantics. For ~Copyable types, that means an implicit mutable borrow of self.

  • In this pitch, mutating on a parameter means a mutable borrow selected by & at the call site.

The argument for accepting that drawback is that the reuse is still conceptually aligned: in both places, mutating means that the callee receives exclusive mutable access to a value rather than a shared value (like borrowing). The gap is narrower for self than for parameters: at the call site of foo.mutatingMethod(), foo is accessed in-place on stored values without a copy-in. The writeback question only surfaces when the receiver is itself a computed property.

A natural future direction would be to allow & on the receiver expression to select the mutate accessor and dispatch through a mutable borrow of self:

struct Counter {
    private(set) var count: Int = 0
    mutating func increment() { count += 1 }
}

struct CounterWrapper {
    private var _counter = Counter()

    var counter: Counter {
        get { _counter }
        set { _counter = newValue }
        borrow { &_counter }
        mutate { &_counter }
    }
}

var wrapper = CounterWrapper()
wrapper.counter.increment()     // uses get + set 
(&wrapper.counter).increment()  // mutable borrow of self

This pitch does not propose that syntax, but the design could accommodate it.


Alternatives Considered

  • Allowing mutate to serve inout directly:

Allowing a mutate accessor to satisfy existing inout parameters was considered. That would make the design look simpler at first, but it creates a library-evolution problem: if a type already exposes get/set, then later adding mutate could silently change existing inout calls from the old writeback path to the new borrow path. In other words, adding mutate to an existing API could become a breaking change in behavior. This pitch instead keeps mutate tied to mutating, so the new path is always opt-in, and makes the semantics of inout more consistent.

  • A distinct prefix operator for immutable borrow:

A separate prefix operator could make immutable and mutable borrows visually distinct at the call site. $ is not a good candidate because Swift already uses it for projections.

Rust distinguishes &x from &mut x, and that choice makes sense in Rust. Rust uses the same family of syntax both for borrowing at call sites and for reference types as first-class values. In that model, the language needs to spell the distinction directly because references themselves are part of the programmer-visible type system and a major part of Rust's design goal is to give programmers explicit control over those distinctions.

Swift has different goals. This pitch is not trying to expose Rust-style reference values for ordinary value types, and Swift does not let programmers bind "a reference to a value" in a let or var as a normal surface-language construct. The borrow here is a use-site access mode, not a new value that is being named and carried around. Because of that, Swift does not have the same pressure to encode mutability directly in the prefix operator itself.

Requiring different syntax for immutable and mutable borrowing could reduce some mistakes, but it would also make common refactorings noisier. Changing a parameter from borrowing to mutating, or vice versa, would force every caller to change syntax even though the ownership keyword in the callee already tells the type checker which kind of borrow is required. That said, this call-site syntax change pass is useful for guiding programmers to audit the mutability changes. This pitch favors Swift's usual balance of ergonomics, safety, and developer experience: keep the call-site marker lightweight, let the parameter ownership annotation carry the mutability distinction, and rely on the type checker as the final guarantor of safety.

3 Likes

Hi @Arma,

Thank you for the post, definitely an interesting topic! I just scrolled through the list of topics and found SE-0519: 'Borrow and Inout Types for Safe First-Class References' . While it's not exactly what you're proposing, if I understand both topics correctly, there is some overlap. It seems like your topic is already being discussed in this forum.

Cheers, Maximilian

Thank you @MaximilianServais, that’s a very relevant pointer. I do mention SE-0519 in my pitch because the two ideas are very complementary.

My pitch is primarily about & at call sites to select borrowed access, and how that solves the incompatibility of get and borrow accessors on the same declaration. The first-class Borrow<T> and Inout<T> of SE-0519 fit very naturally with the model I’m proposing, because they can provide a concrete type for the borrowed access that & selects, both in accessor bodies and at call sites.

So I see SE-0519 as complementary, not as a prerequisite for this pitch.