ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 아이템19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라
    책/이펙티브자바 2021. 8. 17. 13:38

    상속을 고려한 설계와 문서화가 무엇일까 ?

    • 메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다.
    • 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
    • 클래스의 API로 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출할 수 도 있다. 그런데 마침 호출되는 메서드가 재정의 가능 메서드라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 한다.
    • 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담는다. (재정의 가능이란 public과 protected 메서드 중 final 이 아닌 모든 메서드를 뜻한다.)
    • 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.

    문서화

    "Implementation Requirements" 로 시작하는 절을 볼수있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다.

    이 절은 메서드 주석에 @ImpleSpec 을 붙여주면 자바독 도구가 생성해준다.

    // AbstractCollection.java
    
    /**
     * {@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) {
        // ...
    }

    AbstractCollection의 remove를 보면 자바독 주석에 @ImplSpec ... 이라고 나와있는데 이 부분이 자바 API 문서에 들어가보면 아래와 같이 적혀있다.

     

    여기서 Implementation Requirements 설명 부분을 보면 iterator 메서드 재정의시 remove 메서드에 영향을 준다고 하고, iterator 반복자의 동작이 remove 메서드에 영향을 준다고도 정확히 설명한다.

    아이템 18의 HashSet에서 addAll() 부분에서는 알 수 없었는데 아주 대조적이다.

     

    근데 이런 식은 "좋은 API 문서란 '어떻게' 가 아닌 '무엇' 을 설명 해야한다" 라는 격언과 대치된다.

    상속이 캡슐화를 해치기 때문에 일어나는 문제이다.

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


    설계

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

    효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드로 공개해야 할 수도 있다. 드물게는 protected 필드로 공개 할 수도있다.

    // AbstractList.java
    /**
     * Removes from this list all of the elements whose index is between
     * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
     * Shifts any succeeding elements to the left (reduces their index).
     * This call shortens the list by {@code (toIndex - fromIndex)} elements.
     * (If {@code toIndex==fromIndex}, this operation has no effect.)
     *
     * <p>This method is called by the {@code clear} operation on this list
     * and its subLists.  Overriding this method to take advantage of
     * the internals of the list implementation can <i>substantially</i>
     * improve the performance of the {@code clear} operation on this list
     * and its subLists.
     *
     * @implSpec
     * This implementation gets a list iterator positioned before
     * {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
     * followed by {@code ListIterator.remove} until the entire range has
     * been removed.  <b>Note: if {@code ListIterator.remove} requires linear
     * time, this implementation requires quadratic time.</b>
     *
     * @param fromIndex index of first element to be removed
     * @param toIndex index after last element to be removed
     */
    protected void removeRange(int fromIndex, int toIndex) {
      ...
    }
    
    /**
     * Removes all of the elements from this list (optional operation).
     * The list will be empty after this call returns.
     *
     * @implSpec
     * This implementation calls {@code removeRange(0, size())}.
     *
     * <p>Note that this implementation throws an
     * {@code UnsupportedOperationException} unless {@code remove(int
     * index)} or {@code removeRange(int fromIndex, int toIndex)} is
     * overridden.
     *
     * @throws UnsupportedOperationException if the {@code clear} operation
     *         is not supported by this list
     */
    public void clear() {
      removeRange(0, size());
    }

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

    실제로 clear()를 보면 내부에서 removeRange() 를 호출한다.

    removeRange() 가 없다면 하위 클래스에서 clear() 를 호출하면 제거해야할 원소수의 제곱에 비례해 성능이 느려지거나 부분리스트의 메커니즘을 밑바닥 부터 새로 구현해야 해서 만만치 않은 일이다.

     

    그렇다면 상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지 어떻게 결정할까 ?

    심사숙고해서 잘 예측한다음 실제 하위 클래스를 만들어서 시험해보는 것이 최선이다.

    protected 메서드는 내부 구현에 해당하므로 그 수는 가능한 한 적어야한다.

    한편으로는 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.


    상속용 클래스 설계시 제약

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

     

    추가로 제약이 몇 개 더 있다.

    상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다.

    이 규칙을 어기면 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위클래스의 생성자보다 먼저 호출된다. 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도한대로 동작하지 않을 것이다.

    // Super.java
    public class Super {
        public Super() {
            System.out.println("Super 생성자");
            overrideMe();
        }
    
        public void overrideMe() {
            System.out.println("Super.overrideMe()");
        }
    }
    
    // Sub.java
    public final class Sub extends Super {
        private final Instant instant;
    
        Sub() {
            System.out.println("Sub 생성자");
            instant = Instant.now();
        }
    
        @Override
        public void overrideMe() {
            System.out.println("Sub.overrideMe()");
            System.out.println(instant);
        }
    
        public static void main(String[] args) {
            Sub sub = new Sub();
            sub.overrideMe();
        }
    }

    Sub 생성시 → Super 생성자, 재정의한 overrideMe(), instant

    sub.overrideMe() → Sub.overrideMe(), instant

    이 순서로 출력된다.

     

    여기서 일반적으로 Super의 생성자에서 instant 한 번 출력, Sub 생성자에서 instant 한 번 출력해서 두 번 출력하리라 기대하지만, 처음에는 Null 을 출력한다.

    상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe()를 호출하기 때문이다.

     

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


    Cloenable, Serializable 을 구현한 상속클래스 설계 주의

    Cloneable과 Serializable 인터페이스를 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다. 이 인터페이스에 clone(), readObject() 는 생성자와 비슷한 효과를 내는데 만약 이들을 구현하려 한다면 따르는 제약도 생성자와 비슷하다는 점에 주의해야한다.

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


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

    클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당하단걸 배웠다.

    • 그렇다면 상속을 어떻게 금지할 수 있을까 ?
    1. 클래스를 final 로 선언하기
    2. 모든 생성자를 private, package-private으로 선언하고 public 정적 팩토리 만들기

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

    이런 클래스라도 상속을 꼭 허용해야겠다면 합당한 방법이 있다.

    클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남기는 것이다.

    재정의 가능 메서드를 호출하는 자기 사용코드를 완벽히 제거하라는 뜻이다.

    이렇게하면 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지않기에 위험하지 않은 클래스를 만들 수 있다.

     


    결론

    상속용 클래스를 설계하기란 만만치 않다. 클래스 내부에서 스스로를 어떻게 사용하는지 (자기사용 패턴) 모두 문서로 남겨야하며, 문서화한 것은 그 클래스가 쓰이는한 반드시 지켜야 한다.

    그러지 않으면 그 방식을 믿고 활용하던 하위 클래스가 오동작할 수 있다.

    다른이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도있다.

    그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다.

    금지하려면 클래스를 final로 하거나 생성자를 접근 못하게 만들면 된다.

킹수빈닷컴