아이템 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라. - ksw6169/effective-java GitHub Wiki

상속용 클래스에서 문서로 남겨야 할 사항

  • 상속용 클래스에서는 메소드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다.
  • 달리 말하면, 상속용 클래스는 재정의할 수 있는 메소드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
  • 클래스의 API로 공개된 메소드에서 클래스 자신의 또 다른 메소드를 호출할 수도 있다. (자기사용) 이 때 마침 호출되는 메소드가 재정의 가능 메소드라면 그 사실을 호출하는 메소드의 API 설명에 적시해야 한다. ('재정의 가능' 이란 public과 protected 메소드 중 final이 아닌 모든 메소드를 말한다.)
  • 덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.
  • 더 넓게 말하면 재정의 가능 메소드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.
/**
 * ...
 * 이 구현은 지정된 컬렉션을 반복하고 반복자가 반환한 각 개체를 이 컬렉션에 차례로 추가합니다.
 */
public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    for (E e : c)
        if (add(e))
            modified = true;
    return modified;
}

Implementation requirements

  • API 문서의 메소드 설명 끝에서 종종 Implementation Requirements로 시작하는 절을 볼 수 있다.
  • 이 절은 메소드의 내부 동작 방식을 설명하는 곳으로 메소드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다. 다음은 java.util.Abstraction 에서 발췌한 예시다.
public boolean remove(Object o)

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다.(선택적 동작)
더 정확하게 말하면 이 컬렉션 안에 Object.equals(o, e)가 참인 원소 e가 하나 이상 있다면 
그 중 하나를 제거한다. 
주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경됐다면) true를 반환한다.

Implementation Requirements
이 메소드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다.
주어진 원소를 찾으면 반복자의 remove 메소드를 사용해 컬렉션에서 제거한다. 
이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메소드가 반환한 반복자가 
remove 메소드를 구현하지 않았다면 UnsupportedOperationException을 던지니 주의하자.
  • 위 설명에 따르면 iterator 메소드를 재정의하면 remove 메소드의 동작에 영향을 준다는 사실을 알 수 있다.
  • 또한 iterator 메소드로 얻은 반복자의 동작이 remove 메소드의 동작에 주는 영향도 정확히 설명했다.
  • 클래스를 안전하게 상속할 수 있도록 하려면 (상속만 아니었다면 기술하지 않았어야 할) 내부 구현 방식을 설명해야 한다.
  • @implSpec 태그는 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되기 시작했다.
  • 이 태그는 자바 11의 자바독에서도 선택사항으로 남겨져 있으며 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:" 를 지정해주면 된다.

@implSpec

자바독의 커스텀 태그 기능을 이용해 자바 개발팀에서 내부적으로 사용하는 규약이다. @implSpec 이라는 정해진 태그가 있는 것도 아니다. 예컨대 태그 이름을 @구현으로 바꾸고 자바독 명령줄에서 -tag "구현:a:구현 요구사항:" 이라고 지정해도 똑같은 효과를 볼 수 있다.

효율적인 상속을 위해서는 protected 메소드 형태로 제공해야 할 수도 있다.

  • 내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다.
  • 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook) 을 잘 선별하여 protected 메소드 형태로 공개해야 할 수도 있다.

훅(hook) 메소드

abstract 키워드를 붙이면 상속 받은 클래스는 반드시 해당 메소드를 구현해야 하지만 abstract 키워드를 붙이지 않고, 훅 메소드로 만들면 반드시 구현할 필요가 없다. 상속 받은 클래스에서는 선택적으로 오버라이딩 할 수 있다. (아무런 일을 하지 않거나, 기본 행동을 정의해놓은 메소드)

참고 자료 : KTKO 개발 블로그와 여행 일기ㅣ디자인 패턴 - 템플릿 메서드 패턴

  • java.util.AbstractList의 removeRange 메소드를 예로 살펴보자.
protected void removeRange(int fromIndex, int toIndex)

fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다. 
toIndex 이후의 원소들은 앞으로 (index만큼씩) 당겨진다.
이 호출로 리스트는 'toIndex - fromIndex' 만큼 짧아진다. 
(toIndex == fromIndex라면 아무런 효과가 없다.)

이 리스트 혹은 이 리스트의 부분 리스트에 정의된 clear 연산이 이 메소드를 호출한다. 
리스트 구현의 내부 구조를 활용하도록 이 메소드를 재정의하면 
이 리스트와 부분 리스트의 clear 연산 성능을 크게 개선할 수 있다.

