Study/Java

JDK 18 ~ JDK 21 사이 추가된 Feature

Bluesky_ 2023. 10. 5. 01:18
반응형

JDK 버전별 New Features

JDK 21은 기존 3년 주기 LTS (Long-term support) 정책을 2년 주기로 변경한 이후 나온 첫 LTS 버전이다.

지난 LTS 버전인 JDK 17 이후 18, 19, 20, 21을 거쳐 어떠한 것들이 변경되었는지 정리해 보았다.

(이후 추가될 기능과 관련된 Incubator, Preview feature는 제외)

만약 각 JDK별 변경 사항을 확인하고 싶은 경우 이전 글을 참고하면 된다.

2022.03.24 - [Study/Java] - JDK 18 New Features

2022.09.21 - [Study/Java] - JDK 19 New Features

2023.05.07 - [Study/Java] - JDK 20 New Features

2023.10.04 - [Study/Java] - JDK 21 New Features

JDK 18 ~ JDK 21 Final Release Specification Feature Summary

JEP Component 설명
JEP 400: UTF-8 by Default core-libs / java.nio.charsets UTF-8이 이제 기본값으로 사용된다.
JEP 408: Simple Web Server core-libs / java.net 간단하게 띄워볼 수 있는 Simple Web Server를 제공
JEP 431: Sequenced Collections core-libs / java.util:collections 순서가 있는 Collection에 대한 공통 추상화 제공
JEP 439: Generational ZGC hotspot / gc JDK 13부터 제공되기 시작한 ZGC를 확장하여 성능 향상
JEP 440: Record Patterns specification / language record 사용 효율 향상을 위해 제공되는 기능
JEP 441: Pattern Matching for switch specification / language switch 표현식 및 문에 대한 pattern matching 제공
JEP 444: Virtual Threads core-libs 비동기 프로그래밍을 위한 Virtual Thread 기능 제공
JEP 452: Key Encapsulation Mechanism API security-libs / javax.crypto 공개키 암호화에 대한 키 캡슐화 매커니즘(KEMs) API

 

코드 작성과 관련하여 추가된 기능은 specification / language component를 살펴보면 된다.

 

JEP 440: Record Patterns

JEP 405: Record Patterns (Preview)는 JEP 427: Pattern Matching for switch (Thrid Preview) 와 연관되어 있다.
JDK 16에 추가된 JEP 395: Records 를 통해 record에 대한 instanceof pattern matching으로 다음과 같은 처리가 가능해졌었다.

// Prior to Java 16
if (obj instanceof String) {
    String s = (String)obj;
    ... use s ...
}
// As of Java 16
if (obj instanceof String s) {
    ... use s ...
}

Pattern matching and records

record에 대해서도 pattern matching을 처리할 수 있었다.

// As of Java 16
record Point(int x, int y) {}
static void printSum(Object obj) {
    if (obj instanceof Point p) {
        int x = p.x();
        int y = p.y();
        System.out.println(x+y);
    }
}

record의 경우 생성자의 매개변수가 record의 멤버 변수가 되기 때문에 생성자를 바로 사용할 수 있도록 다음과 같은 처리가 가능해지게 된다.

// As of Java 21
static void printSum(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println(x+y);
    }
}

Nested record patterns

생성자에 대한 pattern matching은 중첩 레코드를 사용할 때 우발적 복잡성을 제거하여 해당 Object가 표현하는 데이터에 집중할 수 있도록 한다.

// As of Java 16
record Point(int x, int y) {}
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) {}
record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}

앞서 소개했던 record pattern을 적용한 경우 다음과 같다.

// As of Java 21
static void printUpperLeftColoredPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint ul, ColoredPoint lr)) {
         System.out.println(ul.c());
    }
}

ColoredPoint 값 ul은 그 자체로 record 값이므로 더 분해하고 싶을 수 있다.
따라서 record pattern은 중첩을 지원하므로 record component를 중첩된 pattern에 대해 추가로 일치시키고 분해할 수 있다.
record pattern안에 다른 pattern을 중첩하여 외부 record와 내부 record를 한 번에 분해할 수 있다.

// As of Java 21
static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
        System.out.println(c);
    }
}

nested pattern을 사용하면 집합을 구성하는 코드만큼이나 명확하고 간결한 코드로 집합을 분해할 수 있다.
예를 들어 rectangle을 생성하는 경우 constructor를 하나의 표현식에 중첩할 수 있다.

// As of Java 16
Rectangle r = new Rectangle(new ColoredPoint(new Point(x1, y1), c1), 
                            new ColoredPoint(new Point(x2, y2), c2));

Nested pattern을 사용하면 nested constructor의 structure를 반영하는 코드로 이러한 rectangle을 분해할 수 있다.

// As of Java 21
static void printXCoordOfUpperLeftPointWithPatterns(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point(var x, var y), var c),
                               var lr)) {
        System.out.println("Upper-left corner: " + x);
    }
}

물론 nested pattern은 일치하지 않을 수 있다.

// As of Java 21
record Pair(Object x, Object y) {}

Pair p = new Pair(42, 42);

if (p instanceof Pair(String s, String t)) {
    System.out.println(s + ", " + t);
} else {
    System.out.println("Not a pair of strings");
}

여기서 record pattern Pair(String s, String t) 에는 두 개의 nested type pattern, 즉 String sString t 가 포함된다.
값이 Pair 인 경우 Pair(String s, String t) pattern과 일치하고, 재귀적으로 그 구성 요소 값이 type pattern String sString t 와 일치한다.
위의 예제 코드에서 이러한 재귀 패턴 일치는 record 구성 요소 값 중 어느 것도 문자열이 아니므로 실패하므로 else block이 실행된다.

요약하자면, nested pattern은 객체를 탐색할 때 발생하는 우발적인 복잡성을 제거하여 해당 객체가 표현하는 데이터에 집중할 수 있도록 해준다.
또한 하위 pattern 중 하나 또는 둘 다 일치하지 않으면 값이 nested pattern P(Q)와 일치하지 않으므로 오류 처리를 중앙 집중화 할 수 있는 기능을 제공한다.
전체 패턴이 일치하든 일치하지 않든 각각의 하위 패턴 일치 실패를 일일이 확인하고 처리할 필요가 없다.

