Post

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가 안에서 호출되는데, 재정의한 addaddAll 모두 addCount를 증가시켜 6을 반환한다.

addAll

문제 해결 방법 🤔..?

addAll 메서드를 재정의하지 않는 경우 (HashSet의 addAll을 사용하는 경우)

  • HashSet의 addAll 메서드가 add를 통해 구현했음을 가정한 해법이라는 한계가 있다.
  • addAll은 HashSet이 구현하는 메서드에 전적으로 달려있다.
    • 다음 릴리스에서 다르게 적용된다면 깨지기 쉽다.

addAll 메서드를 재정의하는 경우 (InstrumetedHashSet에서 새로 addAll을 재정의하는 경우)

  • HashSet의 메서드를 더 이상 호출하지 않으니 addAlladd를 사용하는것이 상관없어진다.
  • 상위 클래스의 메서드와 똑같이 동작하도록 구현해야 하는데, 이 방식은 어려울 수도 있으며, 시간도 더 들고, 오류 및 성능 저하를 유발할 수 있다.
  • 만약, 하위 클래스에서 접근할 수 없는 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.