Implementation Requirements
이 메소드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 
ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다. 
주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다. 

Parameters:
    fromIndex  제거할 첫 원소의 인덱스
    toIndex    제거할 마지막 원소의 다음 인덱스
  • List 구현체의 최종 사용자는 removeRange 메소드에 관심이 없지만 그럼에도 이 메소드를 protected로 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear 메소드를 고성능으로 만들기 쉽게 하기 위해서다.
  • removeRange 메소드가 없다면 하위 클래스에서 clear 메소드를 호출했을 때 제거할 원소 수의 제곱에 비례해 성능이 느려지거나 부분리스트의 메커니즘을 밑바닥부터 새로 구현해야 했을 것이다.

상속용 클래스를 설계할 때 어떤 메소드를 protected로 노출해야 하는가?

  • 정답은 없고 심사숙고해서 잘 예측해본 다음, 실제 하위 클래스를 만들어 시험해보는 것이 최선이다.
  • protected로 제공하려는 메소드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 한 적어야 한다.
  • 한편으로는 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.

상속용 클래스를 검증하는 방법

  • 상속용 클래스에서 공개한 내부 사용 패턴과 protected 멤버는 클래스의 성능과 기능에 앞으로 영원한 족쇄가 될 수 있으므로 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
  • 이러한 상속용 클래스를 검증하는 방법은 직접 하위 클래스를 만들어보는 것이 유일하다.
  • 꼭 필요한 protected 멤버가 없다면 하위 클래스를 작성할 때 빈자리가 확연히 드러난다.
  • 반면에 하위 클래스를 작성할 때 전혀 쓰이지 않는 protected 멤버는 private 이어야 할 가능성이 크다.
  • 이러한 검증에는 하위 클래스 3개 정도가 적당하며 이 중 하나 이상은 제3자가 작성해봐야 한다.

상속을 허용하는 클래스가 지켜야 하는 제약

1. 상속용 클래스의 생성자에서 재정의 가능 메소드를 호출해서는 안된다.

  • 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메소드가 하위 클래스의 생성자보다 먼저 호출된다.
  • 이때 재정의한 메소드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도한대로 동작하지 않을 것이므로 상속용 클래스의 생성자에서는 무조건 재정의 가능 메소드를 호출해서는 안된다.
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
2021-08-15T10:13:19.789827300Z

2. 상속용 클래스는 가급적 Cloneable, Serializable을 구현하지 마라.

  • 둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다.
  • 그 클래스를 확장하려는 프로그래머에게 엄청난 부담을 지우기 때문이다.
  • 물론 이 인터페이스들을 하위 클래스에서 원한다면 구현하도록 하는 특별한 방법도 있다.
  • clone과 readObject는 새로운 객체를 만들기 때문에 생성자와 비슷한 효과를 낸다. 따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점에 주의하자. 즉, clone과 readObject 모두 재정의 가능 메소드를 호출해서는 안된다.

3. Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메소드를 갖는다면 이 메소드들은 private이 아닌 protected로 선언해야 한다.

  • 이는 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.

상속용으로 설계하지 않은 클래스는 상속을 금지하라.

  • 상속용으로 설계되거나 문서화되지 않은 클래스는 클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있으므로 상속을 금지해야 한다.

상속을 금지하는 방법

  • (1) 클래스를 final로 선언한다.
  • (2) 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩토리를 제공한다.

상속을 꼭 허용해야 한다면 어떻게 해야 하는가?

  • 만약 상속용으로 설계되지 않은 클래스의 상속을 허용하겠다면 클래스 내부에서 재정의 가능 메소드를 사용하지 않게 만들고 이 사실을 문서로 남긴다.
  • 즉, 재정의 가능 메소드를 호출하는 자기 사용 코드를 완벽히 제거하는 것이다.

재정의 가능 메소드를 호출하는 자기 사용 코드를 제거하는 방법

클래스의 동작을 유지하면서 재정의 가능 메소드를 사용하는 코드를 제거하는 방법이 있다.

  • 재정의 가능 메소드는 자신의 본문 코드를 private 도우미 메소드로 옮긴다.
  • 재정의 가능 메소드들이 이 private 도우미 메소드를 호출하도록 수정한다.
  • 재정의 가능 메소드를 호출하는 다른 코드들도 모두 이 도우미 메소드를 직접 호출하도록 수정한다.

참고 자료

  • Effective Java 3/E