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

반응형
728x90
반응형

상속

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

 

안전한 상속

상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서의 상속일 경우

확장할 목적으로 설계되었고, 문서화도 잘 된 클래스의 경우

 

위험한 상속

구체 클래스를 패키지 경계를 넘어, 다른 패키지의 구체 클래스를 상속하는 경우 

 

해당 아이템은 '위험한 상속'에 속하는 경우를 말하며, 클래스-인터페이스 사이의 구현 및 확장하는 인터페이스 상속과는 무관하다.

 

 

상속의 캡슐화 저하

상속은 캡슐화를 깨뜨린다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 확장이 충분히 고려되지않은 상속에서는 상위 클래스의 수정에 따라 하위 클래스도 계속해서 수정돼어야만한다. 

 

InstrumentedHashSet.java
import lombok.NoArgsConstructor;

import java.util.Collection;
import java.util.HashSet;

@NoArgsConstructor
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

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

 

Main.java
import java.util.List;

public class Main {
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("A", "B", "C"));

        /* 기댓값 : 3, 결과값 : 6 */
        System.out.println(s.getAddCount());

        /**
         * HashSet 의 addAll 메서드가 add 메서드를 사용하여 구현한다.
         * 이런 내부 구현 방식은 hashSet 문서에는 쓰여있지 않다.
         * InstrumentedHashSet 의 addAll 은 addCount 에 3을 더한후, HashSet의 addAll 을 호출했다.
         * HashSet 의 addAll 은 각 원소를 add 메서드를 호출해 추가하는데, 이때 불리는 add 는 InstrumentedHashSet 에서 재정의한 add 메서드다.
         * 따라서 addCount 에 값이 중복으로 더해져, 최종값이 6으로 늘어난 것이다. addAll 메서드로 추가된 원소 하나당 2씩 늘었다.
         */
    }
}

 

우리는 3이라는 결과가 출력되길 기대했지만, 결과는 6이다. 위 코드에 설명에 써있듯이, addAll 메서드에서 호출되는 add 메서드는 InstrumentedHashSet(하위클래스)가 구현한 add 메서드를 호출한다. 

 

1) addAll() 호출

 

2) super.addAll(c);

 

3) AbstractCollection.java의 addAll() 메서드

 

4) InstrumentedHashSet.java의 add()

 

AbstractCollection.java
public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    for (E e : c)
        if (add(e))
            modified = true;
    return modified;
}

 

이런 경우, 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 해결할 수 있다. 하지만 HashSet 의 addAll 메서드가 add 메서드를 이용하여 구현했음을 가정한 해법이다. 이처럼 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하며, 계속 유지될거란 보장이 없다. 

 

addAll 메서드를 다른 식으로 재정의할 수도 있다. 주어진 컬렉션을 순회하면서 원소 하나당 add 메서드를 한번만 호출하는 것이다. 이 방식은 HashSet 의 addAll 을 호출하지 않으니 상위 클래스 메서드와 상관이 없어지는 점에서 조금 더 나아진다. 하지만 상위 클래스의 메서드 동작을 다시 구현해야하는 어려움이 있고 시간도 더 들고, 오류가 더 발생할 수 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.

 

▶ 하위 클래스가 깨지기 쉬운 이유는 또있다.

다음 릴리스에서 상위 클래스에 새로운 메서드가 추가된 경우다.

보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만하는 프로그램이라고 생각해보자. 그 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 필요한 조건을 먼저 검사하게끔 하면 될것 같다. 하지만 이 방식이 통하는 것은 상위 클래스에 또 다른 원소 추가 메서드가 만들어지기 전까지다. 만약 원소 추가 메서드가 생성된다면, 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 '허용되지 않은 원소'를 추가할 수 있게된다. 따라서 클래스를 확장하더라도 메서드를 재정의하는 대신 새로운 메서드를 추가하면 위험이 전혀 없는 것이 아니다.

 

 

해결방안, 컴포지션의 구현

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

 

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

 

ForwardingSet.java
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

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

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

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

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

    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 String toString() {
        return s.toString();
    }

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

 

InstrumentedSet.java
import java.util.Collection;
import java.util.Set;

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

 

InstrumentedSet 은 HashSet 의 모든 기능을 정의한 Set 인터페이스를 활용하여 설계되어 견고하고 아주 유연하다. 임의의 Set 에 계측 기능을 덧씌워 새로운 Set 을 만들었다. 

 

▶ 상속

상속 방식은 구체 클래스를 각각을 따로 확장해야하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야한다.

 

▶ 컨포지션

한번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용이 가능하다.

Set<Instant> times = new IntrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new IntrumentedSet<>(new HashSet<>(INIT_CAPACITY));

 

다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 과 같은 클래스를 래퍼 클래스라고 한다. 다른 Set 에 계측 기능을 덧씌운다는 뜻에서 '데코레이터 패턴'(Decorator pattern) 이라고도 한다. 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우를 '위임'이라고 한다.

 

 

래퍼클래스의 단점

단점은 거의 없고, 래퍼 클래스가 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의하면 된다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 

 

예제코드

https://github.com/seohaebada/2023/tree/main/2023_effectivejava_2/effective-java-part2/src/main/java/me/whiteship/chapter04/item18/callback

 

 

정리

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야한다.

클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.
클래스 A 를 상속하는 클래스 B 를 작성하려 한다면 "B가 정말 A 인가?" 를 확인해봐야 한다.
"그렇다"라고 확신할 수 있을때에만 상속해야한다.
"아니다"라고 한다면, A를 private 인스턴스로 두고 A 와는 다른 API를 제공해야한다.

컴포지션 대신 상속을 사용하기로 결정했을대 마지막으로 자문해보자.

"확장하려는 클래스의 API 에 아무런 결함이 없는가?"
"결함이 있다면, 이 결함이 전파되도 괜찮은가?"

 

 

반응형

Designed by JB FACTORY