[Effective Java] 4 - item 18. 상속보다는 컴포지션을 사용하라.
item 18. 상속보다는 컴포지션을 사용하라
- 일반적인 구체 클래스를 패키지 경계를 넘어, 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
상속은 캡슐화를 깨트린다.
- 보다 정확히 말하자면, 상위 클래스 구현에 따라 하위 클래스의 동작이 달라질 수 있으며 이에 따라 하위 클래스가 오동작할 수 있다.
- 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있고, 이에 따라 하위 클래스가 오작동할 수 있다.
- 상위 클래스 설계자가 확장을 충분히 고려하고 문서화하지 않으면, 하위 클래스는 상위 클래스의 변화에 일일히 따라 수정되어야 한다.
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을 반환한다.HashSet
의addAll
은 각 원소마다add
를 호출하게 구현이 되어있다.- 이때
InstrumentedHashSet
에서 재정의한add
메서드가 있으므로InstrumentedHashSet
의add
가 호출되게 되고, 결국count
의 증가가 중복으로 일어나게 된다.
// `HashSet`의 `addAll` 메소드 (상위 클래스)
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
위와 같은 경우, addAll
메서드를 재정의하지 않거나, 다른 방식의 addAll
재정의를 통해 문제를 해결할 수 있다.
- 재정의 하지 않는 경우 (
HashSet
의addAll
을 사용하는 경우)HashSet
의addAll
메서드가add
메서드를 이용해 구현했다는 것을 가정한다는 한계를 가진다.- 즉, 현재
addAll
메서드의 구조에만 의존하게 되는 것이다. → 구조 변화가 일어나면 문제가 생길 것
- 다른 식의 재정의를 하는 경우 (
InstrumentedHashSet
에서 아예 새롭게addAll
을 재정의 하는 경우)- 상위 클래스 메서드와 똑같이 동작하도록 구현해야 한다.
- 이는 적용하기 어려울 수도 있으며, 시간이 더 들고, 오류 및 성능하락의 문제를 가져올 수 있다.
- 하위 클래스에서 접근 불가능한
private
필드를 사용해야 한다면 이 방식으로는 구현 자체가 불가능하다.
컴포지션 사용하기
- 컴포지션이란, 새로운 클래스를 만들고
private
필드로 기존 클래스의 인스턴스를 참조하게 하는 방법이다.- 기존 클래스가 새로운 클래스의 구성요소로 쓰인다.
- 전달(forwarding)
- 새 클래스의 인스턴스 메서드들(= 전달 메서드(forwarding method) )은 (private 필드로 참조) 기존 클래스에서 이에 대응하는 메서드를 호출해 그 결과를 반환한다.
public class ForwardingSet<E> implements Set<E> {
// 이하는 forwarding method들
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 class InstrumentedSet<E> extends ForwardingSet<E> {
// Wrapper class -- called Decorator pattern
// Wrapper는 자신이 받는 모든 요청을 대상 객체에 위임한다.
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();
}
}
위와 같이 컴포지션을 구현한다면,
- 개발자가 원하는 메서드만 클라이언트에게 공개할 수 있다.
- 상위 클래스의 내부 구현을 숨길 수 있다.
- 상위 클래스에서 제공하는 메서드를 더 나은 버전으로 개선해 제공할 수 있다.
- 참조하는 인스턴스 변수를 변경해 프로그램을 동적으로 변경할 수 있다.
- 상위 클래스의 메서드 형태와 관계없이, 유연하게 하위 클래스의 메서드를 정의할 수 있다.
상속을 사용해야 한다면, 다음과 같은 문제에 대해 충분히 고민하고 적용하자.
- 상위 클래스와 하위 클래스가 완벽한 Is-a 관계인가?
- 상위 클래스의 API에 결함이 없는지 확인하고, 이 결함이 전파되어도 괜찮은지 확인해보자.
Leave a comment