Record patterns

record pattern은 record class type과 해당 record 구성 요소 값과 일치하는 데 사용되는 (비어있을 수 있는) pattern list로 구성된다.

예를 들어 다음과 같은 선언이 있다고 가정한다.

record Point(int i, int j) {}

값 v가 record type Point 의 instance인 경우 record pattern Point(int i, intj) 와 일치하며, 그렇다면 pattern variable i는 값 v에 대해 해당하는 accessor method를 호출한 결과로 초기화되고, pattern variable j는 값 v에 대해 j에 대항하는 accessor method를 호출한 결과로 초기화된다.
(pattern variable의 이름은 record component의 이름과 같을 필요는 없다.
record pattern Point(int x, int y) 는 pattern variable x와 y가 초기화된다는 점을 제외하면 동일하게 동작한다.)

null 값은 어떤 record pattern과도 일치하지 않는다.

record pattern은 구성 요소의 type을 명시하지 않고 var를 사용하여 record 구성 요소와 일치시킬 수 있다.
이 경우 compiler는 var 패턴에 의해 도입된 pattern variable의 type을 유추한다.
예를 들어, Point(var a, var b) pattern은 Point(int a, int b)의 축약형이다.

record pattern으로 선언된 pattern variable 집합에는 pattern list에 선언된 모든 pattern variable이 포함된다.

표현식이 체크되지 않은 변환 없이 pattern의 record type으로 형 변환될 수 있는 경우 record pattern과 호환된다.

record pattern이 일반 record class의 이름을 지정하지만 argument를 제공하지 않는 경우 (즉, record pattern이 원시 유형을 사용하는 경우) argument는 항상 추론된다.
예를 들어

// As of Java 21
record MyPair<S,T>(S fst, T snd){};

static void recordInference(MyPair<String, Integer> pair){
    switch (pair) {
        case MyPair(var f, var s) -> 
            ... // Inferred record pattern MyPair<String,Integer>(var f, var s)
        ...
    }
}

record pattern에 대한 argument의 추론은 record pattern을 지원하는 모든 구문, 즉 instanceof expression, switch 문 및 expression에서 지원된다.

예를 들어 추론은 nested record pattern에서 동작한다.

// As of Java 21
record Box<T>(T t) {}

static void test1(Box<Box<String>> bbs) {
    if (bbs instanceof Box<Box<String>>(Box(var s))) {
        System.out.println("String " + s);
    }
}

여기서 nested pattern Box(var s) 의 argument는 문자열로 추론되므로 pattern 자체는 Box<String>(var s) 로 추론된다.

사실 외부 record pattern에서도 argument를 삭제할 수 있으므로 간결한 코드를 만들 수 있다.

// As of Java 21
static void test2(Box<Box<String>> bbs) {
    if (bbs instanceof Box(Box(var s))) {
        System.out.println("String " + s);
    }
}

여기서 compiler는 pattern의 전체 instance가 Box<Box<String>>(Box<String>(var s)) 라고 추론한다.

호환성을 위해 type pattern은 type argument의 암시적 추론을 지원하지 않는다.
(예: type pattern List l 은 항상 raw type pattern으로 취급됨)

Record patterns and exhaustive switch

JEP 441은 pattern label을 지원하기 위해 switch 표현식과 switch 문을 모두 개선했다.
switch 표현식과 pattern switch문은 모두 완전해야 한다.
switch block에는 선택자 표현식의 가능한 모든 값을 처리하는 절이 있어야 한다.
pattern lable의 경우 pattern의 유형을 분석하여 결정된다.
예를 들어 case label Bar bBar type 및 Bar 의 가능한 모든 subtype의 값과 일치한다.

record pattern을 포함하는 pattern label의 경우 component pattern의 type을 고려하고 sealed 계층 구조를 허용해야 하므로 분석이 더 복잡해진다.
예를 들어 다음과 같은 선언이 있다고 가정하면

class A {}
class B extends A {}
sealed interface I permits C, D {}
final class C implements I {}
final class D implements I {}
record Pair<T>(T x, T y) {}

Pair<A> p1;
Pair<I> p2;

다음 switch는 두 값이 모두 A인 쌍에 대해 일치하는 항목이 없으므로 완전하지 않다.

// As of Java 21
switch (p1) {                 // Error!
    case Pair<A>(A a, B b) -> ...
    case Pair<A>(B b, A a) -> ...
}

이 두 switch는 Interface I 가 sealed 되어 있기 때문에 가능한 모든 경우는 type C와 type D가 된다.

// As of Java 21
switch (p2) {
    case Pair<I>(I i, C c) -> ...
    case Pair<I>(I i, D d) -> ...
}

switch (p2) {
    case Pair<I>(C c, I i) -> ...
    case Pair<I>(D d, C c) -> ...
    case Pair<I>(D d1, D d2) -> ...
}

하지만 아래 switch는 두 값이 모두 type D인 쌍에 대해 일치하는 항목이 없으므로 완전하지 않다.

// As of Java 21
switch (p2) {                        // Error!
    case Pair<I>(C fst, D snd) -> ...
    case Pair<I>(D fst, C snd) -> ...
    case Pair<I>(I fst, C snd) -> ...
}

JEP 441: Pattern Matching for switch

Java 16에 적용된 JEP 394: Pattern Matching for instanceof 를 통해 아래와 같이 instanceof and cast 사용을 간결하게 할 수 있게 되었다.

// Prior to Java 16
if (obj instanceof String) {
    String s = (String)obj;
    ... use s ...
}
// As of Java 16
if (obj instanceof String s) {
    ... use s ...
}

또한 이를 통해 아래와 같이 사용하던 부분도

// Prior to Java 21
static String formatter(Object obj) {
    String formatted = "unknown";
    if (obj instanceof Integer i) {
        formatted = String.format("int %d", i);
    } else if (obj instanceof Long l) {
        formatted = String.format("long %d", l);
    } else if (obj instanceof Double d) {
        formatted = String.format("double %f", d);
    } else if (obj instanceof String s) {
        formatted = String.format("String %s", s);
    }
    return formatted;
}

