Jan 31, 2021
Circular object references can be dangerous. They break code at runtime, and are difficult to guard against at compile time.
Consider the following example:
data class Driver(val name: String, val teamId: String)
data class Team(val id: String, val drivers: List<Driver>)
object Drivers {
val LewisHamilton: Driver = Driver("Sir Lewis Hamilton", Teams.Mercedes.id)
}
object Teams {
val Mercedes: Team = Team("merc", listOf(Drivers.LewisHamilton))
}
fun main() {
println(Drivers.LewisHamilton)
println(Teams.Mercedes)
}
Although innocuous at first glance, this code breaks Kotlin’s non-nullable types.
If you run this example on Kotlin Playground, it produces the following output:
Driver(name=Sir Lewis Hamilton, teamId=merc)
Team(id=merc, drivers=[null])
A null
value snuck into a non-nullable type.
How?
Kotlin object
s are lazily initialized1.
LewisHamilton
gets initialized upon its first-access in main
. Its constructor references another lazy variable Mercedes
, whose constructor has a circular reference to LewisHamilton
.
Therefore, constructor of Mercedes
references a yet-uninitialized LewisHamilton
variable, and leads to a null value being stored in a non-nullable variable.
While this is bug is easy to fix, the problem is that this code fails at runtime, thus silently breaking Kotlin’s non-null types.
Consider starring this issue to get more attention to this problem: KT-44633: Circular object references silently break Kotlin’s non-nullable types
More Circular Reference Madness
Let’s look at a few more examples to illustrate problems with circular references.
data class Foo(var bar: Bar? = null)
data class Bar(var foo: Foo? = null)
fun main() {
val foo = Foo()
val bar = Bar()
foo.bar = bar
bar.foo = foo
println(foo)
}
This code produces a StackoverflowError
. You can try it here.
println
calls toString()
on foo
. Since Foo
is a data class, its toString()
implementation calls the same method on its member properties too. This leads to an ever growing call-stack.
⤷ `Foo.toString()`
⤷ `Bar.toString()`
⤷ `Foo.toString()`
⤷ `Bar.toString()`
⤷ `Foo.toString()`
⤷ ...
A simpler, more condensed version of the same problem can be illustrated as follows:
data class CircularRef(var ref: CircularRef? = null)
fun main() {
val circularRef = CircularRef()
circularRef.ref = circularRef
println(circularRef)
}
This code again produces a StackoverflowError
, as CircularRef.toString()
continues recursing forever. Try it here.
Not just Kotlin
The problem of Circular References plagues other languages too. Here’s an example of the same code in Go.
type Foo struct {
BarRef *Bar
}
func (f *Foo) String() string {
return fmt.Sprintf("Foo { BarRef: %v }", f.BarRef)
}
func (b *Bar) String() string {
return fmt.Sprintf("Bar { FooRef: %v }", b.FooRef)
}
type Bar struct {
FooRef *Foo
}
func main() {
foo := Foo{}
bar := Bar{}
foo.BarRef = &bar
bar.FooRef = &foo
fmt.Println(foo)
fmt.Println(bar)
fmt.Println("Finished")
}
Note circular references in the String()
method on both interfaces. Run this here.
This code never prints Finished. While the execution timeout on play.golang.org prevents this code from running for long, the same code on my local machine prints: fatal error: stack overflow
.
Conclusion
Be on the look out for circular references in your Kotlin code. You will receive no compile time errors or warnings about them.
If you would like to change that, consider starring this issue: KT-44634: Circular object references silently break Kotlin’s non-nullable types.
Thanks to Subhrajyoti Sen for reviewing this post!
Question, comments or feedback? Feel free to reach out to me on Twitter @haroldadmin