상속을 고려한 설계와 문서화란?
상속을 고려한 설계와 문서화란 정확히 무얼 뜻할까?
우선, 메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다.
즉, 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
더 넓게 말하면, 재정의 가능(public과 protected 메서드 중 final이 아닌 모든 메서드) 메서드를 호출할 수 있는 모든 상황을 문자로 남겨야 한다. 예를 들어 백그라운드 스레드나 정적 초기화 과정에서도 호출이 일어날 수 있따.
문서화의 예
API 문서의 메서드 설명 끝에서 종종 "Implementation Requirements"로 시작하는 절을 볼 수 있는데,
그 메서드의 내부 동작 방식을 설명하는 곳이다.
메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.
@implSpec 태그는 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되기 시작했다.
java.util.AbstractCollection에서 발췌한 예이다.
클래스를 안전하게 상속할 수 있도록 하려면 (상속만 아니었다면 기술하지 않았어야 할) 내부 구현 방식을 설명해야만 한다.
내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다.
효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는
훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.
java.util.AbstractList의 removeRange 메서드를 예로 살펴보자.
List 구현체 최종 사용자는 removeRange 메서드에 관심이 없다. 그럼에도 이 메서드를 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다.
상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지는 어떻게 결정할까?
심사숙고해서 잘 예측해본 다음, 실제 하위 클래스를 만들어 시험해보는 것이 최선이다.
protected 메서드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 한 적어야 한다. 한편으로는 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록
주의해야 한다.
상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.
꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 드러난다.
거꾸로, 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 크다.
상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
또한, 상속하려는 사람을 위해 덧붙인 설명은 단순히 그 클래스의 인스턴스만 만들어 사용할 프로그래머에게는 필요 없는 군더더기일 뿐이다.
주의할 점
상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
상위 클래스의 생성자가 하위 클래스 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가
하위 클래스의 생성자보다 먼저 호출된다. 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면
의도대로 동작하지 않을 것이다.
// 재정의 가능 메서드를 호출하는 생성자 - 따라 하지 말 것! (115쪽)
public class Super {
// 잘못된 예 - 생성자가 재정의 가능 메서드를 호출한다.
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
// 생성자에서 호출하는 메서드를 재정의했을 때의 문제를 보여준다. (126쪽)
public final class Sub extends Super {
// 초기화되지 않은 final 필드. 생성자에서 초기화한다.
private final Instant instant;
Sub() {
instant = Instant.now();
}
// 재정의 가능 메서드. 상위 클래스의 생성자가 호출한다.
@Override
public void overrideMe() {
System.out.println(instant);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
이 프로그램이 instant를 두 번 출력하리라 기대했겠지만, 첫 번째는
null을 출력한다. 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴트 필드를
초기화하기도 전에 overrideMe를 호출하기 때문이다.
따라서 이 프로그램에서는 final 필드의 상태가 두 가지다. (정상이라면 단 하나뿐)
만약 overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스 생성자에서
NullPointerException이 발생하게 될 것이다.
- private, final, static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.
Cloneable, Serializable
Cloneable과 Serializable 인터페이스는 상속용 설계의 어려움을 한층 더해준다.
둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다. 그 클래스를 확장하려는
프로그래머에게 엄청난 부담을 지우기 때문이다.
clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
readObject의 경우 하위 클래스의 상태가 미처 다 역질렬화되기 전에 재정의한 메서드부터 호출하게 된다.
clone의 경우 하위 클래스의 clone 메서드가 복제본 상태를 (올바른 상태로) 수정하기 전에 재정의한 메서드를 호출한다.
어느 쪽이든 프로그램 오작동으로 이어질 것이다.
마지막으로 Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다.
그래서 결론은?
클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당하다. 절대 가볍게 생각하고 정할 문제가 아니다.
추상 클래스나 인터페이스의 골격 구현처럼 상속을 허용하는 게 명백히 정당한 상황이 있고,
불변 클래스처럼 명백히 잘못된 상황이 있다.
그렇다면 그 외 일반적인 구체 클래스는 어떨까?
그대로 두면 위험하기에 해결하기 위한 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.
상속을 금지하는 방법은 두 가지다.
- 클래스를 final로 선언
- 모든 생성자를 private or package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법.
하지만 이 조언은 다소 논란의 여지가 있다.
그동안 수많은 프로그래머가 일반적인 구체 클래스를 상속해 계측, 통지, 동기화, 기능 제약 등을 추가해왔을 테니 말이다.
핵심 기능을 정의한 인터페이스가 있고, 클래스가 그 인터페이스를 구현했다면 상속을 금지해도 개발하는데 아무런 어려움이 없을 것이다. (ex - Set, List, Map)
구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기 상당히 불편해진다.
그래서 이런 경우는 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남겨야 한다.
핵심 정리
-
상속용 클래스는 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다.
- 그렇지 않으면 하위 클래스를 오동작하게 만들 수 있다.
-
다른 사람이 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야할 수도 있다.
- 이렇게 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다.
- 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.