아래와 같이 switch를 통해 반환이 가능해졌다.

// As of Java 21
static String formatterPatternSwitch(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> obj.toString();
    };
}

Switches and null

일반적으로 Switch 문과 표현식은 selector 표현식이 null로 평가되면 NullPointerException을 던지므로, null에 대한 테스트는 switch 외부에서 수행해야 한다.

// Prior to Java 21
static void testFooBarOld(String s) {
    if (s == null) {
        System.out.println("Oops!");
        return;
    }
    switch (s) {
        case "Foo", "Bar" -> System.out.println("Great");
        default           -> System.out.println("Ok");
    }
}

switch에서 몇 가지 reference type만 지원할 때는 이 방법이 합리적이었다.
그러나 switch에서 모든 reference type의 selector 표현식을 허용하고 case label에 type pattern이 있을 수 있는 경우 독립된 null test는 불필요한 상용구와 오류 가능성을 불러일으키는 임의적인 구문처럼 느껴진다.
새로운 null case label을 허용하여 switch에 null test를 통합하는 것이 더 나을 것이다.

// As of Java 21
static void testFooBarNew(String s) {
    switch (s) {
        case null         -> System.out.println("Oops");
        case "Foo", "Bar" -> System.out.println("Great");
        default           -> System.out.println("Ok");
    }
}

Case refinement

constant가 있는 case label과 달리 pattern case label은 여러 값에 적용될 수 있다.
이로 인해 switch 규칙의 오른쪽에 조건부 코드가 생기는 경우가 많다.
예를 들면 다음과 같다.

// As of Java 21
static void testStringOld(String response) {
    switch (response) {
        case null -> { }
        case String s -> {
            if (s.equalsIgnoreCase("YES"))
                System.out.println("You got it");
            else if (s.equalsIgnoreCase("NO"))
                System.out.println("Shame");
            else
                System.out.println("Sorry?");
        }
    }
}

여기서 문제는 단일 pattern을 사용하여 사례를 구분하는 것이 단일 조건 이상으로 확장되지 않는다는 것이다.
여러 pattern을 작성하는 것을 선호하지만 pattern에 대한 세분화를 표현할 방법이 필요하다.
따라서 switch block의 경우 절에서 case label을 pattern화 하기 위한 guard를 지정할 수 있도록 허용한다.
(예: case String s when s.equalsIgnoreCase("YES")
이러한 case label을 guard 된 case label이라고 하고 boolean expression을 guard라고 한다.)

이 접근 방식을 사용하면 guard를 사용하여 위의 코드를 다시 작성할 수 있다.

// As of Java 21
static void testStringNew(String response) {
    switch (response) {
        case null -> { }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {
            System.out.println("Sorry?");
        }
    }
}

이렇게 하면 테스트의 복잡성이 switch 규칙의 왼쪽에 표시되고 해당 테스트가 충족될 경우 적용되는 논리가 switch 규칙의 오른쪽에 표시되는 보다 읽기 쉬운 스타일의 switch 프로그래밍이 가능해진다.

다른 알려진 constant string에 대한 추가 규칙을 사용하여 이 예제를 더욱 개선할 수 있다.

// As of Java 21
static void testStringEnhanced(String response) {
    switch (response) {
        case null -> { }
        case "y", "Y" -> {
            System.out.println("You got it");
        }
        case "n", "N" -> {
            System.out.println("Shame");
        }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {
            System.out.println("Sorry?");
        }
    }
}

Switches and enum constants

현재 case label에 enum constant를 사용하는 것은 매우 제한적이다.
switch의 selector 표현식은 enum type이어야 하며 label은 enum constants의 간단한 이름이어야 한다.
예를 들면 다음과 같다.

// Prior to Java 21
public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }

static void testforHearts(Suit s) {
    switch (s) {
        case HEARTS -> System.out.println("It's a heart!");
        default -> System.out.println("Some other suit");
    }
}

pattern label을 추가한 후에도 이 제약 조건으로 인해 불필요하게 장황한 코드가 생성된다.
예를 들면 다음과 같다.

// As of Java 21
sealed interface CardClassification permits Suit, Tarot {}
public enum Suit implements CardClassification { CLUBS, DIAMONDS, HEARTS, SPADES }
final class Tarot implements CardClassification {}

static void exhaustiveSwitchWithoutEnumSupport(CardClassification c) {
    switch (c) {
        case Suit s when s == Suit.CLUBS -> {
            System.out.println("It's clubs");
        }
        case Suit s when s == Suit.DIAMONDS -> {
            System.out.println("It's diamonds");
        }
        case Suit s when s == Suit.HEARTS -> {
            System.out.println("It's hearts");
        }
        case Suit s -> {
            System.out.println("It's spades");
        }
        case Tarot t -> {
            System.out.println("It's a tarot");
        }
    }
}

이 코드는 guard pattern이 많은 대신 각 enum constant에 대해 별도의 case를 사용 수 있다면 더 가독성이 높아질 것이다.
따라서 selector 표현식이 enum이어야 한다는 요건을 완화하고 case constant가 enum constant의 정규화된 이름을 사용할 수 있도록 허용하였다.
이를 통해 위의 코드를 다음과 같이 재작성할 수 있다.

// As of Java 21
static void exhaustiveSwitchWithBetterEnumSupport(CardClassification c) {
    switch (c) {
        case Suit.CLUBS -> {
            System.out.println("It's clubs");
        }
        case Suit.DIAMONDS -> {
            System.out.println("It's diamonds");
        }
        case Suit.HEARTS -> {
            System.out.println("It's hearts");
        }
        case Suit.SPADES -> {
            System.out.println("It's spades");
        }
        case Tarot t -> {
            System.out.println("It's a tarot");
        }
    }
}

Improved enum constant case labels

기존 Java코드와의 호환성을 유지하기 위해 enum type을 switching 할 때 case constant는 switch 되는 enum type의 constant의 간단한 이름을 그대로 사용할 수 있다.

새로운 코드의 경우 enum 처리를 확장한다.
먼저 enum constant의 정규화된 이름이 case constant로 표시되도록 허용한다.
이러한 정규화된 이름은 enum type을 전화할 때 사용할 수 있다.

