-
[Effective Java] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라Java 2023. 2. 2. 11:02
상속을 고려한 설계와 문서화란?
1. 메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다. 즉, 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
- 클래스의 API로 공개된 메소드에서 클래스 자신의 또 다른 메소드를 호출할 수도 있다.
- 이때 호출되는 메소드가 하위 클래스에서 재정의 가능한 메소드라면 이 사실을 호출하는 메소드의 API 설명에 명시해야 한다.
- 재정의 가능 메소드를 호출할 수 있는 모든 상황을 문서로 남겨두어야 한다.
- API 문서의 메서드 설명 끝에서 종종 "Implementation Requirements"로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다. (메서드 주석에 @implSpec 태그를 붙여주면 자동으로 생성해준다.)
예) java.util.AbstractCollection 의 public oolean remove(Object o) 메서드
Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다. 이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsupportedOperationException을 던지니 주의하자.
이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있다.
2. 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메소드 형태로 공개해야 할 수도 있다.
예) java.util.AbstractList 의 protected void removeRange(int fromIndex, toIndex) 메서드
주석을 보면 removeRange 메소드는 이 리스트 또는 부분 리스트의 clear 연산에서 호출한다고 나와있다. 또한 리스트 구현의 내부 구조를 활용하도록 removeRange 메소드를 재정의하면 이 리스트와 부분 리스트의 clear 연산 성능을 크게 개선할 수 있다고 나와있다.
- List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없다. 그럼에도 이 메서드를 제공하는 이유는 단지 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 위해서다.
그렇다면 상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할까?
3. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.
- 하위 클래스를 만들며 상속용 클래스를 검증한다.
- 놓친 protected 멤버는 검증 도중 빈자리가 확연이 드러난다.
- 거꾸로, 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 크다.
- 널리 쓰일 클래스를 상속용으로 설계한다면 문서화한 내부 사용 패턴과, protected 메서드와 필드를 구현하면서 선택한 결정에 영원히 책임져야 한다. -> 그러니 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
4. 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래서에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출된다.
상위 클래스
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.override(); } }
- 이 프로그램이 instant를 두 번 출력하리라 기대했겠지만, 첫 번째는 null을 출력한다.
- 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe를 호출하기 때문이다.
- overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe를 호출할 떄 NullPointerException을 던지게 된다.
- 이 프로그램이 NullPointerExcception을 던지지 않은 이유는 println이 null입력도 받아들이기 때문이다.
5. 상속용으로 설계하지 않은 클래스는 상속을 금지한다.
상속을 금지하는 방법은 두 가지다.
- 클래스를 final로 선언한다.
- 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법이다. - 정적 팩터리 방법은 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다.
구체 클래스가 표준 인터페이스를 구현하지 않았는데도 상속을 꼭 허용해야한다면 방법이 하나 있다.
- 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남기는 것이다.
- 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거하라는 말이다.
- 이렇게 하면 상속해도 그리 위험하지 않은 클래스를 만들 수 있다. 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않기 때문이다.
클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거하려면
- 각각의 재정의 가능 메서드는 자신의 본문 코드를 private '도우미 메서드'로 옮기고, 이 도우미 메서드를 호출하도록 수정한다.
- 그런 다음 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정하면 된다.
상위 클래스
public class Super{ public super(){ //overrideMe(); helpMethod(); } public void overrideMe(){ helpMethd(); } //도우미 메서드 private void helpMethod(){ } }
하위클래스
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.override(); } }
실행결과 이전과는 다르게 하위 클래스의 멤버 변수가 초기화 되기 전에 호출되지 않고, 하위 클래스에서 재정의 된 메서드가 정상적으로 동작한다.
핵심 정리
상속용 클래스를 설계하기란 결코 만만치 않다. 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화 한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다.
그러지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다. 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 나을 것이다.
상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.
참고
'Java' 카테고리의 다른 글
[Effective Java] 톱레벨 클래스는 한 파일에 하나만 담으라 (0) 2023.02.02 [Effective Java] 추상 클래스보다는 인터페이스를 우선하라 (0) 2023.02.02 [Effective Java] clone 재정의는 주의해서 진행하라 (1) 2023.02.02 [Effective Java] toString을 항상 재정의하라 (0) 2023.02.02 [Effective Java] try-finally보다는 try-with-resources를 사용하라 (0) 2023.02.02