반응형

개요

 이번 아이템에서는 상속을 고려한 클래스인 상속용 클래스를 설계할 때 주의할 점에 대해 언급하고 있다. 내용을 읽어보니 이 아이템은 '상속을 고려해 설계하고 문서화하라' 보다는 '상속을 고려해 설계했다면 문서화하라' 라는 제목으로 와닿는 것 같다.

 그럼 상속용 클래스는 왜 문서화를 해야하고, 그러지 않았다면 상속을 금지해야하는지 알아보자.

 


상속이란?

상속(inheritance)이란 기존의 클래스에 기능을 추가하거나 재정의하여 새로운 클래스를 정의하는 것을 의미합니다.
이러한 상속은 캡슐화, 추상화와 더불어 객체 지향 프로그래밍을 구성하는 중요한 특징 중 하나입니다.
출처 : TCP School

 

 먼저 상속의 개념에 대해 한번 다시 짚어보자. 중요한 부분은 '재정의' 이다. 메서드를 재정의할 수 있다는 특성은 잠재적인 문제를 낳는다. 어디선가 자기 사용(self-use)중인 메서드를 재정의 할 수 있기 때문이다. 단순한 예를 들어보겠다.

 


상속을 통한 자기 사용 메서드 재정의

 

public class MyAdd {

    public int add(int a, int b){
        return a + b;
    }

    public int addAll(int... values){
        int result = 0;

        for(int value : values){
            result = add(result, value);
        }
        return result;
    }
}

 

 위 클래스에 add, addAll 메서드가 정의되어 있다. 여기서 만약 add 메서드만 재정의하여 사용하고 싶다면  새로운 클래스에서 이를 상속받은 후 아래와 같이 재정의 할 수 있다.

간단하게 더한 값에 2를 곱하도록 재정의 하였다.

 

X 2

public class OverrideAdd extends MyAdd {

    @Override
    public int add(int a, int b) {
        return (a+b)*2;
    }
}

 

 

  add 메서드를 재정의 한 후, add 메서드를 실행하였다. 2+2를 하고 더블로 한 8이 조회된다. 그런데 손댄 적 없는 addAll 메서드에서 2,2,3,3의 결과값이 10이 아닌 66이 조회된다. 이상하다.

class Main{

    public static void main(String[] args) {
        MyAdd overrideAdd = new OverrideAdd();

        int result = overrideAdd.add(2,2);
        System.out.println(result);  // 8

		...
        
        int result2 = overrideAdd.addAll(2,2,3,3); 
        System.out.println(result2); // 66?!
    }
}

 

 원인은 addAll 메서드에서 자신의 add 메서드를 사용(self-use)하고 있었고, 재정의된 add 메서드에 의해 의도하지 값이 조회된 것이다. 상속을 통한 자기사용 메서드를 재정의했더니 생각지 못한 곳에서 오류가 발생한 것이다.

 

public class MyAdd {

    public int add(int a, int b){
        return a + b;
    }

    public int addAll(int... values){
        int result = 0;

        for(int value : values){
            result = add(result, value); // self-use
        }
        return result;
    }
}

내부 동작을 설명해야 하는 상속용 클래스

 만약 addAll 메서드에 아래와 같은 주석을 추가했다면 어땠을까? 

모든 가변인자를 더합니다.
하지만 매우매우매우 중요한 내용이 있습니다. 각 가변인자를 더할 때 add 메서드를 사용한다는 것입니다!
만약 add 메서드를 재정의한다면 이 메서드도 영향을 받게 되니 주의하세요!

 

 이런 주석이 있었다면 addAll 메서드를 사용하는 시점에 이러한 사실을 알았을테니 add 메서드를 재정의하지 않거나, 다른 방법을 사용하여 변경 로직을 적용했을 것이다. 즉, 자기 사용(Self-use)을 하는 메서드에 대해서는 문서화를 통해 이러한 내부 구현을 알림으로써, 다른 개발자가 이를 인지할 수 있도록 해야한다.


문서화 시 포함되어야 할 내용들

 

