From Scala 2.13 to 3: Some surprises

When migrating a project from Scala 2.13 to Scala 3, I ran into some unexpected issues which I want to share. I don’t know what is a feature and what a bug, so please let me know in case you know.

Scala 3 does not allow to override a val with lazy val:

abstract class A {
  val a: Int
}

class B extends A {
  override lazy val a = ???
}

results in

error overriding value a in class A of type Int;
  lazy value a of type Int may not override a non-lazy value
    override lazy val a = ???

The obvious fix is to remove laziness but that’s not always easy: sometimes the value is expensive to compute and only rarely needed, sometimes it is never needed, and hence ??? would be a good enough implementation.

Scala 3 requires to give the types of implicits:

class A {
    object B
    implicit val b = B
}

results in

type of implicit definition needs to be given explicitly
    implicit val b = B

which makes me wonder what happened to type inference?

Scala 3 ignores the result types of overrides:

abstract class A

object B extends A {
    val b = 1
}

abstract class T {
    val a: A
}

class U extends T {
    override val a = B
    def foo: Unit = {
        println(a.b)
    }
}

results in

value b is not a member of A
        println(a.b)

which again makes me wonder what happened to type inference.

To be clear, all the above examples are valid Scala 2.13 code.

Some of this is covered by the Scala 3 Migration Guide (Compatibility Reference | Scala 3 Migration Guide | Scala Documentation).

Explicit return types on implicits are mentioned at Contextual Abstractions | Scala 3 Migration Guide | Scala Documentation

Return types on overrides is covered at Type Inference | Scala 3 Migration Guide | Scala Documentation

Not sure about the override-adding-lazy thing, but the fact that the error message is so specific seems to imply it’s an intentional design change. I suggest changing a in A from a val to a def.

5 Likes

@SethTisue, I recently browsed the migration guide but somehow missed the sections you referenced, so thanks a lot for the pointers.

Regarding the lazy val overriding issue, I prefer val over def because it both expresses and enforces the requirement for the method to return the same value on each invocation.

This is where the Scala 3 compiler generates the error message:

cbc4bbd1070 compiler/src/dotty/tools/dotc/typer/RefChecks.scala (Martin Odersky        2019-08-29 21:32:08 +0200  434)       else if (member.is(Lazy, butNot = Module) && !other.isRealMethod && !othe
r.is(Lazy) &&
4795fee3460 compiler/src/dotty/tools/dotc/typer/RefChecks.scala (Martin Odersky        2020-08-14 11:04:01 +0200  435)                  !warnOnMigration(overrideErrorMsg("may not override a non-lazy value"), member.srcPos))
5fd20289318 src/dotty/tools/dotc/typer/RefChecks.scala          (Martin Odersky        2016-01-31 16:37:10 +0100  436)         overrideError("may not override a non-lazy value")

The error message was introduced by this commit:

commit 5fd2028931874291b3cf1b7efef4fed7119d9316
Author: Martin Odersky <odersky@gmail.com>
Date:   Sun Jan 31 16:37:10 2016 +0100

    Enforce rule that laziness is preserved when overriding.

The fact that the error message can be turned into a warning indicates that there is no technical issue with a lazy val overriding a val.

Now I wonder why this rule has been put into place? Does it matter to the interface user when and how a val is computed? To me, the constness is what matters. @odersky, can you enlighten us?

In my particular case, I decided to replace ??? by a real implementation although it is never used in current production scenarios.

Btw, I think the new rule should be documented in the migration guide.

1 Like

This was changed to preserve the soundness of the type system. More specifically, we cannot allow lazy vals as prefix of path-dependent types in general (isVolatile incorrect for intersection types · Issue #50 · lampepfl/dotty · GitHub), but that means we must ensure that an abstract val cannot be implemented with a lazy val.

5 Likes

Technically, Scala 3 is a different language than Scala 2. While it is borderline miraculous that they were able to find a translation pathway from Scala 2 to Scala 3, that convenience obscures the most important aspect of Scala 3 itself: the CORE VALUE PROPOSITION of 3 is guaranteeing type soundness.

Given Scala 2 had type soundness holes, Scala 3 closes those aggressively to ensure type soundness. It is possible Scala 3 has gone further than is needed to ensure type soundness. In fact, in various discussions in forums, Odersky and other Scala 3 designers have acknowledged this and have suggested some of the stringency might be backed off as the proofs for type soundness become more refined over time.

tl;dr Scala 3 is very aggressive in ensuring and enforcing type soundness.

Regarding lazy val, here’s an article I found which might help you with some of the type soundness reasoning:
https://dotty.epfl.ch/blog/2016/02/17/scaling-dot-soundness.html

2 Likes