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

임종혁·2024년 1월 23일

상속보다는 컴포지션(조합)?? 을 사용하라 이게 대체 무슨 말인가? 책이랑 여러 블로그를 참고하면서 정리를 해 보려 한다

우선 책에서는
상속은 코드를 재사용하는 강력한 수단이지만 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트 웨어를 만들게 된다 . 이러한 문제는 상위 클래스와 하위 클랜스를 동일한 개발자가 개발하지 않는 경우 발생하게 된다 따라서 다른 클라이언트가 내가 만든 클래스를 상속 받을 수 있게 하려면 주의 해야한다

상속은 캡슐화를 깨뜨린다

  • 상위 클래스가 어떻게 구현되느냐에 따라 하위클래스 동작에 이상이 생긴다
  • 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며 , 그 여파로 코드 한줄 건드리지 않은 하위 클래스가 오작동 할 수 있다.
public class InstrumenteHashSetUseExtends<E> extends HashSet{
	  private int addCount = 0; // 추가된 원소의 수

    public InstrumentedHashSetUseExtends() {
    }

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

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

    @Override
    public boolean addAll(Collection c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

    public static void main(String[] args) {
        InstrumentedHashSetUseExtends<String> s = new InstrumentedHashSetUseExtends<>();
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount());
    }
}
public static void main(String[] args) {
        InstrumentedHashSetUseExtends<String> s = new InstrumentedHashSetUseExtends<>();
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount());
    }

다음과 같이 addAll을 사용하여 추가할시 기대값으로 3개를 예상 할 것이다

허나 다음과 같이 6이 나왔다

예상값과 다르니 코드를 분석해볼 차례이다

@Override
    public boolean addAll(Collection c) {
        addCount += c.size();
        return super.addAll(c);
    }

현재 다음과 같이 super.addAll()를 보낸다
그래서 위로위로 타고 올라고 보기로 했다

public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
    // ...

    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size() / .75f) + 1, 16));
        addAll(c); // super의 addAll 메서드를 호출한다.
    }

    // ...
}
public abstract class AbstractCollection<E> implements Collection<E> {
    // ...

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

    // ...
}

결국에는

  • HashSet에서 addAll 메서드는 add 메서드를 사용해서 구현되어 있다.
  • InstrumentedHashSetUseExtends의 addAll은 addCOunt를 더한 후 hashSet의 addAll을 호출
  • hashSet에 addAll은 AbstractCollection의 addAll로 add() 를 호출하는데 이때 동적 바인딩 때문에 InstrumentedHashSetUseExtends 가 정의한 메서드 호출
  • 따라서 addCount의 값이 중복으로 더해진다

즉 super로 타고 올라갔더니


 addAll(c); //HashSet
if (add(e)) // AbstractCollection<E>
 @Override
    public boolean add(Object o) {
        addCount++;
        return super.add(o);
    } //InstrumentedHashSetUseExtends

이 것들 때문에 우리가 원하는 기대값을 받지 못하는 거였다

이경우 하위 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다.
하지만 HashSet의 addAll 이 add 메서드를 이용해 구현했음을 가정한 해법이라 한계를 가진다
이처럼 자신의 다른 부분을 사용하는 자기 사용 여부는 해당 클래스의 내부 구혀 방식에 해당 한다

이러한 문제 외에도 상위 클래스에 새로운 메서드가 추가되었을 때와 같은 상황에서 다양한 문제가 발생할 수 있다.

이문제들을 해결할 수 있는 방안으로 바로 컴포지션이 있다.

Composition

컴포지션이 뭐냐

  • 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하면 된다
    • 기존 클래스가 새로운 클래스의 구성 요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라한다
  • 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다
    • 이방식을 전달 방식 이라 하며 새 클래스의 메서드들을 전달 메서드라 부른다
    • 그결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며 , 기존 클래스 메서드 추가되더라다 전혀 영향을 받지 않는다 .

글로 보면 이해가 안되니 코드를 보자

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


}

전달 클래스

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

이런식으로 HashSet의 모든 기능을 정의한 Set 인터페이스를 활요해 설계되어 견고하고 유연하게 맏느다
임의의 Set 계측 기능을 덧쒸워 새로운 Set을 만드는 것이 이 클래스 핵심

즉 부모를 자식에 맞추어 새로 만들자 였다.

이러한 컴포지션의 장점으로는

장점

  1. 상속으로 구현하게 되면 상위 클래스 모든 public 메서드가 클라이언트에게 공개
    하지만 컴포지션을 사용해 구현하게 되면 개발자가 원하는 메서드만 클라이언트에게만 공개
  2. 상위클래스 내부구현 숨길수 잇다.
  3. 다중 상속 목적 달성가능
  4. 상위 클래스에서 제공하는 메서드를 더나은 버전 제공 가능 유연성
  5. 참조하고 있는 인스턴스 변수를 변경해 프로그램 동적으로 변경할 수 있다.
  6. 상위클래스의 메서드 형태와 상관없이 유연한 하위 클래스의 메서드를 정의 할수 있다.

고려사항

상속은 상위클래스와 하위클래스가 is-a 관계가 완벽하게 성립하는지 확인하자
컴포지션을 써야할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 것과 같다
(클라이언트에서 노출된 내부에 직접 접근)
(최악의 경우 클라이언트에서 상위 클래스를 직접 수정하여 하위클래스이 불변식을 해칠수도 있다)
상속은 상위클래스 api 결함 까지도 승계함으로 주의

래퍼 클래스로 구현할 적당한 인터페이스가 있을시 고민할 것도 없이 컴포지션 사용하자

0개의 댓글