상속을 고려한 설계와 문서화 = public/protected 메소드 중 final 이 아닌 것

=> 메서드를 재정의하면 어떤 일이 일어나는지(내부적으로 어떻게 사용하고 있는지)를 정리하여 문서화 하자

 

java.util.AbstractCollection

 

위 API 문서의 Implementation Requirement 는 메서드의 내부 동작 방식을 설명하는 곳이다. 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다. 

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다 => 선택적 동작.
더 정확 하게 말하면, 이 컬렉션 안에 ‘Object.equals(o, e)가 참인 원소’ e가 하나 이상 있다면 그 중 하나를 제거한다.
주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경 됐다면) true를 반환한다.

Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다.
주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거 한다.
이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsupportedOperationException 을 던지니 주의하자.

이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 주며, iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 설명하고 있다.

자바 11의 자바독에서 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:"를 지정 해줘야 한다.

 

 

내부 메커니즘을 문서로 남기는 것뿐만 아니라,

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

java.util.AbstractList

fromlndex(이상)부터 tolndex(미만)까지의 모든 원소를 이 리스트에서 제거한다.
tolndex 이후의 원소들은 앞으로 index만큼씩 당겨진다.
이 호출로 리스트는 `tolndex - fromlndex`만큼 짧아진다.

이 리스트/부분리스트에 정의된 clear 연산이 이 메서드를 호출하는데, 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.

Implementation Requirements: 이 메서드는 fromlndex에서 시작하는 리스트 반복 자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다.

주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

removeRange 메서드가 사용되지 않지만 제공된 이유는 하위 클래스에서 부분 리스트의 clear 메서드를 고성능으로 만들기 위해서이다. removeRange 메서드가 없을 때 하위 클래스에서 clear 메서드를 호출하면 제거할 원소 수의 제곱에 비례해 성능이 느려지거나, 부분리스트의 메커니즘을 밑바닥부터 새로 구현해야 했을 것이다.

언제 메서드를 protected로 둘지 일정하진 않다. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 유일하다.(약 3개)

상속을 허용하는 클래스의 경우 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다. 왜냐하면 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의 한 메서드가 하위 클래스의 생성자보다 먼저 호출된다. 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다. 

 

아래 예를 살펴보자.

package com.example.sypark9646.item19;

public class Parent {

    public Parent() {
        System.out.println("parent 생성자");
        overrideMe();
    }

    public void overrideMe() {
        System.out.println("parent");
    }
}
package com.example.sypark9646.item19;

import java.time.Instant;

public class Child extends Parent {

    private final Instant instant;

    public Child() {
        System.out.println("child 생성자");
        this.instant = Instant.now();
    }

    @Override
    public void overrideMe() {
        System.out.println("child");
        System.out.println(instant);
    }
}
package com.example.sypark9646.item19;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class OverrideTimeTest {

    @DisplayName("상속용 클래스의 생성자의 재정의 가능 메소드 호출 테스트")
    @Test
    void overrideTest() {
        Child child = new Child();
        child.overrideMe();
    }
}

위 테스트에서 볼 수 있듯이, parent 생성자를 호출하면서 overrideMe 호출 -> 이때, Child 생성자를 만드는 것이 목표였기 때문에(?) child의 overrideMe를 호출하는데, 아직 Child가 초기화 되기 전이므로 instant = null이 찍힌다는 것을 확인할 수 있다.

Parent 생성자 안에서 부르는 overrideMe가 parent override를 프린트 할 줄 알았지만,,, 아니었다

 

즉, 여기서는 final 필드의 상태가 두 가지이다!! (정상이라면 단 하나)

overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe 를 호출할 때 NullPointerException을 던지게 된다.

(println의 경우 null 도 괜찮기 때문에 넘어간 것..)

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

 

 

Cloneable과 Serializable 인터페이스 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다.

굳이 하고싶다면, clone과 readObject 메서드는 생성자와 비슷한 효과를 내므로(새로운 객체 생성) 재정의 가능 메서드를 호출하면 안된다.

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

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

(특히 clone이 잘못되면 복제본뿐 아니라 원본 객체에도 피해를 줄 수 있다)

 

 

마지막으로, Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 protected로 선언해 야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다. 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.

 

상속을 쓰는 경우는 추상 클래스나 인터페이스의 골격 구현(아이템 20)할 때가 있고, 불변 클래스(아이템 17) 등에는 절대 사용하지 않도록 하자.
일반적인 구체 클래스일 때에는 상속용으로 설계하지 않았을 경우 상속을 금지하도록 하는 것이 좋다.

상속을 금지하는 방법은 두 가지가 있는데, 가장 쉬운 방법은 클래스를 final로 선언하는 방법이다.

두 번째는 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법이다.

 

 

결론적으로 상속은 매우 복잡하다.. 상속 대신 사용할 수 있는 방법은 인터페이스가 될 수 있고, 래퍼 클래스 패턴(아이템18) 도 가능하다.
그렇지만 만약 구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용 하기에 상당히 불편해진다. 이런 클래스라도 상속을 꼭 허용해야겠다면 클래스 내부에서 재정의 가능 메서드를 사용하지 않게 만들고, 이 사실을 문서로 남기도록 하자. 

 


클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거할 수 있는 기계적인 방법은 아래와 같다.

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

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

package com.example.sypark9646.item19;

public class Parent {

    public Parent() {
        System.out.println("parent 생성자");
        helpOverride();
    }

    public void overrideMe() {
        helpOverride();
    }

    private void helpOverride(){
        System.out.println("parent override");
    }
}
package com.example.sypark9646.item19;

import java.time.Instant;

public class Child extends Parent {

    private final Instant instant;

    public Child() {
        System.out.println("child 생성자");
        this.instant = Instant.now();
    }

    @Override
    public void overrideMe() {
        helpOverride();
    }

    private void helpOverride(){
        System.out.println("child override");
        System.out.println(instant);
    }
}

실행 결과를 보면, parent의 overrideMe(helpOverride)를 호출함으로써 instant = null 값이 출력되는 일이 발생하지 않았고,

하위 클래스에서 재정의한 메소드 또한 잘 수행됨을 알 수 있다.

+ Recent posts