Posts [이펙티브자바] 아이템19-상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라
Post
Cancel

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

상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.

  • '재정의 가능' 이란 publicprotected 중 final이 아닌 모든 메서드를 뜻한다.
  • 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.

Implementation Requirements

  • API 문서의 메서드 설명 끝에서 종종 Implementation Requirements로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다.
  • 이 절은 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.
  • 아래는 java.util.AbstractCollection에서 발췌한 예다.

스크린샷 2022-07-19 오후 11 22 36

  • 이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있다. iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 정확히 설명하고 있다.
  • @implSpec 태그는 자바 8에서 처음 도입되어 자바9부터 본격적으로 사용되기 시작했다. 자바 11의 자바독에서도 선택사항으로 남겨져 있으며 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:"를 지정해주면 된다.

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

  • 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.
  • java.util.AbstractList의 removeRange 메서드의 경우를 보면 List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없다.
    • 그럼에도 이 메서드를 제공한 이유는 단지 하위 클래스에서 부분 리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다.
    • removeRange 메서드 없이 하위 클래스에서 clear 메서드를 호출하면 (제거할 원소 수의) 제곱에 비례해 성능이 느려지거나 부분릿스트의 매커니즘을 밑바닥부터 새로 구현해야 했을 것이다.

스크린샷 2022-07-19 오후 11 31 41

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

  • 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 ‘유일’하다.
    • 꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 들어난다.
    • 거꾸로 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private 이었어야 할 가능성이 크다.
    • 경험상 이러한 검증에는 하위 클래스 3개 정도가 적당하고 그리고 이 중 하나 이상은 제3자가 작성해봐야 한다.
  • 그리고 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
  • 또한, 상속하려는 사람을 위해 덧붙인 설명은 단순히 그 클래스의 인스턴스만 만들어 사용할 프로그래머에겐 필요 없는 군더더기 일 뿐이다.
    • 해당 내용은 개인적으로 잘 받아들여지지 않는다. 상속하려는 사람을 위해 덧붙인 설명이 있으면 더 명확한 상속을 위한 클래스가 되지 않을까 하는데 다른 의도가 숨겨져있는 것 같다.

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

  • 이 규칙을 어기면 프로그램이 오동작할 것이다.
    • 상위 클래스의 생성장가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위클래스의 생성자보다 먼저 호출된다.
    • 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다.
    • 아래 예시 코드를 보면 이해가 더 쉬울 것이다.
1
2
3
4
5
6
7
8
9
10
11
// 재정의 가능 메서드를 호출하는 생성자 - 따라 하지 말 것! (115쪽)
public class Super {
    // 잘못된 예 - 생성자가 재정의 가능 메서드를 호출한다.
    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 생성자에서 호출하는 메서드를 재정의했을 때의 문제를 보여준다. (126쪽)
public final class Sub extends Super {
    // 초기화되지 않은 final 필드. 생성자에서 초기화한다.
    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();
    }
}
  • 위 프로그램이 instant를 두번 출력하리라 기대했겠지만, 첫 번째는 null을 출력한다.
    • 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe 재정의 메서드를 호출하기 때문이다.
    • final 필드의 상태가 이 프로그램에선 두 가지임에 주목하자.(정상이라면 단 하나 뿐이어야 한다)
    • 만약 println을 호출하는게 아니라 instant의 메서드를 호출하려한다면 NPE가 발생할 것 이다.

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

Clonealbe과 Serializable 인터페이스는 상속용 설계의 어려움을 한층 더해준다.

  • 일들을 구현할 떄 따르는 제약사항도 위의 생성자와 비슷하다는 점에 주의하자.
  • 즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해선 안된다.
    • readObject의 경우 하위 클래스의 상태가 미처다 역직렬화 되기 전에 재정의한 메서드부터 호출하게 된다.
    • clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 수정하기 전에 재정의한 메서드를 호출한다.
    • 어느쪽이든 프로그램 오작동으로 이어질 것이다.

마지막으로, Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 한다.

  • private으로 선언한다면 하위 클래스에서 무시되기 때문이다.
  • 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중하나다.

final도 아니고 상속용으로 설계되거나 문서화되지 않은 구체 클래스를 상속하는 것 또한 문제를 일으킨다.

  • 클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있기 떄문이다.
    • 실제로 보통의 구체 클래스를 그 내부만 수정했음에도 이를 확장한 클래스에서 문제가 생겼다는 버그가 종종 생긴다.
  • 이를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.
    • 상속을 금지하는 두 가지 방법
    • 1)클래스를 final로 선언
    • 2) 생성자를 private 이나 package-private으로 선언 후 public 정적 팩터리를 만들어주는 것
    • 정적 팩터리는 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다.

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

  • 개인적으로 위의 내용이 직관적으로 이해가 되진 않는다.
  • 하지만 위에서 언급했던 문제들을 해결하는 방법으로 클래스 내부에선 재정의 가능 메서드를 사용하지 않게 만들고 이를 문서로 남기는 것을 얘기하고 있다.
    • 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거함으로써 메서드 재정의시 발생하는 프로그램 오동작을 예방할 수 있을 것이다.
    • 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않게끔 말이다

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

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

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

정리

1) 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.

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

3) 클래스에 final 선언과 정적 팩터리를 활용해 상속용으로 설계하지 않은 클래스는 상속을 금지하도록 만들자.

4) 클래스 내부에선 재정의 가능 메서드를 사용하지 않게 만들고 이를 문서로 남기자.

5) 클래스의 동작을 유지하면서 재정의 가능 메서드를 사용하는 코드를 제거하려면 재정의 가능 메서드를 private으로 변경하여 해결하자.

핵심 정리: 상속용 클래스를 설계하기란 결코 만만치 않다. 클래스 내부에서 스스로를 어떻게 사용하는지(자기 사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그러지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다. 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다. 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 나을 것이다. 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.

This post is licensed under CC BY 4.0 by the author.

[이펙티브자바] 아이템18-상속보다는 컴포지션을 사용하라

[이펙티브자바] 아이템20-추상 클래스보다는 인터페이스를 우선하라