본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

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

※ 여기서 말하는 '상속'은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다. 클래스 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를

    확장하는 인터페이스 상속과는 무관하다.

 

※ 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.

    확장할 목적으로 설계되었고 문서화도 잘 된 클래스아이템19도 마찬가지로 안전하다.

    하지만, 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

[Why]

  • 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
  • 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
    • 클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 정말 A인가?"라고 자문해보고, "그렇다"고 확신할 수 없다면 B는 A를 상속해서는 안 된다. 즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.
  • 컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문은 다음과 같다.
    • 확장하려는 클래스의 API에 아무런 결함이 없는가? 
    • 결함이 있다면, 이 결함이 내 클래스의 API까지 전파돼도 괜찮은가?

 

[When]

문제 1 : 하위 클래스에서 메서드를 오버라이딩할 때

  • 다음 HashSet은 처음 생성된 이후 원소가 총 몇 개 더해졌는지 알 수 있어야 한다. (현재 크기가 아님)
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;
    }
}
  • 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다.
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
  • 이제 getAddCount 메서드를 호출하면 3을 반환할 것 같지만, 실제로는 6을 반환한다.
  • 왜냐하면 HashSet의 addAll 메서드가 add 메서드를 사용해 구현되었기 때문이다.
  • 하위 클래스에서 addAll 메서드를 재정의하지 않으면 당장의 문제는 고칠 수 있다.
    • 하지만, 이처럼 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하여 버전이 올라감에도 유지될지는 알 수 없다.
  • addAll 메서드를 원소 하나당 add 메서드를 한 번만 호출하는 식으로 재정의할 수도 있다. 
    • 하지만 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 더 들고, 오류를 내거나 성능을 떨어뜨릴 수 있다.
    • 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.

문제 2 : 상위 클래스에 새로운 원소 추가 메서드가 추가될 때

  • 하위 클래스에서, 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 특정 조건을 먼저 검사하게끔 했다고 가정하자.
  • 만약 상위 클래스에 또다른 원소 추가 메서드가 추가된다면 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 통해 '허용되지 않은' 원소를 추가할 수 있게 된다.
    메서드를 재정의하지 않고 아예 새로운 메서드를 추가하면 되지 않겠나 생각할 수 있다. 하지만 재수없게도 상위 클래스에 새로운 메서드가 추가되었는데 하필 똑같은 시그니처에 반환 타입만 다르다면 우리 클래스는 컴파일조차 안 된다.

 

[How]

해결책

  • 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자.
// 래퍼 클래스 (상속 대신 컴포지션 사용)
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;
    }
}
// 재사용할 수 있는 전달 클래스
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(); }
}

ForwardingSet 설명

  • 새 클래스의 인스턴스 메서드들은 private 필드로 참조하는 기존 클래스(Set<E> s)의 대응하는 메서드를 호출해 그 결과를 반환한다.
  • 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.

InstrumentedSet 설명

  • 다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.
  • 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
    단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.
  • HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
  • 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
  • 위의 컴포지션 방식은 한번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
static void walk(Set<Dog> dogs) {
    InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    ... // 이 메서드에서는 dogs 대신 iDogs를 사용한다.
}
  • InstrumentedSet을 이용하면 대상 Set 인스턴스를 특정 조건 하에서만 임시로 계측할 수 있다.

래퍼 클래스의 단점

  • 래퍼 클래스가 callback 프레임워크와 어울리지 않는다.
    • 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다.
    • 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 
    • 이를 SELF 문제라고 한다.
  • 실전에서는, 전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리 사용량에 주는 영향이 없다고 밝혀졌다.
    • 그러니 귀찮더라도 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다.