bug icon indicating copy to clipboard operation
bug copied to clipboard

ClassCastException on Value Class when implementing an interface using a Single Abstract Method

Open richard-shurtz opened this issue 1 year ago • 7 comments

Reproduction steps

Scastie

Scala version: 2.13.14 scalac option -Ydelambdafy:method (which is the default setting, bug doesn't happen with -Ydelambdafy:inline)

  case class StringValue(value: String) extends AnyVal

  trait Foo[A] {
    def singleMethod(arg: A): StringValue
  }

  val foo1: Foo[Int] = new Foo[Int] {
    override def singleMethod(arg: Int): StringValue = StringValue(arg.toString)
  }
  val foo2: Foo[Int] = (arg: Int) => StringValue(arg.toString)

  println(foo1.singleMethod(1)) // prints "StringValue(1)"
  println(foo2.singleMethod(1)) // throws ClassCastException
Exception in thread "main" java.lang.ClassCastException: class Main$StringValue cannot be cast to class java.lang.String (Main$StringValue is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')

The stack trace for the exception points to the line of foo2's declaration.

Problem

Implementations of foo1 and foo2 should have identical runtime behavior. foo1's is the correct behavior

richard-shurtz avatar Aug 08 '24 18:08 richard-shurtz

looking at the disassmbly of this code I see:


public static final java.lang.String $anonfun$foo2$1$adapted(java.lang.Object);
    Code:
       0: new           #14                 // class Test$StringValue
       3: dup
       4: aload_0
       5: invokestatic  #74                 // Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
       8: invokestatic  #76                 // Method $anonfun$foo2$1:(I)Ljava/lang/String;
      11: invokespecial #79                 // Method Test$StringValue."<init>":(Ljava/lang/String;)V
      14: checkcast     #81                 // class java/lang/String
      17: areturn
    LineNumberTable:
      line 11: 0
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      18     0   arg   Ljava/lang/Object;

note that this is creating a new StringValue then immediately casting it to a String.I don't completely understand how that method is converted to a Foo, but I'm pretty sure that is getting used to construct it.

And interestingly there is a also the $anonfun$foo2$1 method, which simply calls toString on the Int argument. Which this method calls.

if you define foo2 as:

  val foo2: Foo[Integer] = (arg: Integer) => StringValue(arg.toString)

then it works fine. So I'm guessing the bug has something to do with unboxing the integer.

Although I'm not sure why unboxing arg results in wrapping the return value in a StringValue at runtime, and not respecting the extends AnyVal.

tmccombs avatar Aug 08 '24 22:08 tmccombs

Running the compiler with -Vprint:_ is a good way to try to pinpoint the point in time (which compiler phase) where something first goes wrong.

SethTisue avatar Aug 08 '24 22:08 SethTisue

Does Scala 3 have the bug as well?

SethTisue avatar Aug 08 '24 22:08 SethTisue

I could not reproduce on Scala3

richard-shurtz avatar Aug 08 '24 23:08 richard-shurtz

Observations from the output of scalac -Vprint:_:

After the "uncurry" phase, the definition of the (now private) foo2 field is:

 private[this] val foo2: Test.Foo[Int] = {
     final <artifact> def $anonfun$foo2(arg: Int): Test.StringValue = new Test.StringValue(arg.toString());
     ((arg: Int) => $anonfun$foo2(arg))
   };

I'm not really sure why it factored out the $anonfun$foo2 function. But I'm not terribly familiar with how the compiler works.

Then everything proceeds about how I would expect, until delambdify.

And we have:


    final <static> <artifact> def $anonfun$foo2$1(arg: Int): String = java.lang.Integer.toString(arg);
    def <init>(): Test.type = {
      Test.super.<init>();
      Test.this.foo1 = {
        new <$anon: Test$Foo>()
      };
      Test.this.foo2 = {
        $anonfun()
      };
      ()
    };
    final <static> <artifact> def $anonfun$foo2$1$adapted(arg: Object): String = new Test$StringValue(Test.this.$anonfun$foo2$1(unbox(arg))).$asInstanceOf[String]()

I'm not sure what $anonfun() in the definition of Test.this.foo2 is, since it doesn't seem to be defined, but I'm guessing it has something to do with the new $anonfun$foo2$1$adapted method that was added.

And that method has an obvious type error, because it constructs a new Test$StringValue (that had previously been erased) and immediately casts it to a String.

And this definition persists through the jvm phase.

The anon$1 class that is generated for foo1 on the other hand, has no reference to the Test$StringValue as that class has been erased from it.

NOTE: I wrapped the above code in an object named Test.

tmccombs avatar Aug 09 '24 05:08 tmccombs

I started looking at this earlier, but then got distracted by a different CCE with SAM (insert meme about whistling over my shoulder).

I see you figured this out already. Delambdafy is given correct instructions, but I think the call to adapt the result here is wrong:

https://github.com/scala/scala/blame/2.13.x/src/compiler/scala/tools/nsc/transform/Delambdafy.scala#L247

When it creates the bridge def foo$adapted = foo, it uses ErasedValueType(class StringValue, String) instead of its post-erasure.

        val erasedFunctionResultType = postErasure.elimErasedValueType(functionResultType)
        val bridge = postErasure.newTransformer(unit).transform(DefDef(methSym, List(bridgeParams.map(ValDef(_))),
          //adaptToType(forwarderCall.setType(functionResultType), bridgeResultType))).asInstanceOf[DefDef]
          adaptToType(forwarderCall.setType(erasedFunctionResultType), bridgeResultType))).asInstanceOf[DefDef]

That works, but my day is done for now.

som-snytt avatar Aug 09 '24 07:08 som-snytt

The linked PR tries to do something subtle; I wasn't able to glance at what would dotty do.

som-snytt avatar Aug 10 '24 01:08 som-snytt