상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.
잘못 사용하면 오류를 내기 쉬운 s/w를 만들게 된다.
상위,하위 클래스 모두 같은 개발자가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 안전하다.
하지만 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
주의) 이 책에서 상속은 클래스간 확장 케이스만을 다룸. 인터페이스 구현은 포함 X
다시말해 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
상위클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작할 수 있다.
public class InstrumentedHashSetUseExtends<E> extends HashSet {
private int addCount = 0; // 추가된 원소의 수
public InstrumentedHashSetUseExtends() {
}
public InstrumentedHashSetUseExtends(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override
public boolean add(Object o) {
return super.add(o);
}
@Override
public boolean addAll(Collection c) {
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
addAll()과 add()를 재정의하여 사용하고 있다. 두 메소드 모두 addCount라는 변수를 증가시켜주고 있다.
문제가 되는 부분은 addAll()의 사용에 있다.
원소 3개를 더하면 6을 반환한다.
원인은 HashSet의 addAll메서드가 add메서드를 사용해서 구현된 데 있다.
이런 문제는 HashSet 문서에는 쓰여 있지 않다.
또한 다음 릴리즈에서 부모 클래스에 메서드가 추가된다면 허용되지 않는 동작이 일어날 수 있으며 컴파일조차 일어나지 않을 수 있다.
이런 문제들은 메서드를 재정의가 원인이다.
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하면 된다.
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition)이라 한다.
새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.
그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
즉, 기존 클래스에 메서드가 추가되더라도 전혀 영향받지 않는다.
public class InstrumentedHashSetUseComposition<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedHashSetUseComposition(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;
}
}
클래스 필드 내에 private or public 필드로 클래스의 인스턴스를 참조하게 하고
해당 클래스를 구성하는 부분의 합으로 정의된다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) {
this.s = s;
}
public int size() {
return 0;
}
public boolean isEmpty() {
return s.isEmpty();
}
public boolean contains(Object o) {
return s.contains(o);
}
public Iterator<E> iterator() {
return s.iterator();
}
public Object[] toArray() {
return s.toArray();
}
public <T> T[] toArray(T[] a) {
return s.toArray(a);
}
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 retainAll(Collection<?> c) {
return s.retainAll(c);
}
public boolean removeAll(Collection<?> c) {
return s.removeAll(c);
}
public void clear() {
s.clear();
}
@Override
public boolean equals(Object o) {
return s.equals(o);
}
@Override
public int hashCode() {
return s.hashCode();
}
@Override
public String toString() {
return s.toString();
}
}
InstrumentedHashSetUseComposition은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
즉, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.
확장하려는 클래스의 api에 아무런 결함이 없는가? 결함이 있다면 하위클래스까지 전파돼도 괜찮은가?
컴포지션으로는 이런 결함을 숨기는 새로운 api를 설계할 수 있지만, 상속은 상위 클래스의 api를 그 결함까지도 그대로 승계한다.
내생각 정리
기존 클래스의 소스코드를 재사용하기위해서 상속을 고려할 수 있다.
하지만 상속은 부모클래스의 변경사항에 영향받는다.
때문에 중간에 래퍼클래스를 둬서 결합도(?)를 낮춘다.
예를들어 래퍼클래스에서 정의된 메서드 외에 부모클래스에 추가된 메서드가 있어도 영향이 없다.