March 25, 2019·6 min read·engineeringswiftabimemorystrings

Swift 5 makes runtime compatibility a platform contract

Swift 5 stabilizes its ABI on Apple platforms while changing String storage and release-build memory checks. Those changes explain what the stable runtime must preserve.

A runtime shipped inside every application can evolve with that application. A runtime supplied by the operating system has to keep working for applications built at different times. Swift 5 makes that second arrangement possible on Apple platforms. The March 25 release declares the ABI stable, giving compiled Swift code a shared contract that subsequent runtime implementations must preserve.

The immediate benefit is smaller application distribution where the operating system supplies the Swift libraries. The deeper engineering consequence is that the project has to decide which implementation choices compiled clients may depend on. Two other changes in this release make that work concrete. Native strings adopt UTF-8 storage, and release builds gain runtime enforcement of exclusive memory access by default. Both concern assumptions that become harder to change once independent binaries rely on them.

I value Swift for making value semantics and absence explicit. A stable runtime matters because those language properties need a durable implementation underneath them. The release is also a useful reminder that a carefully designed source language still has deployment constraints and implementation costs.

Moving the runtime changes deployment responsibilities

Swift 5 ships with Xcode 10.2 for Apple development, and the project also supplies Linux toolchains. The stable ABI declaration applies to Apple operating systems. Availability of a compiler on Linux does not establish the same platform compatibility promise there. That distinction matters for projects that share Swift source between an application and a service.

Joe Groff explains the deployment rules in Evolving Swift on Apple Platforms After ABI Stability. Swift 5 does not require an application to raise its minimum operating-system target. An application supporting older systems can still embed the runtime it needs. On a system that supplies the Swift runtime, those compatibility copies are inert.

The consequence for future language development depends on what a feature requires. A compiler-only change can operate independently of a new runtime. A new standard-library declaration or runtime facility may require an operating-system version that provides it. Choosing a new language mode does not itself require a new operating system, because the mode is a compile-time source-compatibility setting.

For an application author, the relevant distinction is between adopting a compiler and depending on new platform functionality. They may happen together, but they are separate decisions. A build configuration that preserves an older language mode can still use the Swift 5 compiler and its ABI. Conversely, source that parses under a new compiler may refer to functionality absent from an older deployment target.

Binary framework distribution has another requirement. The earlier ABI explanation distinguishes runtime compatibility from the ability of a compiler to read a module interface. Module stability is still separate work. A stable ABI should therefore not be interpreted as an immediate guarantee that every framework built with one toolchain can be imported by every other toolchain.

String makes the timing of stabilization visible

Michael Ilseman explains in UTF-8 String why the storage change needed to happen for Swift 5. The preferred encoding is part of the ABI for performance reasons. Stabilizing that choice while leaving the old representation in place would preserve a constraint the standard-library team wanted to remove.

Swift previously used distinct native storage for all-ASCII strings and UTF-16 content. A non-ASCII character could force an otherwise ASCII-heavy string into the UTF-16 representation. Swift 5 uses UTF-8 for both forms of native content. That reduces the number of native paths string operations and their optimizations need to account for.

The representation remains more complicated than a byte buffer. Small strings can place their contents directly in the value, while other strings use allocated storage. Bridged strings also need to accommodate Objective-C representations. The change concerns the preferred native encoding, not a declaration that every string instance has identical storage.

This matters for the relationship between abstraction and optimization. An operation can remain unchanged at the source level while its implementation stops transcoding between encodings. A system exchanging UTF-8 data can avoid conversions that had no purpose beyond satisfying the former internal representation. The useful improvement is the removal of that work at a boundary the application may cross repeatedly.

String indices still require attention because an index cannot be treated as an arbitrary integer position. The release deprecates String.Index.encodedOffset, which had encouraged assumptions about the underlying encoding. Code using the high-level string operations can retain its intent across the storage change. Code interpreting encoded offsets has made an implementation assumption that the abstraction does not preserve.

Objective-C interoperability also explains why UTF-8 is not a complete escape from UTF-16. APIs expressed in UTF-16 offsets still need appropriate mappings. The implementation can maintain information to accelerate those mappings, while native Swift code benefits from UTF-8. Interoperability requires the library to support both ways of describing text without treating their offsets as interchangeable.

Value mutation needs an enforceable access rule

Swift 5 Exclusivity Enforcement describes the second implementation commitment. A variable undergoing modification cannot also be accessed through another name during that modification. An inout argument can hold a modifying access across a function call. A callback invoked during that call may therefore conflict with the access even though its read appears in a different statement.

For example, a helper that applies an operation twice can safely receive a captured increment when that increment was copied before the modifying access begins.

func modifyTwice(_ value: inout Int,
                 by modifier: (inout Int) -> Void) {
    modifier(&value)
    modifier(&value)
}

var count = 1
let increment = count
modifyTwice(&count) { value in
    value += increment
}
assert(count == 3)

Reading count inside the closure instead of reading increment would ask for another access to the variable already passed as inout. The local constant makes the intended snapshot explicit. It also resolves an ambiguity in the computation, since the increment should either retain its original value or follow the changing variable. Those are different programs.

Swift already performed runtime checks in debug builds. Swift 5 enables them by default in release builds too. Some violations are visible to the compiler, while others involve aliases or callbacks that require runtime information. An optimized build reaching one of those violations can now trap instead of proceeding under invalid assumptions.

The rule supports optimization as well as diagnostics. When a compiler can rely on exclusive modification, it can make stronger assumptions about memory access and reference counting. Merely documenting that programmers should avoid overlap leaves an unsafe gap when the language otherwise promises memory safety. Enforcement makes the assumption usable in compiled code.

This is particularly relevant to reference types. Two variables can refer to the same object, so apparently separate arguments may designate the same stored property. Value types reduce some accidental sharing, but enclosing references still require care. I find the explicit access rule consistent with the language design, because it gives a concrete meaning to mutation that optimizations must respect.

A shared result type improves another boundary

The release also adds Result, specified in SE-0235. Its success and failure cases represent a completed computation as a value. The failure type conforms to Error, which lets the representation work with the existing throwing-error model.

A function returning an optional value beside an optional error admits combinations that its caller has to interpret. A result value directly represents success with its payload or failure with its error. That is useful for completion handlers and for storing an outcome until another part of the program handles it. It does not introduce a scheduler or execute work asynchronously by itself.

The distinction is visible in a small transformation.

enum ReadingError: Error {
    case unavailable
}

let reading: Result<Int, ReadingError> = .success(12)
let label = reading.map { value in String(value) }

Mapping the success changes the payload type while retaining the failure type. The caller does not need to separate a value from its error and reconstruct their relationship afterward. A standard representation also gives independent libraries a common type to exchange, reducing the need for adapters between locally defined result enums.

The release also preserves an upgrade path for existing source. Its announcement states source compatibility with Swift 4, Swift 4.1, and Swift 4.2, and points to the Xcode migrator for changes requiring attention. That is a separate promise from retaining old binaries. Source compatibility concerns rebuilding a program, while the ABI contract concerns code already compiled. A source dependency can be rebuilt during that update, while an operating-system library follows its platform compatibility contract. Keeping those requirements separate explains why an application can update its toolchain without immediately changing every other part of its deployment.