- 2023년 9월 19일 출시된 Java 21은 record patterns와 switch pattern matching을 통해 Kotlin·Rust·C#에 가까운 함수형 패턴 표현을 Java 안으로 끌어옴
- Java 14의 switch expressions, Java 16의 records와
instanceof pattern matching, Java 17의 sealed classes가 누적되며 대수적 데이터 타입을 다룰 기반이 Java 21에서 맞물림
- records는 final·불변 참조·정형화된 getter 같은 제약으로 데이터를 안정적으로 분해하게 만들고, record pattern은 중첩 데이터를 switch에서 바로 꺼낼 수 있게 함
- sealed classes/interfaces는 허용된 하위 타입만 열어 sum type에 가까운 모델을 만들며, sealed interface와 record를 함께 쓰면 RGB·CMYK·YUV·HSL 같은 변형을 제한할 수 있음
- Java 21의 switch는
null case와 when guard까지 지원하지만, 잘못된 record accessor나 guard 실행 중 예외는 java.lang.MatchException 으로 이어질 수 있음
Java 21에서 안정화된 패턴 매칭
- Java 21은 2023년 9월 19일 출시됐고, switch 블록과 switch 표현식에서 record patterns를 지원함
- 이 문법은 Java에서도 Kotlin, Rust, C#과 비슷한 방식으로 함수형 프로그래밍 패턴을 표현할 수 있는 전환점으로 평가됨
- 최근 Java 버전의 주요 문법 변화는 Java 21의 패턴 매칭으로 이어짐
- Java 14: switch expressions 안정화
- Java 16: records,
instanceof pattern matching 안정화
- Java 17: sealed classes 안정화
- Java 21: record patterns, switch pattern matching 안정화
- 이 변화 묶음으로 Java는 이전에 표현하기 어려웠던 대수적 데이터 타입(algebraic data types) 과 그 관용적 사용 방식을 다룰 수 있게 됨
타입 이론에서 필요한 최소 개념
- Java 21의 기능을 이해하려면 몇 가지 타입 이론 개념이 필요함
- bottom/empty type은 계산될 수 없는 값의 집합을 나타내며, 일반적인 프로그래밍 언어에서는 보통 빈 집합임
- Kotlin의
Nothing은 생성자가 private이라 인스턴스가 존재할 수 없음
- Java의
Void는 생성자가 private이지만 null을 담을 수 있어 진짜 bottom type으로 보기 어려움
- Java의 primitive
void는 변수 타입으로 사용할 수 없어 이 점에서는 더 가깝게 동작함
- top type은 모든 타입의 모든 값을 나타내는 보편 집합임
- Kotlin에서는
Any가 해당 역할을 함
- Java의
Object는 primitive가 객체 모델과 분리돼 있어 다른 언어의 top type과 같은 의미로 보기 어려움
- unit type은 값이 하나뿐인 타입임
- Java의
void는 메서드 반환에서는 unit type처럼 다룰 수 있지만, 매개변수 타입으로 전달할 수 없음
- Kotlin의
Unit은 object로 정의되며 메서드 매개변수로도 사용할 수 있음
- boolean type은
true와 false 두 값을 가지며, nullable unit type으로도 표현할 수 있지만 실용적이지 않음
Product type과 Java records
- product type은 둘 이상의 구성 타입을 묶은 타입이며, 구성 타입의 수가 arity 또는 degree가 됨
- C의
struct는 product type의 예시임
int, char *, double, int처럼 구성 타입이 반복될 수 있음
- 반복되는 타입은 필드 이름과 함께 ordered pair로 생각하면 구분 가능함
- Python이나 Rust의 tuple도 product type으로 볼 수 있으며, 이 경우 구성 요소의 이름 역할을 인덱스가 맡음
- Java 16에서 안정화된 record class는 product type의 좋은 예시임
- record의 필드는 final이며, record는 상속될 수 없음
- record의 상태는 생성 시점에 설정되고 이후 유지됨
- 단, record 내부에 mutable data type을 넣으면 그 내용의 불변성까지 보장되지는 않음
- 일반 Java class는 public/private 상태, 상속으로 생기는 숨은 상태, mutable/static field, 비표준 getter가 섞일 수 있어 구성 요소를 일반화하기 어려움
- records는 다음 제약으로 패턴 매칭 같은 언어 기능이 안정적으로 동작할 구조를 보장함
- record는 암묵적으로 final class이며 상속될 수 없음
java.lang.Record 외의 클래스를 extend할 수 없음
- record component에는 visibility modifier를 붙일 수 없음
- component 참조는 항상 final이며 불변으로 취급됨
- 기본 getter는 필드 이름을 그대로 사용하며,
a 필드의 getter는 a()가 됨
- backing field는 암묵적으로 private이고 getter를 통해 접근됨
record pattern으로 중첩 데이터 분해
- Java 21의 switch pattern은
instanceof 검사와 명시적 cast를 반복하지 않고 중첩 record 데이터를 분해함
- 예시에서는
record A(Record inner), record B(char b), record SomeOtherRecord()를 사용함
- 기존 방식은
if (r instanceof A) 이후 cast하고, 내부 값에 대해 다시 instanceof와 cast를 반복함
- switch pattern은
case A(B(char a)) -> String.valueOf(a)처럼 중첩 값을 바로 추출함
- switch 블록은 if-else ladder보다 구조가 명확하며, 깊게 중첩된 데이터를 빠르게 꺼내는 데 적합함
- Java 21에서 직접 실행하려면
main.java에 코드를 넣고 다음 명령을 사용할 수 있음
java --enable-preview --source 21 main.java
Sum type과 sealed classes/interfaces
- 제한된 선택지를 표현할 때 Java enum을 사용할 수 있지만, RGB·HSL·YUV·CMYK처럼 서로 다른 데이터 구조를 가진 색상 표현은 enum만으로 다루기 번거로움
- 상속 기반 다형성으로
Color 추상 클래스와 RGB, CMYK, YUV, HSL 클래스를 만들 수 있지만, 일반 class hierarchy는 열려 있음
- 라이브러리 사용자가
RYB 같은 새 클래스를 만들어 Color를 상속할 수 있음
- API가 확장을 의도하지 않았다면 새 변형이 크래시나 멀리 떨어진 코드의 미묘한 버그를 일으킬 수 있음
- sealed classes는 sum type 개념을 Java에서 표현하는 데 쓰임
- sum type은 한 시점에 구성 요소 중 하나일 수 있는 타입임
- tagged union type이라고도 불림
sealed modifier와 permits clause를 사용하면 특정 class만 상속을 허용할 수 있음
public sealed class Color permits RGB, CMYK, YUV, HSL {
}
- sealed class hierarchy에서는 직접 또는 간접 상속자가
sealed, non-sealed, final 중 하나를 가져야 하며, 없으면 compile error가 발생함
sealed: permits에 이름이 있는 타입만 상속 가능
non-sealed: 일반 class처럼 상속 가능
final: 상속 트리의 leaf로 더 이상 확장 불가
sealed interface와 record를 함께 쓰는 방식
- switch pattern matching의 destructuring은 records에 동작하지만, records는
Record 외의 클래스를 상속할 수 없음
- 해결책은 sealed interface를 사용하는 것임
- sealed interface는 sealed class와 비슷하게 동작함
- records와 enums도 sealed interface를 implement할 수 있음
- 예시에서는
Color를 sealed interface로 만들고 RGB, CMYK, YUV, HSL을 record로 구현함
public sealed interface Color permits RGB, CMYK, YUV, HSL {
String getDescription();
}
record RGB(int red, int green, int blue) implements Color {
public String getDescription() {
return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
}
}
- 이후 switch에서 각 record의 값을 바로 추출할 수 있음
switch (color) {
case RGB(int red, int green, int blue) -> {
}
case CMYK(double cyan, double magenta, double yellow, double black) -> {
}
case YUV(int y, int u, int v) -> {
}
case HSL hsl -> {
System.out.println(hsl.getDescription());
}
case null -> {
System.out.println("How did color become null?!");
}
}
- Java 21은 switch 블록과 표현식에서
null case를 처리할 수 있어 switch 전에 별도 null 검사를 하지 않아도 됨
Color가 sealed type이면 Java가 모든 case 처리 여부를 알 수 있어 default case 없이도 exhaustive switch가 가능함
Guard clause와 when
- Java 21은 switch arm에 추가 조건을 붙이는 guard clause를 지원함
- guard clause는
when 키워드로 case label에 조건을 통합함
switch (color) {
case RGB(int red, int green, int blue) when red > 200 -> {
System.out.println("Very red.");
}
case RGB rgb when rgb.green > 100 -> {
System.out.println("Sort of green...");
}
case RGB rgb -> {
System.out.println("Not that red...");
}
}
- 기존에는
case RGB(...) 본문 안에 다시 if (red > 200)을 넣어야 했음
- Java는 true로 평가되는 첫 번째 case를 eager하게 매칭하므로, 더 구체적인 case를 먼저 두고 덜 구체적인 case를 뒤에 두어야 함
- guard가 붙은 RGB case 뒤에는 exhaustive를 유지하기 위해 일반
case RGB rgb가 필요함
MatchException이 발생하는 경우
- Java 21의 패턴 매칭에는
java.lang.MatchException 이 추가로 관련됨
- record accessor가 예외를 던지면 switch pattern이 실패하며
MatchException이 발생할 수 있음
record R(int i) {
public int i() {
return i / 0;
}
}
static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i);
}
}
- 위 예시에서는
i() accessor가 ArithmeticException을 던지기 때문에 switch 블록이 MatchException을 던짐
- JEP 441에 따르면 항상 예외를 던지는 record accessor는 매우 비정상적이며, exhaustive pattern switch가
MatchException을 던지는 경우도 매우 이례적임
- exhaustive switch에서도 selector에 대해 지정된 variant가 하나도 매칭되지 않으면 예외가 발생할 수 있음
- JEP 441은 enum에 대한 exhaustive switch가 매칭에 실패하는 경우를 switch가 컴파일된 뒤 enum class가 변경된 상황으로 설명함
- guard clause 실행 중 예외가 발생해도
MatchException이 발생할 수 있음
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}
남은 범위
- Java 21의 records, sealed types, switch pattern matching, guard clause를 조합하면 함수형 프로그래밍의 빌딩 블록을 Java 코드에 적용할 수 있음
- generics가 switch patterns와 상호작용하는 방식 같은 일부 주제는 다루지 않음
- 다음 글에서는 Java 코드 작성 방식을 개선하는 데 사용할 수 있는 quirks와 실용적인 예시를 다룰 예정임