상속은 코드를 재사용하는 강력한 수단이지만 최선은 아님, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됨
이는 만약 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험함(여기서 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말함, 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관함)
메서드 호출과 달리 상속은 캡슐화를 깨뜨림, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있음
상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작 할 수 있음
그래서 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 발맞춰 수정돼야만 함
예를 들어 아래와 같이 HashSet
을 사용하는데 성능을 높이기 위해 처음 생성된 이후 원소가 몇 개 더해졌는지 알기 위해 추가된 원소의 수를 저장하는 변수와 접근자 메서드와 원소를 추가하는 메서드를 재정의함
public class InstrumentedHashSet<E> extends HashSet<E> {
// 추가된 원소의 수
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@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 getAccount() {
return addCount;
}
}
하지만 위처럼 잘 구현된 것처럼 보여도 잘 작동하지 않음 addAll
을 통해 원소 3개를 더하면 6을 반환함
HashSet
의 addAll
메서드가 add
메서드를 사용해 구현됨 즉 add
에서 쓰는 것과 addAll
에서 addCount
가 중복해서 더해져 6이 된 것임
이 경우에는 addAll
메서드를 재정의하지 않으면 문제를 고칠 수 있음 하지만 이 부분은 한계가 있음
이와 같이 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당되고 이 가정에 기대면 깨지기 쉬움
여기서 만약 addAll
메서드를 컬렉션을 순회하며 원소 하나당 add
메소드를 한 번만 호출하는 것으로 재정의할 수 있음
하지만 그렇게 해도 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고 오류나 성능이 떨어지거나 만약 private
필드를 써야하는 상황이라면 이 방식으로 구현이 불가능함
그리고 하위 클래스가 깨지기 쉬운 이유가 더 있는데 만약 다음 릴리스에서 상위 클래스에 새로운 메서드를 추가한다면 이 메서드를 재정의해 필요한 조건을 먼저 검사하게끔 해야함
하지만 이게 또 다음 릴리스에서 변할 수도 있고 추가적인 수정을 더 해야함
즉 위의 문제 모두 메서드 재정의가 원인이었고 그래서 이렇게 메서드를 재정의하는 대신 새로운 메서드를 추가하는 것을 생각할 수 있는데 이 역시 새로운 릴리스에서 반환 타입이 다르면 또 문제에 봉착함
위와 같은 문제를 해결하기 위한 묘안 중 하나임
기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private
필드로 기존 클래스의 인스턴스를 참조하게 하는 방법임
새 클래스의 인스턴스 메서드들은(private
필드를 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환함, 이 방식을 전달이라 하며 새 클래스의 메서드들은 전달 메서드라 부름
그렇게 해서 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않음
이를 위에서 본 예시에 그대로 응용해서 하나는 집합 클래스 자신이고 하나는 전달 메서드만으로 이뤄진 재사용 가능한 전달 클래스임
// 래퍼 클래스 - 상속 대신 컴포지션을 사용함
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;
}
}
// 재사용할 수 있는 전달 클래스
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
public boolean addAll(Collection<?> c) {return s.addAll(c); }
public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o) { return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
InstrumentedSet
은 HashSet
의 모든 기능을 정의한 ForwardingSet
해당 Set 인터페이스를 활용해 설계되어 견고하고 유연함
Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공함 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만든 것임
위와 같은 방식으로는 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있고 기존 생성자들과도 함께 사용할 수 있음
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // 이 메서드에서는 dogs 대신 iDogs를 사용함
}
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 함
컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부름(엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당함)
래퍼 클래스는 콜백 프레임워크와는 어울리지 않음
콜백 프레임워크는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백)때 사용하도록 함
내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고 콜백 때는 래퍼가 아닌 내부 객체를 호출함 SELF 문제가 발생함
재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 마들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있음
상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 함
클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야함 B가 정말 A인가라고 자문해봐야함
그렇다고 확신할 수 없다면 상속을 하면 안되고 아니다라고 한다면 A를 private
인스턴스로 두고 다른 API를 제공해야 하는 상황이 대다수임 A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐임
컴포지션을 써야할 상황에서 상속을 사용하면 내부 구현을 불필요하게 노출하고 API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한됨
그래서 컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 것은 확장하려는 클래스의 API에 아무런 결함이 없는지 결함이 있다면 이 결함이 클래스의 API까지 전파돼도 괜찮은지 컴포지션은 이 결함을 숨기는 새로운 API를 설계할 수 있지만 상속은 그 결함까지도 승계를 함