Item 18 - 상속보다는 컴포지션을 사용하라
상속은 코드를 재사용하는 강력한 수단이지만 항상 최선은 아니다.
상속의 문제점
아래의 클래스는 정상적으로 보이지만 문제가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet() {}
public InstrumentedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
}
@Override
public boolean add(E e) {
addCount += 1;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
새로운 리스트를 추가한다고 가정하자.
3개의 원소가 추가되었으니 getAddAcount
를 호출하면 3이 반환되어야 하는데, 6이 반환된다.
1
2
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
HashSet의 addAll
은 아래의 코드로 실행된다.
add
가 안에서 호출되는데, 재정의한 add
와 addAll
모두 addCount를 증가시켜 6을 반환한다.
문제 해결 방법 🤔..?
addAll 메서드를 재정의하지 않는 경우 (HashSet의 addAll을 사용하는 경우)
- HashSet의
addAll
메서드가add
를 통해 구현했음을 가정한 해법이라는 한계가 있다. addAll
은 HashSet이 구현하는 메서드에 전적으로 달려있다.- 다음 릴리스에서 다르게 적용된다면 깨지기 쉽다.
addAll 메서드를 재정의하는 경우 (InstrumetedHashSet에서 새로 addAll을 재정의하는 경우)
- HashSet의 메서드를 더 이상 호출하지 않으니
addAll
이add
를 사용하는것이 상관없어진다. - 상위 클래스의 메서드와 똑같이 동작하도록 구현해야 하는데, 이 방식은 어려울 수도 있으며, 시간도 더 들고, 오류 및 성능 저하를 유발할 수 있다.
- 만약, 하위 클래스에서 접근할 수 없는 private 필드를 사용해야 하는 상황이라면 구현 자체가 불가능하다.
컴포지션
- 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것
- 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 그 결과를 반환한다.
- 기존 클래스의 구현이 바뀌거나, 새로운 메서드가 생기더라도 아무런 영향을 받지 않는다.
상속으로 구현했던 예제를 컴포지션으로 바꿔보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class InstrumentedSet<E> extends FowardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@Override
public boolean add(E e) {
addCount++;
super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getCount() {
return addCount;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ForwardingSet<E> implements Set<E> {
// 기존 클래스를 private 인스턴스로 생성
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 int hashCode() { return super.hashCode();}
@Override public boolean equals(Object obj) { return super.equals(obj); }
@Override public String toString() { return super.toString(); }
}
- Set 인스턴스를 감싸고 있는 InstrumentedSet 클래스를 래퍼 클래스(Wrapper Class)라 한다.
- 다른 Set에 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator Pattern)이라고 한다.
💡 핵심 정리
- 상속은 강력하지만 캡슐화를 해친다.
- 그래서 반드시 클래스 간에 is-a 관계일 때만 사용해야한다.
- 컴포지션을 사용해야할 상황에서 상속을 사용하는건 내부 구현을 불필요하게 노출하는 것이다.
- 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수도 있다.
- 웬만하면 상속 대신 컴포지션과 전달을 사용하자.
This post is licensed under CC BY 4.0 by the author.