도당탕탕
Item18 : 상속보다 구성을 선호하라 본문
평범한 concrete 클래스(인스턴스를 만들 수 있는 완전한 클래스)를 상속하는 것은 위험하다. 슈퍼 클래스의 구현이 계속 바뀔 수 있고, 바뀌는 경우 서브 클래스가 더 이상 동작하지 않을 수 있다. 단 슈퍼클래스의 작성자가 상속을 목적으로 정의한 경우라면 상속해도 좋다.
부적절한 상속의 예는 아래와 같다.
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 addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println(s.getAddCount()); // 6
}
}
위 경우에, s.getAddCount()
가 6을 리턴하는데, 그 이유는 add
함수를 기반으로 구현이 되어있기 때문이다. 위 코드는 모든 자바 버전에서 동작한다고 보장할 수 없다. 추후에 HashSet
구현이 변경돼서, 예상치 못하게 동작할 가능성이 존재한다. 여기서 addAll
함수에서 add
함수를 호출하는 방식으로 구현하면 해결은 되지만 모든 문제가 해결되는 것은 아니다. 슈퍼 클래스의 함수를 호출할 수 없는 경우도 존재한다. 슈퍼 클래스의 함수를 호출하지 않는 함수를 새로 정의하는 경우에는 더 안전하다고 말할 수는 있지만, 다음 버전에 운이 나쁘게도 슈퍼클래스에 반환형은 다르고 동일한 메서드 시그니쳐를 가진 함수가 추가되면 더 이상 코드가 컴파일되지 않게 된다.
이러한 문제점을 해결하는 방법이 바로 구성(Composition)이다. 새로운 클래스의 인스턴스 함수는 포함된 인스턴스에 대한 함수를 호출하는데, 이를 forwarding
이라 부른다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
@Override
public int size() { return s.size(); }
@Override
public boolean isEmpty() { return s.isEmpty(); }
@Override
public boolean contains(Object o) { return s.contains(o); }
@Override
public Iterator<E> iterator() { return s.iterator(); }
@Override
public Object[] toArray() { return s.toArray(); }
@Override
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override
public boolean add(E e) { return s.add(e); }
@Override
public boolean remove(Object o) { return s.remove(o); }
@Override
public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
@Override
public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
@Override
public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
@Override
public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
@Override
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(); }
}
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;
}
}
위와 같이 wrapper class는 어떠한 Set
구현체에도 사용할 수 있다.
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
상속은 오직 서브 클래스가 슈퍼 클래스의 하위 타입인 경우에만 사용해야 한다. 즉, 클래스 B는 클래스 A를 오직 "is-a" 관계가 성립할 때만 상속해야 한다.
Java Platform 라이브러리에는 이 규칙을 어긴 경우가 많이 있는데, 예를 들면 Stack
은 Vector
의 일종이 아닌데도 불구하고 Stack
이 Vector
를 상속했다.
구성이 적절한 곳에 상속을 하면, 쓸데없이 슈퍼 클래스의 세부 구현을 노출시키게 되고, API가 상위 클래스의 구현에 묶이게 된다. 또한 사용자 또한 헷갈릴 수 있다. 예를 들면, p가 Properties
인스턴스라고 할때, Properties
클래스를 상속한 클래스에서 p.getProperty(key)
를 재정의 한 경우, 사용자는 원래 있던 p.get(key)
함수와 해깔릴수 있다. p.getProperty
는 디폴트를 고려하지만, p.get(key)
는 디폴트를 고려하지 않는다고 한다.
정리
상속은 유용하지만, 캡슐화를 위반하기 때문에 문제가 있다. 따라서 서브 클래스와 슈퍼 클래스 간에 진정한 서브타입 관계(is-a 관계)가 있을 때 사용해야 한다. 그 외의 경우, 구성과 forwarding
을 사용해야 한다.
'JAVA' 카테고리의 다른 글
Item20 : 추상화 클래스보다 인터페이스를 선호하라. (0) | 2022.12.16 |
---|---|
Item19 : 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) | 2022.12.16 |
Item17 : 변경 가능성을 최소화하라 (2) | 2022.12.15 |
Item16 : public class에서 public field 말고 accessor method(접근자 함수) 를 사용하라. (0) | 2022.12.14 |
Item15 : 클래스와 멤버의 접근 권한을 최소화하라 (0) | 2022.12.14 |