1. 호출되는 재정의 가능한 자기사용 메서드 이름
2. 호출되는 순서
3. 호출 결과에 따른 영향도
4. 재정의 시 발생할 수 있는 모든 상황

 문서화 시 포함되어야 할 내용들은 위와 같다. 항목을 보면 알 수 있듯이 결국 '내부 구현'을 설명해야 한다. 내부 구현에 대한 내용은 @implSpec 어노테이션 사용하여 기재한다. 자바독에서 이 어노테이션을 인식하여 자동으로 내부 구현을 설명하는 Implementation Requirements 항목에 기재한 내용을 포함시켜 문서를 생성해줄것이다.

 


문서화 하기(feat. javadoc)

 

1. 주석 추가하기

MyAdd 클래스에 다음과 같이 주석을 추가한다. addAll의 경우 @ImplSpec을 통해 내부 구현을 기재하였다.

public class MyAdd {

    /**
     * 두 인자를 더합니다.
     * @param a 첫번째 인자
     * @param b 두번째 인자
     * @return 두 인자의 합
     */
    public int add(int a, int b){
        return a + b;
    }


    /**
     * 모든 가변인자를 더합니다.
     * @param values int 형 가변인자
     * @return 모든 가변인자의 합
     * @implSpec 각 가변인자를 더할 때 add 메서드를 사용합니다. 만약 add 메서드를 재정의한다면 이 메서드도 영향을 받게 됩니다.
     */
    public int addAll(int... values){
        int result = 0;

        for(int value : values){
            result = add(result, value);
        }
        return result;
    }
}

 

2. javadoc 문서 만들기 (intellij 기준)

[도구 > JavaDoc 생성] 을 선택하여 javadoc 문서를 생성한다. JavaDoc 범위는 현재 파일로 하고, 명령줄 인수에 필요한 명령어들을 입력한 후 '생성' 버튼을 누른다.

javaDoc 생성

 

 

※ unknown tag: implSpec 오류

 @implSpec 어노테이션은 기본적으로 활성화되어 있지 않다. 만약 unknown tag: implSpec 오류가 난다면 명령줄 인수에 이를 활성화하도록 아래 명령어를 추가해주자.  

-tag "implSpec:a:Implementation Requirements:"

 

※ 인코딩 관련 오류

 만약 인코딩 관련 오류가 난다면 아래 명령어를 추가해주자.

-encoding UTF-8 -docencoding UTF-8 -charset UTF-8

 

3. 문서 확인하기

 생성된 문서를 확인하자. @implSpec 에 기재한 내용이 Implementation Requirements 항목에 포함되어 있따면 내부 구현이 담긴 문서 생성 작업에 성공한 것이다. 만약 이 클래스를 사용하는 개발자가 이 문서를 한번이라도 본다면, add 메서드를 재정의 했을 때 발생할 수 있는 문제 상황에 대해 인지할 수 있을 것이다.

javaDoc으로 생성된 문서

 

AbstractCollection 클래스에서 사용하고 있는 @implSpec 예

@implSpec를 어떻게 사용하고 있는지 AbstractCollection 클래스의 remove 메서드를 통해 확인해봤다.

/** 
* @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.
* {@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.
**/
public boolean remove(Object o)

 

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

 

AbstractCollection 클래스에서도 내부 구현을 @implSpec 을 통해 기재하고 있다. 설명에 따르면 iterator 메서드를 재정의하면 이 remove 메서드의 동작에 영향을 줌을 알 수 있다.

 하지만 단순 내부 메커니즘을 문서화 시키는 것만이 상속을 위한 설계의 전부는 아니다. 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅을 잘 선별하여 재정의하도록 할 수 있도록 하는 것도 중요하다.

 


AbstractList의 removeRange

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

 

번역
fromIndex 부터 toIndex까지의 모든 원소를 리스트에서 제거한다.
... 중략
이 리스트 혹은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 성능을 크게 개선할 수 있다.
 @implSpec
 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다. 주의 : ListIterator.remove 가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

 

 clear 메서드의 훅(hook)으로써의 역할을 하는 removeRange 메서드이다. 서브 클래스에서 접근할 수 있도록 protected 접근제어자로 설계되었다.

 

 clear의 성능을 크게 개선할 수 있다고 말하는 이유는 내부 구현을 담은 @ImplSpec 을 보면 알 수 있다. 리스트 반복자를 얻어 원소를 제거할 때까지 next()와 remove()를 반복 호출하는 것이 기본 내부 구현 로직이라고 언급하고 있다. 만약 이 로직을 ArrayList, LinkedList 등 구현체의 구조에 맞게 적절히 재정의한다면 처리 시간을 단축시킬 수 있고, 이를 hook으로 사용하는 모든 메서드, 즉 clear 메서드의 성능을 개선할 수 있다.

 

 List 구현체의 사용자는 removeRange 메서드에는 관심이 없음에도 불구하고 이에 대한 메서드와 내부 구현을 제공한 이유는 하위 클래스의 clear 메서드를 고성능으로 만들기 쉽게 하기 위함인 것이다. 즉, 서브 클래스의 구현 클래스에서 성능 개선이 가능한 부분이 있다면 이를 재정의하여 hook으로 활용할 수 있도록 protected 접근제어자를 가진 메서드로 설계해야 한다.


