How do access levels become linkage in the object file, and why can an unused internal function disappear in a release build but a public one cannot?
Predict first
Compiled with -O -wmo, which functions get symbols (assembly output)?
import Swift
public func pub() -> Int { 1 }
func internalFn() -> Int { 2 }
private func priv() -> Int { 3 }
@inline(never) func used() -> Int { internalFn() + priv() }
public func entry() -> Int { used() }
func neverCalled() -> Int { 42 }
- pub, used and entry; neverCalled is gone
- only the public functions pub and entry
- all five functions
- only entry
Predict first
Same file, optimised but compiled one file at a time (no -wmo): is neverCalled still emitted, and how is it marked?
import Swift
public func pub() -> Int { 1 }
func internalFn() -> Int { 2 }
private func priv() -> Int { 3 }
@inline(never) func used() -> Int { internalFn() + priv() }
public func entry() -> Int { used() }
func neverCalled() -> Int { 42 }
- Emitted as a hidden (private_extern) symbol
- Removed
- Emitted as a public symbol
- Emitted only for debug builds
Mental model. Public is the shop window, internal is the back room. If you can walk the whole building and see nobody ever uses something in the back room, you can throw it away; you can never know that about the window, because customers come from outside.
The compiler maps access control onto symbol visibility. public and open declarations are exported: they stay in the module’s API, keep a default-visibility symbol, and can be called by any client, so they can never be deleted by the compiler. internal, fileprivate and private declarations are emitted as hidden symbols (.private_extern in the assembly on Apple platforms): they exist in the object file but cannot be called from another image. When the compiler can see every file of the module at once, which is whole-module optimisation, it knows an internal function with no caller can never be called, so it deletes it; in my test neverCalled was gone with -O -wmo and still present, hidden, in the same file compiled one file at a time, because in that mode other files might still call it. public functions such as pub stayed in both.
The linker finishes the job. With dead-code stripping on (the default for release builds in Xcode), a function that nothing reachable references is dropped from the final executable even if the compiler kept it, but anything reachable from an exported symbol, an Objective-C class list, a protocol conformance record or a dynamic lookup stays. Two practical consequences: keep API surface small (a needless public keeps code alive and exposes it), and do not expect a test that “calls an internal function by name” to find a symbol in a release binary. I verified the compiler half with assembly output; the linker half is the documented behaviour and was not run here.
WMO proves neverCalled has no callers and internalFn and priv are inlined; used is @inline(never) so it stays, hidden.
Without whole-module knowledge the compiler keeps internal functions, but hidden from other images.
Common traps
- Making things
public“just in case” in a framework. It pins the code in the binary and in the API forever. - Thinking
privatemeans “not in the binary”. Private and internal functions are present (hidden) unless the optimiser proves them unused.
They might follow up with
- Does
packageaccess change linkage? Package symbols are visible across the modules of one package, so they cannot be removed per module the way internal ones can. - Why does WMO matter for this? Without seeing every file the compiler cannot prove “no callers”.
Related questions
- 5.2.2 What does whole-module optimisation change, and what does it cost?
- 1.7.3 What does package access solve?
- 16.3.1 How do static and dynamic linking differ for an app, what do they cost at launch and in size, and what are…
- 21.7.2 Where does binary size come from, how do you measure and shrink it, and what do optimisation level, stripping…
- 5.8.1 What is name mangling in Swift, how do you read a symbol such as $s4Main3FooV3baryyF, and where do you meet…
- 5.11.1 How do package access and @_spi differ from public and internal, and what do they look like in a…
← What is name mangling in Swift, how do you read a symbol such as… When is a Swift global or static property initialised, what is… →