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

wisdom·2022년 8월 18일
0

Effetctive Java

목록 보기
18/80
post-thumbnail

들어가며

이번 아이템에서 논하는 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다.
클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.


✔️ 상속의 위험성

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

  • 상위 클래스가 릴리스 때 내부 구현이 달라지면, 그 여파로 하위 클래스가 오동작할 수 있다.
  • 메서드를 재정의하는 대신 새로운 메서드를 추가하더라도, 문제가 발생할 수 있다.
    • 상위 클래스의 새 메서드와 시그니처가 같고 반환 타입이 다르다면, 컴파일 오류가 발생한다.
    • 시그니처와 반환 타입 모두 같을 경우, 상위 클래스의 메서드를 재정의한 것이 된다.
    • 새로 추가한 메서드가 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.

✔️ 컴포지션 (Composition)

이러한 문제를 해결하기 위한 방법은 바로 컴포지션이다.

기존의 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자.

새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
이 방식을 전달(forwarding)이라고 하고, 새 클래스의 메서드들을 전달 메서드(forwarding method)라고 한다.

컴포지션 방식을 사용하면 인스턴스도 계측할 수 있고, 기존 생성자들도 함께 사용할 수 있다.

예시 코드

// 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다.
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;
    }
}
// 코드 18-3 재사용할 수 있는 전달 클래스
public class ForwardingSet<E> implements Set<E> {
    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 boolean equals(Object o)
                                       { return s.equals(o);  }
    @Override public int hashCode()    { return s.hashCode(); }
    @Override public String toString() { return s.toString(); }
}

✔️ 컴포지션 단점

컴포지션의 단점은 거의 없다.

한 가지 주의할 점은 클래스가 콜백 프레임워크와는 어울리지 않는다는 점이다.

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



📌 핵심 정리

  • 상속은 강력하지만 캡슐화를 해친다는 문제가 있다.
  • 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 사용해야 한다.
  • is-a 관계일 때도 안심할 수만은 없는 것이, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다.
  • 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자.
    특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다.
    래퍼 클래스는 하위 클래스보다 견고하고 강력하다.
profile
백엔드 개발자

0개의 댓글