Effective Java - Item 19

상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라

상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.

  • 클래스의 API로 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출할 수도 있다.
  • 그런데 마침 호출되는 메서드가 재정의 가능 메서드라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 한다.
  • 덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.
  • 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.

좋은 API 문서란 '어떻게'가 아닌 '무엇'을 하는지를 설명해야 한다라는 격언과는 대치됨. - 클래스를 안전하게 상속할 수 있도록 하려면 (상속만 아니었다면 기술하지 않았어야 할) 내부 구현 방식을 설명해야만 한다.

효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다. 드물게는 protected 필드로 공개해야 할 수도 있다.

상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지는 어떻게 결정할까? - 안타깝게도 마법은 없다. 심사숙고 해서 잘 예측해본 다음, 실제 하위 클래스를 만들어 시험해보는 것이 최선이다. 🥲

상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 ‘유일’하다. 꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 드러난다. 거꾸로 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 크다.

상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.

상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다. 이 규칙을 어기면 프로그램이 오동작할 것이다.

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();
  }
}
  • 예상 : instant를 두 번 출력
  • 결과 : 첫 번째는 null 출력
  • 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe를 호출하기 때문이다.

private, final, static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.

Cloneable과 Serializable 인터페이스는 상속용 설계의 어려움을 한층 더해준다. 둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다.

clone과 readObject 메서드는 생성자와 비슷한 효과를 낸다. clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.

  • readObject의 경우 하위 클래스의 상태가 미쳐 다 역직렬화되기 전에 재정의한 메서드부터 호출하게 된다.
  • clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 (올바른 상태로) 수정하기 전에 재정의한 메서드를 호출한다.
  • 특히 clone이 잘못되면 복제본뿐 아니라 원본 객체에도 피해를 줄 수 있다.

Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다.

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

  • 클래스를 final로 선언하는 방법 (더 쉬운 방법)
  • 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법

상속을 꼭 허용해야겠다면 - 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남긴다. (재정의 가능 메서드를 호출하는 자기사용 코드를 완벽히 제거하라는 말)

클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거할 수 있는 기계적인 방법

  1. 각각의 재정의 가능 메서드는 자신의 본문 코드를 private ‘도우미 메서드’로 옮기고, 이 도우미 메서드를 호출하도록 수정한다.
  2. 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정하면 된다.

핵심 정리

  • 상속용 클래스 설계 쉽지 않다.
  • 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다.
  • 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다.
  • 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 나을 것이다.
  • 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.

Written by@Myunghwan
Nothing changes if nothing changes

GitHub