DEV ℧ Developer Diary

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

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

상속의 문서화

item 18에서는 상속을 염두에 두지 않고 설계 했고 상속할 때의 주의점도 문서화해놓지 않은 ‘외부’ 클래스를 상속할 때의 위험을 경고했다.

상속용 클래스 재정의할 수 있는 메서드들은 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다. 클래스의 API 로 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출할 수도 있다.

그런데 마침 호출되는 메서드가 재정의 가능 메서드라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 한다.

API 문서의 메서드 설명 끝에 종종 “Implementation Requirements”로 시작하는 절을 볼 수 있는데, 그 메서드의 내부동작 방식을 설명하는 곳이다. 다음은 java.util.AbstractCollection 에서 발췌한 예이다.

public boolean remove(Object o)

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다(선택적 동작). 더 정확하게 말하면, 이컬렉션 안에 ‘Object.equals(o, e)가 참인 원소’ e가 하나 이상 있다면, 그 중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경됐다면) true 를 반환한다. Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾아면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다. 이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsupportedOperationException 을 던지니 주의하자.

이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있다. iterator 메서드로 얻은 반복자의 동작의 remove 메서드의 동작에 주는 영향도 정확히 설명했다.

@implSpec

@implSpec 태그는 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되기 시작했다. 이태그가 기본값으로 활성화되어야 바람직하다고 생각되지만, 자바 11의 자바독에서도 선택사항으로 남겨져 있다.

해당 태그를 활성화 하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:를 지정 해주면 된다. 해당 태그를 설정하지 않으면 무시해 버린다.

@implSpec 태그는 해당 메서드와 하위 클래스 사이의 계약을 설명 한다. 하위 클래스들이 그 메서드들을 상속하거나 super 키워드를 이용해 호출할 때 그 메서드가 어떻게 동작하는지를 명확히 인지하고 사용하도록 하는게 목적 이다.

hook

이렇게 내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다.

클래스의 내부 동작 과정 중간에 끼어들 수 있는 훗(hook)을 잘 선별 하여 protected 메서드 형태로 공개해야 할 수도 있다.

다음는 java.util.AbstractListremoveRange 메서드를 예로 살펴보자.

protected void removeRange(int fromIndex, int toIndex)

fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다. toIndex 이후의 원소들은 앞으로 (index 만큼씩) 당겨진다. 이 호출로 리스트는 ‘toIndex - fromIndex’ 만큼 짧아진다. (toIndex == fromIndex 라면 아무런 효과가 없다.) 이 리스트 혹은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메서드르 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다. Implementation Requirements: 이 메서드는 fromIndex 에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.nextListIterator.remove 를 반복 호출하도록 구현되었다. 주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

Parameters: fromIndex 제거할 첫 원소의 인덱스 toIndex 제거할 마지막 원소의 다음 인덱스

List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없다. 그럼에도 이 메서드를 제공한 이유는 단지 하위 클래스에서 부분 리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다.

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

즉, 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어 보는 것이 ‘유일’하다.

널리 쓰일 클래스를 상속용으로 설계한다면 문서화한 내부 사용 패턴과, protected 메서드와 필드를 구현하면서 선택한 결정에 영원히 책임져야 함을 잘 인식 해야 한다. 그러므로 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.

상속 클래스의 제약

상속을 허용하는 클래스가 지켜야할 제약이 아직 몇개 남았다. 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다.

상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출된다.

해당 규칙을 어기는 코드의 예시를 살펴보자.

/* 상위 클래스 */
public class Super {
    /* 잘못된 예 - 생성자가 재정의 가능 메서드를 호출한다. */
    public Super() {
        overrideMe();
    }

    public void overrideMe() {}
}
/* 하위 클래스 */
import java.time.Instant;

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을 출력한다.

null
2022-05-30T14:31:04.309627600Z

상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화 하기도전에 overrideMe를 호출하기 때문이다. 해당 예시에서는 final필드가 두가지임에 주목하자. (정상이라면 단 하나 뿐이어야 한다.)

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

Cloneable 과 Serializable

Cloneable 과 Serializable는 인터페이스 상속용 설계의 어려움을 한층 더해준다.

Cloneable의 clone과, readObject 메서드는 생성자와 비슷한 효과를 낸다(새로운 객체를 만든다.)

즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다.

각각 두개의 메서드를 재정의 할 경우 생기는 문제점을 적어보자.

  • readObject의 경우 하위 클랫그의 상태가 미처 역직렬화되기 전에 재정의한 메서드 부터 호출하게 된다.
  • clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 (올바른 상태로) 수정하기 전에 재정의한 메서드를 호출한다.

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

private으로 선언한다면 하위 클래스에서 무시되기 때문이다.

상속의 금지

위와 같은 문제를 피하기 위한 가장 좋은 방법은

상속용으로 설계하지 않은 클래스의 경우는 상속을 금지하는 것이다.

상속을 금지하는 방법은 두가지이다.

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