Breaking Kotlin's Null-Safety with Circular References

Circular object references can be dangerous. They break code at runtime, and are difficult to guard against at compile time.

Consider the following example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:

1
2
Driver(name=Sir Lewis Hamilton, teamId=merc)
Team(id=merc, drivers=[null])

A null value snuck into a non-nullable type.

How?

Kotlin objects 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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.

1
2
3
4
5
6
⤷ `Foo.toString()`
  ⤷ `Bar.toString()`
    ⤷ `Foo.toString()`
      ⤷ `Bar.toString()`
        ⤷ `Foo.toString()`
          ⤷ ...

A simpler, more condensed version of the same problem can be illustrated as follows:

1
2
3
4
5
6
7
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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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


  1. https://kotlinlang.org/docs/reference/object-declarations.html#object-declarations ↩︎