enum constant 중 하나의 이름이 case constant로 사용되는 경우 select 표현식이 enum type이어야 한다는 요건을 삭제한다.
이러한 상황에서는 이름이 정규화되어야 하고 그 값이 select 표현식의 유형과 할당 호환 가능해야 한다.
(이렇게 하면 enum case constant의 처리 방식이 숫자 case constant와 일치하게 된다.)

예를 들어 다음 두 가지 방법이 허용된다.

// As of Java 21
sealed interface Currency permits Coin {}
enum Coin implements Currency { HEADS, TAILS } 

static void goodEnumSwitch1(Currency c) {
    switch (c) {
        case Coin.HEADS -> {    // Qualified name of enum constant as a label
            System.out.println("Heads");
        }
        case Coin.TAILS -> {
            System.out.println("Tails");
        }
    }
}

static void goodEnumSwitch2(Coin c) {
    switch (c) {
        case HEADS -> {
            System.out.println("Heads");
        }
        case Coin.TAILS -> {    // Unnecessary qualification but allowed
            System.out.println("Tails");
        }
    }
}

다음 예제는 허용되지 않는다.

// As of Java 21
static void badEnumSwitch(Currency c) {
    switch (c) {
        case Coin.HEADS -> {
            System.out.println("Heads");
        }
        case TAILS -> {         // Error - TAILS must be qualified
            System.out.println("Tails");   
        }
        default -> {
            System.out.println("Some currency");
        }
    }
}

Patterns in switch labels

switch block에서 switch label을 읽을 수 있도록 문법이 수정되었다.

SwitchLabel:
  case CaseConstant { , CaseConstant }
  case null [, default]
  case Pattern [ Guard ]
  default

주요 개선 사항은 새로운 case label인 case p를 도입하는 것이다. (여기서 p는 패턴)
select 표현식의 값을 switch label과 비교하여 label 중 하나를 선택하고 해당 label과 연관된 코드를 실행하거나 평가하는 switch의 본질은 변하지 않는다.
이제 pattern이 있는 case label의 경우 선택되는 label이 동일성 테스트가 아닌 pattern matching 결과에 따라 결정된다는 점이 달라졌다.
예를 들어 다음 코드에서 객체의 값은 pattern Long l 과 일치하며, label case Long l 과 연결된 표현식이 평가된다.

// As of Java 21
static void patternSwitchTest(Object obj) {
    String formatted = switch (obj) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> obj.toString();
    };
}

pattern matching에 성공한 후에는 matching 결과를 추가로 테스트하는 경우가 많다.
이로 인해 다음과 같은 번거로운 코드가 발생할 수 있다.

// As of Java 21
static void testOld(Object obj) {
    switch (obj) {
        case String s:
            if (s.length() == 1) { ... }
            else { ... }
            break;
        ...
    }
}

원하는 테스트 (객체가 길이 1의 문자열이라는 것)는 안타깝게도 pattern case label과 다음 if문으로 나뉘어있다.

이 문제를 해결하기 위해 boolean 표현식인 optional guard 가 pattern label 뒤에 올 수 있도록 하여 guarded pattern case label 을 도입했다.
이렇게 하면 위의 코드를 재작성하여 모든 조건 로직이 switch label로 옮겨지도록 할 수 있다.

// As of Java 21
static void testNew(Object obj) {
    switch (obj) {
        case String s when s.length() == 1 -> ...
        case String s                      -> ...
        ...
    }
}

첫 번째 절은 객체가 문자열이면서 길이가 1이면 일치한다.
두 번째 경우는 객체가 길이에 상관없이 문자열이면 일치한다.

pattern label에만 guard가 있을 수 있다.
예를 들어 case constant와 guard가 있는 label을 작성하는 것은 유효하지 않다.
(예: case "Hello" when callRandomBooleanExpression() )

switch에서 pattern을 지원할 때 고려해야 할 5가지 주요 언어 디자인 영역이 있다.

  • enhanced type checking
  • switch 표현식 및 명령문의 완전성
  • pattern variable 선언의 범위
  • null 처리
  • 에러

Enhanced type checking

Selector expression typing

switch에서 pattern을 지원한다는 것은 selector 표현식의 type에 대한 제한을 완화할 수 있다는 것을 의미한다.
현재 일반 switch에서 selector 표현식의 type은 정수형 primitive type (long 제외), 해당 boxed form(예: Character, Byte, Short 또는 Integer), String 또는 enum type 중 하나여야 한다.
이를 확장하여 selector 표현식의 type이 정수형 primitive type (long 제외) 또는 referenced type 이어야 한다.

예를 들어 다음 pattern switch에서 selector 표현식 객체는 class type, enum, record type 및 array type과 함께 case label 및 default가 포함된 type pattern과 일치한다.

// As of Java 21
record Point(int i, int j) {}
enum Color { RED, GREEN, BLUE; }

static void typeTester(Object obj) {
    switch (obj) {
        case null     -> System.out.println("null");
        case String s -> System.out.println("String");
        case Color c  -> System.out.println("Color: " + c.toString());
        case Point p  -> System.out.println("Record class: " + p.toString());
        case int[] ia -> System.out.println("Array of ints of length" + ia.length);
        default       -> System.out.println("Something else");
    }
}

switch block의 모든 case label은 selector 표현식과 호환되어야 한다.
pattern label이라고 하는 pattern이 있는 case label의 경우 표현식과 pattern의 호환성이라는 기존 개념을 사용한다.

Dominance of case labels

pattern case label을 지원한다는 것은 selector 표현식의 주어진 값에 대해 이전에는 최대 하나의 case label만 적용될 수 있었지만 이제 2개 이상의 case label이 적용될 수 있다는 것을 의미한다.
예를 들어 selector 표현식이 문자열로 평가되는 경우 case label인 case lables case String scase CharSequence cs 가 모두 적용된다.

가장 먼저 해결해야 할 문제는 이 상황에서 어떤 label을 적용해야 하는지 정확히 결정하는 것이다.
복잡한 최적 접근 방식을 시도하는 대신 더 간단한 의미론을 채택하였다.
switch block에서 값이 적용되는 첫 번째 case label이 선택된다.

