상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라
상속을 고려한 설계와 문서화
상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
호출되는 메서드가 재정의 가능 메서드(public과 protected 메서드 중 final이 아닌 모든 메서드)라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 한다. 덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.
커스텀 태그 @implSpec
API 문서 메서드 설명 중 “Implementation Requirements”로 시작하는 절은 메서드의 내부 동작 방식을 설명하는 곳이다. 이 절은 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.
java.util.AbstractCollection의 remove 메서드
remove 메서드의 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있다. iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 정확히 설명했다.
좋은 API문서란 ‘어떻게’가 아닌 ‘무엇’을 하는지를 설명해야 한다. 클래스를 안전하게 상속할 수 있도록 하려면 상속만 아니었다면 기술하지 않았어야 할 내부 구현 방식을 설명해야만 한다.
@implSpec 태그는 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되기 시작했다. 이 태그가 기본값으로 활성화되어야 바람직하다고 생각하지만 자바 11의 자바독에서도 선택사항으로 남겨져 있다. 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements
를 지정해주면 된다. (@implSpec은 정해진 태그가 아닌 커스텀 태그이므로 자바독 명령줄에서 지정해줘야 한다. 다만, 언젠가 표준 태그로 정의될지도 모르니 이왕이면 자바 개발팀과 같은 방식으로 사용하는 편이 좋다.)
효율적인 하위 클래스를 위한 메서드 형태
클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다. 드물게는 protected 필드로 공개해야 할 수도 있다.
java.util.AbstractList의 removeRange 메서드
List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없다. 그럼에도 이 메서드를 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다. removeRange 메서드가 없다면 하위 클래스에서 clear 메서드를 호출하면 제거할 원소 수의 제곱에 비례해 성능이 느려지거나 부분리스트의 메커니즘을 밑바닥부터 새로 구현해야 했을 것이다.
protected 메서드는 내부 구현에 해당하므로 그 수는 가능한 한 적어야 한다. 한편으로는 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.
상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
상속용 클래스의 생성자는 재정의 가능 메서드를 호출해서는 안 된다.
상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출된다. 이때 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다.
생성자가 재정의 가능 메서드를 호출하는 잘못된 예
public class Super {
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
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();
// null
// 2022-07-07T06:43:46.667Z
}
}
위의 출력 결과는 첫 번째는 null을 출력한다. 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe를 호출하기 때문이다. final 필드의 상태가 null을 포함한 두 가지 되어 overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe를 호출할 때 NullPointerException을 던지게 된다.
Cloneable, Serializable 인터페이스
Cloneable, Serializable 인터페이스는 상속용 설계의 어려움을 한층 더한다. 둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다. 물론 이 인터페이스들을 하위 클래스에서 구현하도록 하는 방법도 있다.
clone, readObject 메서드
새로운 객체를 만드는 생성자와 비슷한 효과를 낸다. 따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점에 주의해야한다. 즉, clone과 readObject 모두 재정의 가능 메서드를 호출해서는 안 된다.
readObject의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메서드부터 호출하게 된다.
clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 올바른 상태로 수정하기 전에 재정의한 메서드를 호출한다.
readResolve, writeReplace 메서드
이 메서드들은 private이 아닌 protected로 선언해야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다. 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.
구체 클래스의 상속
일반적인 구체 클래스는 final도 아니고 상속용으로 설계되거나 문서화되지도 않아 클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있다.
상속용으로 설계하지 않은 클래스는 상속을 금지
상속용으로 설계되지 않은 클래스는 상속을 금지해야 한다. final 클래스로 선언하거나 생성자를 private이나 package-private로 선언하고 public 정적 팩터리 제공하면 된다.
래퍼 클래스 패턴이 상속 대신 쓸 수 있는 더 나은 대안이다.
구체 클래스가 표준 인터페이스를 구현하지 않았을 경우
이 경우 상속을 금지하면 사용하기 상당히 불편해진다. 이런 클래스라도 상속을 꼭 허용해야겠다면 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남겨야 한다. 이렇게 하면 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않을 수 있다.
클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거하는 방법
- 각각의 재정의 가능 메서드는 자신의 본문 코드를 private 도우미 메서드로 옮기고, 이 도우미 메서드를 호출하도록 수정한다.
- 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정한다.
상속용 클래스를 설계 하려면 클래스 내부에서 스스로를 어떻게 사용하는지 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그러지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다.
클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다. 상속을 금지하려면 클래스를 final로 선언하거나 생성자를 외부에서 접근할 수 없게 만들면 된다.
Comments