Primaries Matter (a discussion of constructors)
2019-08-12 • Márton Braun
Primary constructors play a fundamental role in Kotlin classes. Let’s take a close look at them, and really understand what exactly is part of a primary constructor, and what makes this constructor so special.
Back to Java for a moment
In Java, class creation isn’t exactly strict. The language lets you leave variables uninitialized without any complaints. Take this class for example:
public class Car {
String model;
int year;
double miles;
}
It has three fields, and an implicit constructor with no parameters. When you call its constructor with new Car()
, all of these fields will be initialized to implicit default values: null
, 0
, and 0.0
, respectively. In general, primitive types are initialized to some
resemblance of 0
, while reference types are initialized to null
.
Note that the latter will happen even when annotations such as
@NotNull
are present on a field, as these are just markers, and are not enforced by the compiler.
Kotlin’s safety guarantees
In contrast, Kotlin is very strict about creating instances. A class like this does not compile in Kotlin, because initializing each property when an instance is created is mandatory. Even nullable variables won’t initialize to null
by default - you have to be explicit.
class Car {
val model: String
val year: Int
var miles: Double
}
e: Property must be initialized or be abstract
This forces you to explicitly initialize every value in one way or another, and guarantees that your properties won’t have implicit values stored in them. Whenever you read a property, you’ll get a value out of it that you put there. In the case of non-nullable properties, this also guarantees that you won’t ever read null
unexpectedly, as you can’t assign a null
value to a non-nullable property.
The primary constructor
There are two ways to initialize these properties. You can initialize them inline at their declarations, or in one or more initializer (init
) blocks.
class Car(model: String, year: Int) {
val model: String = model
val year: Int
var miles: Double = 0.0
init {
this.year = year
}
}
These two kinds of initializations are performed from top to bottom, in order. In the example, model
and miles
would be initialized first, and then finally year
would get its value. Any parameters that the primary constructor takes may be used for these initializations.
The IDE warnings at this point will suggest for us to join the declaration and assignment of year
, and to move both model
and year
into the primary constructor. Properties in the primary constructor will be initialized before anything in the body of the class, and again, they’ll be initialized in order.
class Car(val model: String, val year: Int) {
var miles: Double = 0.0
}
Previously initialized variables will also be in scope during initialization if you want to rely on their values:
class Car(val model: String, val year: Int) {
var miles: Double = 0.0
val age: Int
init {
age = getCurrentYear() - year
}
val description: String = "$model ($age years, $miles miles)"
}
We can only initialize description
this way after age
has been initialized. If we
placed it before the init
block, we’d again see an error:
e: Variable ‘age’ must be initialized
(This example class has a bug in it - see if you can spot it!)
If we decompile the bytecode produced for this class using the decompiler of the Kotlin IDEA plugin, we’ll see this corresponding Java source (comments added):
public final class Car {
private double miles;
private final int age;
@NotNull
private final String description;
@NotNull
private final String model;
private final int year;
// Getters & setters ...
public Car(@NotNull String model, int year) {
// Runtime null checks for params with reference types
Intrinsics.checkParameterIsNotNull(model, "model");
// Properties in the primary constructor
this.model = model;
this.year = year;
// Initialization at the declaration
// (This is actually optimized away if we init to 0)
this.miles = 0.0D;
// init block
this.age = Utils.getCurrentYear() - this.year;
// Initialization at the declaration
this.description = this.model + " (" + this.age + " years, " + this.miles + " miles)";
}
}
This shows us how all the different kinds of initializations end up in a constructor body together.
To review, the initialization order:
- Properties in the primary constructor, in declaration order.
- Initializations at property declarations and in initializer blocks, interleaved, in the order that they appear in the class body.
Essentially, you can read the initialization statements in the class top to bottom, and that’s what you’ll get in the “body” of the primary constructor.
In each of these initializations, you can use the values of:
- Constructor parameters, whether or not they’re stored in properties.
- Previously initialized (not just declared!) properties.
Due to these restrictions and safety guarantees, classes created via the primary constructor will always be in a valid state.
Secondary constructors
Of course, there are cases when you want to create class instances with different sets of parameters, which normally requires multiple constructors. Kotlin’s default parameter values make this possible to some extent while still keeping just a primary constructor. However, if you need a constructor that has entirely new parameters or parameters with entirely new types, you’ll need a secondary constructor.
For our example, let’s say we need to be able to create cars with a model, year, and mileage, all provided as strings. Our primary constructor can’t accommodate these parameters, so it’s time to write a new one. This could be our first attempt:
constructor(
model: String,
year: String,
mileage: String
) {
this.model = model
this.year = year.toInt()
this.miles = mileage.toDouble()
}
This code would fail at constructing a valid Car
instance, and so it doesn’t compile (though it certainly would in Java). For example, it doesn’t set the age
and description
properties of the instance, which we expect to be initialized by every constructor.
We also get an error for trying to set
year
andmiles
here: Val cannot be reassigned. As aval
can only be initialized once, that one initialization will always have to happen in the primary constructor.
The fix, and the rule for secondary constructors is simple: it has to first call the primary constructor, and only after that can it perform further initialization on the instance that was created. After the primary constructor is called by the secondary constructor, the instance is already in a guaranteed valid state, so it’s safe to operate on it in the body of the secondary constructor.
Let’s fix our constructor to invoke the primary constructor first:
constructor(
model: String,
year: String,
mileage: String
): this(model, year.toInt()) {
miles = mileage.toDouble()
}
The call to the primary constructor doesn’t have to be direct, it can also happen indirectly through calling another secondary constructor, but this chain eventually has to end in a call to a primary constructor.
Here’s an example of yet another new constructor, which calls the previous secondary constructor:
constructor(data: Array<String>) : this(
model = data[1],
year = data[3],
mileage = data[7]
)
What we really have here is a graph of the various constructors in our class calling each other.
- A primary constructor is valid if it initializes all properties.
- A secondary constructor is valid if it eventually calls the primary constructor, i.e. if there’s a directed path to the node representing the primary constructor from the node of the secondary constructor.
- This also means no cycles within secondary constructor nodes, and no disconnected nodes.
- The entire class is valid if all constructors are valid.
Without primaries
One last approach you can take is to not use a primary constructor at all. This is useful when trying to convert the inputs of the various constructors into one canonical form is just too cumbersome to do.
In this case, the strict initialization requirements will apply to the secondary constructors. If you declare a property that’s not initialized at its declaration or in an initializer block, it must be initialized by all secondary constructors.
You may still use those two ways of initializing properties that were used in the primary constructor before, and these will run before each of your secondary constructor’s bodies. This means that any initialization that makes use of constructor parameters will now have to be placed inside the secondary constructors, and potentially needs to be duplicated. This is the downside of not using a primary constructor: losing its superpowers of accessing parameters in the class body.
For example, you can no longer initialize age
this way, since the initialization of year
will only happen after the initializer block has run, in the body of each secondary constructor:
class Car {
val model: String
val year: Int
var miles: Double = 0.0
val age: Int
init {
age = getCurrentYear() - year
}
constructor(
model: String,
year: Int
) {
this.model = model
this.year = year
}
constructor(
model: String,
year: String,
mileage: String
) {
this.model = model
this.year = year.toInt()
this.miles = mileage.toDouble()
}
}
Thankfully, this results in a compile time check, and an error:
e: Variable ‘year’ must be initialized
One solution for this would be to move the age
initialization into each constructor separately:
constructor(
model: String,
year: Int
) {
this.model = model
this.year = year
this.age = getCurrentYear() - year
}
constructor(
model: String,
year: String,
mileage: String
) {
this.model = model
this.year = year.toInt()
this.miles = mileage.toDouble()
this.age = getCurrentYear() - year
}
In certain cases, you can get rid of this kind of duplication by calling one secondary constructor from another. In this case, the first constructor will guarantee that the class is already fully initialized, and the second one has no strict initialization requirements imposed on it.
constructor(
model: String,
year: Int
) {
this.model = model
this.year = year
age = getCurrentYear() - year
}
constructor(
model: String,
year: String,
mileage: String
) : this(model, year.toInt()) {
miles = mileage.toDouble()
}
Visually, we’ve now lost the single node in the graph that we can delegate safe initialization to. Therefore, each secondary constructor has to perform correct initialization on its own merits, or call another, already known to be correct secondary constructor. We now have a forest instead of a tree.
Thanks to /u/mariusmora and Ilya Gorbunov for pointing out that I was missing this section in the original version of the article.
Conclusion
That’s all you need to know about primary and secondary constructors in Kotlin! Hopefully you now have a better feel for how these constructors interact with each other, and how all initialization “flows” towards the primary constructor.
A similar topic is how constructor calls work with inheritance, which gets quite interesting when you have multiple constructors in your subclass. You can read about this - with similar visualizations - in my article about implementing View constructors with @JvmOverloads
.
You might also like...
Building a macOS screen saver in Kotlin
This article documents how I created my own custom screen saver for macOS, built almost entirely in Kotlin, using the powers of Kotlin Multiplatform compiled to macOS. Join me for a tale of Kotlin success and macOS failures.
How I Finally Memorized Modifier Ordering in Compose
For the longest time, I proudly had no idea of how Modifier ordering works, and would just guess and then guess again when something didn't look quite right. Here's how I finally ended up remembering how the ordering works.
Wrap-up 2021
Another year over, a new one's almost begun. Here's a brief summary of what I've done in this one.
The conflation problem of testing StateFlows
StateFlow behaves as a state holder and a Flow of values at the same time. Due to conflation, a collector of a StateFlow might not receive all values that it holds over time. This article covers what that means for your tests.