protected 메서드 설계의 기준

 

마법은 없다. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.

 

 하지만 protected 메서드 설계에 대한 정해진 기준은 따로 없다고 한다. 책에서는 심사숙고해서 잘 예측해본 다음, 실제 하위 클래스를 만들어 시험해보는 것이 최선이라고 말하고 있다.


재정의 가능 메서드는 생성자에서 호출하면 안된다.

 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다. 아래와 같이 생성자에서 재정의 가능 메서드를 호출한다면, 이를 재정의했을 때 오작동을 일으킬 수 있다.

public class Super {

    // 생성자가 재정의 가능 메서드를 호출하는 잘못된 예
    public Super(){
        overrideMe();
    }

    public void overrideMe(){

    }
}

public class Sub extends Super {

    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();
    }
}

 

 sub.overrideMe() 메서드를 호출하면 instant를 두 번 출력하리라 기대하겠지만, 첫 번째는 null을 출력한다. 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기 전에 overrideMe()를 호출하기 때문이다.

실행 결과

 

 만약 생성자에 메서드를 넣어야한다면 해당 메서드를 재정의하지 못하도록 private 접근 제어자를 사용하거나 final, static 타입으로 생성자를 설계하면 된다.

 


상속용 클래스는 족쇄가 되면 안된다.

 널리 쓰일 클래스를 상속용으로 설계한다면 문서화한 내용과, protected 메서드, 필드를 구현하면서 선택할 결정에 영원히 책임져야 한다. 그 클래스의 성능과 기능에 족쇄를 채울 수 있기 때문이다.

 처음 보여준 MyAdd 클래스의 경우 add 메서드를 재정의하면 addAll 메서드에 문제가 발생할 수 있기 때문에 함부로 수정할 수 없다. 기능에 대한 족쇄가 채워진 것이다.

 


상속용 클래스 말고 일반 클래스는?

 그럼 일반적은 구체 클래스는 어떨까? 상속용으로 설계된 클래스가 아니니 문서화도 되어있지 않고, 다른 클래스에서 상속도 가능하다. 이 클래스에 변경 사항이 생기게 되면 이를 상속받는 하위 클래스에서 문제가 발생하거나, 하위 클래스에서 재정의하여 사용할 경우 자기 호출로 인한 문제도 발생할 수 있다.

 이 문제를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.

 


상속을 금지하는 방법

 

1. 클래스를 final 로 선언하기

 클래스를 final로 선언할 경우 해당 클래스를 다른 클래스에서 상속할 수 없다. 만약 MyAdd 클래스를 final로 선언한다면 다른 클래스에서 상속 시 컴파일 에러가 발생하게 된다.

상속이 불가능한 MyAdd #1

 

2. 모든 생성자를 private로 선언하고 정적 팩터리 메서드로 만들기.

 상속 시 기본적으로 부모 클래스의 기본 생성자를 호출하게 된다. 기본 생성자를 호출하지 못하도록 private로 막고, 정적 펙터리 메서드를 통해 인스턴스를 제공하는 방법이다.

public class MyAdd {

    private MyAdd(){
        
    }
    
    public static MyAdd newInstance(){
        return new MyAdd();
    }

 

상속이 불가능한 MyAdd #2

 


정리

 상속용 클래스를 설계하기란 결코 만만치 않다. 클래스 내부에서 사용되는 자기사용 패턴을 모두 문서로 남겨야 하고, 내부 구현에서는 이를 반드시 지켜야한다. removeRange처럼 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다. 하지만 이에 대한 기준은 없으며 설계자의 많은 고민과 테스트가 필요하다.

 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지시키도록 설계하는 것이 정신건강에 좋다.

반응형

+ Recent posts