// As of Java 21
static void first(Object obj) {
    switch (obj) {
        case String s ->
            System.out.println("A string: " + s);
        case CharSequence cs ->
            System.out.println("A sequence of length " + cs.length());
        default -> {
            break;
        }
    }
}

이 예제에서는 객체 값이 String type이면 첫 번째 case label이 적용되고 String type이 아닌 CharSequence type이면 두 번째 pattern label이 적용된다.

하지만 이 두 case label의 순서를 바꾸면 어떻게 될까?

// As of Java 21
static void error(Object obj) {
    switch (obj) {
        case CharSequence cs ->
            System.out.println("A sequence of length " + cs.length());
        case String s ->    // Error - pattern is dominated by previous pattern
            System.out.println("A string: " + s);
        default -> {
            break;
        }
    }
}

이제 객체 값이 String type인 경우 switch block에서 가장 먼저 나타나므로 CharSequence case label이 적용된다.
String case label은 selector 표현식의 값을 선택하게 하는 값이 없다는 점에서 도달할 수 없다.
연결할 수 없는 코드와 유사하게이는 프로그래머 오류로 간주되어 compile-time error가 발생한다.

좀 더 정확하게 말하면, 첫 번째 case label case 구분 기호인 CharSequence cs 가 두 번째 case label 구분 기호인 String s 를 지배한다고 말할 수 있는데, 이는 String s pattern과 일치하는 모든 값이 CharSequence cs pattern과도 일치하지만 그 반대의 경우는 아니기 때문이다.
이는 두 번째 pattern의 type인 String 이 첫 번째 pattern의 type인 CharSequence 의 subtype이기 때문이다.

unguarded pattern case label은 동일한 pattern을 가진 guarded pattern case label 보다 우선한다.
예를 들어 (unguarded) pattern case label case String s 는 guarded pattern case label case String when s.length() > 0 에 일치하는 모든 값이 case String s when s.length() > 0 에 일치해야 하기 때문에 guarded pattern case label case String s 보다 우위에 있다.

guarded pattern case label은 전자의 pattern이 후자의 pattern을 지배하고 guard가 참이라는 값의 constant 표현인 경우에만 다른 pattern case label (guard 또는 unguard)를 지배한다.
예를 들어 guarded pattern case label String s 가 pattern case label String s 를 지배하는 경우 일반적으로 결정할 수 없는 문제인 pattern label과 일치하는 값을 보다 정확하게 결정하기 위해 guard 표현식을 더 이상 분석하지 않는다.

pattern case label은 constant case label을 지배할 수 있다.
예를 들어 A가 enum class type E의 멤버인 경우 pattern case label Integer i 가 constant case label case 42 를 지배하고 pattern case label case E 가 constant case label case A 를 지배한다.
guard가 없는 동일한 pattern case label이 있는 경우 guarded pattern case label이 constant case label을 지배한다.
즉 일반적으로 결정할 수 없으므로 guard를 확인하지 않는다.
예를 들어 pattern case label case String s when s.length() > 1 는 예상대로 constant case label case "hello" 를 지배하지만 case Integer i when i != 0 은 case label case 0 을 지배한다.

이 모든 것은 constant case label이 guarded pattern case label앞에 표시되고 guarded pattern case label이 unguarded pattern case label 앞에 표시되어야 하는 간단하고 예측 가능하며 읽기 쉬운 case label 순서를 제안한다.

// As of Java 21
Integer i = ...
switch (i) {
    case -1, 1 -> ...                   // Special cases
    case Integer j when j > 0 -> ...    // Positive integer cases
    case Integer j -> ...               // All the remaining integers
}

compiler는 모든 case label을 확인한다.
switch block의 case label이 해당 switch block의 선행 case label에 의해 지배되는 것은 compil 시 오류이다.
이 우세 요건은 switch block에 type pattern case label만 포함된 경우 subtype 순서로 표시되도록 한다.

(우세 개념은 type문의 catch 절에 대한 조건과 유사하며, exception class E를 잡는 catch 절 앞에 E 또는 E의 상위 class를 잡을 수 있는 catch 절이 오면 오류이다.
논리적으로 앞의 catch 절이 뒤의 catch 절보다 우선한다.)

또한 switch 식 또는 switch 문의 switch block에 match-all switch label이 두 개 이상 있는 것도 compile-time error이다.
match-all label은 pattern이 selector 표현식과 무조건 일치하는 기본 및 pattern case label이다.
예를 들어 type pattern String s 는 String type의 selector 표현식과 무조건 일치하고 type pattern Object o 는 모든 참조 유형의 selector 표현식과 무조건 일치한다.

// As of Java 21
static void matchAll(String s) {
    switch(s) {
        case String t:
            System.out.println(t);
            break;
        default:
            System.out.println("Something else");  // Error - dominated!
    }
}

static void matchAll2(String s) {
    switch(s) {
        case Object o:
            System.out.println("An Object");
            break;
        default:
            System.out.println("Something else");  // Error - dominated!
    }
}

Exhaustiveness of switch expressions and statements

Type coverage

switch 표현식은 selector 표현식의 가능한 모든 값을 switch block에서 처리해야 한다.
즉, 완전해야 한다.
이렇게 하면 switch 표현식을 성공적으로 평가하면 항상 값이 산출된다는 속성을 유지할 수 있다.

일반 switch 표현식의 경우, 이 속성은 switch block의 간단한 추가 조건 집합에 의해 적용된다.

pattern switch 표현식 및 문의 경우 switch block에서 switch label의 type coverage 개념을 정의하여 이를 구현한다.
그런 다음 switch block에 있는 모든 switch label의 type 적용 범위를 결합하여 switch block이 selector 표현식의 모든 가능성을 소진하는지 여부를 결정한다.

이 (잘못된) pattern switch 표현식을 가정해 보자.

// As of Java 21
static int coverage(Object obj) {
    return switch (obj) {           // Error - not exhaustive
        case String s -> s.length();
    };
}

