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

Jimin Lim·2022년 5월 7일
0

Effective Java

목록 보기
19/38
post-thumbnail

아이템 19

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

아이템 18에서 상속을 염두해 두지 않으면서 설계했고, 상속할 때의 주의할 점을 문서화해놓지 않은 외부 클래스를 상속할 때의 위험을 경고했다.

그렇다면 상속을 고려한 설계와 문서화란 어떤 것을 의미하는 것일까?

상속 문서화

상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
예를 들어, API 문서의 메서드 설명 끝에 Implementation Requirements로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다. 이 절은 메서드 주석에 @implSpec태그를 붙여주면 자바독 도구가 생성해준다.

  • AbstractCollection - remove 메서드
    주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다. 
    이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsuppoertedOperationException을 던지니 주의하자.
    이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 알 수 있다.

하지만 이런 식으로 내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다. 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면, 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅을 잘 선별해 protected 메서드 형태로 공개해야 할 수도 있다.

  • AbstractList - removeRange 메서드
    fromIndex 부터 toIndex까지의 모든 원소를 이 리스트에서 제거, toIndex 이후의 원소들은 앞으로 당겨진다. 
    이 리스트 훅은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다. 
    리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.
    
    Implementation Requirements: 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다. 
    List 구현체의 최종 사용자는 위의 메서드에는 관심이 없다. 하지만 이 메서드를 제공한 이유는 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다. 만약 이 메서드가 없다면 하위 클래스에서 clear 메서드를 호출하면 제곱에 비례해 성능이 느려지거나 부분 리스트의 메커니즘을 새로 구현해야 했을 것이다.

상속 검증

상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출할지는 어떻게 결정할까?
이는 실제 하위 클래스를 만들어 시험해보는 것이 최선이다. 이 책에서는 3개 정도 검증해보기를 권장한다.

상속용 클래스의 생성자

상속용 클래스의 생성자는 재정의 가능 메서드를 호출해서는 안된다.
생성자의 실행 순서는 (1) 상위 클래스 (2) 하위 클래스 이기에 재정의한 메서드가 하위 클래스의 생성자에서 초기하는 값에 의존한다면 의도대로 작동하지 않을 수 있다.

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

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

}

위와 같이 코드를 작성한다면 재정의한 overrideMe 메서드가 두 번 호출될 것이라고 예측되지만, 상위 클래스의 생성자가 먼저 호출하기에 아래와 같이 null 결과가 반환된다.

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

도우미 메서드 사용

재정의 가능 메서드는 자신의 본문 코드를 private helper로 옮기고 이 도우미 메서드를 호출하도록 한다.
그 후, 재정의 가능 메서드를 호출하는 다른 코드도 helper를 호출하도록 한다.

public class Super2 {
    public Super2(){
        helper();
    }

    public void overrideMe(){
    }

    private void helper(){
        System.out.println("logic");

    }
}

public final class Sub2 extends Super2 {
    private final Instant instant;

    Sub2() {
        instant = Instant.now();
    }

    @Override
    public void overrideMe(){
        System.out.println(instant);
    }

    public static void main(String[] args) {
        Sub2 sub2 = new Sub2();
        sub2.overrideMe();
    }

}
//logic
//날짜 반환

상속을 금지하는 방법

  1. 클래스를 final로 선언한다.
  2. 모든 생성자를 private이나 package-private으로 선언한 뒤, 정적 팩터리를 만들어준다.

만약 상속을 꼭 허용해야겠다면, 클래스 내부에서는 재정의 가능 메서드를 사용하지 않도록 하고 문서로 남기는 것이다.

정리

  • 클래스 내부에서 스스로를 어떻게 사용 (자기 사용 패턴) 하는지 모두 문서로 남겨야 한다.
  • 하위 클래스를 만들 수 있도록 메서드를 protected로 제공할 수도 있으니 상속은 금지하는 편이 낫다.
  • 상속을 금지하려면 클래스를 final 선언, 생성자 모두를 외부에서 접근할 수 없도록 한다.
profile
💻 ☕️ 🏝 🍑 🍹 🏊‍♀️

0개의 댓글