상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 일반적인 구체 클래스를 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험하다. 이때의 '상속'은 다른 패키지의 구체 클래스를 확장하는 '구현 상속'을 말하며, 인터페이스의 상속과는 무관하다.
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;
}
}
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
// ...
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size() / .75f) + 1, 16));
addAll(c); // super의 addAll 메서드를 호출한다.
}
// ...
}
public abstract class AbstractCollection<E> implements Collection<E> {
// ...
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
// ...
}
이 경우 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다. 하지만 HashSet의 addAll이 add 메서드를 이용해 구현했음을 가정한 해법이라는 한계를 가진다. 이처럼 자신의 다른 부분을 사용하는 '자기 사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당한다. 따라서 이런 가정에 기댄 InstrumentedHashSetUseExtends도 깨지기 쉽다.
addAll 메서드를 다른 식으로 재정의할 수도 있다. 하지만 상위 클래스의 메서드 동작을 다시 구현하는 것은 어렵고, 시간도 더 들고, 오류를 내거나 성능을 떨어뜨릴 수도 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로는 구현자체가 불가능하다.
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;
}
}
아래는 재사용할 수 있는 전달클래스인 ForwardingSet이다.
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();
}
}
상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다. 하지만 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
래퍼 클래스와 관련하여 알아두면 좋을 것들이 있다.