Lesson 02 / 24
Constants and Variables
Measures `let` and `var` together with type inference, explicit type annotation and the moment of assignment: is a constant evaluated immediately, and is an assignment evaluated every time?
Published Updated
In this lesson
- Explain the difference between `let` and `var` through assignment behavior
- Compare type inference with explicit type annotation
- Interpret the evaluation moment of deferred initialization and of reassignment
- Read from sorted output that a global constant in another file is set up on first access
Contents
In the previous lesson you saw that a let constant is evaluated immediately, on the line where it is defined. This lesson pushes that observation from three directions: does writing a type on a constant change anything, what does var do on each assignment, and does the moment shift if the value is assigned after the definition? You know variables and types from the Programming Fundamentals course (M01/K02); here we measure only what is specific to Swift.
Constants, Variables and Types
In Swift, let defines a constant that is bound once, and var defines a variable that can change later. You usually do not write the type: the compiler infers it from the right-hand side (type inference). If you like, you write the type after a colon (explicit type annotation). A second assignment to a constant is rejected by the compiler: the snippet below does not compile in Swift 6.4, which is why it has no output.
let rate = 4 rate = 5
The same assignment is allowed for var. The difference is not only in the syntax: a var line evaluates its right-hand side again on every assignment.
Three Forms of Assignment
First the Probe.swift helper file; it stays the same 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)")
}
}
Now three Probe objects: one tracks a constant whose type is inferred, one a constant whose definition and assignment are separated, and one a variable that is assigned three times.
// main.swift — assignment moments of a constant and a variable
let inferredProbe = Probe("inferred-constant")
let deferredProbe = Probe("deferred-constant")
let counterProbe = Probe("variable-assignment")
let rate = inferredProbe.eval(4)
let explicitRate: Double = 4
_ = inferredProbe.use { rate }
_ = inferredProbe.use { rate }
print("\(type(of: rate)) \(type(of: explicitRate))")
let isWeekend = false
let level: Int
if isWeekend {
level = deferredProbe.eval(1)
} else {
level = deferredProbe.eval(2)
}
_ = deferredProbe.use { level }
_ = deferredProbe.use { level }
var total = counterProbe.eval(0)
total = counterProbe.eval(total + 5)
total = counterProbe.eval(total + 7)
_ = counterProbe.use { total }
_ = counterProbe.use { total }
let allProbes = [inferredProbe, deferredProbe, counterProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
Int Double inferred-constant: 1 evaluations, 2 uses -> immediately deferred-constant: 1 evaluations, 2 uses -> immediately variable-assignment: 3 evaluations, 2 uses -> on-every-use immediately: 2 on-first-use: 0 on-every-use: 1 never: 0
The first line shows the types: the 4 written without a type is an Int, and the same number declared as Double is a Double. Type inference did not change the evaluation moment; the inferred constant was evaluated on the line where it is defined, before the first use, so it is immediately.
level is the interesting case: an if sits between the definition (let level: Int) and the value, but the assignment still happened before any use. Both branches call eval, but only one of them runs; the count is one, and the moment is again immediately. The compiler rejects a read before the assignment (the first snippet of the previous lesson), so no read can sit between the definition and the assignment; but if the assignment is made inside a wrapper around a use, the measurement gives on-first-use (see the Deferred Initial Value section below).
var is different. total was assigned three times, and each assignment evaluated its right-hand side again; the rule reads this as on-every-use. A variable is not a constant bound once; every assignment is a new evaluation.
LB8. The assignments to counterProbe happen before the uses, but because there are three evaluations the rule says on-every-use; when the count is more than one, “before or after” is not examined.
LB9. The type(of:) output prints only the names Int and Double; a type name points at the result of inference, not at the measurement axis.
Type Inference and Explicit Type Annotation
When you define a constant or a variable, you do not have to write the type. On the line let attempts = 3, the compiler infers Int from the literal on the right; this is called type inference. If you like, you write the type explicitly after a colon: let ratio: Double = 3. This is called explicit type annotation. The only difference between the two is who decides the type: with inference the type comes from the value, with an explicit annotation the form of the value comes from the type.
The difference shows most when you write a literal number. The literal 3 becomes an Int under inference, but if you write Double on the left, the same literal is read as 3.0. The block below binds the same literal 3 in two ways, passes each through a Probe and reads each twice.
// Literal.swift — the same literal, two types
let inferredProbe = Probe("inferred-int")
let declaredProbe = Probe("explicit-double")
let attempts = inferredProbe.eval(3)
let ratio: Double = declaredProbe.eval(3)
_ = inferredProbe.use { attempts }
_ = inferredProbe.use { attempts }
_ = declaredProbe.use { ratio }
_ = declaredProbe.use { ratio }
print(attempts, ratio)
print(type(of: attempts), type(of: ratio))
inferredProbe.report()
declaredProbe.report()
3 3.0 Int Double inferred-int: 1 evaluations, 2 uses -> immediately explicit-double: 1 evaluations, 2 uses -> immediately
attempts was printed as 3 and ratio as 3.0; the type(of:) line confirms that the types are Int and Double. The literal was written the same way, but the type coming from the context turned it into two different values. The two Probe objects gave the same moment: both are immediately. The type determines only what the value is, not when it is evaluated. The compiler finds the type without running the code; all that remains at run time is a single eval call.
So an explicit type annotation does two jobs. The first is to ask for a type different from the one inference would choose. The second is to show the reader the intent behind the value. On the measurement axis, the two are equal.
LB10. Inference and annotation were measured with the same literal 3; how early the type is found in a more complex expression lies outside this lesson.
Tuple Decomposition and Several Definitions on One Line
Swift lets you define more than one name on a single constant or variable line. You can write var left = 0, right = 0 separated by a comma; each name is bound to its own expression separately. Another way is to take a tuple apart: let (low, high) = (1, 9) defines two constants and distributes the elements of the tuple to them in order. This syntax is known as tuple decomposition.
The question that comes to mind is this: when two names are defined on the same line, are their expressions evaluated at the same moment, or does one come before the other? The block below defines two names from a tuple with two separate Probe objects, then defines two var names on a single line and assigns them in turn.
// Decompose.swift — tuple decomposition and several definitions on one line
let lowProbe = Probe("lower-bound")
let highProbe = Probe("upper-bound")
let (low, high) = (lowProbe.eval(1), highProbe.eval(9))
_ = lowProbe.use { low }
_ = lowProbe.use { low + 1 }
_ = highProbe.use { high }
_ = highProbe.use { high - 1 }
var left = 0, right = 0
left = lowProbe.eval(low)
right = highProbe.eval(high)
_ = lowProbe.use { left }
_ = highProbe.use { right }
print(low, high, left, right)
lowProbe.report()
highProbe.report()
1 9 1 9 lower-bound: 2 evaluations, 3 uses -> on-every-use upper-bound: 2 evaluations, 3 uses -> on-every-use
Both elements of the tuple were evaluated on the definition line, before any read. But that was not the only evaluation: lowProbe and highProbe each showed one evaluation, and then the assignments to left and right added a second one each. That is why both measurement lines gave two evaluations, three uses and on-every-use. A definition on one line or a tuple decomposition does not merge the expressions into a single step; each element is evaluated as many times as its own expression.
The principle here is this: how many times an expression is evaluated is decided not by how many lines you write it on, but by how many evaluation points like eval there are. Tuple decomposition shortens the writing; it does not change the order at run time.
LB11. Tuple decomposition was measured with two elements and constant literals; the order in which nested tuples are taken apart lies outside this lesson.
Shadowing
In an inner scope you can redefine the same name as in an outer scope. This is called shadowing: the name in the inner scope hides the one in the outer scope until the scope ends. The outer variable or constant does not change in any way; only the reads in the inner scope go to the new name. This behavior is the safe way to transform a value temporarily and keep using the same name.
Shadowing is not a new assignment. When amount is defined a second time with let, the outer amount stays as it is; the new definition is a second evaluation, and it lives only in its own scope. The block below shadows the outer constant amount inside a do scope, prints the inner value, leaves the scope and prints the outer value again.
// Shadow.swift — shadowing does not change the outer value
let outerProbe = Probe("outer-scope")
let innerProbe = Probe("inner-scope")
let amount = outerProbe.eval(1)
do {
let amount = innerProbe.eval(amount + 10)
_ = innerProbe.use { amount }
_ = innerProbe.use { amount * 2 }
print(amount)
}
_ = outerProbe.use { amount }
_ = outerProbe.use { amount + 5 }
print(amount)
outerProbe.report()
innerProbe.report()
11 1 outer-scope: 1 evaluations, 2 uses -> immediately inner-scope: 1 evaluations, 2 uses -> immediately
Inside the inner scope amount printed 11: the amount on the right side of the inner definition was still the outer constant, because the inner name had not been bound yet. After leaving the scope, amount was 1 again; shadowing never changed the outer constant. The two Probe objects each showed one evaluation and two uses: both are immediately. Shadowing adds a new evaluation, but because it is not a reassignment, it does not raise the count on the outer side.
In practice, shadowing is a tidy way to transform a value without multiplying variable names. In return, the reader has to know which amount they are looking at; keeping the scope short reduces that burden.
LB12. Shadowing was measured with a do scope; the behavior of shadowing a function parameter or a loop variable lies outside this lesson.
Deferred Initial Value
You do not have to give a let constant its value on the line where it is defined. You can write the type and assign the value later, as long as the compiler can see that the constant receives a value exactly once on every path. This rule keeps the “unchangeable” meaning of the let declaration: a constant cannot be read until it receives its first value, and it cannot be reassigned after that. In a branching flow, each branch fills the constant once, and the constant is ready at the end of the branch.
This has an effect that can be measured: only the expression of the chosen branch runs. The block below writes a function. In the function, the constant label is declared first, then filled with a value from the Probe in one branch and with a fixed string in the other branch through an if. The function is called first with true, then with false.
// Deferred.swift — deferred initial value
func pick(open: Bool, probe: Probe) -> String {
let label: String
if open {
label = probe.eval("open")
} else {
label = "closed"
}
return label
}
let pickProbe = Probe("deferred-constant")
print(pickProbe.use { pick(open: true, probe: pickProbe) })
print(pickProbe.use { pick(open: false, probe: pickProbe) })
pickProbe.report()
open closed deferred-constant: 1 evaluations, 2 uses -> on-first-use
The true call gave open and the false call gave closed. The compiler accepted this because a new label constant is defined on each call and each one is filled once. The Probe showed one evaluation for two uses: probe.eval ran only in the true branch, and the fixed string of the false branch never touched the Probe. An expression that is evaluated on first use and does not run again is the mark of the on-first-use moment; here the second call never reached that expression.
This behavior shows that deferred initial value is a rule: every path fills the constant exactly once, and a path that does not fill it computes nothing. The value of the constant comes from the expression of whichever branch the flow enters. If you forget the assignment in a branch, the compiler does not let you use the constant after that point.
The rule is also useful when you prepare the return value of a function: the constant is declared, the branches fill it, and a single return is left at the end of the function. Using var and changing it later would also work, but let tells the compiler and the reader that the value is assigned once.
LB13. Deferred initial value was measured with two branches and inside a function; the rules of the same structure in top-level code or in properties lie outside this lesson.
The Bounding Measurement
A constant being evaluated immediately does not hold everywhere. A global constant defined in another file is set up on first access. The helper file below puts a print in the initialization closure; the closure is inside the let definition.
// Config.swift — a global constant in another file
let appName: String = {
print("setting up appName")
return "academia"
}()
main.swift defines a local constant with the same pattern and shows, in line order, when the two constants are set up.
// main.swift — setup moment of a global and a local constant
print("start")
let localName: String = {
print("setting up localName")
return "academia"
}()
print("second line")
print("read: \(appName)")
print("local: \(localName)")
start setting up localName second line setting up appName read: academia local: academia
localName was set up in the top level of main.swift, on the line where it is written (immediately); appName was set up after the second line, inside the print that read it first (on-first-use). The same way of writing has two different moments.
LB14. Probe cannot enter this observation: Probe is not Sendable and cannot be held as a global constant under the Swift 6 rules; so the moment here is read from the order of the print lines, not from a count.
LB15. This measurement covers only one global constant and only one helper file; we do not measure other cases of the ordering rule (for example, several global constants whose setup depends on each other).
Summary
letis bound once, and a second assignment is rejected by the compiler;varcan be reassigned.- Type inference and explicit type annotation do not change the evaluation moment.
- Deferred initialization can be read only after the assignment; it gives
immediatelyif the assignment comes before the use, andon-first-useif it is inside a wrapper around a use. - Every
varassignment evaluates its right-hand side again; the rule reads this ason-every-use. - This lesson’s moment is immediately (a constant evaluated on the line where it is defined or assigned).
Next Step
We measured the assignment moments of constants and variables. The next lesson looks at the types of the values placed in them: when is a conversion between Int, Double, Bool and String made?
To keep your progress and take notes, Log in
My notes
Log in to take notes.