상속은 잘못될 위험성이 있다. 상속은 하위클래스가 상위 클래스를 재사용함과 동시에 추가 또는 새로운 api가 추가될 수 있는 데 이 때 sub 클래스는 super 클래스를 의존하게 된다. 이런 의존이 뜻하지 않은 결과를 만들어 낼 수 있기 때문에 상속의 경우 미리 이를 염두해 두고 설계해야 한다. 염두에 두고 설계하지 않았다면 상속을 금지하도록 클래스를 만든다.
java.utils.AbstractList의 removeRange 메서드를 살펴보자.
removeRange는 내부적으로 Iterator를 사용한다.
그리고 이는 clear에서 내부적으로 호출한다.
List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없다. 그럼에도 불구하고 이러한 메서드를 제공한 이유는 단지 하위 클래스에서 부분 리스트의 clear 메서드를 고성능으로 커스터마이징하게끔 제공해주려는 의도이다.
clear() 에서 호출하는 removeRange() 메서드의 문서중 Implementation Requirements를 살펴보면 해당 메서드는 fromIndex에서 toIndex까지 리스트 Iterator를 얻어 모든 원소를 제거할 때까지 ListIterator.next를 호출하도록 구현되어 있다. 만약 삭제의 성능이 O(1)이 아니라면 해당 알고리즘은 O(N)이기 때문에 제곱에 비례한 시간 복잡도를 가질 수 있다.
List를 우리가 재정의해 사용할 일은 없겠지만 이러한 상황이 발생할 수 있기 때문에 라이브러리에서는 문서화를 해놓았다.
item13에서 Cloneable 사용에 대해서 신중하게 고려하라는 내용이 있다. 바로 상속시 사이드 이펙트가 발생할 우려가 있기 때문이다. Cloneable 인터페이스는 기존의 Object의 clone 메서드를 재정의할 수 있도록 해준다. 문제는 clone 메서드는 생성자와 비슷한 효과를 낸다는 점이다.
clone의 경우 하위 클래스의 clone 메서드가 복제본 상태를 수정하기 전에 재정의한 메서드를 호출한다. 따라서 clone에 내부적인 결함이 있다면내부 자료구조의 복제본까지 모두 문제가 생길 수 있다.
Serializable의 readObject는 역직렬화가 진행되기 전에 재정의한 메서드부터 진행한다. 이 경우도 clone과 마찬가지로 문제가 생길 위험이 있다.
상속은 의도치 않은 위험성을 가지고 있기 때문에 설계가 까다롭고, 부작용을 찾기가 쉽지 않을 수 있다. 되도록 상속을 금지하는 것이 좋지만, 써야 하는 경우 어떻게 활용하면 좋을 지 알아보자.
상속을 고려한 설계와 문서화는 상속용 클래스는 재정의 할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남기는 것이다. private 또는 package-private이 아닌 경우 외부 패키지에서 해당 클래스의 멤버 변수와 메서드에 접근이 가능하다. 특히 재정의한 메서드의 경우 호출 순서에 따라 super 또는 sub 객체에 어떤 영향을 끼칠 수 있다. 그렇기에 protected, public으로 공개되는 api도 재정의 용도에 대해서 명확하게 문서를 남겨야 한다.
위에 문제점에서 다룰 때 removeRange의 문서 사진을 보면 Implementation Requirements로 상속시 유의할 점에 대해서 미리 문서를 작성해 놓았다. 이렇게 써두면 해당 api가 어떤 목적으로 쓰이는지 재정의시 side effect를 미리 알 수 있기 때문에 효과적이다.
Implementatio Requirements를 작성하기 위해서는 써야할 내용 주석 위헤 @implSpec를 써주면 된다.
상속 클래스는 뜬금 없이 클래스를 상속해서 사용하는 것이 아닌 처음부터 설계시 상속을 염두해 두고 클래스를 설계한다. 이 말은 설계 단계부터 하위 클래스를 만들어 검증하는 방법이 있다. 이를 통해 현재 API의 안정성과 상속 시 안정성을 모두 챙길 수 있다.
상속용 클래스의 생성자는 하위 클래스의 생성자보다 먼저 호출한다. 그러나 만약 하위 클래스 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않는다. 이를 예제를 통해 살펴보자.
public class Super {
public Super() {
overrideMe();
}
protected void overrideMe() {
}
}
final class Sub extends Super {
private final Instant instant;
public Sub() {
super();
instant = Instant.now();
}
@Override
protected void overrideMe() {
System.out.println(instant);
}
}
생성자에서 overrideMe() 메서드를 호출하고 있다.
class SuperTest {
@Test
void test() {
Sub sub = new Sub();
sub.overrideMe();
}
}
그리고 Sub 생성자를 호출하는 테스트 코드를 작성했다. 이를 테스트해본다.
Instance가 두 번 호출되는 것이 아닌 첫번째는 null이 호출되었다. 이러한 이유가 일어나는 이유는 무엇일까?
Test코드의 동작 과정은 다음과 같다.
Sub 클래스의 생성자를 호출한다. 객체가 생성되기 이전에 Super클래스를 호출하는데 이 때의 overrideMe()는 재정의 되기 이전의 Super 클래스의 overrideMe() 메서드가 호출된다. System.out.println()는 null을 받을 수 있기 때문에 예외가 발생하지 않았지만 실제 이런 방식으로 구현했다면 NPE 예외가 발생했을 것이다.