🪄 상속용 클래스는 신경써라
상속용 클래스는 반드시 상속을 염두에 두고 설계해야 한다(Item18).
이번 아이템에서는 상속을 고려한 설계와 문서화가 뭔지 공부한다.
상속을 허용할 클래스는, 클래스에서 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.
클래스의 API로 공개된 메서드에서, 클래스 자신의 또 다른 메서드를 호출할 수도 있다(자기사용). 그런데 만약 '재정의 가능 메서드'를 호출하는 형태라면, 호출하는 메서드의 API 설명에 그 사실을 적시해야 한다. 어떤 순서로 호출하는지, 각 호출 결과가 처리에 어떤 영향을 주는지 말이다.
*재정의 가능이란?: public과 protected 메서드 중 final이 아닌 모든 메서드다.
*백그라운드 스레드나 정적 초기화 과정에서도 호출이 일어날 수 있다.
*API 문서의 메서드 설명 끝에 "Implementation Requirements"로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다. Javadoc을 이용할 때 @implSpec 태그가 생성하는 절이다.
🪄 올바른 문서화 예시: 내부 매커니즘을 문서화하기
java.util.AbstractCollection에서 발춰한 예시이다.
public boolean remove(Object o)
주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다(선택적 동작). 더 정확하게 말하면, 이 컬력션 안에 'Object.equals(o, e)가 참인 원소' e가 하나 이상 있다면 그 중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경됐다면) true를 반환한다.
Implementation Requirements: 이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다. 이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsupportedOperationExcption을 던지니 주의하자.
iterator 메서드를 재정의하면 remove의 동작에 영향을 줌을 확실히 알 수 있는 설명이다.
iterator 메서드를 통해 얻은 Iterator(반복자)의 동작이 remove 메서드의 동작에 주는 영향도 정확히 설명했다.
*그러고보니, 내가 1년 전에 remove의 동작에 의문을 가져 문서를 찾아보았을 때 정확히 이 부분을 보고 명확해진 것을 기억한다. 문서화가 잘 된 라이브러리의 힘을 경험했던 것이다.
*대조적으로 Hashset에서는 add를 재정의하면 addAll에 영향을 줌을 문서화해두지 않아서 알 수 없었고, 문제가 되었다.(Item18).
🪄 동작 중간에 끼어들 수 있는 hook을 잘 선별해 protected로 노출하기
java.util.AbstractList의 removeRange라는 protected 메서드가 그 예시다.
List구현체의 최종 사용자는 removeRange 메서드에 관심이 없지만 이 메서드를 공개하여 제공한다.
다음을 보자.
protected void removeRange(int fromIndex, int toIndex)
fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다.
...설명...
이 리스트 혹은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.
Implementaion Requirements: 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다. 주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.
이걸 왜 공개할까? 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서다. removeRange 메서드가 없다면 하위 클래스에서 clear 메서드를 호출하면 제거할 원소 수의 제곱에 비례해 성능이 느려지거나, 부분리스트의 매커니즘을 처음부터 새로 구현해야 했을 것이다.
상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지 결정하는 방법은?
"심사숙고해서 잘 예측해본 다음, 적절히 실제 하위 클래스를 만들어 시험해보는 것"이 유일한 방법이다!
∨ 주의점은?
- protected 메서드 하나하나가 내부 구현이므로 그 수는 가능한 한 적어야 한다.
- 한편으론 너무 적게 노출해서 상속으로 얻는 이점까지 없애지 않도록 주의해야 한다.
.
.
∨ 체크하기
- 꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈 자리가 확연히 드러나난다.
- 거꾸로, 하위 클래스를 여러 개 만들 때까지 전혀 사용하지 않은 protected 멤버는 사실 private이어야 할 가능성이 크다.
경험상 이러한 검증에는 하위 클래스 3개 정도 만들어봐야 하고, 이 중 하나는 제3자가 작성해봐야 한다.
이러한 결정들이 클래스의 성능과 기능에 영원한 족쇄가 될 수 있으므로, 상속용으로 설계한 클래스는 배포 전 반드시 하위 클래스를 만들어 검증하도록 하자.
*참고: Serializable을 구현한 상속용 클래스가 readResolve나 wirteReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다. 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.
🪄 상속용 클래스의 생성자에 유의하자
상속용 클래스의 생성자는 "직접적으로든 간접적으로든, 재정의 가능 메서드를 호출해서는 안 된다!"
이 규칙을 어기면 프로그램이 오작동할 것이다. 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 '메서드'가 하위 클래스의 '생성자'보다 먼저 호출된다. 만약 하위 클래스의 생성자에서 초기화한 값에 해당 재정의 메서드가 의존한다면.. 당연히 안 되겠지?
다음은 이 규칙을 어긴 코드다.
이 프로그램이 instant를 두 번 출력하기를 기대할텐데, 첫번째는 null을 출력한다.
위에서 설명한 이유다. 필드가 초기화되기 전에 재정의 메서드가 호출되었기 때문이다.
*println이 아니고 일반적인 참조였다면 null을 출력하는 게 아니라 NullPointerException이 발생할 것이다.
// 상위 클래스
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();
}
}
*private, final, static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.
🪄 상속을 어렵게 하는 Cloneable, Serializable
Cloneable과 Serializable 인터페이스를 구현한 클래스를 설계했다면, 그 클래스를 상속용으로 설계하는 것은 좋지 않은 생각이다. 그 클래스를 확장하려는 프로그래머에게 엄청난 부담을 주기 때문이다.
*물론 원한다면 이 인터페이스들을 하위 클래스에서 구현하도록 하는 특별한 방법도 있으며 (Item13), (Item86)에서 설명한다.
왜 주의해야 할까? clone과 readObject 메서드는 새로운 객체를 만들기 때문에 생성자와 비슷한 효과를 낸다. 따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 '생성자'와 비슷함을 유의하자. 즉 clone과 readObject 또한 위에서 설명한 것 처럼 재정의 가능 메서드를 호출해서는 안된다.
즉 더욱 구제적으로 설명하자면,
- readObject의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메서드부터 호출하게 된다.
- clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 올바른 상태로 수정하기 전에 재정의한 메서드를 호출한다.
*특히 그렇게 clone이 잘못 동작했다면, 원본 객체에도 피해를 줄 수 있어 위험하다. clone이 깊숙한 내부 자료구조까지 복제하지 못했어서, 복제본 내부 어딘가에서 여전히 원본 객체의 데이터를 참조하고 있다면 그렇다.
🪄 상속용이 아닌 일반적인 구체 클래스는 상속하지 마라
일반적 구체 클래스는 final도 아니고, 상속용으로 만들어진 것도 않아서 그렇게 문서화되지 않았다.
좋은 해법은, "상속용으로 설계하지 않았다면 상속을 금지하는 것이다"
∨ 상속을 금지하는 방법
- 클래스를 final로 선언하는 방식이 쉽다.
- 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법이다.
*정적 팩터리 방법은 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다(Item17)
이 조언은 다소 논란의 여지가 있다. 그동안 많은 프로그래머가 일반적인 구체클래스를 상속해 계측, 통지, 동기화, 기능 제약 등을 추가해왔기 때문이다. 만약 핵심 기능을 정의한 인터페이스가 있고, 클래스가 그 인터페이스를 구현했다면 상속을 금지해도 개발하는 데 아무런 어려움이 없을 것이다. Set, List, Map이 좋은 예시다. (Item18)에서 설명한 래퍼 클래스 패턴 역시 기능을 증강할 때 상속 대신 쓸 수 있는 더 나은 대안이라 하겠다.
∨ 그래도 상속해야 한다면?
구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기에 상당히 불편해진다. 만약 구체클래스에서 상속을 허용해야겠다면 좋은 방법이 하나 있다. 클래스 내부에서는 재정의 기능 메서드를 사용하지 않게 만들고, 그 사실을 문서에 남기는 것이다. 자기사용 코드를 완전 제거하라는 것이다. 메서드를 재정의해도 다른 메서드의 동작에 아무 영향을 주지 않도록 말이다.
∨ 그렇게 하는 TIP
재정의 가능 메서드는 자신의 본문 코드를 private '도우미 메서드'로 옮기고, 모든 코드들이 이 도우미 메서드를 호출하도록 한다.
🪄 마무리
클래스를 상속용으로 설계하려면 엄청난 노력이 들고,
클래스에 상당한 제약을 준다.
가볍게 생각하지 말자.
추상 클래스나 인터페이스의 골격 구현(Item20)처럼 상속을 허용하는 게 명백히 정당한 상황이 있고,
불변 클래스(Item17)처럼 명백히 잘못된 상황이 있다는 것을 알자.
'JAVA > Effective Java' 카테고리의 다른 글
[이펙티브 자바] 인터페이스는 타입을 정의하는 용도로만 사용하라 ─ 4장:클래스와 인터페이스:Item22 (0) | 2023.12.10 |
---|---|
[이펙티브 자바] 추상 클래스보다는 인터페이스를 우선하라 ─ 4장:클래스와 인터페이스:Item20 (0) | 2023.12.10 |
[이펙티브 자바] 상속보다는 컴포지션을 사용하라 ─ 4장:클래스와 인터페이스:Item18 (1) | 2023.10.30 |
[이펙티브 자바] 변경 가능성을 최소화하라 ─ 4장:클래스와 인터페이스:Item17 (0) | 2023.10.25 |
[이펙티브 자바] public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 ─ 4장:클래스와 인터페이스:Item16 (0) | 2023.10.25 |