[Effective Java] 아이템 18 - 상속보다는 컴포지션을 사용하라

HyeBin, Park·2022년 6월 4일
0

Effective Java Study

목록 보기
14/20
post-thumbnail

이번 아이템에서 논하는 문제는 인터페이스 상속과는 무관하다.

아이템 18 - 상속보다는 컴포지션을 사용하라

🧸 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

  • 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
    => 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 하위 클래스가 오작동 할 수 있다.

⭐ 컴포지션

  • 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 한다.
  • 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결고를 반환한다. 이 방식을 forwarding이라 하며, 새 클래스의 메서드들을 forwarding method라 부른다.
  • 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나고 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.

⛔ 문제 상황

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;
    }
}

// 아래의 코드를 실행하고 getAddCount 메서드를 호출하면 6이 반환됩니다.
 InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
 s.addAll(List.of("틱", "탁탁", "펑"));
  • 위와 같은 문제가 생긴 이유는 ?

    • HashSet의 addAll 메서드가 add 메서드를 사용해 구현되었기 때문이다.
    • HashSet의 addAll은 각 원소를 add 메서드를 호출해 추가하는데, 이 때 add는 instrumentedHashSet에서 재정의한 메서드이다.
    • addCount에 값이 중복해서 더해져 6이 되었다.
  • 하위 클래스에서 addAll 메서드를 재정의하지 않으면 되는거 아닌가?

    • 당장은 동작하겠지만 HashSet의 addAll이 add 메서드를 이용해 구현했음을 가정한 해법이라는 한계가 있다.
    • 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당하며 자바 플랫폼 전반적인 정책인지, 다음 릴리스에서 유지될지 알 수 없다. 즉, InstrumentedHashSet도 깨지기 쉽다.
  • addAll 메서드를 다른 식으로 재정의할 수 있는거 아닌가?

    • 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고 시간이 많이 든다. 오류를 내거나 성능 저하가 생길 수 있다.
    • private 필드를 써야하는 상황이라면 구현 자체가 불가능하다.

💦 재사용이 가능한 클래스

public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

    public ForwardingSet(Set<E> s) { this.s = s;}

    @Override
    public void clear() { s.clear();}

    @Override
    public boolean contains(Object o) { return s.contains(o);}

    @Override
    public boolean isEmpty() { return s.isEmpty();}

    @Override
    public int size() { return s.size();}

    @Override
    public Iterator<E> iterator() { return s.iterator();}

    @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 removeAll(Collection<?> c) { return s.removeAll(c);}

    @Override
    public boolean retainAll(Collection<?> c) { return s.retainAll(c);}

    @Override
    public Object[] toArray() { return s.toArray();}

    @Override
    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();}
}
  • 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
  • 상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다.
    => 하지만 ! 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있고 기존 생성자들과도 함께 사용할 수 있다.
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
  • InstrumentedSet을 이용하면 대상 Set 인스턴스를 특정 조건하에서만 임시로 계측할 수 있다.

🥠 래퍼 클래스

static void walk(Set<Dog> dogs) {
	InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
	..// 해당 메서드에서는 dogs 대신 iDogs를 사용한다.
}
  • 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.
  • 컴포지션과 전달의 조합은 넓은 의미로 위임 이라고 부른다.
    => 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.
  • 단점
    => 콜백 프레임 워크와는 어울리지 않는 점을 주의 해야한다.

콜백?

  • 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다.
  • 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다.
    => SELF 문제

🤔 그래서 상속은 언제 사용해야하는가?

  • 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야한다.
  • 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야한다.
  • "B가 정말 A인가?"
    • 그렇다 : 상속
      • 확장하려는 클래스의 API에 결함이 없는가?
      • 결함이 있다면 클래스의 API 까지 전파돼도 괜찮은가?
    • 아니다 : A는 B의 필수 구성요소가 아닌 구현 하는 방법 중 하나
  • 컴포지션은 결함을 숨기는 새로운 API를 설계할 수 있지만 상속은 상위 클래스의 API를 결함까지도 그대로 승계한다.

정리하기

상속은 캡슐화를 해치는 문제가 있어서 순수한 is-a 관계일 때만 써야한다. 하지만 이때도 여전히 문제가 될 수 있다. 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자. 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그러다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.

0개의 댓글