본문 바로가기

Reading/Effective Java

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

상속을 고려한 설계와 문서화


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

클래스의 API로 공개된 메소드에서 클래스 자신의 또 다른 메소드를 호출할 수도 있다. 

ex) 아이템 18에서 AbstractCollection 클래스의 addAll 메소드가 내부적으로 add 메소드를 호출하는 것

재정의 가능 메소드(public, protected 메소드 중 final이 아닌 모든 메소드)를 호출할 수 있는 모든 상황은 문서로 남겨야 한다.

(호출하는 메소드가 뭔지, 어떤 순서로 호출하는지, 호출 처리 중의 영향 등)

예를 들어, 백그라운드 스레드나 정적 초기화 과정에서도 호출이 일어날 수 있다.

 

API 문서에서 Implementation Requirements로 시작하는 절은 그 메소드의 내부 동작 방식을 설명하는 곳이다.

@ImplSpec 태그를 붙혀주면 자바독 도구가 생성해주고, 이 태그를 활성화하려면 명령줄 매개변수로

-tag "implSpec:a:Implementation Requirements:" 를 지정해주면 된다.

 

java.util.AbstractCollection을 보자.

 

 

@implSpec에 달린 내용을 보면, 이 메소드는 컬렉션을 순회하고 주어진 원소를 찾게 구현되었으며, 주어진 원소를 찾으면 반복자의 remove 메소드를 사용해 컬렉션에서 제거하고 이 컬렉션이 주어진 객체를 갖고 있으나 이 컬렉션의 iterator 메소드가 반환한 반복자가 remove 메소드를 구현하지 않았다면 예외를 던진다고 되어있다. 

 

iterator 메소드를 재정의하면 remove 메소드의 동작에 영향을 준다는 것을 알 수 있다.

클래스를 안전하게 상속할 수 있게 하려면 상속만 아니었다면 기술하지 않았어야 할 내부 구현 방식을 설명해야만 한다.

 

 

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

 

아래는 java.utilAbstractList의 removeRange 메소드다.

 

 

AbstractList는 List Interface의 랜덤접근(ex 배열)리스트를 구현하는데 최소한의 노력이 필요한 skeleton 추상 클래스다.

removeRange의 설명에 ListIterator.remove 메소드가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다고 되어있다.

그리고 이 메소드는 하위클래스에서 부분 리스트의 clear 메소드를 고성능으로 쉽게 만들기 위해서라고 나와 있다.

 

ListIterator.remove 메소드를 호출할 때, 랜덤 접근이 가능하니 인덱스로 직접 접근하여 제거는 한 번만에 가능하지만,

특정 원소를 제거 후 배열의 위치를 재조정 해줘야한다. ArrayList 구현체를 예시로 보자.

 

 

이 메소드는 ArrayList.remove 메소드에서 검증 과정을 제외하고 실제 삭제하는 과정을 담은 private 메소드다.

modCount(수정 횟수)를 늘리고, size를 1개 줄여서 System.arraycopy 메소드를 이용한다.

삭제할 위치의 i+1 번째 이후의 원소들을, i번째 이후의 원소들에 newSize-i(조정된 크기-인덱스위치, 즉 끝까지)만큼 덮어 씌우고나서

마지막 원소의 값을 null로 비워둬서 GC가 처리할 수 있게한다. 즉, 삭제 최악의 시간복잡도는 O(N)이다.

 

이렇게 되면 ArrayList는 removeRange와 clear 메소드를 재정의하지 않았다면 최악의 경우 시간복잡도는 O(N^2) 이다.

(ListIterator.remove 메소드 실행시간 O(N) ) * (삭제하려는 원소의 개수O(M)) = O(N^2)

 

아래 AbstractList.clear 메소드를 보자. clear()는 정확하게 최악의 시간복잡도 O(N^2)이 된다.

 

 

아래 AbstractList.SubList.removeRange 메소드를 보자. SubList가 clear()를 호출했다면 이 메소드를 호출할테고

root는 SubList의 최상위, 즉 본래 구현체 리스트다. SubList의 크기만큼 removeRange 메소드를 호출하니,  최악은 O(N^2)이 다.

 

 

하지만 ArrayList는 아래와 같이 clear 메소드를 재정의했다.

재정의하지 않았다면 O(N^2)이 걸렸을 메소드를 내부 구조를 활용하게 재정의하여 O(N)으로 바뀌었다.

단지 Size를 0으로 만들고 내부 참조를 전부 null로 참조해 GC가 수집할 수 있게 변경했기 때문이다!

 

 

하지만 ArrayList.SubList.clear는 AbstractList.clear를 호출하고, 재정의하지 않아 root.RemoveRange를 호출한다. 

그런데 ArrayList는 아래와 같이 removeRange 메소드도 재정의했다.

위에서 System.arraycopy 메소드로 위치를 재정렬하고 삭제하는 과정은 O(N)이 걸린다고 했었다.

