What does copying a struct that holds class references cost, and how do you see it?
Predict first
What are the retain-count changes?
import Foundation
final class R { var n = 0 }
struct Holder { var a = R(), b = R(), c = R(); var tag = 1 }
@inline(never) func rc(_ o: AnyObject) -> Int { Int(CFGetRetainCount(o)) }
var h = Holder()
let base = [rc(h.a), rc(h.b), rc(h.c)]
func delta(_ label: String) { print(label, [rc(h.a) - base[0], rc(h.b) - base[1], rc(h.c) - base[2]]) }
delta("one owner:")
var copy = h
delta("after var copy = h:")
copy.tag = 2
delta("after changing the Int field of the copy:")
copy.a = R()
delta("after the copy gets a new a:")
var arr = [h, h, h]
delta("after [h, h, h]:")
arr.removeAll()
delta("after removeAll:")
- [0, 0, 0] / [1, 1, 1] / [1, 1, 1] / [0, 1, 1] / [3, 4, 4] / [0, 1, 1]
- The Int change adds a retain; all deltas stay 0 otherwise
- [0, 0, 0] every time because structs are values
Mental model. Photocopying a folder of 3 shared documents does not copy the documents, but each original gets one more person with a “keep this” note.
A struct copy is a bitwise copy plus one retain per stored reference (including those nested inside other fields and the buffer of an array or string). In the check a struct with three object fields and an Int goes from counts [0, 0, 0] to [1, 1, 1] when copied; changing the Int in the copy changes nothing; replacing one reference moves only that object back to its own count [0, 1, 1]; and three more copies in an array add three retains to each remaining reference ([3, 4, 4]), which removeAll undoes. So the cost of passing or storing a struct scales with the number of references inside it, which is why a struct with a dozen strings and arrays is not as cheap as its field count suggests, and why the optimiser outlines the copy as a function call.
Practical consequences: big structs of many references are expensive to copy in hot loops (prefer borrowing parameters, Q155), the struct is not “deeply” independent (mutating a shared final class field changes every copy), and moving to a class wrapper turns N retains into one. Do not guess: measure with Instruments (retain/release counts) or check with CFGetRetainCount deltas as above, comparing relative counts rather than absolute ones because the call itself and the runtime add some. The counts are an observation, not an API, and optimised builds may elide some of them.
Each copy retains each reference field once; only reference fields matter.
Common traps
- Thinking a struct copy is a plain memcpy. It retains every reference it contains.
- Using absolute
CFGetRetainCountvalues. Only deltas between two points in the same program mean something.
They might follow up with
- How do I avoid the retains? Pass
borrowing, store a class or a single COW buffer instead of many fields, or use indices. - Does an array of structs retain each element’s references? Copying the array shares one buffer; the elements’ references are retained only when elements are copied individually.
Related questions
- 3.6.1 Where do hidden retains and releases come from, and how do you reduce them?
- 3.6.2 What do guaranteed and owned parameters mean, and what do borrowing and consuming add?
- 3.1.2 What is a reference count, and how do you check that you hold the only reference?
← A closure captures [weak self] and promotes it with guard let. Can a… Can a reference be weak let, or unowned and optional, and what does… →