instanceof와 오버라이딩
instanceof와 패턴 매칭으로 타입을 확인해 안전하게 다운캐스팅하는 방법과, 타입을 묻지 않아도 실제 객체가 오버라이딩한 메서드가 실행되는 규칙을 배웁니다.
앞 글에서 본 다운캐스팅은 실제 객체가 대상 타입이 아니면 ClassCastException으로 실패합니다. 그래서 무엇이 들어 있을지 모르는 부모 타입 변수는 instanceof로 먼저 확인한 뒤에 캐스팅했습니다. 이 글은 그 방법을 두 방향으로 발전시킵니다. 먼저 확인과 캐스팅을 한 번에 하는 패턴 매칭을 배웁니다. 이어서 타입을 묻는 대신 객체가 자기에게 맞는 메서드를 실행하게 하는 오버라이딩으로 넘어갑니다.
오버라이딩은 상속과 메서드 오버라이딩에서 처음 배웠고, 부모 타입 변수로 자식 객체 다루기에서는 부모 타입 변수로 호출해도 실제 객체의 구현이 실행된다는 규칙을 보았습니다. 여기서는 그 규칙이 instanceof 분기를 어떻게 대신하는지, 오버라이딩이 의도대로 되었는지를 @Override로 어떻게 확인하는지 살펴봅니다. 마지막으로 이 규칙이 적용되지 않는 필드와 static 메서드도 확인합니다.
instanceof로 확인한 뒤 캐스팅하기
출발점은 앞 글에서 쓴 방식입니다. 다음 예제의 inspect 메서드는 Post를 받아서, 코드 게시글이면 브랜치 이름을, 텍스트 게시글이면 본문 길이를 출력합니다.
public final class SafeInstanceof {
public static void main(String[] args) {
inspect(new CodePost("feature/login"));
inspect(new TextPost(120));
}
private static void inspect(Post post) {
if (post instanceof CodePost) {
CodePost codePost = (CodePost) post;
System.out.println("branch=" + codePost.branch);
} else if (post instanceof TextPost) {
TextPost textPost = (TextPost) post;
System.out.println("bodyLength=" + textPost.bodyLength);
}
}
private static class Post {}
private static final class CodePost extends Post {
private final String branch;
CodePost(String branch) { this.branch = branch; }
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(int bodyLength) { this.bodyLength = bodyLength; }
}
}branch=feature/login
bodyLength=120post instanceof CodePost는 post가 null이 아니고 가리키는 객체가 CodePost이거나 그 하위 클래스이면 true가 됩니다. 첫 호출의 객체는 CodePost이므로 첫 분기에 들어가 캐스팅한 뒤 branch=feature/login을 출력합니다. 둘째 호출의 TextPost는 첫 검사에서 false, 둘째 검사에서 true가 되어 bodyLength=120을 출력합니다. inspect(null)을 호출하면 null은 어떤 타입에 대한 instanceof에서도 false이므로 두 분기를 모두 건너뛰고, 아무것도 출력하지 않은 채 끝납니다.
이 방식은 안전하지만 같은 타입 이름을 세 번 적어야 합니다. 검사, 캐스트, 변수 선언에 CodePost가 한 번씩 나옵니다. 번거로운 것보다 더 큰 문제는 세 이름이 서로 맞는지를 컴파일러가 확인해 주지 않는다는 점입니다. 분기를 복사해 붙이다가 if (post instanceof CodePost) 안에 (TextPost) post를 남겼다면, 이 코드는 컴파일되고 실행 중에 ClassCastException으로 멈춥니다. 검사한 타입과 캐스팅한 타입이 달라도 막을 장치가 없는 것입니다.
참고로 instanceof도 캐스트와 같은 컴파일 검사를 받습니다. post instanceof String처럼 결과가 언제나 false일 수밖에 없는 검사는 incompatible types: Post cannot be converted to String 컴파일 오류가 됩니다.
패턴 매칭: 확인과 캐스팅을 한 번에
Java 16부터는 instanceof 뒤에 타입과 함께 변수 이름을 적을 수 있습니다. 이를 instanceof 패턴 매칭(pattern matching)이라고 합니다. 검사가 성공하면 그 변수가 만들어지고, 대상 타입으로 캐스팅된 같은 참조가 담깁니다. 이렇게 패턴에서 선언하는 변수를 패턴 변수라고 부릅니다.
public final class PatternMatchingInstanceof {
public static void main(String[] args) {
printDetail(new CodePost("feature/login"));
printDetail(new Post());
}
private static void printDetail(Post post) {
if (post instanceof CodePost codePost) {
System.out.println("branch=" + codePost.branch());
return;
}
System.out.println("general");
}
private static class Post {}
private static final class CodePost extends Post {
private final String branch;
CodePost(String branch) { this.branch = branch; }
String branch() { return branch; }
}
}branch=feature/login
generalpost instanceof CodePost codePost는 앞 절의 검사, 캐스트, 변수 선언 세 가지를 한 식으로 합친 것입니다. 첫 호출에서는 검사가 성공하므로 CodePost 타입 변수 codePost가 생기고, 이 변수로 branch()를 호출해 branch=feature/login을 출력한 뒤 return합니다. 둘째 호출의 new Post()는 검사에 실패하므로 if 블록을 건너뛰고 general을 출력합니다. 타입 이름은 한 번만 적으므로 검사한 타입과 캐스팅한 타입이 어긋날 일이 없습니다.
패턴 변수 codePost는 검사가 성공한 경우에만 쓸 수 있습니다. 실패한 경우에는 객체가 CodePost라는 보장이 없으니 변수에 담을 값도 없기 때문입니다. 그래서 이 예제의 if 블록 아래, general을 출력하는 줄에서는 codePost를 쓸 수 없습니다. 그 자리에 codePost를 쓰면 cannot find symbol 컴파일 오류가 납니다. 아래 그림에서 두 입력이 각각 어느 길로 가는지, 그리고 각 위치에서 codePost를 쓸 수 있는지를 단계별로 따라가 보세요.
패턴 검사 성공 경로의 변수와 조기 반환 범위
instanceof가 성공하면 패턴 변수로 branch 출력 후 return하고 실패/null은 general만 출력합니다. 도달 경로와 패턴 변수 사용 가능 범위가 실제 제어 전환으로 드러납니다.
PatternMatchingInstanceof의 CodePost 입력은 성공 블록에서 codePost를 사용하고 반환한다. Post 입력은 블록을 건너뛰고 general을 출력하며 이 위치에 codePost는 범위 밖이다.
이 예제의 codePost는 성공 블록 안에서 사용합니다
PatternMatchingInstanceof.printDetail(Post post)의 검사입니다. 조건은 post instanceof CodePost codePost이고, 성공 블록 안에 return이 있습니다.
| 실제 입력과 조건 | codePost 범위 | 출력과 종료 |
|---|---|---|
CodePost
| 성공 블록에서 같은 객체를 가리키는 codePost를 사용할 수 있습니다. | branch=feature/login 출력 후 반환합니다.아래 |
new Post()
| 성공 블록을 건너뜁니다. 아래 문장에서는 codePost를 사용할 수 없습니다. | general을 출력하고 메서드가 끝납니다. |
new CodePost("feature/login")· 성공조건이
true인 블록에서 같은 객체의 참조를 가진codePost로branch()를 호출합니다.branch=feature/login출력 후 반환하므로 아래general은 실행하지 않습니다.new Post()· 실패조건이
false여서 성공 블록을 건너뜁니다. 아래 문장에서codePost는 범위 밖이고,general을 출력한 뒤 메서드가 끝납니다.
null도 이 패턴 검사에서는 false입니다. 다만 위 main은 null을 전달하지 않습니다. 이 예제에서는 성공 경로가 먼저 반환하므로 두 경로가 아래 출력 문장에 합류하지 않습니다.
이 예의 조건
- main은 CodePost와 일반 Post를 호출하며 null false는 미실행 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | CodePost true→성공 블록의 codePost 변수로 branch 호출→return, 일반 Post false→general/변수 범위 밖이다. 성공 경로는 아래 general과 합류하지 않는다. null도 false이지만 원 main에는 2개 입력만 있다. |
| 구분 | 내용 |
|---|---|
| 설명 | CodePost true→성공 블록의 codePost 변수로 branch 호출→return, 일반 Post false→general/변수 범위 밖이다. 성공 경로는 아래 general과 합류하지 않는다. null도 false이지만 원 main에는 2개 입력만 있다. |
| 전체 읽기 정보 | 패턴 검사 성공 경로의 변수와 조기 반환 범위 패턴 검사 성공 경로의 변수와 조기 반환 범위 PatternMatchingInstanceof의 CodePost 입력은 성공 블록에서 codePost를 사용하고 반환한다. Post 입력은 블록을 건너뛰고 general을 출력하며 이 위치에서 codePost는 범위 밖이다. 이 예제의 codePost는 성공 블록 안에서 사용합니다 PatternMatchingInstanceof.printDetail(Post post) 의 검사입니다. 조건은 post instanceof CodePost codePost 이고, 성공 블록 안에 return 이 있습니다. 두 main 입력의 패턴 변수 범위와 출력·반환 실제 입력과 조건 codePost 범위 출력과 종료 CodePost branch = "feature/login" true 성공 블록에서 같은 객체를 가리키는 codePost 를 사용할 수 있습니다. branch=feature/login 출력 후 반환합니다. 아래 general 출력에는 도달하지 않습니다. new Post() false 성공 블록을 건너뜁니다. 아래 문장에서는 codePost 를 사용할 수 없습니다. general 을 출력하고 메서드가 끝납니다. new CodePost("feature/login") · 성공 조건이 true 인 블록에서 같은 객체의 참조를 가진 codePost 로 branch() 를 호출합니다. branch=feature/login 출력 후 반환하므로 아래 general 은 실행하지 않습니다. new Post() · 실패 조건이 false 여서 성공 블록을 건너뜁니다. 아래 문장에서 codePost 는 범위 밖이고, general 을 출력한 뒤 메서드가 끝납니다. null 도 이 패턴 검사에서는 false 입니다. 다만 위 main 은 null 을 전달하지 않습니다. 이 예제에서는 성공 경로가 먼저 반환하므로 두 경로가 아래 출력 문장에 합류하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 패턴 검사 post가 CodePost와 호환되는지 검사합니다. |
|
|
| 성공 블록 codePost로 branch를 읽고 return합니다. |
|
|
| 실패 경로 일반 Post는 general을 출력하고 끝납니다. |
|
|
패턴 검사 성공 경로의 변수와 조기 반환 범위
instanceof가 성공하면 패턴 변수로 branch 출력 후 return하고 실패/null은 general만 출력합니다. 도달 경로와 패턴 변수 사용 가능 범위가 실제 제어 전환으로 드러납니다.
PatternMatchingInstanceof의 CodePost 입력은 성공 블록에서 codePost를 사용하고 반환한다. Post 입력은 블록을 건너뛰고 general을 출력하며 이 위치에 codePost는 범위 밖이다.
이 예제의 codePost는 성공 블록 안에서 사용합니다
PatternMatchingInstanceof.printDetail(Post post)의 검사입니다. 조건은 post instanceof CodePost codePost이고, 성공 블록 안에 return이 있습니다.
| 실제 입력과 조건 | codePost 범위 | 출력과 종료 |
|---|---|---|
CodePost
| 성공 블록에서 같은 객체를 가리키는 codePost를 사용할 수 있습니다. | branch=feature/login 출력 후 반환합니다.아래 |
new Post()
| 성공 블록을 건너뜁니다. 아래 문장에서는 codePost를 사용할 수 없습니다. | general을 출력하고 메서드가 끝납니다. |
new CodePost("feature/login")· 성공조건이
true인 블록에서 같은 객체의 참조를 가진codePost로branch()를 호출합니다.branch=feature/login출력 후 반환하므로 아래general은 실행하지 않습니다.new Post()· 실패조건이
false여서 성공 블록을 건너뜁니다. 아래 문장에서codePost는 범위 밖이고,general을 출력한 뒤 메서드가 끝납니다.
null도 이 패턴 검사에서는 false입니다. 다만 위 main은 null을 전달하지 않습니다. 이 예제에서는 성공 경로가 먼저 반환하므로 두 경로가 아래 출력 문장에 합류하지 않습니다.
이 예의 조건
- main은 CodePost와 일반 Post를 호출하며 null false는 미실행 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | CodePost true→성공 블록의 codePost 변수로 branch 호출→return, 일반 Post false→general/변수 범위 밖이다. 성공 경로는 아래 general과 합류하지 않는다. null도 false이지만 원 main에는 2개 입력만 있다. |
| 구분 | 내용 |
|---|---|
| 설명 | CodePost true→성공 블록의 codePost 변수로 branch 호출→return, 일반 Post false→general/변수 범위 밖이다. 성공 경로는 아래 general과 합류하지 않는다. null도 false이지만 원 main에는 2개 입력만 있다. |
| 전체 읽기 정보 | 패턴 검사 성공 경로의 변수와 조기 반환 범위 패턴 검사 성공 경로의 변수와 조기 반환 범위 PatternMatchingInstanceof의 CodePost 입력은 성공 블록에서 codePost를 사용하고 반환한다. Post 입력은 블록을 건너뛰고 general을 출력하며 이 위치에서 codePost는 범위 밖이다. 이 예제의 codePost는 성공 블록 안에서 사용합니다 PatternMatchingInstanceof.printDetail(Post post) 의 검사입니다. 조건은 post instanceof CodePost codePost 이고, 성공 블록 안에 return 이 있습니다. 두 main 입력의 패턴 변수 범위와 출력·반환 실제 입력과 조건 codePost 범위 출력과 종료 CodePost branch = "feature/login" true 성공 블록에서 같은 객체를 가리키는 codePost 를 사용할 수 있습니다. branch=feature/login 출력 후 반환합니다. 아래 general 출력에는 도달하지 않습니다. new Post() false 성공 블록을 건너뜁니다. 아래 문장에서는 codePost 를 사용할 수 없습니다. general 을 출력하고 메서드가 끝납니다. new CodePost("feature/login") · 성공 조건이 true 인 블록에서 같은 객체의 참조를 가진 codePost 로 branch() 를 호출합니다. branch=feature/login 출력 후 반환하므로 아래 general 은 실행하지 않습니다. new Post() · 실패 조건이 false 여서 성공 블록을 건너뜁니다. 아래 문장에서 codePost 는 범위 밖이고, general 을 출력한 뒤 메서드가 끝납니다. null 도 이 패턴 검사에서는 false 입니다. 다만 위 main 은 null 을 전달하지 않습니다. 이 예제에서는 성공 경로가 먼저 반환하므로 두 경로가 아래 출력 문장에 합류하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 패턴 검사 post가 CodePost와 호환되는지 검사합니다. |
|
|
| 성공 블록 codePost로 branch를 읽고 return합니다. |
|
|
| 실패 경로 일반 Post는 general을 출력하고 끝납니다. |
|
|
실패한 경우를 먼저 끝내는 방식
패턴 변수를 쓸 수 있는 범위는 중괄호가 아니라 실행 흐름으로 정해집니다. 컴파일러는 '이 문장에 도달했다면 검사가 반드시 성공한 상태인가'를 따집니다. 그래서 실패한 경우를 먼저 return으로 끝내면, 그 아래 문장 전체에서 패턴 변수를 쓸 수 있습니다. 앞 예제의 printDetail만 다음처럼 바꿔도 출력은 똑같습니다.
private static void printDetail(Post post) {
if (!(post instanceof CodePost codePost)) {
System.out.println("general");
return;
}
System.out.println("branch=" + codePost.branch());
}!가 붙었으므로 if 블록은 검사가 실패했을 때 실행됩니다. 일반 Post와 null은 여기서 general을 출력하고 메서드를 끝냅니다. 그러니 마지막 줄에 도달하는 것은 검사에 성공한 경우뿐이고, 컴파일러는 그 줄에서 codePost를 쓰도록 허락합니다. 이 형태는 검사할 조건이 여러 개일 때 특히 읽기 좋습니다. 조건을 통과하지 못한 경우를 하나씩 위에서 걸러 내고, 아래에는 정상적인 경우의 처리만 남기기 때문입니다.
반대로 실패 블록의 return을 지우면 마지막 줄에서 codePost를 쓸 수 없게 됩니다. 일반 Post도 general을 출력한 뒤 마지막 줄까지 내려올 수 있어서, 그 줄에 도달했다고 검사가 성공했다는 보장이 사라지기 때문입니다. 컴파일하면 이번에도 cannot find symbol 오류가 납니다.
같은 원리로 &&의 오른쪽에서는 패턴 변수를 쓸 수 있습니다. 증감·비교·논리·대입 연산자에서 배운 단축 평가 때문에 &&의 오른쪽은 왼쪽이 true일 때만 계산되고, 왼쪽이 true라는 것은 검사가 성공했다는 뜻입니다.
if (post instanceof CodePost codePost && !codePost.branch().isEmpty()) {
System.out.println("branch=" + codePost.branch());
}||의 오른쪽에서는 쓸 수 없습니다. ||는 왼쪽이 false일 때 오른쪽을 계산하는데, 그때는 검사에 실패해 패턴 변수에 담을 값이 없기 때문입니다.
더 깊이: switch에서 쓰는 타입 패턴
패턴 매칭은 instanceof에서 시작해 Java 16에 정식 기능이 되었고(JEP 394), Java 21부터는 switch에서도 정식으로 쓸 수 있습니다(JEP 441). 앞의 inspect처럼 타입별로 갈라지는 분기는 case CodePost codePost -> ...처럼 case마다 타입 패턴을 적어 쓸 수 있습니다. null을 다루는 방식은 다릅니다. instanceof는 null에 false를 돌려주지만, switch는 case null이 없으면 null이 들어왔을 때 NullPointerException을 던집니다. 가능한 경우를 빠짐없이 다뤘는지 컴파일러가 검사하는 규칙과 함께 패턴 매칭과 데이터 분기에서 자세히 다룹니다.
타입을 묻는 대신 객체에게 맡기기
패턴 매칭으로 캐스팅 코드는 줄었지만, 게시글 종류에 따라 분기하는 구조는 그대로입니다. 영상 게시글이 추가되면 inspect에 else if를 하나 더 붙여야 하고, 같은 분기가 프로그램의 여러 곳에 있다면 그 모두를 찾아 고쳐야 합니다. 하나라도 빠뜨리면 새 게시글은 그 자리에서 아무 일도 하지 않고 지나갑니다.
모든 게시글이 같은 종류의 일을 각자의 방식으로 해야 한다면 다른 길이 있습니다. 그 일을 부모 Post의 메서드로 선언하고 각 자식이 오버라이딩하게 하는 것입니다. 다음 예제에서 publish()는 게시글을 게시판에 올릴 때 보여 줄 문자열을 돌려줍니다.
public final class OverrideDispatch {
public static void main(String[] args) {
Post first = new TextPost();
Post second = new CodePost();
run(first);
run(second);
}
private static void run(Post post) {
System.out.println(post.publish());
}
private static class Post {
String publish() {
return "general";
}
}
private static final class TextPost extends Post {
@Override
String publish() {
return "text";
}
}
private static final class CodePost extends Post {
@Override
String publish() {
return "code";
}
}
}text
coderun에는 instanceof도 캐스트도 없고, TextPost나 CodePost라는 이름도 나오지 않습니다. 그런데도 같은 post.publish() 한 줄이 첫 호출에서는 text를, 둘째 호출에서는 code를 출력했습니다. 부모 타입 변수로 자식 객체 다루기에서 본 두 단계 규칙이 그대로 적용되기 때문입니다.
| 호출 | 컴파일할 때 확인하는 것 | 실행할 때 고르는 구현 | 출력 |
|---|---|---|---|
run(first) | Post에 publish()가 있는가 | 실제 객체가 TextPost이므로 TextPost.publish() | text |
run(second) | Post에 publish()가 있는가 | 실제 객체가 CodePost이므로 CodePost.publish() | code |
컴파일러는 변수의 타입 Post에 publish()가 선언되어 있는지만 확인하고 호출을 허락합니다. 실행할 때는 JVM이 post가 가리키는 객체의 클래스에서부터 publish()를 찾고, 그 클래스에 없으면 부모 쪽으로 한 단계씩 올라갑니다. 가장 먼저 찾은 구현, 즉 실제 객체에 가장 가까운 구현이 실행됩니다. 이렇게 실행할 구현을 실행 시점에 실제 객체를 보고 고르는 것을 동적 디스패치(dynamic dispatch)라고 합니다. 앞 절의 분기가 하던 '이 게시글은 어떤 종류인가'라는 판단을 JVM이 객체를 보고 대신 해 주는 셈입니다.
이 구조에서는 영상 게시글을 추가해도 run을 고칠 필요가 없습니다. VideoPost가 Post를 상속하고 publish()를 오버라이딩하기만 하면, 기존 run에 그대로 넘길 수 있습니다. 새 종류를 추가할 때 바뀌는 곳이 새 클래스 하나로 모이는 것입니다.
그렇다고 instanceof가 필요 없어지는 것은 아닙니다. 앞 글의 exportCodeDetail처럼 코드 게시글에만 있는 브랜치 정보가 관리 화면 한 곳에서만 필요하다면, 모든 게시글에 의미 없는 메서드를 부모에 추가하는 것보다 그 자리에서 타입을 확인하는 편이 자연스럽습니다. 모든 게시글이 각자의 방식으로 해야 하는 일이라면 오버라이딩을 쓰고, 특정 자식만 가진 정보가 몇몇 곳에서 필요하다면 instanceof를 씁니다.
@Override로 오버라이딩 실수 잡기
동적 디스패치는 자식의 메서드가 부모의 메서드를 정말 오버라이딩했을 때만 일어납니다. 이름이나 매개변수가 조금만 달라도 자식의 메서드는 부모 메서드와 상관없는 새 메서드가 되는데, 그래도 컴파일은 문제없이 통과합니다. 다음 코드의 CodePost는 publish를 오버라이딩하려다 실수로 매개변수 int viewCount를 붙였습니다. 다만 메서드 위에 @Override를 적어 두었습니다.
public final class BadOverrideSignature {
public static void main(String[] args) {
Post post = new CodePost();
post.publish();
}
private static class Post {
void publish() {
System.out.println("board");
}
}
private static final class CodePost extends Post {
@Override
void publish(int viewCount) {
System.out.println("code=" + viewCount);
}
}
}BadOverrideSignature.java:14: error: method does not override or implement a method from a supertype
@Override
^
1 error매개변수가 없는 부모의 publish()와 int를 받는 자식의 publish(int)는 이름만 같은 서로 다른 메서드입니다. 메서드 오버로딩과 자동 형변환에서 본 오버로딩이 된 것입니다. @Override는 '이 메서드는 부모의 메서드를 오버라이딩한다'는 표시이므로, 컴파일러가 부모 쪽에서 같은 모양의 메서드를 찾다가 찾지 못하면 위 오류를 냅니다.
@Override 줄을 지우면 이 코드는 컴파일되고, 실행하면 board가 출력됩니다. CodePost 객체에는 부모에게 물려받은 publish()와 새로 만든 publish(int)가 함께 있고, post.publish()는 매개변수가 없는 쪽을 호출하기 때문입니다. 오버라이딩한 줄 알았던 개발자는 code=가 출력되지 않는 이유를 실행 결과를 보고 나서야 찾아 나서게 됩니다. @Override를 붙여 두면 이런 실수를 컴파일할 때 바로 알 수 있습니다.
오버라이딩이 되는 조건은 상속과 메서드 오버라이딩에서 정리했습니다. 그 조건을 어겼을 때 무슨 일이 생기는지 다시 보면, 컴파일러가 알아서 오류를 내는 경우와 조용히 통과하는 경우로 나뉩니다.
| 조건 | 어기면 |
|---|---|
| 이름과 매개변수 목록이 부모와 같다 | 별개의 새 메서드가 되어 조용히 통과 |
부모 메서드가 private이 아니다 | 물려받지 않은 메서드이므로 별개의 새 메서드가 되어 조용히 통과 |
| 반환 타입이 같거나, 참조형이면 그 하위 타입이다 | return type ... is not compatible with ... 컴파일 오류 |
| 접근 범위를 부모보다 좁히지 않는다 | attempting to assign weaker access privileges 컴파일 오류 |
부모 메서드가 final이 아니다 | overridden method is final 컴파일 오류 |
| 부모와 자식 메서드가 모두 인스턴스 메서드다 | 한쪽만 static이면 컴파일 오류, 둘 다 static이면 오버라이딩이 아닌 숨김 (다음 절) |
셋째부터 다섯째 줄의 조건은 @Override가 없어도 컴파일 오류가 납니다. 위험한 것은 위의 두 줄입니다. 이름이나 매개변수가 다르거나 부모 메서드가 private이면 자식 메서드는 오류 없이 새 메서드가 되어 버리고, 문제는 실행 결과로만 드러납니다. @Override는 바로 이 두 경우를 컴파일 오류로 바꿔 줍니다. 마지막 줄처럼 둘 다 static인 메서드에 @Override를 붙여도 static methods cannot be annotated with @Override 오류가 납니다. 그래서 오버라이딩하려는 메서드에는 언제나 @Override를 붙입니다.
필드와 static 메서드는 변수의 타입을 따른다
동적 디스패치는 인스턴스 메서드에만 적용됩니다. 부모와 자식에 같은 이름의 필드나 같은 모양의 static 메서드가 있을 때 어느 것이 선택되는지 확인해 보겠습니다. 다음 예제의 Parent와 Child는 같은 이름의 필드 name, 인스턴스 메서드 name(), static 메서드 staticName()을 각각 가지고 있습니다.
public final class FieldAndMethodDispatch {
public static void main(String[] args) {
Parent value = new Child();
System.out.println("field=" + value.name);
System.out.println("method=" + value.name());
System.out.println("static=" + value.staticName());
}
private static class Parent {
String name = "parent";
String name() { return "parent"; }
static String staticName() { return "parent"; }
}
private static final class Child extends Parent {
String name = "child";
@Override String name() { return "child"; }
static String staticName() { return "child"; }
}
}field=parent
method=child
static=parentvalue의 타입은 Parent이고 가리키는 객체는 Child인데, 세 줄 가운데 child가 나온 것은 가운데 줄뿐입니다. 첫 줄의 필드 value.name은 변수의 타입 Parent에 선언된 필드를 읽었습니다. Child 객체 안에는 Parent가 선언한 name과 Child가 선언한 name이 따로 들어 있는데, 어느 쪽을 읽을지는 컴파일할 때 변수의 타입으로 정해지기 때문입니다. 둘째 줄의 인스턴스 메서드는 동적 디스패치로 실제 객체 Child의 구현을 실행했습니다. 셋째 줄의 static 메서드는 객체와 상관없이 클래스에 속한 메서드라서, 변수의 타입인 Parent의 staticName()이 선택되었습니다.
자식이 부모와 같은 이름의 필드를 선언하는 것을 필드 숨김(hiding)이라고 하고, 같은 모양의 static 메서드를 선언하는 것은 메서드 숨김이라고 합니다. 오버라이딩과 달리 숨김은 부모의 것을 대신하지 않습니다. 둘이 함께 있고, 어느 것이 보이는지가 식의 타입에 따라 달라질 뿐입니다. 그래서 같은 객체라도 Child childView = (Child) value;로 받아서 childView.name과 childView.staticName()을 쓰면 이번에는 둘 다 child가 나옵니다. 객체가 바뀐 것이 아니라 식의 타입이 Child로 바뀐 결과입니다.
예측해 보기: 부모 타입으로 다시 보면?
새 객체를 만들지 않고 childView를 (Parent) childView로 업캐스팅해 씁니다. ((Parent) childView).name, ((Parent) childView).name(), Parent.staticName()은 각각 어떤 문자열을 돌려줄까요?
차례로 parent, child, parent입니다. 첫째는 필드 접근이므로 식의 타입인 Parent의 필드가 선택됩니다. 둘째는 인스턴스 메서드 호출이고, 실제 객체는 여전히 Child이므로 Child의 구현이 실행됩니다. 셋째는 Parent에 선언된 static 메서드를 클래스 이름으로 직접 호출한 것입니다.
아래 그림은 같은 value 식에서 멤버의 종류에 따라 선택 기준이 어떻게 갈리는지 정리한 것입니다.
FieldAndMethodDispatch에서 Parent value는 Child 객체를 가리킨다. name 필드와 staticName은 Parent 기준이고 name 인스턴스 메서드는 Child의 재정의를 선택한다.
필드·static과 인스턴스 메서드는 선택 기준이 다릅니다
FieldAndMethodDispatch의 선언은 Parent value = new Child();입니다. value의 정적 타입은 Parent, 참조 대상의 실제 타입은 Child입니다.
| 연산 | 선택 기준 | 선택된 멤버와 출력 |
|---|---|---|
value.name필드 | 참조 표현식의 정적 타입 Parent | Parent에 선언된 name
|
value.name()인스턴스 메서드 | Parent의 메서드 계약으로 컴파일한 뒤 실제 Child의 재정의를 선택 | Child.name()
|
value.staticName()정적 메서드 | 참조 표현식의 정적 타입 Parent인스턴스 동적 디스패치가 아님 | Parent.staticName()
|
value.name· 필드- 정적 타입
Parent에 선언된name필드를 선택하여field=parent를 출력합니다. value.name()· 인스턴스 메서드Parent의 계약으로 호출을 컴파일하고 실제 객체의Child.name()을 실행하여method=child를 출력합니다.value.staticName()· 정적 메서드- 정적 타입 기준
Parent.staticName()을 선택하여static=parent를 출력합니다. 인스턴스 동적 디스패치가 아닙니다.
세 결과는 객체가 Parent로 바뀌어서 달라진 것이 아닙니다. 필드 숨김·정적 메서드 숨김과 인스턴스 메서드 오버라이딩은 선택 규칙이 다릅니다.
이 예제가 주는 교훈은 두 가지입니다. 첫째, 객체마다 다르게 동작해야 하는 것은 필드가 아니라 인스턴스 메서드로 표현해야 합니다. 필드는 private으로 감추고 메서드로만 읽게 하면 자식이 같은 이름의 필드를 선언해도 혼란이 생기지 않고, 필요하면 그 메서드를 오버라이딩할 수 있습니다. 둘째, static 메서드는 value.staticName()처럼 변수를 통해 부르지 말고 Parent.staticName()처럼 클래스 이름으로 부릅니다. 변수를 통해 부르면 객체에 따라 달라질 것처럼 보이지만 실제로는 변수의 타입으로 정해지므로, 읽는 사람을 오해하게 만듭니다. javac -Xlint:static으로 컴파일하면 컴파일러도 이 줄에 'static 메서드는 타입 이름으로 불러야 한다'는 경고를 냅니다.
더 깊이: 세 가지 선택은 바이트코드에서 갈린다
javap -c로 FieldAndMethodDispatch의 main을 열어 보면 세 줄이 서로 다른 명령으로 컴파일되어 있습니다. 필드 읽기는 getfield인데, 읽을 필드가 Parent.name으로 이미 정해져 있습니다. static 메서드 호출은 invokestatic이고 호출할 메서드가 Parent.staticName으로 고정되어 있습니다. 둘 다 컴파일할 때 선택이 끝난 것입니다. 인스턴스 메서드 호출만 invokevirtual인데, 이 명령에 적힌 Parent.name은 '이런 이름과 모양의 메서드를 호출한다'는 뜻일 뿐입니다. 실제로 실행할 구현은 JVM이 실행 시점에 수신 객체의 클래스에서부터 찾습니다. 앞 절의 OverrideDispatch에 있는 post.publish()도 invokevirtual로 컴파일되어 있습니다.
정리
instanceof는 참조가null이 아니고 실제 객체가 그 타입이거나 하위 타입일 때true입니다. 확인한 뒤 캐스팅하면ClassCastException을 피할 수 있습니다.post instanceof CodePost codePost같은 패턴 매칭은 확인, 캐스트, 변수 선언을 한 번에 하므로 검사한 타입과 캐스팅한 타입이 어긋날 수 없습니다.- 패턴 변수는 검사가 반드시 성공한 위치에서만 쓸 수 있습니다. 실패한 경우를 먼저
return으로 끝내면 그 아래에서,&&를 쓰면 그 오른쪽에서 쓸 수 있습니다. - 모든 자식이 각자의 방식으로 해야 하는 일은 부모에 메서드로 선언하고 오버라이딩합니다. 부모 타입 변수로 호출해도 실제 객체에 가장 가까운 구현이 실행되므로 타입별 분기가 필요 없습니다.
- 오버라이딩하려는 메서드에는
@Override를 붙입니다. 매개변수가 다르거나 부모 메서드가private이면 오류 없이 새 메서드가 되는데,@Override가 이를 컴파일 오류로 알려 줍니다. - 필드와
static메서드는 오버라이딩되지 않고 숨겨지며, 식의 타입으로 선택됩니다. 다르게 동작해야 하는 것은 인스턴스 메서드로 만들고,static메서드는 클래스 이름으로 부릅니다.
다음 글에서는 오버라이딩을 이용해 타입별로 복사된 코드를 하나로 합치고, 그 자체로는 객체로 만들 이유가 없는 부모 클래스를 추상 클래스로 바꿉니다.
연습 문제
문제 1: 패턴 매칭으로 게시글 상세 출력하기
Post 배열을 돌면서 CodePost이면 branch를, TextPost이면 bodyLength를 패턴 매칭 instanceof로 출력하세요. 그 밖의 게시글은 general을 출력합니다.
해설 보기
public final class PatternDetailReport {
public static void main(String[] args) {
Post[] values = {
new CodePost("main"),
new TextPost(80),
new Post()
};
for (Post value : values) {
print(value);
}
}
private static void print(Post value) {
if (value instanceof CodePost codePost) {
System.out.println("branch=" + codePost.branch);
} else if (value instanceof TextPost textPost) {
System.out.println("bodyLength=" + textPost.bodyLength);
} else {
System.out.println("general");
}
}
private static class Post {}
private static final class CodePost extends Post {
private final String branch;
CodePost(String branch) { this.branch = branch; }
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(int bodyLength) { this.bodyLength = bodyLength; }
}
}branch=main
bodyLength=80
generalcodePost는 첫 검사가 성공한 블록 안에서만, textPost는 둘째 검사가 성공한 블록 안에서만 쓸 수 있으므로 따로 캐스트를 적을 필요가 없습니다. 세 번째 Post 객체는 두 검사에 모두 실패해 else로 갑니다. else 블록에서는 두 패턴 변수를 모두 쓸 수 없는데, 그 블록에 도달했다는 것은 두 검사가 모두 실패했다는 뜻이기 때문입니다.
문제 2: 같은 출력을 오버라이딩으로 만들기
문제 1과 같은 출력을 instanceof 없이 만들어 보세요. Post에 "general"을 돌려주는 detail() 메서드를 두고 두 자식이 오버라이딩하게 합니다. 그리고 두 방식 가운데 VideoPost를 추가할 때 고칠 곳이 적은 쪽은 어느 것인지 설명하세요.
해설 보기
public final class OverrideDetail {
public static void main(String[] args) {
Post[] values = {
new CodePost("main"),
new TextPost(80),
new Post()
};
for (Post value : values) {
System.out.println(value.detail());
}
}
private static class Post {
String detail() {
return "general";
}
}
private static final class CodePost extends Post {
private final String branch;
CodePost(String branch) { this.branch = branch; }
@Override
String detail() {
return "branch=" + branch;
}
}
private static final class TextPost extends Post {
private final int bodyLength;
TextPost(int bodyLength) { this.bodyLength = bodyLength; }
@Override
String detail() {
return "bodyLength=" + bodyLength;
}
}
}branch=main
bodyLength=80
general반복문의 value.detail()은 원소마다 실제 객체의 구현을 실행합니다. Post 객체는 오버라이딩한 자식이 아니므로 부모의 general이 그대로 나옵니다.
VideoPost를 추가할 때는 이 방식이 고칠 곳이 적습니다. VideoPost에 detail()을 오버라이딩하기만 하면 반복문은 그대로 둘 수 있습니다. 문제 1의 방식은 print에 분기를 하나 더 붙여야 하고, 같은 분기가 다른 곳에도 있다면 모두 찾아 고쳐야 합니다. 대신 이 방식은 상세 정보를 만드는 일이 모든 게시글의 일이 된다는 뜻이기도 합니다. 그 정보가 어느 한 화면에서만 필요하다면 문제 1처럼 그 자리에서 타입을 확인하는 편이 나을 수 있습니다.
문제 3: 오버라이딩하지 않은 손자 클래스
다음 프로그램의 출력을 예상하세요. FeaturedCodePost는 kind()를 오버라이딩하지 않습니다.
public final class KindLookup {
public static void main(String[] args) {
Post[] posts = { new Post(), new CodePost(), new FeaturedCodePost() };
for (Post post : posts) {
System.out.println(post.kind());
}
}
private static class Post {
String kind() { return "post"; }
}
private static class CodePost extends Post {
@Override
String kind() { return "code"; }
}
private static final class FeaturedCodePost extends CodePost {
}
}해설 보기
post
code
code첫 객체는 Post이므로 Post의 구현이 실행됩니다. 둘째 객체는 CodePost가 오버라이딩한 구현을 실행합니다. 셋째 객체는 FeaturedCodePost인데, JVM이 이 클래스에서 kind()를 찾지 못하고 부모 CodePost로 올라가 거기서 찾습니다. 그래서 post가 아니라 code가 나옵니다. 동적 디스패치는 언제나 Post의 구현과 실제 클래스의 구현 가운데 하나를 고르는 것이 아니라, 실제 클래스에서부터 위로 올라가며 가장 먼저 만나는 구현을 고릅니다.