[Effective Java]Item18. 상속보다는 컴포지션을 사용하라

최강일·2024년 6월 6일
0

Effective Java

목록 보기
10/17

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.
잘못 사용하면 오류를 내기 쉬운 s/w를 만들게 된다.

상위,하위 클래스 모두 같은 개발자가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 안전하다.

하지만 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

주의) 이 책에서 상속은 클래스간 확장 케이스만을 다룸. 인터페이스 구현은 포함 X

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

다시말해 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
상위클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작할 수 있다.

잘못된 예

public class InstrumentedHashSetUseExtends<E> extends HashSet {
    private int addCount = 0; // 추가된 원소의 수

    public InstrumentedHashSetUseExtends() {
    }

    public InstrumentedHashSetUseExtends(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(Object o) {
        return super.add(o);
    }

    @Override
    public boolean addAll(Collection c) {
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

}

addAll()과 add()를 재정의하여 사용하고 있다. 두 메소드 모두 addCount라는 변수를 증가시켜주고 있다.
문제가 되는 부분은 addAll()의 사용에 있다.

원소 3개를 더하면 6을 반환한다.
원인은 HashSet의 addAll메서드가 add메서드를 사용해서 구현된 데 있다.
이런 문제는 HashSet 문서에는 쓰여 있지 않다.

또한 다음 릴리즈에서 부모 클래스에 메서드가 추가된다면 허용되지 않는 동작이 일어날 수 있으며 컴파일조차 일어나지 않을 수 있다.

해결방법

이런 문제들은 메서드를 재정의가 원인이다.

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하면 된다.
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition)이라 한다.
새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.

그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
즉, 기존 클래스에 메서드가 추가되더라도 전혀 영향받지 않는다.

래퍼 클래스 - 상속 대신 컴포지션을 사용

public class InstrumentedHashSetUseComposition<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedHashSetUseComposition(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;
    }
}

재사용할 수 있는 전달 클래스

클래스 필드 내에 private or public 필드로 클래스의 인스턴스를 참조하게 하고
해당 클래스를 구성하는 부분의 합으로 정의된다.

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;

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

    public int size() {
        return 0;
    }

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

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

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

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

    public <T> T[] toArray(T[] a) {
        return s.toArray(a);
    }

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

    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }

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

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

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

    @Override
    public String toString() {
        return s.toString();
    }
}

InstrumentedHashSetUseComposition은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.

상속 사용시 주의사항

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
즉, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.

확장하려는 클래스의 api에 아무런 결함이 없는가? 결함이 있다면 하위클래스까지 전파돼도 괜찮은가?
컴포지션으로는 이런 결함을 숨기는 새로운 api를 설계할 수 있지만, 상속은 상위 클래스의 api를 그 결함까지도 그대로 승계한다.

Summary

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

내생각 정리
기존 클래스의 소스코드를 재사용하기위해서 상속을 고려할 수 있다.
하지만 상속은 부모클래스의 변경사항에 영향받는다.
때문에 중간에 래퍼클래스를 둬서 결합도(?)를 낮춘다.
예를들어 래퍼클래스에서 정의된 메서드 외에 부모클래스에 추가된 메서드가 있어도 영향이 없다.

profile
Search & Backend Engineer

0개의 댓글