상속의 위험성
아이템 18에서 상속을 염두에 두지 않은 설계와 상속할 때의 주의점을 문서화 해놓지 않은 '외부'클래스를 상속할 때에 대한 위험성이 존재한다는 것을 확인했다. 여기에서 말하는 '외부'클래스란 프로그래머의 통제권 밖에 있는 클래스를 의미한다.
상속을 고려한 설계와 문서화
메소드를 재정의하면 어떤 일이 발생할지 정확히 정리하여 문서로 남겨야 한다.
상속용 클래스는 재정의할 수 있는 메소드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
클래스의 API로 공개된 메소드에서 클래스 자신의 또 다른 메소드를 호출할 수 있다. 이 때 마침 호출되는 메소드가 재정의 가능 메소드라면 그 사실을 호출하는 메소드의 API 설명에 적시해야 한다. 덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.
즉, 다시말해 재정의 가능 메소드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.
Implementation Requirements
메소드의 내부 동작 방식을 설명하는 절이다. 이 절은 메소드 주석에 @implSpec 태그를 붙이면 자바독 도구가 생성해준다.
java.util.AbstractCollection에서의 예
public boolean remove(Object o)
주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다(선택적 동작). 더 정확하게 말하면, 이 컬렉션 안에 'Object.equals(o, e)가 참인 원소' e가 하나 이상 있다면 그 중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경됐다면) true를 반환한다.
Implementation Requirements: 이 메소드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove 메소드를 사용해 컬렉션에서 제거한다. 이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메소드가 반환한 반복자가 remove 메소드를 구현하지 않았다면 UnsupportedOperationException을 던지니 주의하자.
위와 같은 API 설명에 따르면 iterator 메소드를 재정의하면 remove 메소드에 영향을 줄 수 있음을 알 수 있다.
(아이템 18에서의 HashSet의 add()와 addAll()의 호출관계 의 사실을 알 수 없었던 것에 비하면 확실히 대조적)
문서를 작성하기는 하지만....
좋은 API 문서란 '어떻게' 가 아닌 '무엇'을 하는지를 설명해야 한다는데...
👉🏻 상속이 캡슐화를 해치기 때문에 상속만 아니었다면 작성하지 않아도 될 내부 구현 방식을 설명해야만 한다.
protected 메소드 노출 및 검증
내부 메커니즘을 문서로 남기는 것만이 상속 설계의 전부가 아니다.
효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메소드 형태로 공개해야 할 수도 있다.
java.util.AbstractList의 removeRange 메소드 예
protected void removeRange(int fromIndex, int toIndex)
fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다.
toIndex 이후의 우너소들은 앞으로 (index만큼씩) 당겨진다. 이 호출로 리스트는 'toIndex - fromIndex'만큼 짧아진다.(toIndex==fromIndex라면 아무런 효과가 없다)
이 리스트 혹은 이 리스트의 부분 리스트에 정의된 clear 연산이 이 메소드를 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메소드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.
Implemenation Requirements: 이 메소드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListInterator.remove를 반복 호출하도록 구현되었다. 주의: ListIterator.remove가 선형 시간이 걸리면 이 구현 성능은 제곱에 비례한다.
이 메소드를 굳이 알 필요가 없음에도 메소드 내용 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear메소드를 고성능으로 만들기 쉽게 하기 위함이다.
그렇다면 상속용 클래스를 설계할 때 어떤 메소드를 protected로 노출해야할지는 어떻게 정하는가?
실제 하위 클래스를 만들어 시험해보는 방법밖에 없다.
상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.
protected 메소드 하나하나가 내부 구현에 해당하므로 그 수는 최대한 적어야 한다. 한편, 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.
protected 메소드를 놓치면 하위 클래스를 작성할 때 빈자리가 확연히 드러난다. 거꾸로, 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 크다. 이러한 상황을 검증하기 위해서는 3개 정도의 하위 클래스 생성이 적절하다고 본다.
상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스르 만들어 검증해야 한다.
재정의 가능 메소드 호출 주의
상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안된다.
상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메소드가 하위 클래스의 생성자보다 먼저 호출된다. 이때 재정의한 메소드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다....
public class Super{
//잘못된 예 - 생성자가 재정의 가능 메소드를 호출한다.
public Super(){
overrideMe();
}
public void overrideMe(){
}
}
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();
}
}
상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe를 호출하기에 첫 번째는 null을 반환한다.
Cloneable과 Serializable 인터페이스
상속용 설계를 더 어렵게 만든다. 둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 확장하려고자 하는 프로그래머에게 부담을 주기에 일반적으로 좋지 않은 생각이다.
clone과 readObject 메소드는 새로운 객체를 만든다는 점에서 생성자와 비슷한 효과를 낸다.
따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점을 주의하자.
즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안된다.
👉🏻 하위 클래스의 상태가 수정되기 이전에 재정의한 메소드부터 호출되기 때문이다.
Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메소드를 갖는다면 이 메소드들은 private이 아닌 protected로 선언해야 한다.
👉🏻 private으로 선언한다면 하위 클래스에서 무시되기 때문이다.
그 외의 일반적인 구체 클래스에서의 상속
일반적인 구체 클래스라면 어떠할까?
전통적으로 이런 클래스들은 final도 아니고 상속용으로 설계되지도 문서화되지도 않았다. 하지만 그대로 두면 위험하다.
👉🏻 클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있기 때문
이러한 문제를 해결하기 위한 가장 좋은 방법은 상속용으로 설계되지 않은 클래스는 상속을 금지한다.
상속 금지에는 두 가지 방법이 있다.
클래스를 final로 선언하거나 모든 생성자를 private 후 public 정적 팩토리를 만들어주거나
정적 팩토리 방법은 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다.
하지만,
구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기에 상당히 불편해진다.
따라서 굳이 상속을 허용시켜야만 하겠다면 재정의 가능 메소드를 사용하지 않게 만들고 이를 문서화시키는 방법을 택해야 한다.
👉🏻 재정의 가능 메소드를 호출하는 자기 사용 코드를 완벽히 제거
위 방식을 택하면 상속해도 그리 위험하지 않은 클래스를 만들 수 있다.
👉🏻메소드를 재정의해도 다른 메소드의 동작에 아무런 영향을 주지 않기 때문
클래스의 동작을 유지하면서 재정의 가능 메소드를 사용하는 코드를 제거할 수 있는 기계적인 방법?
먼저 각각의 재정의 가능 메소드는 자신의 본문 코드를 private '도우미 메소드'로 옮기고, 이 도우미 메소드를 호출하도록 수정
그런 다음 재정의 가능 메소드를 호출하는 다른 코드들도 모두 이 도우미 메소드를 직접 호출하도록 수정하면 된다.
핵심 정리
상속용 클래스를 설계하기란 결코 만만치 않다 👉🏻 다양한 문제가 발생하고 이를 대처해야하기 때문
클래스 내부에서 스스로를 어떻게 사용하는지(자기 사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다.
그렇지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스에서의 오동작이 발생할 수 있다.
다른 이가 효율 좋은 클래스를 만들 수 있도록 일부 메소드를 protected로 제공해야 할 수도 있다. 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 나을 것이다.
상속을 금지하려면 final 또는 private + public static 팩토리 제공
'Effective Java' 카테고리의 다른 글
[Effective Java] 아이템 21 - 인터페이스는 구현하는 쪽을 생각해 설계하라 (0) | 2022.02.09 |
---|---|
[Effective Java] 아이템 20 - 추상클래스보다는 인터페이스를 우선하라 (0) | 2022.02.09 |
[Effective Java] 아이템 18 - 상속보다는 컴포지션을 사용하라 (0) | 2022.02.02 |
[Effective Java] 아이템 17 - 변경 가능성을 최소화하라 (0) | 2022.02.02 |
[Effective Java] 아이템16 - public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2022.01.25 |