티스토리 뷰
상속은 코드를 재사용하는 강력한 수단이나 항상 최선은 아니다.
다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
캡슐화를 깨뜨리는 상속
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있음
설계자가 확장을 충분히 고려하지 않으면, 하위 클래스는 상위 클래스 변화에 맞춰 수정되어야만 한다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
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;
}
}
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("짝", "짝짝", "짝짝짝"));
getAddCount()를 호출하면 3이 반환되어야 하는데 6이 반환된다.
InstrumentedHashSet의 addAll()은 addCount에 3을 더한후 HashSet의 addAll()을 호출한다.
HashSet의 addAll()은 각 원소를 add()로 추가하는데, 이 때 이 add()는 InstrumentedHashSet에서 재정의한 메서드가 호출된다. 그래서 값이 중복으로 더해져서 6이 반환되는 것이다.
이는 다음과 같은 방법으로 문제를 해결할 수 있다.
- 재정의 하지 않는 경우 (HashSet의 addAll() 을 사용하는 경우)
- HashSet의 addAll 메서드가 add 메서드를 이용해 구현했다는 것을 가정한다는 한계를 가진다.
- 현재 addAll 메서드의 구조에만 의존하게 되는 것 → 구조 변화가 일어나면 문제가 생길 것
- 다른 식의 재정의를 하는 경우 (InstrumentedHashSet에서 아예 새롭게 addAll() 을 재정의 하는 경우)
- 상위 클래스 메서드와 똑같이 동작하도록 구현해야 하는데, 이 방식은 어려울 수도 있으며, 시간도 더 들고, 오류 및 성능하락의 문제를 가져올 수 있다.
이는 메서드 재정의에서 문제가 일어났다.
그렇다면 메서드를 재정의하는 대신 새로운 메서드를 추가한다면?
다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, 하위 클래스에서 추가한 메서드와 시그니처가 같고
반환 타입은 다르면 컴파일 오류가 난다.
—> 즉, 상위 클래스와 하위 클래스가 서로 너무 강하게 결합되어 있다.
이러한 문제들을 다 피해갈 수 있는 방법은 상속 대신 컴포지션을 사용하는 것이다.
컴포지션
새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것을 말한다.
새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 결과를 반화받는다.
이 방법은 새로운 기존 클래스의 내부 구현 방식의 영향에서 벗어난다.
위에 상속으로 구현했던 예제를 컴포지션으로 바꿔보자.
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 class ForwardingSet<E> implements Set<E> {
// 기존 클래스를 private 인스턴스로 생성
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(); }
}
Set 인스턴스를 감싸고 있는 InstrumentedSet 클래스를 래퍼 클래스(Wrapper class)라 한다.
다른 Set에 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.
결론
상속은 강력하지만 캡슐화를 해친다.
그래서 반드시 클래스 간에 is-a 관계일때만 사용해야한다.
컴포지션을 사용해야할 상황에서 상속을 사용하는건 내부 구현을 불필요하게 노출하는 것이다.
클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수도 있다.
웬만하면 상속 대신 컴포지션과 전달을 사용하자.
'Java > Effective Java' 카테고리의 다른 글
[Effective Java] 20.추상 클래스보다는 인터페이스를 우선하라 (0) | 2022.03.16 |
---|---|
[Effective Java] 19.상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) | 2022.03.16 |
[Effective Java] 17.변경 가능성을 최소화하라 (0) | 2022.03.12 |
[Effective Java] 16.public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2022.03.12 |
[Effective Java] 15.클래스와 멤버의 접근 권한을 최소화하라 (0) | 2022.03.12 |