상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.
상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 마찬가지로 안전하다.
하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 다른 패키지의 구체 클래스를 상속하는 것은 위험하다.
메서드 호출과 달리 상속은 캡슐화를 깨드린다.
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
상위 클래스의 릴리스에 따라 내부 구현이 달라지게 되면 그 여파로 하위 클래스가 오작동 할 수 있다는 것이다.
상위 클래스 설계자가 확장은 충분히 고려하고 문서화를 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 맞춰 수정되어야 한다.
또한 다른 릴리스의 상위 클래스에 메서드가 추가된다면 하위 클래스도 그것을 만족시켜야 하고, 보안에 구멍이 생길 수도 있다.
이러한 문제를 모두 피해가는 묘안은 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것 이다.
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 한다.
새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
이 방식을 전달이라 하고, 새 클래스의 메서드들을 전달 메서드라고 부른다.
결과로 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나고, 기존 클래스에 새로운 메서드가 추가되어도 전혀 영향을 받지 않는다.
// 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽)
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(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount());
}
}
위 코드에서 main을 실행하면 count가 3이 아닌 6이 나온다.
HashSet의 addAll은 내부 구현에서 add를 호출해 추가하기 때문이다.
이 경우 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다.
하지만 당장은 제대로 동작할지 모르나, HashSet의 addAll이 add메서드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다.
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 static void main(String[] args) {
InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
s.addAll(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount());
}
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 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(); }
}
InstrumentedSet은 HashSet의 모든 기능을 정의한 Set인터페이스를 활용해 설계되어 아주 견고하고 유연하다.
임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다.
이러한 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
인터페이스에 대해 재사용 할 수 있는 전달 클래스를 하나씩 만들어두면 전달 클래스들을 아주 손쉽게 구현할 수 있다.
상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
'JAVA > 이펙티브 자바' 카테고리의 다른 글
20. 추상 클래스보다는 인터페이스를 우선하라(템플릿 메서드 패턴) (0) | 2023.07.03 |
---|---|
19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) | 2023.07.03 |
17. 변경 가능성을 최소화하라 (0) | 2023.07.01 |
16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2023.07.01 |
15. 클래스와 멤버의 접근 권한을 최소화하라 (0) | 2023.07.01 |