JDK-11 Regression: Data class toString(), automatic delegation, Path.toFile()


#1

Consider the following code that behaves differently on JDK-11:

import java.nio.file.Path
import org.junit.Assert.assertEquals
import org.junit.Test
import java.nio.file.Paths

data class WorkingDir(val path: Path) : Path by path

class WorkingDirTest {

  val dir = WorkingDir(Paths.get("/tmp/dir"))

  @Test
  fun string1() = assertEquals("/tmp/dir", dir.path.toString())

  @Test
  fun string2() = assertEquals("/tmp/dir", dir.path.toFile().toString())

  @Test // passes on JDK-8, fails on JDK-11 (with actual="WorkingDir(path=/tmp/dir)")
  fun string3() = assertEquals("/tmp/dir", dir.toFile().toString())

  @Test // fails on both JDK-8 and JDK-11 (with actual="WorkingDir(path=/tmp/dir)")
  fun string4() = assertEquals("/tmp/dir", dir.toString())

}

In both cases the toString() method is ultimatively used in order to pass the String representation of the path to the File constructor.

The difference is though that in JDK-8 the invocation of this.toString() is delegated to the inner path delegate, while in JDK-11 the invocation of this.toString() is not delegated to the path delegate, but happens on the outer data class, which in turn returns a different toString() value.

I guess this difference in behavior happens because in JDK-8 Path.toFile() is a regular (abstract) interface method. But in JDK-11 Path.toFile() is an interface method with a default implementation within the Path interface.

But let’s leave the reasons why this is happening on the side for a moment - here two issues seem to exist.

First, automatic delegation can change behavior when abstract interface methods are pulled-up as default interface methods. Any ideas on how to handle this so that behavior does not change unexpectedly?

Second, data classes do not automatically delegate toString(). What about equals() and hashCode()? I don’t know whether this is by design or by accident. Anyway, perhaps this special situation should be documented. Any recomendation for a best practice here?


#2

Your code looks like

val number="0.010".toDouble()
assertEquals("0.010", number.toString())

Compare Path with Path, File with File and WorkingDir with WorkingDir.
If not specified the toString() representation is mainly for debug purpose.

Finally you can define

data class WorkingDir(val path: Path) : Path by path {
    override fun toString(): String = path.toString()
}

#3

I am not sure I understand your point. The example I posted is runnable and demonstrates the problem.

Path.toString() is defined as follows:
“Returns the string representation of this path.”

Kotlin defines delegation as follows:
" A class Derived can implement an interface Base by delegating all of its public members to a specified object."

That same data class results in a broken string representation when used under JDK-11, toString() is no longer delegated. Therefore this does not seem like a trivial gotcha, but rather an issue.

And you are right, defining/overriding toString() manually in the data class forces toString() to be delegated. But it looks like a workaround and is not an intuitive thing to do.

So my question is - should we recommend redefining toString() manually in such cases or is there a better way, perhaps supported by the compiler?


#4

data classes provide their own implementation of .toString so that will trump whatever implementation you bring in via inheritance, delegation or not.

This is intentional, and a feature of data classes.
Same goes for .equals() and hashCode() data classes have their own implementations and those will be used unless explicitly overridden within the class body.

Also I’m unclear on what you’re tying to achieve, it seems you’re trying to define a class which is in all purposes identical to a Path instance except that that’s a data class. What advantage would that have over using a plain Path?


#5

Unless I’m missing something, isn’t the main issue here that given an instance of:

data class WorkingDir(val path: Path) : Path by path

shouldn’t calling the delegated .toFile() always give the same value as the direct .path.toFile()? The third test seems to indicate that on JDK 11, it doesn’t.

The issue with toString() seems separate to me. (Perhaps it could be worked around rather more elegantly by calling path on the File instead, as @fvasco indicated. Relying on the implementation of toString() when there’s a more specific call available seems silly.)


#6

data classes provide their own implementation of .toString so that will trump whatever implementation you bring in via inheritance, delegation or not.

This sounds like a reasonable explanation for the behavior.

This is intentional, and a feature of data classes.
Same goes for .equals() and hashCode() data classes have their own implementations and those will be used unless explicitly overridden within the class body.

But without specs it must be viewed as a (reasonable) assumption.
It should be definitely documented in the official docs.

Also I’m unclear on what you’re tying to achieve, it seems you’re trying to define a class which is in all purposes identical to a Path instance except that that’s a data class. What advantage would that have over using a plain Path?

While the same can be achieved by using Path directly, here WorkingDir serves as a type alias. The advantage is that you can use the custom Type in method signatures and fields, which makes it very easy and obvious to find what kind of path is used where and you get extra type safety. This is harder to achieve with types from the standard library like Path, because you get all kinds of findings when doing a usage search on the type.

That is exactly the issue. But it is related to the toString() method because:

JDK-8:

interface Path {
    
    File toFile();

}

JDK-11:

interface Path {

    default File toFile() {
        if (getFileSystem() == FileSystems.getDefault()) {
            return new File(toString());
        } else {
            throw new UnsupportedOperationException("Path not associated with "
                    + "default file system.");
        }
    }

}

Relying on the implementation of toString() when there’s a more specific call available seems silly.)

Actually the Path type relies extensively on its toString(), since its introduction in Java-7.


#7

Maybe it should be part of the docs, but I’m not 100% sure about that. Delegation is not used that much and the combination with data classes are even rarer still. Stating that the data class implementation trumps the implementation via delegation is also unnecessary. If you would not want to use the data class implementation you would simply not use the data keyword.


I think you should create an issue about the default case in you example. Delegation should work with default methods IMO.


#8
interface Path {

    default File toFile() {
        if (getFileSystem() == FileSystems.getDefault()) {
            return new File(toString());

Urgghh!

A great illustration of the dangers of relying too much on the toString() implementation — especially in an interface…

Unfortunately, the JDK code isn’t easily fixed (even if anyone admits it’s broken).


#9

Path.toString has a documented and specified behavior in Java (similar to CharSequence.toString). If a Kotlin data class violates that specification by returning garbage, that’s a problem with the Kotlin implementation, not the JDK Path interface.


#10

My bad. I was under the impression that the default keyword was breaking kotlin delegation. But you’re right. It seems like the wrong implementation of toString is at fault here.


#11

Both delegation and data classes provide implementations of some methods. If two delegate objects provide implementation for same methods, user has to choose explicitly. I think, the same should apply to this situation, i.e. if user wants to delegate data class to an object providing toString or hasCode or equals implementations, they should override these methods.