Lesson 15 / 24
Extensions
Measures adding methods, computed properties and protocol conformance to an existing type from the outside; shows that a stored property cannot be added, and the type-level exception.
Published Updated
In this lesson
- Adds methods and computed properties to a standard type with an extension
- Gives a type protocol conformance through an extension and writes a mutating method
- Explains that an extension cannot add a stored instance property, so every property it adds is computed
- Measures that a static let in an extension is set up once, on first access
Contents
In the previous lesson the extension of a protocol gave every conforming type a default body, and that body ran again on every read. The concrete form of the same mechanism is extending a type from the outside. You cannot touch the source of a standard type such as Int or String, but you can write a new method on top of it. This lesson asks when what an extension adds is evaluated.
An extension adds new behavior to an existing type without changing its definition. What can be added: methods, computed properties, subscripts, static members, initializers, nested types and protocol conformance. One thing cannot be added, and the weight of this lesson rests on it: a stored instance property.
The Measurement Setup
Probe sits in the first block, in the same form in every lesson.
// Probe.swift — records when an expression is evaluated
final class Probe {
let label: String
private(set) var evaluations = 0
private(set) var uses = 0
private var evaluatedBeforeUse = false
init(_ label: String) {
self.label = label
}
func eval<T>(_ value: T) -> T {
evaluations += 1
if uses == 0 { evaluatedBeforeUse = true }
return value
}
func use<T>(_ access: () -> T) -> T {
uses += 1
return access()
}
var moment: String {
if evaluations == 0 { return "never" }
if evaluations > 1 { return "on-every-use" }
return evaluatedBeforeUse ? "immediately" : "on-first-use"
}
func report() {
print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
}
}
func tally(_ probes: [Probe]) {
for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
let count = probes.filter { $0.moment == moment }.count
print("\(moment): \(count)")
}
}
TS27. The body of an extension reaches Probe as a parameter or as a field of the type being extended; one of these two ways was chosen in the measurements, and binding a global Probe to an extension was not tried.
Everything Added Is Computed
The block below makes four additions to three types. A method and a mutating method are added to Int, and a method to String. To our own Meter type we add a computed property and a protocol conformance: the CustomStringConvertible conformance gives the type a text representation, because it asks for the description property.
// main.swift — everything added by an extension runs on every use
struct Meter {
let value: Int
let tracker: Probe
}
extension Int {
func cubed(tracking tracker: Probe) -> Int { tracker.eval(self * self * self) }
mutating func triple() { self *= 3 }
}
extension String {
func padded(to width: Int, tracking tracker: Probe) -> String {
tracker.eval(self + String(repeating: ".", count: max(0, width - count)))
}
}
extension Meter {
var centimeters: Int { tracker.eval(value * 100) }
}
extension Meter: CustomStringConvertible {
var description: String { tracker.eval("\(value) m") }
}
let intMethod = Probe("int-method")
let stringMethod = Probe("string-method")
let computedExtra = Probe("added-computed-property")
let conformance = Probe("added-conformance")
_ = intMethod.use { 3.cubed(tracking: intMethod) }
_ = intMethod.use { 4.cubed(tracking: intMethod) }
_ = stringMethod.use { "ab".padded(to: 5, tracking: stringMethod) }
_ = stringMethod.use { "ab".padded(to: 5, tracking: stringMethod) }
let meter = Meter(value: 7, tracker: computedExtra)
_ = computedExtra.use { meter.centimeters }
_ = computedExtra.use { meter.centimeters }
let described = Meter(value: 7, tracker: conformance)
_ = conformance.use { "\(described)" }
_ = conformance.use { "\(described)" }
var counter = 2
counter.triple()
counter.triple()
print(counter)
let all = [intMethod, stringMethod, computedExtra, conformance]
for probe in all { probe.report() }
tally(all)
18 int-method: 2 evaluations, 2 uses -> on-every-use string-method: 2 evaluations, 2 uses -> on-every-use added-computed-property: 2 evaluations, 2 uses -> on-every-use added-conformance: 2 evaluations, 2 uses -> on-every-use immediately: 0 on-first-use: 0 on-every-use: 4 never: 0
All four additions came out as on every use. A method runs on every call; you expect that. What deserves attention is the centimeters property: it is read like a field on the type but it is not stored, it is calculated again on every read. The CustomStringConvertible conformance is the same: description is built again in every string interpolation. A mutating method changes a value in place; counter became 18 after two triplings. Because this change is the update of a value and not the evaluation of an expression, it needed no separate Probe.
A Stored Property Cannot Be Added
If you try to write a stored instance property in an extension, the compiler stops you. We checked this first: the fragment below did not compile on Swift 6.4, so it stays without output.
extension Meter {
var unit = "m"
}
The reason lies in the memory layout. An instance of a type takes up as much space as the stored properties in its definition add up to; this size is fixed when the type is defined. A field added from the outside would have to change this size outside the definition, so the rule of the language closes stored instance properties to extensions. This is why every property you add with an extension is, by definition, a computed property: the value does not sit anywhere, it is produced when asked for. This is the reason all of the previous block came out as on-every-use. TS28. The rejection in this section was verified only for instance-level storage; adding a lazy or an observed property to an extension was not tried separately.
A Constrained Extension
An extension can be added not to every type but only to the types that meet a certain condition. When you write extension Array where Element: Numeric, the added members are visible only on arrays whose elements are numeric. A constrained extension is the form for types of the where condition we saw for protocol extensions in lesson 06. The constraint narrows where the addition applies; it does not change when the added body runs. We measure this by calling the same method on two different array types.
// main.swift — a constrained extension is added only to arrays that meet the condition
extension Array where Element: Numeric {
func total(tracking tracker: Probe) -> Element {
tracker.eval(reduce(0, +))
}
}
let intTotal = Probe("int-array")
let doubleTotal = Probe("double-array")
let counts = [1, 2, 3]
let weights = [0.5, 1.5]
_ = intTotal.use { counts.total(tracking: intTotal) }
_ = intTotal.use { counts.total(tracking: intTotal) }
_ = doubleTotal.use { weights.total(tracking: doubleTotal) }
_ = doubleTotal.use { weights.total(tracking: doubleTotal) }
let all = [intTotal, doubleTotal]
for probe in all { probe.report() }
int-array: 2 evaluations, 2 uses -> on-every-use double-array: 2 evaluations, 2 uses -> on-every-use
The same body ran separately on Int and Double arrays, and in both it ran on every call. The constraint makes sure the total method is not visible on a [String]; that is a decision made at compile time. The only thing we see at run time is that the method of a constrained extension behaves like an ordinary method: the result is stored nowhere. TS29. That the constrained extension is not visible on [String] was not verified separately; only the two types that meet the constraint were measured here.
An Initializer and a Nested Type in an Extension
An extension can add an initializer to a type as well. For structures this has a special benefit: if you write the initializer in the body of the definition, the memberwise initializer the compiler generates disappears; if you write it in an extension, the two stay together. An extension can also add a nested type: an enum, struct or class that is defined inside another type and belongs to it. The block below uses both together. The initializer does a Probe evaluation at setup time; the nested type carries a method that runs as it is called.
// main.swift — an initializer and a nested type in an extension
struct Length {
let meters: Int
}
extension Length {
init(centimeters: Int, tracking tracker: Probe) {
self.init(meters: tracker.eval(centimeters / 100))
}
enum Unit {
case meter, kilometer
func factor(tracking tracker: Probe) -> Int {
switch self {
case .meter: tracker.eval(1)
case .kilometer: tracker.eval(1000)
}
}
}
}
let initProbe = Probe("initializer-in-extension")
let nestedProbe = Probe("nested-type")
let direct = Length(meters: 5)
let converted = Length(centimeters: 700, tracking: initProbe)
_ = initProbe.use { converted.meters }
_ = initProbe.use { converted.meters }
print(direct.meters)
_ = nestedProbe.use { Length.Unit.kilometer.factor(tracking: nestedProbe) }
_ = nestedProbe.use { Length.Unit.kilometer.factor(tracking: nestedProbe) }
let all = [initProbe, nestedProbe]
for probe in all { probe.report() }
5 initializer-in-extension: 1 evaluations, 2 uses -> immediately nested-type: 2 evaluations, 2 uses -> on-every-use
The memberwise initializer Length(meters:) stayed usable despite the initializer in the extension; the two stood side by side. In the initializer in the extension the evaluation was done once, at setup time, and the result was stored in the meters field; reading the field twice produced no new evaluation: immediately. This does not contradict added properties running on every read. An initializer does not add a property, it fills an existing stored field; the storage is in the meters field of the definition. The method of the nested type, on the other hand, ran on every call like an ordinary method: on every use. TS30. The nested type was measured here as an enum; a nested struct or class added from an extension was not tried separately.
A Subscript in an Extension
An extension can add not only methods and computed properties but also a subscript. A subscript is a member that lets you read a value in the form value[index], and its body works like the getter of a computed property. Below we add to Array a read that does not crash on an out-of-range index. A subscript can take extra parameters; we pass our measuring setup through this.
// main.swift — a subscript in an extension runs on every read
extension Array {
subscript(safe index: Int, tracking tracker: Probe) -> Element? {
let found: Element? = indices.contains(index) ? self[index] : nil
return tracker.eval(found)
}
}
let subscriptProbe = Probe("subscript")
let names = ["a", "b", "c"]
_ = subscriptProbe.use { names[safe: 1, tracking: subscriptProbe] }
_ = subscriptProbe.use { names[safe: 9, tracking: subscriptProbe] }
subscriptProbe.report()
subscript: 2 evaluations, 2 uses -> on-every-use
Two reads gave two evaluations; the out-of-range index also ran the body, and only the result was nil. Although a subscript looks like a property, it does no storing: every [...] expression runs the body again, on every use. This shows that the rule of the section “Everything Added Is Computed” extends to the subscript as well. TS31. Only a read-only subscript was measured here; a subscript that writes (set) was not tried.
A Mutating Method in an Extension
In value types (structures and enumerations), a method that changes self is marked mutating. A method in an extension obeys the same rule; an extension can add a mutating method that is not in the definition. What is changed is the stored field in the definition, so the extension uses the existing storage without adding storage.
// main.swift — a mutating method in an extension
struct Counter {
var count = 0
}
extension Counter {
mutating func bump(tracking tracker: Probe) {
count += tracker.eval(1)
}
}
let bumpProbe = Probe("mutating-method")
var counter = Counter()
bumpProbe.use { counter.bump(tracking: bumpProbe) }
bumpProbe.use { counter.bump(tracking: bumpProbe) }
print(counter.count)
bumpProbe.report()
2 mutating-method: 2 evaluations, 2 uses -> on-every-use
The field received two increments in two calls and the value stayed in counter.count; the extension added the method itself, not storage. The body ran again on every call: on every use. Putting the earlier sections together, the table comes out like this: whatever an extension adds, be it a property, a subscript or a method, the added body runs on every use. The initializer stays outside this table, because it is a setup step and not a read. TS32. The mutating method was measured only on a structure; it was not verified separately for a class or an enumeration.
A Static Member in an Extension
Members that belong to the type and are called with the type itself rather than a value are static members (static). An extension can add these too. In the Properties lesson we saw a static let, and it was set up once, on first access. A static method, on the other hand, is an ordinary method; the distinction is the one between a method and a property. The extension below adds to Length a factory called from the type itself, and measures it.
// main.swift — a static method in an extension
struct Length {
let meters: Int
}
extension Length {
static func fromKilometers(_ kilometers: Int, tracking tracker: Probe) -> Length {
Length(meters: tracker.eval(kilometers * 1000))
}
}
let staticProbe = Probe("static-method")
_ = staticProbe.use { Length.fromKilometers(2, tracking: staticProbe) }
_ = staticProbe.use { Length.fromKilometers(3, tracking: staticProbe) }
staticProbe.report()
static-method: 2 evaluations, 2 uses -> on-every-use
The static method ran on every call: on every use. Belonging to a type does not cache its body; in this measurement caching was seen only in a stored static property such as static let. So what you write in an extension depends on whether it is computed or stored, and an extension forbids the stored property anyway. A static stored property does not run into this ban, because the storage is in the type itself and not in the instance. TS33. The result of the static method produced a new Length each time; instance equality or identity was not measured.
The Bounding Measurement
The ban applies only at the instance level. An extension can add a static let to a type, because there is a single copy per type and it does not affect the memory layout. We watch when this property is set up with print. Probe cannot be used here: Probe is not Sendable, and in the Swift 6 language mode a global or type-level let property asks for a Sendable type. The print line inside the setup is the counter that shows how many times, and in what order, the setup ran.
// main.swift — a static let in an extension is set up once, on first access
struct Scale {
let factor: Int
}
extension Scale {
static let unit: Int = {
print("unit built")
return 100
}()
}
print("before access")
print(Scale.unit)
print(Scale(factor: 3).factor * Scale.unit)
print(Scale.unit)
before access unit built 100 300 100
The line unit built appeared once, right before the first access. The added static let gives the moment on first use at the type level: it waits until the first read, is set up once, and is stored afterwards. So the storage that is banned at the instance level in an extension is allowed, and lazy, at the type level. Added instance properties were measured as on-every-use, and the added type property as on-first-use; the two can stand side by side in the same extension. TS34. This observation rests on the order of print; that the setup runs once when the first access comes from two places at the same time was not measured here.
Summary
- An extension adds methods, computed properties, initializers and protocol conformance to an existing type without changing its definition.
- Methods can be added to standard types such as
IntandStringas well; amutatingmethod updates the value in place. - An extension cannot add a stored instance property; every property it adds is computed and runs again on every read.
- A
static letin an extension is the exception: at the type level, it is set up once, on first access. - This lesson’s moment: on every use (a computed property and a method added by an extension).
Next Step
The extension added behavior to a type, but the added behavior was tied to a single type. The cubed you wrote for Int has to be written again for Double. The next lesson moves on to generics, which let you write the same body once for many types.
To keep your progress and take notes, Log in
My notes
Log in to take notes.