Java 19 compiler issues when trying record patterns in switch expressions
Asked Answered
E

4

5

I downloaded the release candidate of JDK19 from here https://jdk.java.net/19/ to play a little bit with the new record patterns that were implemented there, but I'm encountering some issues. In my tests, I wrote a version of Optional based on sealed interface plus records, in the following way:

package tests.patterns;

import java.util.Objects;

public class TestRecordPatter
{
    public static void main(final String[] args)
    {
        final Opt<String> opt1 = computeAnswer(23);
        final String answer1 = switch (opt1) {
            case Opt.Some<String>(String ans) -> ans;
            case Opt.None __ -> "no answer";
            default -> throw new IllegalStateException("This should not happen"); // A
        };
        System.out.println(answer1);

        final Opt<String> opt2 = computeAnswer(35);
        final Object answer2 = switch (opt2) { // B
            case Opt.Some<String>(var ans) -> ans; // C
            case Opt.None __ -> "no answer";
            default -> throw new IllegalStateException("This should not happen"); // A-2
        };
        System.out.println(answer2);

        final Opt<String> opt3 = computeAnswer(84);
        final String answer3 = switch (opt3) { // D
            case Opt.Some<String> s -> s.value();
            case Opt.None __ -> "no answer";
        };
        System.out.println(answer3);
    }

    private static Opt<String> computeAnswer(final int question)
    {
        if (question % 2 == 0) {
            return Opt.some(String.valueOf(question / 2));
        } else {
            return Opt.none();
        }
    }

    private sealed interface Opt<T> permits Opt.Some, Opt.None
    {

        static <T> Opt<T> of(final T value)
        {
            return value == null ? none() : some(value);
        }

        static <T> Opt<T> some(final T value)
        {
            return new Opt.Some<>(value);
        }

        @SuppressWarnings("unchecked")
        static <T> Opt<T> none()
        {
            return Opt.None.NONE;
        }

        record Some<T>(T value) implements Opt<T>
        {

            public Some
            {
                Objects.requireNonNull(value, "Value must not be null");
            }
        }

        @SuppressWarnings({ "rawtypes" })
        enum None implements Opt
        {
            NONE;
        }
    }
}

The first switch expression is using record deconstruction to get the value out of the Some variant using an explicit type for the ans variable but in this case the java compiler requires the default branch (line marked with A) otherwise it fails with the following error:

TestRecordPatter.java:[10,40] the switch expression does not cover all possible input values

In the second switch, the issue is that using var instead of explicit type for ans binds the returned type of the switch to Object and not to String. (line marked with B). Moreover, but this is not a java compiler issue, IntelliJ complains on line marked as C, highlighting the var ans part and saying something like "Type T is required but null is provided".

Finally, the third switch works fine, but that's the "old" (java 17 is old, right?) way of doing it.

Can someone help me? Am I doing something wrong?

EDIT: I just downloaded the Oracle JDK-19 GA version and it has the same issue.

EDIT 2: the second switch, the one with var has the same behaviour as the first one, so it needs a default case, as pointed by Holger

Evvie answered 20/9, 2022 at 13:57 Comment(2)
Since your problem with var is not a java compiler issue, I’m not sure whether including it here, is helpful. An IntelliJ bug is an entirely different question (if we consider it a question at all, rather than something to report to the IntelliJ developers).Landmass
I reported it there since that switch is compilable with javac but not really usable, at least for now, since IntelliJ fails to parse it. It was just a note.Evvie
E
0

I just tried the code with the compiler of JDK 20 and the issue have been fixed

Evvie answered 24/3, 2023 at 17:43 Comment(0)
C
6

Am I doing something wrong?

Yes and no. You're making assumptions about how enums and sealed types interact (and also about constant case labels) that make sense if you are coming from Haskell, but for which Java has not yet caught up with. Specifically, exhaustiveness checking for enums is restricted to switches over that enum type, and so when an enum type is used as a permitted subtype of a sealed type, exhaustiveness for the values of the enum is not yet treated as exhausting the permitted subtype. This is because constant case labels are not yet treated as patterns, but in their old meaning. Mixing constant case labels and sealed types in exhaustiveness is something that will come in the future (recall this is still a preview feature.)

In the mean time, the following works fine:

sealed interface Opt<T> { ... }

record Some<T>(T t) implements Opt<T> { }
record None<T>() implements Opt<T> { }

I get why you reached for enums, and in the fullness of time that move will work, but it does not yet.

