[이펙티브 자바] 아이템 18. 상속보다는 컴포지션을 사용하라

June·2022년 2월 27일
0

[이펙티브자바]

목록 보기
17/72

상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다. 하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다. 이번 아이템에서 '상속'은 (클래스가 다른 클래스를 확장하는) 구현 상속이다. (클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는) 인터페이스 상속과는 무관하다.

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
상위 클래스의 내부 구현이 달라지면 하위클래스까지 영향을 미친다.

예를 들어 보자.

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

        public InstrumentedHashSet() {
        }

        @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> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));

일반적으로 위 코드 실행 후 addCount가 3이 될 것이라 예상할 것이다. 하지만 실제로는 6이다. 이유는 부모 클래스인 HashSetaddAll 메서드 안에서 add메서드를 호출하기 때문이다.

처음에 이게 이해가 안됐다. addAll을 호출하면 내부에서 add를 호출하는데 add가 상위 클래스의 add를 호출할줄 알았다. 하지만 InstrumentedHashSetadd를 호출했다.

하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다. 하지만 당장은 제대로 동작해도, HashSetaddAlladd 메서드를 이용해 구현했음을 가정한 해법이라는 한계가 있다. 잣니의 다른 부분을 사용하는 자기사용(self-use) 여부는 내부 구현 방식에 해당해서 다음 릴리스에도 유지될지 알 수 없다.

addAll 메서드를 다른 식으로 재정의할 수도 있다. 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 하나만 호출하는 것이다. 조금 나은 방법이지만 상위 클래스의 메서드 동작을 다시 구현하는 것은 어렵고, 비용이든다. 또한 하위 클래스에서 접근할 수 없는 private 필드를 써야 한다면 이 방식으로는 구현 자체가 불가능하다.

이렇게 문서화가 되지 않으니 사용자의 예상과는 다르게 동작했다.
상속은 코드를 재사용하는 강력한 수단이지만, 최선은 아니다. 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

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

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

상속 대신 컴포지션 사용

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

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

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

InstrumentedSetHashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 구체적으로는 Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다. 임의의 Set에 계측 기능을 덧씌어 새로운 Set으로 만드는 것이 이 클래스의 핵심이다. 이 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들도 함께 사용할 수 있다.

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

다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.

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

컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.

참고

참고 1

``` java
    public class Parent {
        private int addCount = 0;

        public Parent() {
        }

        public void add() {
            System.out.println("parent add");
            addCount += 100;
        }

        public void addAll(int n) {
            for (int i = 0; i <n ; i++) {
                System.out.println("parent addAll~~~~");
                add();
            }
        }

        public int getAddCount() {
            return addCount;
        }
    }

    public class Son extends Parent{
        private int addCount = 0;

        public Son() {
        }

        @Override
        public void add() {
            System.out.println("son add");
            addCount++;
            super.add();
        }

        @Override
        public void addAll(int n) {
            for (int i = 0; i <n ; i++) {
                System.out.println("son addAll");
                addCount += n;
                super.addAll(n);
            }
        }

        public int getAddCount() {
            return addCount;
        }
    }

    @Test
    void testCount2() {
        Son s = new Son();
        s.addAll(3);
        System.out.println(s.getAddCount());
    }

https://velog.io/@injoon2019/%EC%99%9C-%EC%A1%B0%EC%83%81-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%97%90%EC%84%9C-%EC%9E%90%EC%86%90-%ED%81%B4%EB%9E%98%EC%8A%A4-%EB%A9%94%EC%84%9C%EB%93%9C%EB%A5%BC-%ED%98%B8%EC%B6%9C%ED%95%98%EB%82%98

0개의 댓글