Item18 - 상속보다는 컴포지션을 사용하라

2023. 11. 30. 23:22Book/이펙티브 자바

 

*주의! 여기서 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다.(인터페이스x)

 

상속은 코드 재사용성을 높여주지만, 항상 최선은 아니다.

상위 클래스와 하위 클래스를 모두 같은 개발자가 통제하는 패키지 안에서라면 상속도 안전할 수 있다.

확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 안전하다.

 

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

 

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 이러한 이유로 상위 클래스 설게자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 맞춰 수정돼야함 한다.

 

예제코드

// 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽)
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;
    }

    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("오이", "사과", "멜론"));
        System.out.println(s.getAddCount());
    }
}

 

위 코드는 HashSet(Set을 구현한 구체 클래스)을 상속받고 원소의 수를 저장하는 변수와 접근자 메서드를 추가했다.

또 HashSet의 add와 addAll을 재정의 했다.

 

 

문제점 : HashSet의 구현 방식에 따라 캡슐화가 깨질 수 있다.

 

위 코드를 실행하면 원소 수는 6개가 나온다.

 a. 첫 번째로 재정의된 addAll이 호출,  count는 3이 된 후 HashSet의 addAll 메서드를 호출한다.

boolean modified = false;
    for (E e : c)
        if (add(e))
            modified = true;
    return modified;
}

 b. 두 번째로 HashSet의 내부 구현 로직이 자신의 다른 메소드인(self-use) add() 메소드를 호출한다.

 c. 세 번째로 InstrumentedHashSet의 재정의된 add() 메서드가 3번 호출되어 3이 중복으로 더해지는 것이다.

 

addAll을 재정의하지 않으면 안되느냐 할 수 있지만, HashSet의 내부 구현 로직이 언제 바뀔지 모르고 바껴도 알 길이 없다. 

 

마찬가지로 다른 방식으로 원소를 추가하는 메서드가 생길 수 있는데, 하위 클래스에서 바로바로 재정의 해주지 않는다면 상속받은 클래스는 자신이 할 일을 잘 못 하게 된다.

 

그 뿐만 아니라 하위 클래스에서 추가한 메서드가 나중에 HashSet에 추가된 메서드와 시그니처가 같고 반환 타입이 다르다면 컴파일조차 되지 않는다. 행여나 같더라도 메서드가 재정의 되는 꼴이니 위에서 우려한 같은 상황이 발생할 수 있다.

 

대안 : 컴포지션을 사용하라!

이상의 모든 문제를 피해갈 수 있느 방법이 있다.

새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 한다.

 

// 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽)
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(); }
}

=================================================================================

// 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽)
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 static void main(String[] args) {
        InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
        s.addAll(List.of("오이", "사과", "멜론"));
        System.out.println(s.getAddCount());
    }
}

 

메인 클래스를 실행하면 3이 나온다.

 

a. 재정의된 addAll을 호출하면 3을 더하고 래퍼 클래스의 Set 타입을 가진 s 필드의 addAll() 메서드를 호출한다.

b. s 필드는 자신의 add() 메소드 3번 호출해 원소를 추가한다.

 

위에서 우려한 일들은 딱히 걱정하지 않아도 된다. HashSet의 내부 로직이 어떻게 바뀌든 우리는 신경쓰지 않아도 되고 메서드가 추가되도 상관없다. 만약 Set 인터페이스에 메서드가 추가된다면 새로운 메서드를 implement 하라는 경고를 해줄테니 추가하면 된다.