Lesson 14 / 23
Object Declarations
Measures the object declaration, the companion object, the `data object`, and the object expression with the criterion that makes two values equal, and shows that a declaration is a singleton while an expression builds a new object on every evaluation.
Published Updated
In this lesson
- Writes an object declaration and measures that a value read twice is a single object
- Separates the companion object (`companion object`) from its factory function
- Reads the `toString` output and the equality of a `data object` declaration
- Explains that an object declaration and an object expression behave differently although they are written with the same keyword
Contents
In the previous lesson, “Sealed Types and Enumerations”, we saw that enumeration entries and data object values are singletons: reading the same entry twice returned the same object. This lesson goes down to the source of that singleton behavior. The object keyword shows up in Kotlin in three separate places: in a declaration, inside a class as a companion object, and at the start of an expression. All three are written with the same keyword; this lesson measures whether the three do the same thing.
The measure is the same question in every lesson of this course: what makes these two values equal? There are four possible answers: they are the same object (singleton), equality rests on identity (identity), their contents are equal (content), and only some of their contents are equal (partial content).
An object declaration defines a type and builds the single instance of that type at the same time. The name of the declaration is the name of both the type and that single value. The constructor is not called; writing the name is reading the value itself.
The Measurement Setup
Criterion stays in the first block, in the same form, in every lesson. Each observation takes three values: first, twin (a value built separately with the same recipe), and other (a value that differs in only one respect). The rule sees the result, not the reason: if first === twin, the criterion is singleton; if not, and first == twin is false, it is identity; if first == other is false, it is content; if none of these, it is partial-content.
// 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 }}")
}
}
TN21. In an object declaration twin cannot be built separately, because there is no second instance; in this lesson we gave as twin the value of the same declaration read a second time. As other we used a declaration with the same body but a different name.
A Declaration Is a Single Object
The block below writes two object declarations. IdSource keeps a counter; OtherSource is a separate declaration with the same body. IdSource is read into two separate variables, and the counter is advanced through these two variables and through its name directly.
// Main.kt — an object declaration read twice
object IdSource {
private var next = 1
fun take(): Int = next++
}
object OtherSource {
private var next = 1
fun take(): Int = next++
}
fun main() {
val a = IdSource
val b = IdSource
println(a.take())
println(b.take())
println(IdSource.take())
Criterion("object-declaration", a, b, OtherSource).report()
}
1 2 3 object-declaration: same-object=true, twin=true, other=false -> singleton
The three take() calls advanced the same counter, whichever variable they were made through, because a and b point to the same object. The rule wrote this as singleton: since === came out true, the equality of the twin was not even looked at. The counter of OtherSource is separate, so it is not equal to the first object.
Because a declaration is not a constructor call, putting parentheses after the name does not work either. We verified this first: the fragment below did not compile on Kotlin 2.4.21, which is why it stays without output.
object IdSource
fun main() {
val source = IdSource()
}
TN22. We do not write the error text of the rejection in the lesson; it was only verified that the fragment does not compile. Whether a declaration can inherit from another class or implement an interface was not measured in this lesson.
The Companion Object and the Factory
A companion object is an object declaration written inside a class as companion object. It holds the members that can be called with the class name. Since it is a declaration, it is a singleton. But a factory function written inside it builds instances of the class, and those instances are not singletons. We measure the two side by side.
// Main.kt — the companion object is a singleton, the factory builds a new object every time
class Account private constructor(val owner: String) {
companion object {
fun open(owner: String): Account = Account(owner)
}
}
fun main() {
val checks = listOf(
Criterion("companion-object", Account.Companion, Account.Companion, Any()),
Criterion("factory-result", Account.open("ada"), Account.open("ada"), Account.open("can")),
)
for (check in checks) check.report()
summarize(checks)
}
companion-object: same-object=true, twin=true, other=false -> singleton factory-result: same-object=false, twin=false, other=false -> identity singleton: 1 identity: 1 content: 0 partial-content: 0
The companion-object line came out singleton and the factory-result line came out identity. Two object counterparts written inside the same class gave two separate criteria: the companion object itself is one, and the accounts it produces are separate on every call. Even when the owners of two Account objects are the same, == did not return true, because Account is an ordinary class and does not redefine equals; equality falls to identity. In the language of the rule, this means identity. Making the constructor private (private constructor) narrowed the way of building instances to the function of the companion object; the factory thus became the only entry door.
TN23. That the factory builds a new Account on every call was measured here only for this class. It is also possible for a factory to return an instance built beforehand; in that case the criterion would be singleton, but we did not write such a factory in this lesson.
data object
A data object adds to an object declaration the readable toString and the equals/hashCode of a data class. We do not print the default toString output of a plain object in this course; a data object, on the other hand, writes only its name. This is the reason state objects in sealed hierarchies are written as data object: a readable name shows up in the output.
// Main.kt — a data object carries a readable toString
data object Accepted
fun main() {
println(Accepted)
println(Accepted == Accepted)
val checks = listOf(Criterion("data-object", Accepted, Accepted, "Accepted"))
for (check in checks) check.report()
}
Accepted true data-object: same-object=true, twin=true, other=false -> singleton
The first line of the output contains nothing but the name of the declaration. The criterion is singleton again: a data object does not produce a new instance, it only adds readability to the existing singleton object. The "Accepted" string we gave as other is not equal to Accepted; even though the two are spelled the same, their types are separate.
Creation on First Access and the Interface
When is the single instance of an object declaration built? When the program starts, or at the moment it is first used? The answer to this question shows up in the init block in the body of the declaration: whenever the block runs, the instance has been built at that time. A declaration can also implement an interface, that is, bind its behavior to a contract like an ordinary class. Finally, a declaration can be matched in a when expression like a constant.
In the block below, Polite is an object declaration that implements the Greeter interface and prints a line when it is built. Idle is a data object. The describe function matches a value in three ways: equality with Idle, a type check with is Polite, and everything else. main prints started on its first line; this way, where the init line shows up reveals at which moment the declaration was built.
// Main.kt — the object is built on first access, implements an interface, matches with when
interface Greeter {
fun greet(name: String): String
}
object Polite : Greeter {
init {
println("polite created")
}
override fun greet(name: String): String = "hello " + name
}
data object Idle
fun describe(state: Any): String = when (state) {
Idle -> "empty"
is Polite -> "polite"
else -> "other"
}
fun main() {
println("started")
val greeter: Greeter = Polite
println(greeter.greet("ada"))
println(Polite.greet("can"))
println(describe(Idle))
println(describe(Polite))
println(describe(Any()))
Criterion("interface-object", greeter, Polite, Idle).report()
}
started polite created hello ada hello can empty polite other interface-object: same-object=true, twin=true, other=false -> singleton
The polite created line showed up after started and before the first greet call. Polite was not built at the start of the program: the declaration was built the moment its name was first accessed, and the init block ran at that time. The second access did not print a new line, because the instance already existed. greeter and Polite pointed to the same object, and the criterion came out singleton; even though the type of the greeter variable is Greeter, the object underneath did not change.
describe gave three separate results. empty was written for Idle, polite for Polite, and other for an ordinary Any(). Since an object declaration is a singleton, it matches like a constant inside when: the Idle -> line comes down to the Idle == state comparison. is Polite worked too, because a declaration defines both a value and a type; for a type with a single instance, the two give the same result.
The practical conclusion from this measurement has two sides. First, leaving a side effect in the init block, that is, printing a line or opening a resource, ties the moment the declaration is built to the flow of the program; which line runs first depends on the place of the first access. Second, when an object declaration implements an interface, every variable typed with that interface still carries the same single instance; the criterion therefore does not differ by type, it stays bound to the object itself.
TN24. The first access to an object declaration being made from several threads at the same time was not measured in this lesson; only the order of creation in a single flow was observed.
The Reading Rule from the Measurements
Up to this point we measured lines, and every line answered the same question: do two reads give the same object? Putting the lines side by side, a reading rule comes out. A declaration produces a value when its name is written; Stamp, Polite, Accepted, and Idle came out singleton for this reason. An expression builds a new object every time it is evaluated; makeMarker() came out identity for this reason. The factory of a class is a function, not a declaration; Account.open built a new Account on every call, and the result was again identity. The rule looks at where the keyword is written: if a name goes to a single value, it is a singleton; if a call or an expression builds a new value, it is identity.
A common mistake is to turn this rule around. A reader who sees the object keyword says “this is a singleton”; but the object : Type { ... } form is an expression. The opposite is wrong too: when a companion object is written inside a class, what the companion object produces is taken to be a singleton. The measurement showed that the companion object itself is one, while what its factory produces is separate. For this reason, state bound to a singleton object, for example a counter or a cache, is shared by all parts of the program; state bound to an object produced by a factory belongs only to the owner of that object.
The moment of creation is a second detail. The polite created line showed up on the first access and not on the second. The creation cost or side effect of a singleton object is paid not at the start of the program but at the place where it is first used. This is useful when you need to work out when what you write in an init block will run: the order depends on which code accesses it first, and if that code moves, the line moves too. For this reason it is wise to keep the init block short, to set up only fields, and not to produce output; in this lesson we printed output only to make the measurement visible.
The third detail is the when expression. Matching with Idle and the type check with is Polite do the same job, because the declaration has a single instance. In a type with a single instance, the questions “is this value equal to that value” and “is this value of that type” give the same answer. In a class where two instances are possible, these two questions would part ways; the absence of this parting is an observable consequence of being a singleton.
Predicting Before Measuring
Predicting the criterion before running a line tests whether you really understand the rule. The Criterion setup asks three questions: is === true when the same object is given, is == true when the twin is given, is == true when a different object is given? For an object declaration the first question is always answered true, because the name goes to a single value; so the twin must be the same object too, and the third question comes out false only when a different type is given. The criterion is written as singleton.
For an object expression, even the first question is answered false. Calling the same function twice gives two separate objects, and the twins are not counted as equal to each other; when both are false, the criterion becomes identity. You can put every line in this lesson on one of these two paths: if the name points to a declaration, singleton; if it points to a call or an expression, identity. Even the data object line did not step outside this rule; what the data prefix added was not equality but a readable text.
The place where a prediction fails is the place where you remembered a rule incompletely. For example, if you knew that describe would write empty for Idle but expected other instead of polite for Polite, it means you thought that the is check does not work with the name of a declaration. The measurement corrected this assumption: a declaration is both a value and a type.
The Bounding Measurement
An object expression is the use of the object keyword at the start of an expression: object { ... }, or, after a type name, object : Type { ... }. It carries no name, and it builds a new object every time the expression is evaluated. The difference between a declaration and an expression is exactly here: the same keyword produces a single object in a declaration and a new object every time in an expression. This is why the measure of this course separates them.
// Main.kt — an object expression builds a new object on every evaluation
object Stamp
fun makeMarker(): Any = object {
val side = 4
}
fun main() {
val checks = listOf(
Criterion("declaration", Stamp, Stamp, makeMarker()),
Criterion("expression", makeMarker(), makeMarker(), makeMarker()),
)
for (check in checks) check.report()
summarize(checks)
}
declaration: same-object=true, twin=true, other=false -> singleton expression: same-object=false, twin=false, other=false -> identity singleton: 1 identity: 1 content: 0 partial-content: 0
The declaration line came out singleton and the expression line came out identity. Although the expression has no name and therefore no constructor, every makeMarker() call built a new object, and since these objects do not redefine equals, they did not come out equal to one another. This measurement is a boundary: it is not enough to assume that something is a singleton when you see the object keyword; you need to look at whether the keyword stands in a declaration or in an expression.
TN25. How a variable captured by an object expression appears, and the expression extending a type, were not measured in this lesson; only a nameless, typeless expression was used.
Summary
- An object declaration builds a type and the single instance of that type together; writing the name is reading the value, and two reads give the same object.
- A companion object is an object declaration inside a class; it is a singleton, but its factory can build the instances of the class separately on every call.
- A
data objectadds only a readabletoStringand equality to the singleton object; it does not change the criterion. - An object expression builds a new object on every evaluation; the same
objectkeyword is a singleton in a declaration and identity in an expression. - This lesson’s criterion: singleton. When two reads give the same object,
===comes out true and the reason for the equality is not sought.
Next Step
In this lesson we looked at whether an object is built as one or anew every time, and we kept the behavior only in the body of the class itself. The next lesson separates behavior from a type: interfaces write a contract, and a class can hand it over to another object. Whether handing over also hands over equality will be measured.
To keep your progress and take notes, Log in
My notes
Log in to take notes.