상속보다는 컴포지션을 사용하라
이 책에서 말하는 상속은 구현 상속을 말하는 것이다.
상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 이를 상속한 하위 클래스는 오작동할 수 있다. 즉, 상위 클래스 변경에 따라 하위 클래스도 변경이 필요할수 있다.
구체적인 예는 다음과 같다.
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 getAddCount(){
return getAddCount();
}
}
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
이후로 getAddCount메서드를 호출한다면, 예상(3)과 다른 결과(6)을 반환한다.
먼저, InstrumentedHashSet의 addAll을 호출한다면 addCount에 3을 저장하게 되고, 그 후 HashSet의 addAll을 호출한다. HashSet의 addAll은 아래와 같이 구현되어 있다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e)) //add 호출
modified = true;
return modified;
}
여기서 add를 호출하고 있는데, 여기 add는 재정의한 add로, addCount ++;
연산을 하고 있다. for 문 안에 정의 되어있으니 addCount에 값이 중복해서 더해져서 최종 결과가 6으로 반환이 된다.
상위 클래스에서 하위 클래스가 재정의한 메서드를 사용하는 경우가 아니더라도, 하위 클래스가 깨지기 쉬운 이유가 존재한다. 바로 상위 클래스에 새로운 메서드를 추가하는 것이다.
예를 들어, 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야 하는 프로그램을 작성한다고 가정하자. 이를 위해 그 컬렉션을 상속해 원소를 추가하는 모든 메서드를 재정의해서 필요한 조건을 검사할 수 있다. 하지만 상위 클래스에서 원소를 추가하는 또 다른 메서드를 추가한다면 하위 클래스에서 재정의하지 못한 경우가 생겨, 특정 조건을 만족하지 못하는 경우가 존재하게 된다.
위의 문제들을 피해가는 묘안이 있는데, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 방식 (Composition) 이다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s; //private 필드로 참조
public ForwardingSet(Set<E> s) { //Set을 인자로 받는다.
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 remove(Object o) {return s.remove(o); }
public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
public boolean addAll(Collection<? extends E> 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(); }
}
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;
}
}
이 방식을 사용한다면, 상위 클래스의 함수를 참조하고 있기에 하위 클래스가 재정의한 add를 호출하지 않고 원래의 함수 add를 호출할 수 있다.
(1) ForwardingSet
: 전달 클래스, Set에 영향주지 않고 사용하기 위한 Class
(2) InstrumentedSet
: 래퍼 클래스, 다른 인스턴스를 감싼다.
상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에만 쓰여야 한다. 즉, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.
컴포지션을 써야 할 상황에서 상속을 사용한다면 내부 구현을 불필요하게 노출하는 것이다.