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