Lesson 02 / 23
Variables
Introduces declaration with `val` and `var`, type inference, and the `const val` constant; measures that the same string constant is a single object in two variables.
Published Updated
In this lesson
- States the difference between `val` and `var` and when reassignment is rejected
- Explains what type inference finds and when writing an explicit type is required
- Shows that two variables taking the same constant point to the same object, while a value built at run time falls under the content criterion
Contents
In the previous lesson we saw the four criteria of equality across eight values. In this lesson we look at how a value is bound to a name: variables declared with val and var. The question is this: if I bind the same constant to two separate variables, are two separate objects created, or is a single object given two names?
val, var, and Type Inference
In Kotlin, you declare a variable with either val or var. A val is a read-only variable: once the first value is given, no other value can be assigned to the same name. A var can be reassigned. Neither form forces you to write the type when you give a value; the compiler finds the type from the expression on the right. This inference (type inference) happens at compile time, and the type of the variable stays fixed afterward. If you want to write the type explicitly, you put a colon and the type between the name and the value.
It would be wrong to think that the object a val variable is bound to never changes: val says only that the name can never be bound to another value again. If the object it is bound to can change internally, it can change. This distinction will be useful in later lessons; here we look only at whether the name can be reassigned.
In this lesson too, the measurements are made with the helper file introduced in the first lesson. The Criterion class takes a value, its twin, and a different value, and asks three questions; the file is given below exactly as it was.
// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
val sameObject = first === twin
val equalTwin = first == twin
val equalOther = first == other
val name: String = when {
sameObject -> "singleton"
!equalTwin -> "identity"
!equalOther -> "content"
else -> "partial-content"
}
fun report() {
println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
}
}
fun summarize(checks: List<Criterion>) {
for (name in listOf("singleton", "identity", "content", "partial-content")) {
println("$name: ${checks.count { it.name == name }}")
}
}
The program below shows the four faces of declaration together. city cannot be reassigned, visits is reassigned, ratio writes its type explicitly, and inferred takes its type from inference. The const val at the top of the file is a compile-time constant: a constant whose value is known during compilation, which can be only a primitive type or a string, and which stands at the top level or inside an object declaration.
// Main.kt — val, var, type inference, and const val
const val MAX_VISITS = 3
fun main() {
val city = "Ankara"
var visits = 1
visits = visits + 1
val ratio: Double = 2.0
val inferred = 7
println("$city, visits: $visits, ratio: $ratio")
println("inferred type: ${inferred::class.simpleName}")
println("upper limit: $MAX_VISITS")
}
Ankara, visits: 2, ratio: 2.0 inferred type: Int upper limit: 3
In the output, the line Ankara, visits: 2, ratio: 2.0 shows that the visits variable was reassigned once. The line inferred type: Int confirms that the type inferred from the constant 7 is Int; we asked for it at run time with inferred::class.simpleName. The last line prints the value of the const val constant. The same output was obtained in the sandbox with Kotlin 2.4.21.
LB8. The inferred type was measured only with the constant 7; what inference does in contexts that require Long or Double is the subject of the next lesson.
LB9. const val was measured only at the top of the file; the rules for its form inside an object or a companion object are outside this lesson.
The practical consequence of this output is as follows. A counter such as visits wants var, because its value changes in the flow of the program. A name such as city wants val, because binding it to another value after the first assignment would be a mistake. The decision is made according to the role of the variable; whether the type is written or inferred does not affect it.
Reassignment Is Rejected
Assigning a value a second time to a variable declared with val is rejected by the compiler. We saw this by compiling in the sandbox; the fragment below is therefore without output and carries no main.
val limit = 10 limit = 20
The compiler rejects the second line because a val cannot be reassigned, and the program never runs. This makes reassignment a compile-time problem and not a run-time one. In Kotlin, val is the default choice; var is written only when the value really has to be reassigned. A reader looking at a name then does not have to search for whether that name is bound to another value somewhere.
LB10. The rejection was measured with a single local val; the form of the error message for a top-level val and for a val that is a class property was not verified in this lesson.
The Same Constant, Two Variables
Now the measurement of the lesson. There are four observations. val-literal: two separate val variables are bound to the same "kotlin" constant. var-literal: a var is compared with the same constant. val-built and var-built build the same text at run time, one with joinToString and the other with buildString. In this way we measure whether it makes a difference that the declaration form is val or var.
In every observation, Criterion takes three things: the measured value, a twin built the same way, and a different value. In the constant observations, the twin is a second variable or the constant itself. In the built observations, the twin is produced by calling the same method once more; so both sides are created separately at run time. The different value is each time the string kotlim, with one letter changed.
// Main.kt — the same literal in two variables
fun main() {
val first = "kotlin"
val twin = "kotlin"
var changeable = "kotlin"
val built = listOf("kot", "lin").joinToString("")
var builtVar = buildString {
append("kot")
append("lin")
}
val checks = listOf(
Criterion("val-literal", first, twin, "kotlim"),
Criterion("var-literal", changeable, "kotlin", "kotlim"),
Criterion("val-built", built, listOf("kot", "lin").joinToString(""), "kotlim"),
Criterion("var-built", builtVar, buildString {
append("kot")
append("lin")
}, "kotlim"),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
val-literal: same-object=true, twin=true, other=false -> singleton var-literal: same-object=true, twin=true, other=false -> singleton val-built: same-object=false, twin=true, other=false -> content var-built: same-object=false, twin=true, other=false -> content singleton: 2 identity: 0 content: 2 partial-content: 0
val-literal is singleton: first and twin are the same object, same-object=true. The same string constant is held as a single object during compilation, and every expression that uses it reaches the same object. var-literal is singleton too; using var does not break the constant being the same object, because var affects only whether the name can be reassigned. val-built and var-built are content: joinToString and buildString produce a new string at run time, so same-object=false; with the twin == still gives true, and with the different value false.
The summary lines show two singleton and two content. So binding a value with val or with var does not decide the criterion; what decides it is where the value comes from: a constant, or a value built at run time.
LB11. That a string constant is a single object was measured on the JVM target of Kotlin 2.4.21; this behavior rests on the JVM’s string pool, and it is not claimed to be the same on other targets.
LB12. The built strings were measured with joinToString and buildString; that other ways of building give the same result was not measured in this lesson.
val Does Not Freeze the Bound Object
Above, we said for val that “the name can never be bound to another value again.” The limit of this sentence is that the bound object itself may change. The block below builds a mutable list declared with val and adds an element to it. It then shows a val whose first value is not given on the declaration line but assigned afterward.
// Main.kt — val can change the bound object, the first value can be given later
fun main() {
val names = mutableListOf("ann")
names.add("bob")
println(names)
val assigned: Int
val flag = names.size > 1
if (flag) {
assigned = 10
} else {
assigned = 20
}
println(assigned)
}
[ann, bob] 10
names is a val; even so, the add call turns the list into [ann, bob]. Nothing raises an error, because the name names is still bound to the same list; what changed is the inside of the list. The second variable, assigned, receives no value on its declaration line, its type is written, and it is assigned exactly once in each of the if and else branches. The compiler accepts this pattern and prints 10 on the screen. The rule is this: a val variable can receive a value at most once on every path of its scope.
A common mistake is to read the word val as “the object cannot be changed.” If you want to freeze the contents of a list, a read-only collection type is chosen (listOf); val alone does not provide this. Java comparison: a final variable in Java carries the same limit, an element can be added to a final list, and a final variable whose first value is given afterward is also a known pattern in Java. These two Java behaviors were not measured here; they were mentioned as known behavior.
LB13. In this section the measurement used MutableList and Int; whether the properties change when a val is bound to a data class instance was not measured in this lesson.
Changing a var from Inside a Lambda
Kotlin allows a lambda to read and change a var variable outside itself. The block below updates a total and a call counter from inside forEach; then it copies the current value of the total into a val and changes the original var variable.
// Main.kt — changing a var from inside a lambda
fun main() {
var total = 0
var calls = 0
listOf(1, 2, 3).forEach {
total += it
calls++
}
println("total: $total, calls: $calls")
val snapshot = total
total = 100
println("copy: $snapshot, var: $total")
}
total: 6, calls: 3 copy: 6, var: 100
The first line gives total: 6, calls: 3: the lambda ran three times and both variables outside were updated. The second line shows that the copy stayed separate. The value of snapshot stays 6, while total becomes 100. Because an Int value is copied at the moment it is assigned, a var that changes afterward is not reflected in the copy.
The reason for the mechanism is that the lambda captures the outside variable not as a value but by carrying the variable itself; the lambda and the outside code work on the same var. In Java, for the same job, the local variable that the lambda captures must be effectively final, so a container such as an array or an AtomicInteger is used to keep a counter. In Kotlin there is no need for such a workaround. This Java rule and how Kotlin carries the captured variable behind the scenes were not measured in this lesson; what was measured is only that the outside var values are current after the lambda.
LB14. The captured variable was of type Int and was measured with forEach; the behavior of the same update in a multi-threaded context is outside this lesson.
const val and Concatenation
At the start of the lesson, we said of const val “a constant whose value is known during compilation.” This definition has a consequence: the concatenation of two const val strings is also computed during compilation. The block below compares the concatenation of two const val with a top-level val that builds the same text at run time with joinToString. In both measurements the twin comes from writing the same expression once more.
// Main.kt — const val concatenation and a run-time value
const val PREFIX = "kot"
const val FULL = PREFIX + "lin"
val runtimeFull = listOf("kot", "lin").joinToString("")
fun main() {
val checks = listOf(
Criterion("const-concat", FULL, "kotlin", "kotlim"),
Criterion("runtime-top-level", runtimeFull, "kotlin", "kotlim"),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
const-concat: same-object=true, twin=true, other=false -> singleton runtime-top-level: same-object=false, twin=true, other=false -> content singleton: 1 identity: 0 content: 1 partial-content: 0
const-concat is singleton: the expression PREFIX + "lin" turns into the constant "kotlin" at compile time and is bound to the single object in the string pool. runtime-top-level is content: when the same text is built at run time, it becomes a separate string, same-object=false. The summary line shows one singleton and one content.
The common mistake here is to treat const val and a top-level val variable as interchangeable. Both stand at the top of the file and neither can be reassigned; but one takes its value during compilation and the other while the program runs. The boundary case follows from this: a const val is built only with values the compiler can compute. Java comparison: a static final field in Java carries a similar distinction, since there are static final fields that are compile-time constants and ones that are not. This Java distinction was not measured.
LB15. This comparison was measured with two const val strings and joinToString; the behavior of numeric const val concatenations was not measured in this section.
Habits That Come from the Measurements
In this lesson’s measurements, the difference between val and var appeared as the difference between binding a variable and the contents of an object. A val means a name bound once; if the bound object is a mutable list, an element can be added to the list, and the output [ann, bob] showed this. So saying “immutable” for a val calls for care: what cannot be reassigned is the binding, not the object. To protect the contents, a read-only collection type or val fields inside a data class are used.
When a var was changed from inside a lambda, the counter was visible outside too: total: 6, calls: 3. The measurement showed that the lambda and the outside code look at the same variable; it is known that underneath this lies the variable being wrapped in a box, but this was not measured in this lesson. The copied value, on the other hand, stayed separate: in the line copy: 6, var: 100, the copy was not affected when the var was reassigned. A common mistake is to think that when a value is captured and the variable is changed later, the captured value changes too; the difference between a copy and a reference has to be told apart by looking at where the assignment is made.
A const val is a compile-time constant and can take only a primitive type or a string value; it is defined at the top level, in an object declaration, or in a companion object. In the measurement, the concatenation of two const val appeared as a single object, while a concatenation computed at run time gave a value equal in content but separate. The practical rule is this: if a value can be known at compile time, write const val; if it cannot, write val. In Java, the same distinction is expressed with static final, but whether a field is a compile-time constant depends on its initializer expression and cannot be read from the declaration.
If these three observations had to be reduced to one sentence: ask whether a name changes separately from whether the object the name is bound to changes. Each time you see a val while reading code, keeping these two questions apart catches a large part of mistakes in advance.
The Bounding Measurement
The singleton criterion is limited to the constant. When the same text is built at run time, the criterion turns back to content: val-built and var-built are two examples of this. The same "kotlin" text falls under the singleton criterion when it is written as a constant in one place, and under the content criterion when it is built with joinToString or buildString in another. For this reason, when comparing text in code, == is used instead of ===; the former gives the right answer in both cases.
Summary
valis a variable that cannot be reassigned, andvaris one that can; the type of both can be found by inference.const valis a compile-time constant; its value must be known during compilation.- A second assignment to a
valvariable is rejected by the compiler. - Choosing
valorvardoes not decide the equality criterion; whether the value comes from a constant or from run time does. - This lesson’s criterion: singleton; bounding measurement: the same text built at run time falls under the content criterion.
Next Step
The type of a variable was found by inference. In the next lesson we look at basic types: numbers, the character, and the logical value. The same number 1 is an Int in one context and a Long in another; we will measure why an == that mixes these two types is rejected.
To keep your progress and take notes, Log in
My notes
Log in to take notes.