switch block에는 단 하나의 switch label, case String s만 있다.
이는 type이 String의 하위 유형인 객체의 모든 값과 일치한다.
따라서 이 switch label type coverage는 String의 모든 subtype이라고 할 수 있다.
이 pattern switch 표현식은 switch block의 type 범위(String의 모든 하위 유형)에 selector 표현식(OBject)의 유형이 포함되지 않으므로 완전하지 않다.

이 (여전히 잘못된) 예를 생각해 보자.

// As of Java 21
static int coverage(Object obj) {
    return switch (obj) {           // Error - still not exhaustive
        case String s  -> s.length();
        case Integer i -> i;
    };
}

이 switch block의 type coverage는 두 switch label의 coverage를 합한 것이다.
즉, type coverage는 String의 모든 subtype 집합과 Integer의 모든 subtype 집합이다.
그러나 type coverage에 여전히 selector 표현식의 유형이 포함되지 않으므로 이 pattern switch 표현식도 완전하지 않으며 compile-time error가 발생한다.

default label의 type coverage는 모든 type이므로 이 예제는 (마침내!) 유효하다.

// As of Java 21
static int coverage(Object obj) {
    return switch (obj) {
        case String s  -> s.length();
        case Integer i -> i;
        default -> 0;
    };
}

Exhaustiveness in practice

type coverage라는 개념은 non-pattern switch 표현식에도 이미 존재한다.
예를 들면 다음과 같다.

// As of Java 20
enum Color { RED, YELLOW, GREEN }

int numLetters = switch (color) {   // Error - not exhaustive!
    case RED -> 3;
    case GREEN -> 5;
}

enum class에 대한 이 switch 표현식은 예상되는 입력 YELLOW가 포함되지 않기 때문에 완전하지 않다.
예상대로 YELLOW enum constant를 처리하기 위해 case label을 추가하면 switch를 완전하게 만드는데 충분하다.

// As of Java 20
int numLetters = switch (color) {   // Exhaustive!
    case RED -> 3;
    case GREEN -> 5;
    case YELLOW -> 6;
}

이러한 방식으로 작성된 switch는 두 가지 중요한 이점이 있다.

첫째, 이미 모든 경우를 처리했기 때문에 exception을 던지는 기본 적을 작성해야 하는 것은 번거로울 수 있다.

int numLetters = switch (color) {
    case RED -> 3;
    case GREEN -> 5;
    case YELLOW -> 6;
    default -> throw new ArghThisIsIrritatingException(color.toString());
}

이 상황에서 기본 절을 수동으로 작성하는 것은 짜증스러울 뿐만 아니라 compiler가 기본 절 없이도 완정성을 더 잘 검사할 수 있기 때문에 실제로는 해롭다.
(case default, case null, default 또는 unconditional type pattern과 같은 다른 match-all 절의 경우에도 마찬가지이다.)
기본 절을 생략하면 case label을 잊어버렸는지 runtime에 발견하지 않고 complie 시점에 발견하게 되며, 심지어는 발견하지 못할 수도 있다.

더 중요한 것은 나중에 누군가 Color enum에 다른 constasnt를 추가하면 어떻게 될까?
명시적으로 일치하는 모든 절을 사용하면 runtime에 새 constant 값이 표시되는 경우에만 발견할 수 있다.
하지만 compile 시점에 알려진 모든 constant가 포함하도록 switch를 코딩하고 match-all 절을 생략하면 다음에 switch가 포함된 class를 다시 compile 할 때 이 변경 사항을 알 수 있다.
match-all 절은 완전성 오류를 덮어버릴 위험이 있다.

결론적으로 가능하면 match-all 절이 없는 전체 switch가 match-all 절이 있는 switch 보다 낫다.

runtime을 살펴볼 때 새로운 Color constant가 추가되었는데 switch가 포함된 class가 다시 compile 되지 않으면 어떻게 될까?
새 constant가 switch에 노출될 위험이 있다.
이러한 위험은 enum에 항상 존재하므로 enum switch에 일치 절이 없는 경우 compiler는 예외를 던지는 기본 절을 합성한다.
이렇게 하면 절 중 하나를 선택하지 않으면 switch가 정상적으로 완료되지 않는다.

완전성의 개념은 모든 합리적인 경우를 포괄하는 동시에 실제 가치가 거의 없는 드문 corner case를 많이 작성하도록 강요하지 않으면서도 코드를 오염시키거나 심지어 지배하지 않도록 균형을 맞추기 위해 고안된 개념이다.
다시 말해 완전성은 실제 runtime 완전성에 대한 compile-time 근사치이다.

Exhaustiveness and sealed classes

selector 표현식의 type이 sealed class 인 경우 type coverage check에서 sealed class의 허용 절을 고려하여 switch block이 포괄적인지 여부를 결정할 수 있다.
이렇게 하면 때때로 default 절의 필요성을 제거할 수 있으며, 이는 위에서 설명한 대로 좋은 관행이다.
세 개의 허용된 subclass A, B, C가 있는 sealed interface S의 다음 예를 보자.

// As of Java 21
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {}    // Implicitly final

static int testSealedExhaustive(S s) {
    return switch (s) {
        case A a -> 1;
        case B b -> 2;
        case C c -> 3;
    };
}

compiler는 switch block의 type coverage가 A, B, C type임을 확인할 수 있다.
selector 표현식 S의 type은 허용되는 subclass가 정확히 A, B, C인 sealed interface이므로 이 switch block은 포괄적이다.
따라서 default label이 필요하지 않다.

허용된 direct subclass가 (generic) sealed superclass의 특정 매개변수화만 구현하는 경우 약간의 주의가 필요하다.
예를 들면 다음과 같다.

// As of Java 21
sealed interface I<T> permits A, B {}
final class A<X> implements I<String> {}
final class B<Y> implements I<Y> {}

static int testGenericSealedExhaustive(I<Integer> i) {
    return switch (i) {
        // Exhaustive as no A case possible!
        case B<Integer> bi -> 42;
    };
}

허용되는 I의 subclass는 A와 B 뿐이지만 compiler는 selector 표현식이 I type이고 A의 매개변수화가 I의 subtype이 아니므로 switch block이 B class만 포괄하면 충분하다는 것을 감지할 수 있다.

