I’m trying to build an intuition for the parameter ownership modifiers—specifically borrowing—as described in SE-0377. My mental model (possibly wrong!) is that a borrowing parameter gives the callee temporary, read-only access to the value without taking ownership, which (for copyable types) should* mean the callee avoids making a copy of the parameter if it is a struct.
The small example below (a Foo struct wrapping a _Storage class with checks via isKnownUniquelyReferenced) surprised me. I pass an instance foo into the method fast(foo: borrowing Foo), then—before fast returns—on another queue I check isKnownUniquelyReferenced(&foo.storage) and observe “copy detected”.
Since a second strong reference to my storage class instance appeared, this suggests a struct copy, contrary to my expectation that borrowing would behave like “pass by reference”.
final class _Storage {
var x: Int
init(_ x: Int) { self.x = x }
}
struct Foo {
private var storage: _Storage
init(_ x: Int) { storage = _Storage(x) }
var x: Int { storage.x }
mutating func ensureUnique() {
if isKnownUniquelyReferenced(&storage) {
print("No copy for now")
} else {
print("Copy detected")
}
}
mutating func setX(_ new: Int) {
ensureUnique()
}
}
func fast(foo: borrowing Foo) {
Thread.sleep(forTimeInterval: 2.0)
print(foo.x)
}
My calling code:
var foo = Foo(1) // 1. Create Foo
foo.ensureUnique() // 2. Verify there is only one Foo --> "No copy for now"
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
foo.ensureUnique() // 4. Check while fast() is running --> A copy is detected!
}
fast(foo: foo) // 3. Start fast() method -- foo is passed -- is it by pass by ref or pass by value?
A potential confounder I’m considering and would love help ruling in/out:
Caller-side retain/copy during a borrow. Even with borrowing, the call site may legally create a temporary/retain so the argument stays alive while borrowed; that would make isKnownUniquelyReferenced report false during the overlap. (Joe Groff notes in this reply here that caller copies/retains are an orthogonal decision, even for borrowed params!) How can I structure a demo that proves no extra owner is introduced at the call boundary?
If my expectation is off:
What’s the precise, checkable statement I should be testing to understand the behavior of borrowing?
For example, is the right mental model: “borrowing guarantees the callee won’t implicitly copy, but the caller may still retain/copy to satisfy exclusivity and lifetime rules”?
– If so: Any minimal example (or SIL/IR inspection tips for a N00b) that demonstrates the intended semantics—ideally one that avoids these confounders—would be greatly appreciated.
How can I get the performance benefit similar to C++’s pass by const reference to avoid copying large structs? Would I have to implement Copy-On-Write for my custom structs using something like CowBox?
That asyncAfter could have made a copy was my initial hunch (and also what chatgpt was gaslighting me about), but it was fairly easy to rule out with this small change.
var foo = Foo(1) // 1. Create Foo
foo.ensureUnique() // 2. Verify there is only one Foo --> This prints "No copy for now"
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
foo.ensureUnique() // 4. Check for copy
}
Thread.sleep(forTimeInterval: 2.0) // 3. Sleep
This yields:
No copy for now
No copy for now
The difference indicates it the copying comes directly from the fast method call.
That example isn’t quite the same, because you’re not keeping a reference to foo alive in the outer scope. If you add print(foo) to the bottom, you should see a copy occurring once again.
I think that is correct. The problem I think you're encountering is the subtle difference between noncopyable values and copyable ones. If I'm not mistaken, the default modifier for passing a copyable value is borrowing, so it doesn't need to be explicitly declared. Declaring it probably does nothing or gives a hint (not enforced) to the compiler that it should only be borrowed, but it doesn't make the copyable value noncopyable.
You need to explicitly make your struct conform to ~Copyable to fully utilize the ownership modifiers (and possibly conform your types to Sendable).
var foo = Foo(1) // 1. Create Foo
foo.ensureUnique() // 2. Verify there is only one Foo --> This prints "No copy for now"
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
foo.ensureUnique() // 4. Check for Copy
}
Thread.sleep(forTimeInterval: 2.0) // 3. Sleep
print(foo.x) // 5. Print foo
Swift will conservatively copy copyable values in some situations even when passing to a borrowing parameter. Otherwise, it would be source-breaking for callers if a function adopted borrowing.
We do want to make Swift better about avoiding these conservative copies when they can easily be proven semantically unnecessary. Your example code, of course, has a data race in it, and while the conservative copy doesn't actually fix this — the data race is innate — it does make observable misbehavior less likely in practice.
John, thank you for taking the time to elaborate. My example code, of course, intentionally data-raced in order for me observe/verify the underlying behavior.
Could you suggest a better way of observing some behaviors of borrowing in code – especially whether a copy is made (without having to look at perf/SIL/AST)?
^ I’m not sure if I follow. If I change the signature of a method in my library, I think users of my library will agree that it is fair game for me to break their code.
No, we don't have great tools for observing this right now.
Understood, but we didn't want to consider adding borrowing to be a signature change in that way, because that would prevent it from being used to get the explicit-copies semantics in the callee.
The most reliable mechanism in the language as it stands would be to just make the type non-copyable and provide a copy() method. I understand that this isn't necessarily a great solution; the team is looking at providing better support for this in the language.
The fact that it's used multiple times does not by itself require it to be consumed, no. Only if one of the uses (necessarily the last) is consuming does it need to be consumed.
struct X: ~Copyable {
var value: Int
func copy() -> X {
return X(value: value)
}
}