What exactly is happening under the hood when circular dependency exists?

Viewed 70

When running the following code,

object Foo {
  val hoge = Hoge
}

sealed abstract class Hoge(
    val foo: Foo.type = Foo
)

object Hoge {

  case object A extends Hoge

  case object B extends Hoge

  case object C extends Hoge

  val values = IndexedSeq(A, B, C)

}

object Main extends App {
  println(Hoge.B)
  println(Hoge.A)
  println(Hoge.C)
  println(Hoge.values)
}

the output is

B
A
C
Vector(A, null, C)

but changing the main to

object Main extends App {
  println(Hoge.values)
}

results in

Vector(A, B, C)

What exactly is happening under the hood that causes this behaviour?

1 Answers

Objects are lazily initialized. So they are nulls that are later on initialized to some value, and if during their initialization something wanted to get their value it would get null instead.

So what happens here is:

  • Foo and Hoge instances are nulls
  • you request object Hoge.B which should require initialization of Hoge, then B, but in fact it can initialize B without initializing the rest of Hoge as they are separate objects after all
  • however B requires initialization of Foo (because of that non-lazy val)
  • while Foo requires initialization of Hoge
  • at this point A, B, C and values are all nulls though B is being initialized - depending on circumstances such circular dependency ends up with either stack overflow or obtaining a null reference
  • here we have the latter case, so A and C gets initialized (because values ask for them), B is waiting for its dependencies to end initializing, and values is initialized with whatever values are there at the moment it is initialized - B still being null
  • with that you end up with all values initialized: A, B, C, values where values the last one is made of A, null, C

In other words lazy implementation of object is something like:

if (!initiailization) { // check flag
  initialization = true
  value = initialize()
}
value

without any synchronization. If you access value when initialization is still in progress you skip the recursive call and fetch null.

Your second example doesn't create this circular dependency case, so it prints fine.

One way to solve this particular issue would be deferring initialization of values, e.g. by making it lazy val (see example). However each time you add lazy you might solve one issue by creating another, so it makes sense to use it carefully and avoid using lazy and circular dependencies.

Related