다시 말하지만, 완전성의 개념은 근사치이다.
별도의 compile로 인해 interface I의 새로운 구현이 runtime에 표시될 수 있으므로 compiler는 이 경우 throw 하는 합성 기본 절을 삽입한다.

record pattern은 중첩될 수 있기 때문에 완전성 개념은 record pattern(JEP 440)에 의해 더 복잡해진다.
따라서 완전성 개념은 이러한 잠재적 재귀 구조를 반영해야 한다.

Exhaustiveness and compatibility

완전성 요건은 pattern switch 표현식과 pattern switdch 문 모두에 적용된다.
이전 버전과의 호환성을 보장하기 위해 기존의 모든 switch 문은 변경되지 않고 compile 된다.
그러나 switch 문이 이 JEP에 설명된 switch 개선 사항 중 하나를 사용하는 경우 compiler가 완전성 여부를 확인한다.
(향후 Java 언어의 compiler는 완전하지 않은 legacy switch 문에 대해 경고를 표시할 수 있다.)

보다 정확하게는 pattern 또는 null label을 사용하거나 selector 표현식이 legacy type (char, byte, short, int, Character, Byte, Short, Integer 또는 enum type) 중 하나가 아닌 모든 switch 문에 대해 완전성이 요구된다.
예를 들면 다음과 같다.

// As of Java 21
sealed interface S permits A, B, C {}
final class A implements S {}
final class B implements S {}
record C(int i) implements S {}    // Implicitly final

static void switchStatementExhaustive(S s) {
    switch (s) {                   // Error - not exhaustive;
                                   // missing clause for permitted class B!
        case A a :
            System.out.println("A");
            break;
        case C c :
            System.out.println("C");
            break;
    };
}

Scope of pattern variable declarations

pattern variable (JEP 394)는 pattern으로 선언되는 local variable이다.
pattern variable 선언은 그 범위가 흐름에 민감하다는 점에서 특이하다.
다시 한번 정리하자면 다음 예제에서 type pattern String s가 pattern variable s를 선언하는 경우를 생각해 보자.

// As of Java 21
static void testFlowScoping(Object obj) {
    if ((obj instanceof String s) && s.length() > 3) {
        System.out.println(s);
    } else {
        System.out.println("Not a string");
    }
}

s의 선언은 pattern variable s가 초기화될 코드 부분의 범위 내에 있다.
이 예에서는 && 표현식의 오른쪽 피연산자와 "then" block에 있다.
그러나 s는 "else" block의 범위 내에 있지 않다.
"else" block으로 제어권이 넘어가려면 pattern match가 실패해야 하며, 이 경우 pattern variable이 초기화되지 않을 것이다.

pattern variable 선언에 대한 이러한 흐름에 민감한 범위 개념을 확장하여 세 가지 새로운 규칙을 통해 case label에서 발생하는 pattern 선언을 포함하도록 한다.

  1. guarded case label에서 발생하는 pattern variable 선언의 범위에는 guard 즉 when 표현식이 포함된다.
  2. switch 규칙의 case label에서 발생하는 pattern variable 선언의 범위에는 화살표 오른쪽에 표시되는 표현식, block 또는 throw 문이 포함된다.
  3. switch label이 지정된 문 그룹의 case label에서 발생하는 pattern variable 선언의 범위에는 해당 문 그룹의 block 문이 포함된다.
    pattern variable을 선언하는 case label을 통과하는 것은 금지되어 있다.

이 예에서는 첫 번째 규칙의 적용을 보여준다.

// As of Java 21
static void testScope1(Object obj) {
    switch (obj) {
        case Character c
        when c.charValue() == 7:
            System.out.println("Ding!");
            break;
        default:
            break;
    }
}

pattern variable c의 선언 범위에는 guard, 즉 c.charValue() == 7 식이 포함된다.

다음 변형은 두 번째 규칙의 적용을 보여준다.

// As of Java 21
static void testScope2(Object obj) {
    switch (obj) {
        case Character c -> {
            if (c.charValue() == 7) {
                System.out.println("Ding!");
            }
            System.out.println("Character");
        }
        case Integer i ->
            throw new IllegalStateException("Invalid Integer argument: "
                                            + i.intValue());
        default -> {
            break;
        }
    }
}

여기서 pattern variable c의 선언 범위는 첫 번째 화살표 오른쪽에 있는 block이다.
pattern variable i의 선언 범위는 두 번째 화살표 오른쪽에 있는 throw 문이다.

세 번째 규칙은 더 복잡하다.
먼저 switch label이 지정된 문 그룹에 case label이 하나만 있는 예를 살펴보자.

// As of Java 21
static void testScope3(Object obj) {
    switch (obj) {
        case Character c:
            if (c.charValue() == 7) {
                System.out.print("Ding ");
            }
            if (c.charValue() == 9) {
                System.out.print("Tab ");
            }
            System.out.println("Character");
        default:
            System.out.println();
    }
}

pattern variable c의 선언 범위에는 문 그룹의 모든 문, 즉 두 개의 if 문과 println 문이 포함된다.
첫 번째 문 그룹의 실행이 default switch label을 통과하여 이러한 문을 실행할 수 있지만 범위에는 default 문 그룹의 문이 포함되지 않는다.

pattern variable을 선언하는 case label을 통과할 수 있는 가능성을 금지한다.
이 잘못된 예는 다음과 같다.

// As of Java 21
static void testScopeError(Object obj) {
    switch (obj) {
        case Character c:
            if (c.charValue() == 7) {
                System.out.print("Ding ");
            }
            if (c.charValue() == 9) {
                System.out.print("Tab ");
            }
            System.out.println("character");
        case Integer i:                 // Compile-time error
            System.out.println("An integer " + i);
        default:
            break;
    }
}

이를 허용하고 객체의 값이 Character인 경우 switch block의 실행은 pattern variable i가 초기화되지 않았을 경우 Integer i 다음에 두 번째 문 그룹을 통과할 수 있다.
따라서 pattern variable을 선언하는 case label을 통해 실행을 허용하면 compile-time error가 발생한다.

