아이템 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.
상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
공개된 메서드에서 클래스 자신의 재정의 가능 메서드를 호출한다면 그 사실을 API 설명에 적시해야 한다. 덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.
▼ java.util.AbstractCollection의 Implementation Requirements
이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있다. iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 정확히 설명했다. 아이템 18 의 HashSet을 상속하여 add를 재정의하는 것이 addAll에 영향을 준다는 사실을 알 수 없었던 것과 대조적이다.
하지만 "좋은 API문서란 '어떻게'가 아닌 '무엇'을 하는지를 설명해야 한다"라는 격언과 대치된다. 상속이 캡슐화를 해치기 때문에 일어나는 안타까운 현실이다. 클래스를 안전하게 상속할 수 있도록 하려면 (상속이 아니었다면 기술하지 않았어야 할) 내부 구현 방식을 설명해야 한다.
@implSpec 태그는 자바8에서 처음 도입되어 자바9부터 본격적으로 사용되기 시작했다.
@implSpec 사용방법
% javadoc -d target/apidoc src/main/java/me/whiteship/chapter04/item19/impespec/*
Building tree for all the packages and classes...
Generating target/apidoc/me/whiteship/chapter04/item19/impespec/ExtendableClass.html...
src/main/java/me/whiteship/chapter04/item19/impespec/ExtendableClass.java:11: error: unknown tag: implSpec
* @implSpec
^
기본적으로 @implSpec 태그가 활성화되어있지 않기 때문에 에러가 발생한다.
% javadoc -h
-tag <name>:<locations>:<header>
Specify single argument custom tags
tag 옵션값에 대한 설명을 볼 수 있다.
% javadoc -d target/apidoc src/main/java/me/whiteship/chapter04/item19/impespec/* -tag "implSpec:a:Implementation Requirements:"
명령줄 매개변수로 -tag "태그이름:a(위치.문서전부):치환할문자열"을 지정해주면 된다. Implementation Requirements가 일반적이다.
이것은 자바 개발팀에서 내부적으로 사용하는 규약이며 @implSpec이라는 정해진 태그가 있는 것은 아니다. "구현:a:구현 요구사항:"이라고 지정해도 똑같은 효과를 볼 수 있다. 다만, 언젠가 표준 태그로 정의될지도 모르니 이왕이면 자바 개발팀과 같은 방식으로 사용하자.
클래스 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.
상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지는 어떻게 결정할까? 실제 하위 클래스를 만들어 시험해보는 것이 최선이다. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 유일하다. 꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 드러나고, 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 크다. 하위 클래스 3개가 적당하며 이 중 하나 이상은 제 3자가 작성해야 한다.
상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
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();
}
}
Sub 클래스만 보았을 때는 이상한 점이 없지만, Super 클래스의 생성자가 재정의 가능한 overrideMe 메서드를 호출하고 있다. 상위 클래스의 생성자는 하위 클래스의 생성자보다 먼저 실행되므로 다음과 같이 출력된다.
null
2023-08-01T21:30:51.648180Z
System.out.println이 아니었다면 NPE를 던졌을 수도 있다.
Cloneable과 Serializable 인터페이스를 구현할 때 따르는 제약도 생성자와 비슷하다.
clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.