상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.
상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
하지만 일반적인 구체 클래스를 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 것은 위험하다.
메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
상위 클래스는 릴리스마다 내부 구현이 달리질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오작동할 수 있다.
이러한 이유로 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 발맞춰 수정돼야만 한다.
하위 클래스가 깨지기 쉬운 또 다른 이유는 새로운 메서드가 추가된다면 그 방법을 제정의 해줘야 하기 때문이다.
위의 두 문제 모두 메서드 재정의가 원인이다.
따라서 클래스를 확장하더라도 메서드를 재정의하는 대신 새로운 메서드를 추가하면 괜찮으리라 생각할 수도 있다.
이 방식이 훨씬 안전한 것은 맞지만, 위험이 전혀 없는 것은 아니다.
다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, 운 없게도 하필 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다르다면 클래스는 컴파일조차 되지 않는다.
혹 반환 타입이 같다면 상위 클래스의 새 메서드를 재정의한 꼴이니 앞서의 문제와 똑같은 상황에 부닥치게 된다.
이러한 문제를 피해 가는 묘안은 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하면 된다.
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(구성)이라 한다.
새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
이 방식을 전달이라하며, 새 클래스의 메서드들을 전달 메서드라 부른다.
그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
책에서의 예제는 아래와 같다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public add(E e) {return s.add(e)}
public addAll(Collection<? extends E> c) { return s.addAll(c); }
...
}
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) { super(s) }
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
위에서 ForwardingSet 그리고 InstrumentedSet 같은 클래스를 Set 인스턴스를 감싸고 있다는 뜻에서 레퍼 클래스(Wrapper Class)라고 한다.
그리고 InstrumentedSet의 경우 추가로 기능이 있다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.
컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.
(위임 관련 추가 자료 : https://jwlee010523.tistory.com/entry/%ED%81%B4%EB%9E%98%EC%8A%A4-%EC%9C%84%EC%9E%84)
간단 요약
class B implements A {
@Override
public void doA() {}
}
class CBDelegate implements A{
private final B b;
public BADelegate(B b) {
this.b = b;
}
@Override
public void doA() {
b.doA();
}
}
정리해 보면 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.
그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다.
더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.