Caddric answered 20/9, 2022 at 16:32 Comment(4)
Hi Brian, thanks for your answer. I think I'm not getting it, though. If the problem is the enum variant, so why are the second and third switches in my example total? The case that matches on the enum is the same in all three cases, the only thing that changes is how the case that matches the Some variant is defined. Also, I tried your suggested approach but it still fails to compile. I'll post the code in an answer below.Evvie
@Evvie I can not reproduce the behavior you describe for the second case. Using var makes no difference at all. As you already said, the type inference issue stems from IntelliJ, not javac. And it doesn’t affect the completeness test.Landmass
@Holger, you're right. The switch with var has the same behaviour as the first one. The only difference is the type returned by the switch (Object instead of String). My bad, sorry for that.Evvie
We need constant patterns. Strings, enums, ints.. are already usable in a classic switch. But that begs the question: What is actually a constant?Chemosynthesis
L
3

The enum type is a red herring. The behavior is exactly the same, whether we use an enum type for the second case or a record with a deconstruction pattern. The reason is that the problematic case is the first one.

Consider

import java.util.Objects;

public class TestRecordPattern2 {
    public static void main(final String[] args) {
        for(int i = 0; i < 4; i++) {
            final Opt<String> opt = computeAnswer(i);
            final String answer = switch(opt) {
                case Opt.Some<String>(String ans) -> ans;
                case Opt.None<String>() -> "no answer";
            };
            System.out.println(answer);
        }
    }
    private static Opt<String> computeAnswer(final int question) {
        return question % 2 == 0? Opt.some(String.valueOf(question / 2)): Opt.none();
    }
    private sealed interface Opt<T> {
        static <T> Opt<T> of(final T value) {
            return value == null ? none() : some(value);
        }
        static <T> Opt<T> some(final T value) {
            return new Opt.Some<>(value);
        }
        @SuppressWarnings("unchecked") static <T> Opt<T> none() {
            return (Opt<T>)Opt.None.NONE;
        }
        record Some<T>(T value) implements Opt<T> {
            public Some { Objects.requireNonNull(value, "Value must not be null"); }
        }
        record None<T>() implements Opt<T> {
            static final None<?> NONE = new None<>();
        }
    }
}

This reproduces the problem and so does

import java.util.Objects;

public class TestRecordPattern3 {
    public static void main(final String[] args) {
        for(int i = 0; i < 4; i++) {
            final Opt<String> opt = computeAnswer(i);
            final String answer = switch(opt) {
                case Opt.Some<String>(String ans) -> ans;
                case Opt.None __ -> "no answer";
            };
            System.out.println(answer);
        }
    }
    private static Opt<String> computeAnswer(final int question) {
        return question % 2 == 0? Opt.some(String.valueOf(question / 2)): Opt.none();
    }
    private sealed interface Opt<T> {
        static <T> Opt<T> of(final T value) {
            return value == null ? none() : some(value);
        }
        static <T> Opt<T> some(final T value) {
            return new Opt.Some<>(value);
        }
        @SuppressWarnings("unchecked") static <T> Opt<T> none() {
            return (Opt<T>)Opt.None.NONE;
        }
        record Some<T>(T value) implements Opt<T> {
            public Some { Objects.requireNonNull(value, "Value must not be null"); }
        }
        @SuppressWarnings({ "rawtypes" }) enum None implements Opt { NONE }
    }
}

However, if we change the line

  case Opt.Some<String>(String ans) -> ans;

to

  case Opt.Some<String>(Object ans) -> ans.toString();

both variants work without problems. It seems, the exhaustiveness test has problems with the type of the record component here.

We could also use

final String answer = switch(opt) {
    case Opt.Some<String>(String ans) -> ans;
    case Opt.Some<String>(Object ans) -> throw new IllegalStateException("heap pollution");
    case Opt.None<String>() -> "no answer";
};

resp.

final String answer = switch(opt) {
    case Opt.Some<String>(String ans) -> ans;
    case Opt.Some<String>(Object ans) -> throw new IllegalStateException("heap pollution");
    case Opt.None __ -> "no answer";
};

for the enum variant.

Though this should not be necessary for a sound generic type system.

This also contradicts the “Record patterns and exhaustive switch” section of JEP 405 which shows examples illustrating that this exhaustiveness check should work.

