상속용 클래스는 재정의할 수 있는 메스드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
📌 상속을 고려한 문서화
재정의 가능 메서드(public과 protected 중 final이 아닌 모든 메서드)를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.
클래스의 API로 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출할 수 있는데, 마침 호출되는 메서드가 재정의 가능 메서드라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 한다. (어떤 순서로 호출하며, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.)
API 문서의 메서드 설명 끝에서 종종 "Implementation Requirements"로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다. 이 절은 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.
/**
* {@inheritDoc}
*
* @implSpec
* This implementation iterates over the collection looking for the
* specified element. If it finds the element, it removes the element
* from the collection using the iterator's remove method.
*
* <p>Note that this implementation throws an
* {@code UnsupportedOperationException} if the iterator returned by this
* collection's iterator method does not implement the {@code remove}
* method and this collection contains the specified object.
*
* @throws UnsupportedOperationException {@inheritDoc}
* @throws ClassCastException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
*/
public boolean remove(Object o) {
Iterator<E> it = iterator();
if (o==null) {
while (it.hasNext()) {
if (it.next()==null) {
it.remove();
return true;
}
}
} else {
while (it.hasNext()) {
if (o.equals(it.next())) {
it.remove();
return true;
}
}
}
return false;
}
아이템 18에서 HashSet을 상속하여 add를 재정의한 것이 addAll까지 영향을 준다는 사실을 알 수 없었는데,
AbstractCollection에서는 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 주는 것을 명시해주고 있다!
다만 이 방식은 "좋은 API 문서란 'How'가 아닌 'What'을 하는지 설명해야 한다"라는 격언과도 대치된다.
이는 상속이 캡슐화를 해치기 때문에 일어나는 안타까운 현실이다.
클래스를 안전하게 상속할 수 있도록 하려면 (상속만 아니었다면 기술하지 않았어야 할) 내부 구현 방식을 설명해야 한다.
📌 상속을 고려한 설계
꼭 내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계 전부는 아니다.
클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.
public boolean removeAll(Collection<?> c) {
Objects.requireNonNull(c);
boolean modified = false;
Iterator<?> it = iterator();
while (it.hasNext()) {
if (c.contains(it.next())) {
it.remove();
modified = true;
}
}
return modified;
}
List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없다.
그럼에도 이 메서드를 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다.
removeRange 메서드가 없다면 하위 클래스에서 clear 메서드를 호출하면 (제거할 원소 수의) 제곱에 비례해 성능이 느려지거나 부분 리스트의 메커니즘을 밑바닥부터 새로 구현해야 했을 것이다.
protected 메서드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 한 적어야 한다.
한편으로는 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.
💡 어떤 메서드를 protected로 노출해야 할지는 실제 하위 클래스를 만들어 시험해보는 것이 최선이다.
📌 상속용 클래스 테스트
상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.
꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 드러난다.
반대로 하위 클래스를 여러 개 만들 때까지 쓰이지 않은 protected 멤버는 사실 private였어야 할 가능성이 크다.
경험상 이 검증에는 하위 클래스 3개 정도가 적당하며, 하나 이상은 제 3자가 작성해봐야 한다.
💡 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
📌 주의 사항 (1)
- 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
- 위 규칙을 지키지 않으면 프로그램 오작동을 일으킬 수 있다.
- 하위 클래스에서 재정의한 메서드가 하위 클래스 생성자보다 먼저 실행될 수 있기 때문이다. 이는 상위 클래스 생성자가 하위 클래스 생성자보다 먼저 실행되기 때문에 발생한다.
public class Super {
public Super() {
overrideMe();
}
public void overrideMe() {
}
}
public final class Sub extends Super {
private final Instacnce instacnce;
Sub() {
instacnce = Instacnce.now(); // create instance in constructor
}
// 재정의 가능 메서드. 상위 클래스의 생성자가 호출해버린다.
@Override public void overrideMe() {
System.out.println(instacnce);
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
instant를 두 번 출력하리라 기대했을 수 있지만, 첫 번째는 null을 출력한다.
그렇다면 final 필드의 상태가 이 프로그램에서는 두 가지임에 주목하자. (정상적이라면 하나 뿐이어야 한다.)
만약 private, final, static 메서드라면 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.
📌 주의 사항 (2)
- clone과 readObject 메서드는 생성자와 비슷한 효과를 낸다. (생성자와 비슷한 제약을 갖는다)
- 즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
- clone의 경우 하위 클래스의 clone 메서드가 복제본 상태를 (올바른 상태로) 수정하기 전에 재정의한 메서드를 호출하게 된다. (복제본 뿐 아니라 원본 객체까지 피해를 줄 수 있다.)
public class Super implements Cloneable{
String type;
public Super() {
this.type = "super";
}
public void overrideMe() {
System.out.println("super method");
}
@Override public Super clone() throws CloneNotSupportedException {
overrideMe();
return (Super) super.clone();
}
}
public class Sub extends Super{
String value;
@Override public void overrideMe() {
System.out.println("sub mehtod");
System.out.println(value);
type = "sub";
}
@Override public Sub clone() throws CloneNotSupportedException {
Sub clone = (Sub) super.clone();
clone.value = "temp";
return clone;
}
}
class SubTest {
@Test
void cloneTest() throws CloneNotSupportedException {
Sub sub = new Sub();
assertThat(sub.type).isEqualTo("super"); // it's true, pass
Sub clone = sub.clone();
assertThat(sub.type).isEqualTo("sub");
}
}
📌 가장 좋은 방법
- 상속용으로 설계하지 않은 클래스는 상속을 금지해라
- 방법1. 클래스에 final 키워드를 붙여라
- 방법2. 모든 생성자를 private, package-private로 선언하고 public 정적 팩터리를 만들어라
- 굳이 상속을 허용하겠다면 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남겨라
- 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거하라는 의미이다.
- 먼저 각각의 재정의 가능 메서드는 자신의 본문 코드를 private '도우미 메서드'로 옮기고, 이 도우미 메서드를 호출하도록 수정한다. 그 다음 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정하면 된다.