lo는 fromIndx고 hi는 toIndex이니, fromIdx 이후의 값을 toIdx 이후의 값으로 옮기고, 이후 뒤에 있는 지워야 할 값들을

전부 null로 만들어 GC 대상이 되게 하고 사이즈를 줄이면 SubList의 clear 메소드에도 O(N^2)을 O(N)으로 바꾸는데 성공했다!

 

 

List 구현체의 최종 사용자는 protected니 이 메소드를 직접 사용하지 않아 관심이 없겠지만, 이 메소드를 제공하는 건 하위 클래스에서 부분리스트의 clear 메소드를 고성능으로 만들기 쉽게하기 위해서다. removeRange 메소드가 없다면 하위 클래스에서 clear 메소드를 호출하면 제거할 원소 수의 제곱에 비례해 성능이 느려지거나 부분 리스트의 매커니즘을 밑바닥부터 새로 구현해야 했을 것이다.

 

AbstractList가 removeRange의 골격을 잡아줬기 때문에 위에서 설명한 ArrayList 구현체는 매커니즘을 쉽게 구현할 수 있었고

문서로 남겨진 내용을 통해서 자신의 구현체 내부구조에 맞게 O(N^2) 시간복잡도를 O(N)으로 줄일 수 있었다.

 

이처럼 내부 매커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다. 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하기 위해 이러한 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별해 protected 메소드로 공개해야 한다는 것이다. 

 

 

3. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 유일하며, 상속용으로 설계한 클래스는 배포 전 반드시 하위 클래스를 만들어 검증해야 한다.

protected 메소드 하나하나가 내부 구현에 해당해 가능한 적어야하며, 너무 적게 노출해 상속을 얻는 이점이 사라지지않게 주의해야한다.

 

필요한 protected 멤버를 놓쳤으면 하위 클래스를 작성할 때 빈 자리가 드러나며, 하위클래스를 여러개 만들 때까지 전혀 쓰이지 않는protected 멤버는 private이었어야 할 가능성이 크다. 검증을 위해 하위 클래스 3개가 적당하며 하나 이상은 제3자가 작성해봐야 한다.

 

널리 쓰일 클래스를 상속용으로 설계한다면 문서화한 내부 사용 패턴, 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.overrideMe();
    }
}

 

상위클래스 Super 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화 하기 전에 overrideMe를 호출한다.

 

 

따라서, null이 출력되고 메인문에서 overrideMe를 호출하면 인스턴스 필드가 초기화 되어서 현재 날짜가 출력된다.

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

 

 

그리고 Cloneable의 clone 메소드와 Serializable의 readObject 메소드 모두 직·간접적으로 재정의 가능 메소드를 호출하면 안된다.

clone과 readObject 메소드는 새로운 객체를 만든다는 점에서 생성자와 비슷한 효과를 내기 때문이다.

 

readObject 메소드의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메소드부터 호출하게 되고,

clone 메소드의 경우 복제본의 상태를 올바른 상태로 수정하기 전에 재정의한 메소드를 호출한다.

clone이 완벽하지 못하다면 복제본 내부 어딘가는 여전히 원본 객체를 참조하고 있게되고, 그러면 원본 객체도 피해를 입게 된다.

 

그리고 Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메소드를 갖는다면 이 메소드들은 private이 아닌 protected로 선언해야 한다. private으로 선언한다면 하위 클래스에서 무시되기 떄문이다. 이 것 역시 상속 허용을 위해 내부 구현 클래스 API를 공개하게되는 예다.

 

[ 직렬화/역질렬화 나중에 학습 후 예제 짜보기]

 

 

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

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

  • 클래스를 final로 선언하기
  • 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩토리 만들어 주기

두번째 방법의 경우 내부에서 다양한 하위 클래스를 만들어 쓰는 유연성을 준다[아이템17]

 

구체클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기 불편해진다.

이런 클래스라도 상속을 허용해야한다면 클래스 내부에선 재정의 가능 메소드를 사용하지 않게 만들고

이 사실을 문서화하는 것이다. 재정의 가능 메소드를 호출하는 자기 사용 코드를 완벽히 제거하는 것이다.

이렇게 하면 상속해도 그리 위험하지 않은 클래스를 만들 수 있고 메소드를 재정의해도 다른 메소드의 동작에 아무 영향을 주지 않는다.

 

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

각각의 재정의 가능 메소드는 자신의 본문 코드를 private 도우미 메소드로 옮기고, 이 도우미 메소드를 호출하도록 수정한다.

그리고 재정의 가능 메소드를 호출하는 다른 코드들도 모두 이 도우미 메소드를 직접 호출하도록 수정하면 된다.

도우미 메소드 관련 이슈 참조:

 

[아이템 19] private 도우미클래스 · Issue #54 · Java-Bom/ReadingRecord

p. 128, 클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거할 수 있는 방법. private 도웅미 메서드를 사용하라. 예제좀! 이해가 안갑니당

github.com

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.