맞는데 왜 틀릴까..?

Java/Effective Java

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

안도일 2023. 3. 28. 18:48

상속을 염두에 두지 않고 설계하고, 상속할 때의 주의점도 문서화해놓지 않은 '외부' 클래스를 상속할 때는 매우 위험하다.

 

그렇다면 상속을 고려한 설계와 문서화는 정확히 무엇일까?

 

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

 

앞서 Item 18에서 살펴본 예시 "HashSet을 상속하여 add()를 오버라이딩한 것이 addAll()까지 영향을 준다"를 보면 Item 19의 필요성을 알 수 있을 것이다.

 

오버라이딩 가능한 메서드는 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지 등 메서드의 내부 동작 방식 (Implementation Requirements)을 문서로 남겨야 한다.

 

 

오버라이딩 가능한 메서드 
public과 protected 메서드 중 final이 아닌 모든 메서드를 뜻한다

 

 

위 규칙을 따라 java.util.AbstractCollection에서 명시한 API 문서

 

public boolean remove(Object o)

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다. 더 정확하게 말하면 이 컬렉션 안에 'Object.equals(e, e)가 참인 원소' e가 하나 이상 있다면 그 중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있다면 true를 반환한다.
@implSpec (Implementation Requirements) 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다. 이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았으면 UnsupportedOperationException을 던지니 주의하자.

 

이 설명에 따르면 iterator 메서드를 오버라이딩하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있고, iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 정확히 설명했다.

 

클래스를 안전하게 상속할 수 있도록 하려면 내부 구현 방식을 설명해야 한다.

 

 

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

 

java.util.AbstractList의 removeRange 메서드를 예로 살펴보자.

 

protected void removeRange(int fromIndex, int toIndex)

fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다. toIndex 이후의 원소들은 앞으로 (index 만큼씩) 당겨진다. 이 호출로 리스트는 'toIndex - fromIndex'만큼 짧아진다. (toIndex == fromIndex라면 아무 효과도 없다.) 이 리스트 혹은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다. 
Implementation Requirements
: 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다. 
주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

Parameters: fromIndex 제거할 첫 원소의 인덱스 toIndex 제거할 마지막 원소의 다음 인덱스

 

해당 메서드는 하위 클래스에서 부분리스트의 clear() 메서드를 고성능으로 만들기 쉽게 하기 위해서 존재한다. 만약 이 메서드가 없었다면 하위 클래스에서 clear() 메서드는 성능이 매우 느릴 것이다. 

따라서 AbstractList에서 protected로 구현된 removeRange() 메서드는 List 인터페이스를 구현하는 구현체 클래스에서 사용될 수 있는 훅(Hook)으로 활용될 수 있다. 

훅(Hook)

프로그램이나 시스템이 동작할 때 특정 이벤트나 상황이 발생하면 미리 정의된 훅(Hook) 함수가 실행되어, 추가적인 작업을 수행하거나 동작을 변경한다.

 

이처럼 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다.

 

 

상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.

 

상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지는 실제 하위 클래스를 만들어 시험해 보는 것이 최선이다. 

 

  •  protected 메서드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 적어야 한다.
  • 너무 적게 노출해 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.
  • 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 private이었어야 할 가능성이 크다.

 

상속용 클래스의 생성자는 재정의 가능 메서드를 호출해서는 안된다.

 

상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출된다. 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다.

 

예시 : 상위 클래스 Super의 생성자에서 하위 클래스가 오버라이딩한 메서드 overrideMe()를 호출한다.

 

 

 

  • 이 프로그램이 instant를 두 번 출력하리라 기대했겠지만, 첫 번째는 null을 출력한다.
  • 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe()를 호출하기 때문이다.
  • private, final, static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.

 

clone과 readObject 모두 재정의 가능 메서드를 호출해서는 안된다.

 

Clonable과 Serializable 인터페이스를 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점에서 주의하자.

 

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

 

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

 

clone이 완벽하지 못했어서 복제본의 내부 어딘가에서 여전히 원본 객체의 데이터를 참조하고 있다면 원본 객체에까지도 피해를 줄 수 있다.

 

 

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

 

private으로 선언한다면 하위 클래스에서 무시된다.

상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.

 

 

상속을 금지하는 방법

 

상속용으로 설계하지 않은 클래스는 상속을 금지하자.

 

1. 클래스를 final로 선언

 

2. 모든 생성자를 private이나 package-private로 선언하고 public 정적 팩터리를 생성

내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다.

 

 

만약 상속을 허용해야 한다면

재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽히 제거해 주자.

 

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

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

 

 

마치 getter/setter와 비슷한 구조다.

 

이렇게 하면 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않는다.