이것이 바로 여러 개의 pattern label로 구성된 switch label(예: case String c: case Integer i: ...)이 허용되지 않는 이유이다.
단일 case label 내에서 여러 pattern을 금지하는 것도 비슷한 논리가 적용된다.
case Character c, Integer i: ...case Character c, Integer i -> ... 모두 허용되지 않는다.
이러한 case label이 허용되면 콜론 또는 화설표 뒤의 범위에 c와 i가 모두 포함되지만 객체의 값이 문자 또는 정수인지에 따라 둘 중 하나만 초기화된다.

반면에 이 예제에서 볼 수 있듯이 pattern variable을 선언하지 않는 label을 통과하는 것은 안전하다.

// As of Java 21
void testScope4(Object obj) {
    switch (obj) {
        case String s:
            System.out.println("A string: " + s);  // s in scope here!
        default:
            System.out.println("Done");            // s not in scope here
    }
}

Dealing with null

일반적으로 switch는 selector 표현식이 null로 평가되면 NullPointerException을 던진다.
이는 잘 알려진 동작이며 기존 switch 코드에 대한 변경을 제안하지 않는다.
그러나 pattern matching과 null 값에 대한 합리적이고 Exception을 발생시키지 않는 시맨틱이 있으므로 pattern switch block에서 기존 switch 시맨틱과 호환성을 유지하면서 보다 일반적인 방식으로 null을 처리할 수 있다.

먼저 새로운 null case label을 도입한다.
그런 다음 selector 표현식의 값이 null이면 switch가 즉시 NullPointerException을 던진다는 포괄적인 규칙을 해제한다.
대신 case label을 검사하여 switch의 동작을 결정한다.

  • selector 표현식이 null로 평가되면 case label이 모두 일치하는 것으로 간주된다.
    switch block과 연관된 label이 없으면 switch는 이전과 마찬가지로 NullPointerException을 던진다.
  • selector 표현식이 null이 아닌 값으로 평가되면 정상적으로 일치하는 case label을 선택한다.
    일치하는 case label이 없으면 모든 default label이 일치하는 것으로 간주된다.

예를 들어 아래 선언이 주어졌을 때 nullMatch(null)을 평가하면 NullPointerException을 던지는 대신 null! 을 출력한다.

// As of Java 21
static void nullMatch(Object obj) {
    switch (obj) {
        case null     -> System.out.println("null!");
        case String s -> System.out.println("String");
        default       -> System.out.println("Something else");
    }
}

case null label이 없는 switch block은 본문이 NullPointerException을 던지는 case null 규칙이 있는 것처럼 취급된다.
즉 다음의 코드는

// As of Java 21
static void nullMatch2(Object obj) {
    switch (obj) {
        case String s  -> System.out.println("String: " + s);
        case Integer i -> System.out.println("Integer");
        default        -> System.out.println("default");
    }
}

다음과 동일하다.

// As of Java 21
static void nullMatch2(Object obj) {
    switch (obj) {
        case null      -> throw new NullPointerException();
        case String s  -> System.out.println("String: " + s);
        case Integer i -> System.out.println("Integer");
        default        -> System.out.println("default");
    }
}

두 예제 모두에서 nullMatch(null)을 평가하면 NullPointerException이 발생하게 된다.

기존 switch 구조의 직관성을 유지하여 null에 대해 switch를 수행하는 것은 예외적인 작업이다.
pattern switch의 차이점은 switch 내부에서 이 경우를 직접 처리할 수 있다는 점이다.
switch blcok에 null label이 표시되면 해당 label은 null 값과 일치한다.
switch block에 null label이 표시되지 않으면 이전과 마찬가지로 null 값으로 switching 하면 NullPointerException이 발생한다.
따라서 switch block에서 null 값 처리가 정규화되었다.

null case와 default를 결합하는 것은 의미 있는 일이며 드문 일은 아니다.
이를 위해 null case label에 optional default를 허용한다.

// As of Java 21
Object obj = ...
switch (obj) {
    ...
    case null, default ->
        System.out.println("The rest (including null)");
}

객체의 값이 null 참조값이거나 다른 case label이 일치하지 않는 경우 이 label과 일치한다.

switch block에 default가 함께 있는 null case label과 default label이 함께 있는 경우 compile-time error가 발생한다.

Errors

pattern matching이 갑자기 완료될 수 있다.
예를 들어 record pattern에 대해 값을 일치시킬 때 record의 accessor method가 갑자기 완료될 수 있다.
이 경우 pattern matching은 MatdchException을 던져 갑작스럽게 완료되도록 정의된다.
이러한 pattern이 switch의 label로 나타나면 switch도 MatchException을 던져 갑작스럽게 완료된다.

case pattern에 guard가 있고 guard 평가가 갑자기 완료되면 같은 이유로 switch도 갑자기 완료된다.

pattern switch의 label이 selector 표현식의 값과 일치하지 않으면 pattern switch는 완전해야 하므로 MatchException을 던저 switch가 갑작스럽게 완료된다.

예를 들면

// As of Java 21
record R(int i) {
    public int i() {    // bad (but legal) accessor method for i
        return i / 0;
    }
}

static void exampleAnR(R r) {
    switch(r) {
        case R(var i): System.out.println(i);
    }
}

exampleAnR(new R(42))MatchException 을 던진다.
(항상 예외를 던지는 record accessor method는 매우 불규칙하며, MatchException을 던지는 철저한 pattern switch는 매우 드문 경우이다.)

이와는 대조적으로

// As of Java 21
static void example(Object obj) {
    switch (obj) {
        case R r when (r.i / 0 == 1): System.out.println("It's an R!");
        default: break;
    }
}

example(new R(42))ArithmeticException 을 발생시킨다.

pattern switch 시맨틱과 일치하도록 enum class에 대한 switch 표현식은 이제 runtime에 switch label이 적용되지 않을 때 IncompatibleClassChangeError 대신 MatchException을 던진다.
이는 호환되지 않는 사소한 언어 변경이다.
(enum에 대한 완전한 switch는 switch가 컴파일된 후에 enum class가 변경된 경우에만 일치하지 않는데 이는 매우 드문 경우이다.)

반응형