Study/Java

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

monitor 2023. 3. 5. 22:59

1. 상속을 고려한 설계와 문서화의 이해


상속을 사용 할 때 메소드 재정의하면 무슨 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다.

덧붙여서 어떤 순서로 호출 할 지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.

더 넓게 말하면, 재정의 가능 메서드를 호출 할 수 있는 모든 상황을 문서로 남겨야 한다.

 

기존의 API 문서의 메서드 설명 끝에서 종종 "Implementation Requirementss"로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다.

 

다음 예는 java.util.AbstractCollection 에서 발췌한 예이다.

내부 동작 방식을 설명하는 이유
    /**
     * {@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)

이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove 메소드를 사용하여 컬렉션에서 제거한다. 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메소드를 구현하지 않았다면 UnsupportedOperationException 을 던지니 주의하자 (remove 반복)

 

이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드에 영향을 주는 것으로 확인 할 수 있다. 

 

즉, 무분별한 재정의에 의해서 상속 받는 메서드가 바뀔 수 있으니 내부 동작 방식을 설명을 상세히 하여야 한다.

 

 

훅 선별

 

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

 

    /**
     * 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) {
        ListIterator<E> it = listIterator(fromIndex);
        for (int i=0, n=toIndex-fromIndex; i<n; i++) {
            it.next();
            it.remove();
        }
    }
    
    
    
        /**
     * 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());
    }

위와 같이 clear에서 사용하는 removeRange가 protected로 설정되어 있을 시 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들 수 있다. (상황 예시 : protected로 설정된 메서드는 다시 하위 클래스에서 오버라이딩이 가능하므로 AbstractList를 상속 받는 하위 클래스에서 clear() 메서드 호출 시 protected로 설정된 removeRange를 하위 클래스에서 쓰임새에 맞게 오버라이딩하면 된다.)

 

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

 

생성자는 재정의 가능 메서드를 호출해서는 안된다.

 

 

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


public final class Sub extends Super {
	//초기화되지 않은 final field 생성자에서 초기화한다.
    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();
    }
}

 

이렇게 생성하면 overrideMe() - 상위 클래스 생성자가 실행되어 재정의 메서드 먼저 실행되고, 이 후에 instant 필드가 초기화 되고 나서 sub의 override 메서드가 실행되어 진다.

 

 

 

만일, Cloneable과 Serializable 인터페이스를 구현한 클래스를 상속하여 사용하게 되면 clone과 readObject와 같은 메서드는 생성자와 비슷한 효과를 가져서 (새로운 객체를 생성한다.) 위와 같은 상황이 만들어지지 않게 재정의가 가능한 메서드를 호출하면 안된다. (역직렬화 또는 복제전에 재정의 메서드가 호출되는 상황이 발생)

 

일반적인 구체 클래스의 상속

결론만 전달하면 클래스의 변화가 생길 때마다 하위 클래스를 오동작하게 만들여지가 존재한다. 이 때문에 상속용으로 만들어진 클래스가 아니라면 상속을 금지하는 것이 현명하다.

 

상속을 막는 방법

 

1. package-private 또는 private 등으로 생성자를 선언하고 public 정적 팩토리를 만들어주는 방법으로 사용하여 상속을 막을 수 있다. 

 

public class Test{

    int x;

    private Test(int x){
        this.x = x;
    }

    public static Test init(int x) { 
        return new Test(x);
    }
    
    public static void main(String[] args){
        Test test = Test.init(500);
    }
}

 

2. 클래스를 final로 만든다.

 

public final class Test{

    int x;

    public Test(int x){
        this.x = x;
    }
}

final로 만든 객체 extends 시 나는 에러

결론

클래스 내부에서 어떻게 사용할지 모두 문서로 남겨야 하여야한다. 그리고, 효율 좋은 하위 클래스 제작을 위해서 일부 메서드를 protected로 제공해야 할 수도 있다. 또한, 클래스를 확장할 이유가 없다면 상속을 막는 것이 좋다.