Goodnight Wiki / ABI Stability

ABI Stability

ABI stability is one of those problems where the interesting part isn't the solution -- it's the tradeoffs the solution reveals about everything else in your language's design. Swift's ABI is the most ambitious attempt any modern language has made at stable dynamic linking, and the fact that Rust hasn't bothered tells you something important about both languages.

What an ABI Actually Is

An ABI (Application Binary Interface) specifies three things: how to call functions (calling conventions, register usage), how to lay out data in memory (struct layout, alignment, padding), and how to find symbols at link time (name mangling). If all three are stable -- meaning they don't change between versions -- you can dynamically link a library compiled yesterday against an application compiled today.1

Dynamic linking matters because it lets system libraries update without recompiling every application. On iOS, this also means every app shares a single copy of the standard library, saving significant memory on battery-constrained devices. This was the driving motivation for Swift's ABI work: Apple wanted system APIs written in Swift.

Why C Is ABI-Friendly and C++ Isn't

C is "friendly" to ABI stability not because it has a stable ABI (it doesn't -- ABIs are platform-specific), but because its feature set doesn't fight the concept. Struct layouts are predictable. Function calling conventions are simple. There's not much hidden behavior.

C++ templates break this completely. A templated function has no symbol -- it's monomorphically compiled, meaning the implementation is copy-pasted with concrete types substituted. If I give you a header declaring template<typename T> bool process(T value), you can't use it without the implementation source. The whole Standard Template Library is right there in the name: it's not very useful to dynamically link to.1

Rust has the same problem. Generics in Rust are monomorphized, producing specialized code for each concrete type. An ABI-stable Rust would be limited to C-like interfaces, which is exactly what extern "C" functions provide today. The Rust project has embraced this limitation rather than fighting it.

How Swift Threads the Needle

Swift's trick is that it defaults to hiding implementation details behind runtime indirection, then lets you opt in to concrete layouts as a performance optimization. This is the opposite of C/Rust, where layouts are concrete by default and opacity requires deliberate effort.1

The concept is called resilience. When you compile a Swift dylib, the compiler generates code that doesn't assume anything about struct sizes, field offsets, or enum discriminants. Instead, it looks these up at runtime from metadata tables. Adding a field to a struct, reordering enum cases, or changing the internal representation of a type -- all of these are invisible to the application.

This has a real performance cost. Accessing a field in a resilient struct requires loading the field offset from a metadata table, adding it to the struct pointer, then loading the value. In a non-resilient struct (like in Rust), it's a single load at a compile-time-known offset. But the cost is bounded and predictable, and Swift's developers argue that the instruction-cache savings from sharing one copy of the standard library often outweigh the per-access overhead.

The Annotation System

What makes Swift's approach sophisticated is the granularity of opt-in. You can annotate individual types as @frozen, which commits to their layout and unlocks C-like direct access. You can annotate functions as @inlinable, which ships their implementation in the module's compiled interface file, allowing cross-module inlining at the cost of locking in that implementation.

Some annotations can even be added after a library ships without breaking the old ABI. New applications get the performance benefit; old applications keep working against the resilient fallback. This is genuinely novel -- most systems force you to choose upfront between performance and evolvability.

The Swift developers care about code size in a way that the C++ and Rust communities don't. Their argument is that for system-wide battery life, instruction cache utilization matters more than per-function throughput. Monomorphization (Rust's approach) produces faster individual functions but more total code, polluting the instruction cache with multiple copies of similar code. Swift's polymorphic compilation produces less code at the cost of runtime indirection. On a phone where dozens of apps share the same standard library, the cache effects dominate.1

What This Means for Language Design

The Swift/Rust divergence on ABI reveals a fundamental tension in language design. Rust chose static dispatch, monomorphization, and zero-cost abstractions, which produces maximally fast code but makes dynamic linking impractical for idiomatic Rust. Swift chose dynamic dispatch as the default with static dispatch as an optimization, which makes dynamic linking natural but introduces overhead that Rust users would consider unacceptable.

Neither is wrong. They're optimizing for different deployment models. Rust targets scenarios where you control the entire build (embedded systems, CLI tools, server infrastructure). Swift targets Apple's platform, where system libraries are updated independently of applications and shared across dozens of processes.

The deeper lesson is that ABI stability isn't really a property of a language -- it's a property of a deployment model that certain language designs make easier or harder to support. C's simplicity made it the lingua franca of system interfaces almost by accident. Swift achieved the same thing by deliberate, ambitious engineering. And Rust decided, probably wisely, that the engineering cost wasn't worth it for its target users.

Footnotes

  1. How Swift Achieved Dynamic Linking Where Rust Couldn't by Aria Beingessner -- source 2 3 4

Open in stacked reader →