패턴 매칭 사용하기
패턴 매칭 사용하기
패턴 매칭(Pattern Matching)은 Java 언어에서 아직도 개발이 진행 중인 기능이에요. 이 기능의 일부 요소는 Java 언어의 최종(final) 기능으로 출시됐고, 일부는 프리뷰(preview) 기능으로 나왔으며, 일부는 아직 논의 중이에요.
패턴 매칭에 대해 더 배우고 피드백을 주고 싶다면 Amber 프로젝트 페이지를 방문해야 해요. Amber 프로젝트 페이지는 Java 언어의 패턴 매칭과 관련된 모든 것의 집결지입니다.
패턴 매칭이 처음이라면, 아마 정규 표현식(regular expression)의 패턴 매칭을 먼저 떠올릴 거예요. 그렇다면 이런 의문이 들 수도 있죠. "그게 'Pattern Matching for instanceof'와 무슨 상관이지?" 정규 표현식은 문자열을 분석하기 위해 만들어진 한 형태의 패턴 매칭이에요. 이해하기 쉽고 좋은 출발점이죠.
본문
패턴 매칭 소개
먼저 다음 코드를 작성해볼게요.
String sonnet = "From fairest creatures we desire increase,\n" +
"That thereby beauty's rose might never die,\n" +
"But as the riper should by time decease\n" +
"His tender heir might bear his memory:\n" +
"But thou, contracted to thine own bright eyes,\n" +
"Feed'st thy light's flame with self-substantial fuel,\n" +
"Making a famine where abundance lies,\n" +
"Thyself thy foe, to thy sweet self too cruel.\n" +
"Thou that art now the world's fresh ornament,\n" +
"And only herald to the gaudy spring,\n" +
"Within thine own bud buriest thy content,\n" +
"And, tender churl, mak'st waste in niggardly.\n" +
"Pity the world, or else this glutton be,\n" +
"To eat the world's due, by the grave and thee.";
Pattern pattern = Pattern.compile("\\bflame\\b");
Matcher matcher = pattern.matcher(sonnet);
while (matcher.find()) {
String group = matcher.group();
int start = matcher.start();
int end = matcher.end();
IO.println(group + " " + start + " " + end);
}
이 코드는 셰익스피어의 첫 소네트를 텍스트로 삼아요. 이 텍스트를 정규 표현식 \bflame\b로 분석해요. 이 정규 표현식은 \b로 시작하고 끝나요. \b라는 이스케이프 문자는 정규 표현식에서 특별한 의미를 갖습니다. 단어의 시작이나 끝을 나타내죠. 이 예에서는 이 패턴이 flame이라는 단어에 매칭된다는 뜻이에요.
정규 표현식으로는 훨씬 더 많은 일을 할 수 있어요. 하지만 그건 이 튜토리얼의 범위를 벗어나요. 정규 표현식에 대해 더 배우고 싶다면 Regular Expressions 페이지를 확인해보세요.
이 코드를 실행하면 다음과 같이 출력돼요.
flame 233 238
이 결과는 소네트에서 flame이 인덱스 233과 238 사이에 정확히 한 번 나타난다는 걸 알려줘요.
정규 표현식으로 하는 패턴 매칭은 이렇게 동작해요.
- 주어진 패턴에 매칭돼요. 여기 예에서는
flame이 그 패턴이고, 이를 텍스트에 매칭하죠. - 그다음 패턴이 매칭된 위치에 대한 정보를 알려줘요.
이 튜토리얼의 나머지 부분을 위해 기억해둘 개념이 세 가지 있어요.
- 무엇을 매칭할 것인가 — 이를 **매칭 대상(matched target)**이라고 불러요. 여기서는 소네트예요.
- 무엇에 대해 매칭할 것인가 — 이를 **패턴(pattern)**이라고 불러요. 여기서는 정규 표현식
flame이죠. - 매칭의 결과 — 여기서는 시작 인덱스와 끝 인덱스예요.
이 세 요소가 패턴 매칭의 근본적인 요소입니다.
instanceof를 위한 패턴 매칭
instanceof로 객체를 타입에 매칭하기
패턴 매칭을 확장하는 방법은 여러 가지가 있어요. 그중 첫 번째로 다룰 것은 Pattern matching for instanceof인데, Java SE 16에서 최종 기능으로 출시됐어요.
앞선 섹션의 예제를 instanceof 사용 사례로 확장해볼게요. 다음 예를 살펴보죠.
public void print(Object o) {
if (o instanceof String s){
IO.println("This is a String of length " + s.length());
} else {
IO.println("This is not a String");
}
}
여기서 아까 말한 세 요소를 설명할게요.
매칭 대상은 어떤 타입의 객체든 상관없는 객체예요. instanceof 연산자의 왼쪽 피연산자, 즉 o죠.
패턴은 타입과 그 뒤에 오는 변수 선언이에요. instanceof의 오른쪽에 위치하죠. 타입은 클래스, 추상 클래스, 인터페이스가 될 수 있어요. 이 경우에는 String s예요.
매칭의 결과는 매칭 대상에 대한 새로운 참조예요. 이 참조는 패턴의 일부로 선언된 변수, 이 예에서는 s에 담겨요. 매칭 대상이 패턴에 매칭되면 이 변수가 생성돼요. 이 변수는 매칭한 타입을 갖고 있어요. s 변수를 패턴의 **패턴 변수(pattern variable)**라고 불러요. 어떤 패턴은 패턴 변수가 하나 이상일 수도 있어요.
이 예에서 매칭해야 할 요소는 변수 o, 즉 여러분의 매칭 대상이에요. 패턴은 String s 선언이죠. 매칭의 결과는 String 타입과 함께 선언된 변수 s예요. 이 변수는 o가 String 타입일 때만 생성돼요.
instanceof와 함께 타입과 변수를 함께 선언할 수 있는 이 특별한 문법은 Java SE 16에 추가된 새로운 문법이에요.
String s 패턴은 매칭 대상의 타입을 검사하므로 **타입 패턴(type pattern)**이라고 불러요. String 타입이 CharSequence 타입을 확장하기 때문에, 다음 패턴도 매칭된다는 점 참고하세요.
public void print(Object o) {
if (o instanceof CharSequence cs) {
IO.println("This is a CharSequence of length " + cs.length());
}
}
패턴 변수 사용하기
컴파일러는 변수 s를 쓰는 것이 의미 있는 어디서든 사용하도록 허용해요. 가장 먼저 떠오르는 범위는 if 분기죠. 그런데 알고 보면 이 변수를 if 문의 일부 구간에서도 쓸 수 있어요.
다음 코드는 object가 String 클래스의 인스턴스인지, 그리고 비어 있지 않은 문자열인지 확인해요. 보시다시피 && 뒤의 boolean 표현식에서 변수 s를 사용하고 있어요. 첫 부분이 true일 때만 boolean 표현식의 이 부분을 평가하니 전적으로 합리적이에요. 그 경우에 변수 s가 생성되니까요.
public void print(Object object) {
if (object instanceof String s && !s.isEmpty()) {
int length = s.length();
IO.println("This object is a non-empty string of length " + length);
} else {
IO.println("This object is not a string.");
}
}
변수의 실제 타입을 검사하고, 그 타입이 기대한 것이 아니면 나머지 코드를 건너뛰는 경우가 있어요. 다음 예를 볼게요.
public void print(Object o) {
if (!(o instanceof String)) {
return;
}
String s = (String)o;
// do something with s
}
Java SE 16부터는 instanceof를 위한 패턴 매칭을 활용해 이 코드를 이렇게 작성할 수 있어요.
public void print(Object o) {
if (!(o instanceof String s)) {
return; // this code exits the method here...
}
// ... so s is available here
IO.println("This is a String of length " + s.length());
}
패턴 변수 s는, 코드가 if 분기에서 메서드를 빠져나가는 한(return으로 빠지거나 예외를 던져서) if 문 밖에서도 사용할 수 있어요. 만약 코드가 if 분기를 실행한 뒤에도 메서드의 나머지를 계속 진행할 수 있다면, 패턴 변수는 생성되지 않아요. 두 번째 예제가 그걸 보여줘요.
public void print(Object o) {
if (!(o instanceof String s)) {
IO.println("No string: " + o); // this code does not exit the method here...
}
// ... so s is not available here, and you get a compiler error
IO.println("This is a String of length " + s.length());
}
컴파일러가 매칭이 실패할 거라고 판단할 수 있는 경우도 있어요. 다음 예를 보죠.
Double pi = Math.PI;
if (pi instanceof String s) {
// this will never be true!
}
컴파일러는 String 클래스가 final이라는 걸 알고 있어요. 그래서 변수 pi가 String 타입일 방법이 전혀 없죠. 컴파일러는 이 코드에 오류를 내요.
instanceof를 위한 패턴 매칭으로 더 깔끔한 코드 작성하기
이 기능을 사용하면 코드가 훨씬 읽기 좋아지는 곳이 많아요.
equals() 메서드를 가진 Point 클래스를 하나 만들게요. hashCode() 메서드는 여기서 생략할게요.
public class Point {
private int x;
private int y;
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
Point point = (Point) o;
return x == point.x && y == point.y;
}
// constructor, hashCode method and accessors have been omitted
}
이것은 equals() 메서드를 쓰는 전형적인 방식이에요. IDE가 생성해준다고 해도 무방한 코드죠.
이 equals() 메서드를 instanceof를 위한 패턴 매칭 기능을 활용한 다음 코드로 다시 쓰면 훨씬 읽기 좋은 코드가 돼요.
public boolean equals(Object o) {
return o instanceof Point point &&
x == point.x &&
y == point.y;
}
switch를 위한 패턴 매칭
switch 표현식을 확장해 case 레이블에 타입 패턴 사용하기
Pattern Matching for Switch는 JDK 21의 최종 기능이에요. Java SE 17, 18, 19, 20에서는 프리뷰 기능으로 소개됐죠.
Pattern Matching for Switch는 switch 문이나 switch 표현식을 사용해요. 하나의 매칭 대상을 여러 패턴에 동시에 매칭할 수 있게 해주죠. 지금까지의 패턴은 instanceof를 위한 패턴 매칭에서처럼 타입 패턴이에요.
이 경우 매칭 대상은 switch의 **선택자 표현식(selector expression)**이에요. 이런 기능에는 패턴이 여러 개 있죠. switch 표현식의 각 case는 그 자체로 타입 패턴인데, 앞선 섹션에서 설명한 문법을 따릅니다.
다음 코드를 볼게요.
Object o = ...; // any object
String formatted = null;
if (o instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (o instanceof Long l) {
formatted = String.format("long %d", l);
} else if (o instanceof Double d) {
formatted = String.format("double %f", d);
} else {
formatted = String.format("Object %s", o.toString());
}
여기 타입 패턴이 세 개 있는 걸 볼 수 있어요. 각 if 문에 하나씩이죠. Pattern matching for switch는 이 코드를 다음과 같이 쓸 수 있게 해줘요.
Object o = ...; // any object
String formatter = switch(o) {
case Integer i -> String.format("int %d", i);
case Long l -> String.format("long %d", l);
case Double d -> String.format("double %f", d);
default -> String.format("Object %s", o.toString());
};
Pattern matching for switch는 코드를 더 읽기 좋게 만들 뿐 아니라 더 빠르게 만들어요. if-else-if 문을 평가하는 비용은 그 문이 가진 분기의 개수에 비례해요. 분기를 두 배로 늘리면 평가 시간도 두 배가 되죠. 반면 switch를 평가하는 비용은 case의 개수와 무관해요. if 문의 시간 복잡도는 O(n), switch 문의 시간 복잡도는 *O(1)*이라고 말해요.
지금까지 설명한 것은 패턴 매칭 자체의 확장이 아니라, case 레이블로서 타입 패턴을 받아들이는 switch의 새로운 기능이에요.
현재 버전의 switch 표현식은 case 레이블에 다음을 받아들여요.
- 이 숫자형 타입들:
byte,short,char,int(long은 받지 않아요) - 대응하는 래퍼 타입:
Byte,Short,Character,Integer String타입- 열거형 타입
Pattern matching for switch는 case 레이블에 타입 패턴을 쓸 수 있는 가능성을 추가해요.
가드 패턴(Guarded Patterns) 사용하기
Pattern Matching for instanceof에서 이미 알고 있듯, 매칭 대상이 패턴에 매칭될 때 생성되는 패턴 변수는 instanceof를 포함한 boolean 표현식 안에서 사용할 수 있어요. 다음 예처럼요.
Object object = ...; // any object
if (object instanceof String s && !s.isEmpty()) {
int length = s.length();
IO.println("This object is a non-empty string of length " + length);
}
이건 if 문에서는 잘 동작해요. if 문의 인자는 boolean 타입이니까요. 그런데 switch 표현식의 case 레이블은 boolean이 될 수 없어요. 그래서 다음 코드는 작성할 수 없어요.
Object o = ...; // any object
String formatter = switch(o) {
// !!! THIS DOES NOT COMPILE !!!
case String s && !s.isEmpty() -> String.format("Non-empty string %s", s);
case Object o -> String.format("Object %s", o.toString());
};
그래서 pattern matching for switch는 타입 패턴 뒤에 boolean 표현식을 추가할 수 있도록 확장됐습니다. 이 boolean 표현식을 **가드(guard)**라고 하고, 그 결과로 생기는 case 레이블을 **가드된 case 레이블(guarded case label)**이라고 불러요. 이 boolean 표현식은 when 절에 다음 문법으로 추가할 수 있어요.
Object o = ...; // any object
String formatter = switch(o) {
case String s when !s.isEmpty() -> String.format("Non-empty string %s", s);
default -> String.format("Object %s", o.toString());
};
이 확장된 case 레이블을 가드된 case 레이블이라고 불러요. String s when !s.isEmpty() 표현식이 바로 그런 가드된 case 레이블이죠. 타입 패턴과 boolean 표현식으로 이뤄져 있어요.
레코드 패턴(Record Pattern)
**레코드(record)**는 Java SE 16에서 소개된, 이런 식으로 작성하는 특수한 종류의 불변 클래스예요. 이 기능에 대해 더 배우려면 Record 페이지를 방문해보세요.
**레코드 패턴(record pattern)**은 Java SE 21에서 최종 기능으로 공개된 특수한 종류의 패턴이에요. Java SE 19와 20에서는 프리뷰 기능으로 제공됐죠. 레코드는 컴포넌트(component) 위에 구축되는데, 컴포넌트는 레코드 선언의 일부로 선언돼요. 다음 예에서 Point 레코드는 x와 y라는 두 컴포넌트를 가져요.
public record Point(int x, int y) {}
이 정보는 **레코드 분해(record deconstruction)**라는 개념을 가능하게 하는데, 레코드 패턴 매칭에 사용돼요. 다음 코드는 레코드 패턴 사용의 첫 번째 예입니다.
Object o = ...; // any object
if (o instanceof Point(int x, int y)) {
// do something with x and y
}
*대상 피연산자(target operand)*는 여전히 o 참조예요. 그것을 레코드 패턴 Point(int x, int y)에 매칭해요. 이 패턴은 패턴 변수를 두 개 선언해요. x와 y죠. o가 실제로 Point 타입이라면 이 두 바인딩 변수가 생성되고, Point 레코드의 해당 접근자(accessor)를 호출해 초기화돼요. 접근자에 방어적 복사(defensive copy)가 있을 수 있으니 이 점이 중요해요.
레코드 패턴은 레코드의 이름(여기서는 Point)과, 그 레코드의 컴포넌트마다 한 개의 타입 패턴으로 구성돼요. 그래서 o instanceof Point(int x, int y)라고 쓰면, int x와 int y가 타입 패턴이 되어 Point 레코드의 첫 번째와 두 번째 컴포넌트에 매칭되는 거예요. 이 경우 원시 타입으로 타입 패턴을 정의한다는 점에 주목하세요.
레코드 패턴은 레코드의 **정규 생성자(canonical constructor)**와 같은 모델로 구축돼요. 주어진 레코드에 정규 생성자가 아닌 다른 생성자를 만들어도, 그 레코드의 레코드 패턴은 항상 정규 생성자의 문법을 따라가요. 그래서 다음 코드는 컴파일되지 않아요.
record Point(int x, int y) {
Point(int x) {
this(x, 0);
}
}
Object o = ...; // any object
// !!! THIS DOES NOT COMPILE !!!
if (o instanceof Point(int x)) {
}
레코드 패턴은 타입 추론을 지원해요. 패턴을 작성할 때 쓰는 컴포넌트의 타입은 var로 추론될 수 있고, 레코드에 선언된 실제 타입의 확장일 수도 있어요.
각 컴포넌트의 매칭이 실제로는 타입 패턴이기 때문에, 컴포넌트의 실제 타입을 확장하는 타입에 매칭할 수 있어요. 만약 레코드 컴포넌트의 실제 타입의 확장이 될 수 없는 타입을 패턴에 사용하면 컴파일 오류가 나요.
바인딩 변수의 실제 타입을 컴파일러가 추론하게 하는 첫 번째 예를 볼게요.
record Point(double x, double y) {}
Object o == ...; // any object
if (o instanceof Point(var x, var y)) {
// x and y are of type double
}
다음 예에서는 Box 레코드의 컴포넌트 타입에 따라 switch를 쓸 수 있어요.
record Box(Object o) {}
Object o = ...; // any object
switch (o) {
case Box(String s) -> IO.println("Box contains the string: " + s);
case Box(Integer i) -> IO.println("Box contains the integer: " + i);
default -> IO.println("Box contains something else");
}
instanceof의 경우와 마찬가지로, 불가능한 타입을 검사할 수는 없어요. 여기서 Integer 타입은 CharSequence 타입을 확장할 수 없으므로 컴파일 오류가 발생해요.
record Box(CharSequence o) {}
Object o = ...; // any object
switch (o) {
case Box(String s) -> IO.println("Box contains the string: " + s);
// !!! THE FOLLOWING LINE DOES NOT COMPILE !!!
case Box(Integer i) -> IO.println("Box contains the integer: " + i);
default -> IO.println("Box contains something else");
}
레코드 패턴은 박싱(boxing)과 언박싱(unboxing)을 지원하지 않아요. 그래서 다음 코드는 유효하지 않습니다.
record Point(Integer x, Integer y) {}
Object o = ...; // any object
// !!! DOES NOT COMPILE !!!
if (o instanceof Point(int x, int y)) {
}
마지막으로, 레코드 패턴은 **중첩(nesting)**을 지원해요. 그래서 다음 코드를 작성할 수 있어요.
record Point(double x, double y) {}
record Circle(Point center, double radius) {}
Object o = ...; // any object
if (o instanceof Circle(Point(var x, var y), var radius)) {
// Do something with x, y and radius
}
더 많은 패턴
패턴 매칭은 이제 Java 언어의 세 요소에서, 최종 기능 또는 프리뷰 기능으로 지원돼요.
instanceof키워드- switch 문과 switch 표현식
- 그리고
extendsfor 루프
이들 모두 두 종류의 패턴을 지원해요. 타입 패턴과 레코드 패턴이죠.
가까운 미래에 더 많은 것이 추가될 예정이에요. Java 언어의 더 많은 요소가 수정될 수 있고, 더 많은 종류의 패턴이 추가될 거예요. 이 페이지는 그런 변경을 반영해 업데이트될 거예요.