Out of curiosity, I added the JEP’s example to your case. When I use

    public static void main(final String[] args) {
        final Opt.Some<I> opt = (Opt.Some<I>)Opt.<I>some(new A());
        final String answer = switch(opt) {
            case Opt.Some<I>(A ans) -> "A";
            case Opt.Some<I>(B ans) -> "B";
        };
        System.out.println(answer);
    }

    private sealed interface I {}
    static final class A implements I {}
    static final class B implements I {}

The exhaustiveness test did indeed work. However, when I expand it to

    public static void main(final String[] args) {
        final Opt<I> opt = Opt.some(new A());
        final String answer = switch(opt) {
            case Opt.Some<I>(A ans) -> "A";
            case Opt.Some<I>(B ans) -> "B";
            case Opt.None<I>() -> "None";
        };
        System.out.println(answer);
    }

it again fails to recognize the exhaustiveness and requires to add an actually impossible Object case like

    public static void main(final String[] args) {
        final Opt<I> opt = Opt.some(new A());
        final String answer = switch(opt) {
            case Opt.Some<I>(A ans) -> "A";
            case Opt.Some<I>(B ans) -> "B";
            case Opt.Some<I>(Object ans) -> throw new AssertionError();
            case Opt.None<I>() -> "None";
        };
        System.out.println(answer);
    }

(and not even case Opt.Some<I>(I ans) -> … does work, just like with your String example)


Finally, the most simplified example is

public class TestRecordPattern5 {
    public static void main(final String[] args) {
        final Opt<String> opt = new Some<>("hello");
        final String answer = switch(opt) {
            case Some<String>(String ans) -> ans;
        };
        System.out.println(answer);
    }
    private sealed interface Opt<T> {}
    record Some<T>(T value) implements Opt<T> {}
}
Landmass answered 21/9, 2022 at 14:26 Comment(0)
E
1

As I wrote in the comment above, I tried to change the code with what Brian suggested but the first switch still fails the completeness check. The code that I tried is the following

package tests.patterns;

import java.util.Objects;

public class TestRecordPatter
{
    public static void main(final String[] args)
    {
        final Opt<String> opt1 = computeAnswer(23);
        final String answer1 = switch (opt1) {
            case Opt.Some<String>(String ans) -> ans;
            case Opt.None<String> __ -> "no answer";
            // default -> throw new IllegalStateException("This should not happen"); // A
        };
        System.out.println(answer1);
    }

    private static Opt<String> computeAnswer(final int question)
    {
        if (question % 2 == 0) {
            return Opt.some(String.valueOf(question / 2));
        } else {
            return Opt.none();
        }
    }

    private sealed interface Opt<T> permits Opt.Some, Opt.None
    {

        static <T> Opt<T> of(final T value)
        {
            return value == null ? none() : some(value);
        }

        static <T> Opt<T> some(final T value)
        {
            return new Opt.Some<>(value);
        }

        static <T> Opt<T> none()
        {
            return new Opt.None<>();
        }

        record Some<T>(T value) implements Opt<T>
        {

            public Some
            {
                Objects.requireNonNull(value, "Value must not be null");
            }
        }

        record None<T>() implements Opt<T> {}
    }
}

The only way to make it compile is to decomment line marked with A.

The compiler output is the following:

$ javac --enable-preview --release 19 tests/patterns/TestRecordPatter.java
tests/patterns/TestRecordPatter.java:10: error: the switch expression does not cover all possible input values
                final String answer1 = switch (opt1) {
                                       ^
Note: tests/patterns/TestRecordPatter.java uses preview features of Java SE 19.
Note: Recompile with -Xlint:preview for details.
Evvie answered 21/9, 2022 at 7:27 Comment(3)
You still use case Opt.None<String> __ -> rather than the decomposition pattern case Opt.None<String>() -> but it doesn’t matter anyway. I tried a bit and the problem is the case Opt.Some<String>(String ans) ->. Not mentioned in my answer is that we can even remove the None type altogether, it wouldn’t make a difference. A sealed type with exactly one possibility and yet the compiler says the switch with the only one possible case was not complete.Landmass
I was using case Opt.None<String> __ -> since in the none case I don't need decomposition. I tried also adding the decomposition pattern in that case but I saw that nothing changes, so I decided to not write about it. Nice findings in you code snippets below! Thanks for exploring further!Evvie
I added a last example incorporating all findings (TestRecordPattern5), simplified as much as possible. It perfectly demonstrates that the form of the second type doesn’t matter, as there is none.Landmass
E
0

I just tried the code with the compiler of JDK 20 and the issue have been fixed

Evvie answered 24/3, 2023 at 17:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.