How big is an any Protocol value, and when does storing one allocate on the heap?

3.10.2 · L5 · Interview

Predict first

How wide is the container, and which struct gets boxed?

import Foundation
protocol Shape { var area: Double { get } }
struct Small: Shape { var a = 1.0, b = 2.0, c = 3.0; var area: Double { a } }          // 24 bytes
struct Big: Shape { var a = 1.0, b = 2.0, c = 3.0, d = 4.0; var area: Double { a } }   // 32 bytes
func blocks() -> Int { var s = malloc_statistics_t(); malloc_zone_statistics(nil, &s); return Int(s.blocks_in_use) }
@inline(never) func extraBlocks<T: Shape>(_ make: () -> T) -> Bool {
    var arr: [any Shape] = []
    arr.reserveCapacity(1000)
    let before = blocks()
    for _ in 0..<1000 { arr.append(make()) }
    return blocks() - before > 500          // about one heap box per element?
}
print("any Shape:", MemoryLayout<any Shape>.size, " any Error:", MemoryLayout<any Error>.size,
      " class-bound:", MemoryLayout<any Shape & AnyObject>.size)
print("Small", MemoryLayout<Small>.size, "Big", MemoryLayout<Big>.size)
print("1000 Small boxed on the heap:", extraBlocks { Small() })
print("1000 Big boxed on the heap:", extraBlocks { Big() })
  • any Shape: 40, any Error: 8, class-bound: 16; Small 24, Big 32; Small boxed false; Big boxed true
  • every size is 8 and both are boxed
  • any Shape: 16; both boxed false

Mental model. A lunchbox with three slots: a small sandwich goes straight in, but a large cake gets a separate bag, and the box only holds the bag’s handle.

An any P for a non-class protocol is a 40-byte container on 64-bit: a three-word inline buffer for the value, a pointer to the type’s metadata, and a pointer to the protocol witness table. A class-bound existential is 16 bytes (reference plus witness table), and any Error is a single 8-byte box pointer. If the value fits in the three words, it is stored inline, with no allocation; a bigger value is copied into a heap box and the buffer holds the pointer. The check measures it with the malloc zone’s block count: appending 1000 24-byte structs adds no heap blocks, 1000 32-byte structs add about a thousand (one box each, so the test reports true). Q8 describes the container conceptually; the new point is that one extra field can change the cost from free to an allocation per value.

Consequences: arrays of [any P] are 40 bytes per element plus any boxes, and calls through a witness table cannot inline. If profiling shows existential cost, use generics or some P (monomorphised, no container), store an enum of the concrete cases, or make the large payload a class so only a reference is stored (but then copies retain). The three-word limit and the layout are implementation details measured here with Swift 6.4 on arm64, not a documented guarantee; the check only asserts the order of magnitude (about one block per element or almost none). Do not rewrite a whole codebase for this: measure with Allocations first.

Three inline words are enough for 24 bytes, but 32 bytes spill to a heap box per value.

Common traps

  • Treating any P as free because it looks like a reference. It is five words wide and may box.
  • Assuming struct size is irrelevant. Crossing the inline buffer size turns each existential into an allocation.

They might follow up with

  • Is some P boxed? No, it is the concrete type with an opaque name.
  • Does any Error allocate? Errors are always boxed, so throwing allocates a box.

Related questions

  • 3.4.3 What actually allocates on the heap in Swift, and how do you see it?
  • 3.4.1 What does a class instance look like in memory?
  • 3.4.2 What do MemoryLayout’s size, stride and alignment mean, and why does field order matter?
  • 1.4.6 What is an existential container? What is a witness table?

← Why is Optional nine bytes but Optional eight, and how do enums…   What is ManagedBuffer, and how does tail allocation put a header and… →