JAVA/Effective Java

item 18. 상속보다는 컴포지션을 사용해라

Garonguri 2022. 4. 26. 10:31
728x90

상속

- 코드를 재사용하는 강력한 수단. 같은 패키지 안에서 확장을 목적으로 사용된다면 안전한 방법이다. (인터페이스 상속)

- 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됨. 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 안전하지 않을 수 있다. (구현 상속 )

 

[ 상속을 구현할 때 주의 사항 ]

1. 캡슐화를 깨뜨린다.

  • 상위 클래스의 구현에 따라 하위 클래스 동작에 이상이 생길 수 있다.
public class item18 {
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        //정적 팩터리 메소드인 list.of 사용
        s.addAll(List.of("틱", "탁", "펑"));

        int answer = s.getAddCount();
        System.out.println(answer); //answer : 6  (result =3)
        /* addAll method가 add method를 사용해서 구현했지 때문에 잘못된 값을 반환했다.*/
    }

    private static 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) {
            // addCnt에 3을 더한 후 HashSet의 addAll 구현을 호출했다.
            // Hashset은 각 원소를 add메서드를 호출해 추가한다.
            // 이 때 add는 재정의된 메서드이므로, 따라서 ddCount에 값이 중복해 더해지게 된다.
            // 따라서 반환되는 값이 원래 값의 두 배가 된다.
            addCount = c.size();
            return super.addAll(c);
        }

        //접근자 메서드
        public int getAddCount(){
            return addCount;
        }
    }
}

-> 이처럼 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당하며, 다음 릴리스까지도 유지되는지는 알 수 없다. -> 잘못 동작하기 쉽다.


  • 잘못 재정의된 메서드는 다른 식으로도 재정의할 수 있지만, 여전히 문제점은 남아있다.
  • 메서드를 자칫 잘못 재정의하면,
    • 상위 클래스에서 새로운 메서드를 추가해야 할 시, 전 단계 릴리스의 모든 메서드를 다시 재정의해야할 수 있다.
    • 다음 릴리스에서 오류가 생겼을 때 하위 클래스에서 재정의하지 못한 새로운 메서드를 이용해 허용되지 않은 동작이 일어날 수 있다.
  • 클래스를 확장할 때, 재정의가 아닌 새로운 메서드를 추가한다면?
    • 기존 메서드와 타입은 다르고 시그니처가 같아지는 경우, 상위 클래스 메서드가 요구하는 규약을 만족시키지 못할 경우 등 다양한 오류의 변수를 고려해야 한다.

[ 해결 방안 ]

Composition 사용

- 새로운 클래스를 만들고, private field로 기존 클래스의 인스턴스를 참조하여 기존 클래스가 새로운 클래스의 구성 요소가 되게 하는 설계.

- 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 결과를 반환한다. (Forwarding)

- 새 클래스의 메서드들은 전달 메서드(Forwarding method)라 부른다.

 

[ 장점 ]

- 새로운 클래스는 기존 클래스의 내부 구현 방식에서 벗어난다.

- 기존 클래스에 새로운 메서드가 추가되도 영향을 받지 않는다.

- 상속을 사용함으로써 발생할 수 있는 내부 구현의 불필요한 노출을 방지할 수 있다.

  • 상속을 잘못 사용한다면 결과 API가 내부 구현에 묶이고, 클래스 성능도 제한된다.
  • 또 클라이언트가 노출된 내부 구현을 직접 건드릴 수 있다.
  • 클라이언트가 상위 클래스를 직접 수정해 하위 클래스의 불변식을 해칠 수도 있다.
public class item18 {
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>(new HashSet<>());
        //정적 팩터리 메소드인 list.of 사용
        s.addAll(List.of("틱", "탁", "펑"));

        int answer = s.getAddCount();
        System.out.println(answer); // answer :3, result :3
    }

    // 다른 set 인스턴스를 감싸고 있으므로 래퍼 클래스이다.
    // 다른 set에 기능을 덧씌우므로 Decorator pattern이다.
    public static class InstrumentedHashSet<E> extends ForwardingSet<E> {
        private int addCount = 0;
        public InstrumentedHashSet(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 class ForwardingSet<E> implements Set<E> {
        // 기존 클래스를 Private 인스턴스로 선언
        private final Set<E> s;
        public ForwardingSet(Set<E> s) { this.s = s; }

        // Set methods -> 기존 클래스에 대응하는 메서드를 호출
        @Override public int size() { return s.size(); }
        @Override public boolean isEmpty() { return s.isEmpty(); }
        @Override public boolean contains(Object o) { return s.contains(o); }
        @Override public Iterator<E> iterator() { return s.iterator(); }
        @Override public Object[] toArray() { return s.toArray(); }
        @Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
        @Override public boolean add(E e) { return s.add(e); }
        @Override public boolean remove(Object o) { return s.remove(o); }
        @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
        @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
        @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
        @Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
        @Override public void clear() { s.clear(); }
    }
}

 


[ 래퍼 클래스의 단점 ]

- callback 프레임워크와 어울리지 않는다. 내부 객체는 래퍼의 존재를 모르니 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출한다.

(callback 프레임워크는 자기 자신의 참조를 다른 객체에 넘겨 다음 호출 때 사용한다.)

 


재사용 가능한 전달 클래스들을 인터페이스 당 하나씩 만들어두자.


클래스 A와 클래스 A를 상속하려는 클래스 B가 있을 때,

상속은 클래스 B가 클래스 A와 is-a관계일 때만 사용한다.

 

[상속을 구현할 때 자문해야 할 몇가지 질문들...]

- B가 정말 A인가?

- 확장하려는 클래스의 API에 아무 결함이 없는가?

- 결함이 있다면 이게 내 클래스의 API까지 전파되도 괜찮나?

언제나 '네'라고 대답할 수 있어야 상속을 할 수 있는 것이다.

 

-> Bad example) Vector을 확장한 Stack, Hashtable을 확장한 Properties.


결론 : 컴포지션과 Forwarding을 사용하자.

728x90