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

Dayeon myeong·2022년 3월 6일
0

이펙티브자바

목록 보기
7/15

상속의 문제점 : 하위 클래스의 캡슐화를 깨뜨린다

상속은 하위 클래스의 캡슐화를 깨기 쉽다. 상위 클래스가 어떻게 구현되었느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 하위 클래스의 캡슐화가 깨지고 상위 클래스의 여파가 전달된다.

하위 클래스의 캡슐화가 깨지기 쉬운 이유

  • 상위 클래스의 메서드를 재정의할 때 self use 자기 사용으로 인해 하위 클래스가 오작동한다.
  • 상위 클래스에서 새로운 메서드가 추가되어 하위클래스에서는 허용되지 않은 원소를 상위클래스를 통해 넣을 수 있다.
  • 하위 클래스에 새로운 메서드를 추가했을 때 상위클래스에서 같은 시그니처를 가진 메서드가 새로 생길 수 있다.

상위 클래스의 메서드를 재정의할 때 self use 자기 사용으로 인해 하위 클래스가 오작동한다.

//상속을 잘못 사용한 경우
public 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) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

public class Item18 {
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("틱", "탁탁", "펑"));//addAll

        System.out.println(s.getAddCount()); // 3이 아닌 6이라는 잘못된 값 반환
    }
}

위 코드는 3을 반환하리라 기대했지만 6을 출력한다. 그 원인은 HashSet의 addAll메서드가 내부적으로 자신의 다른 부분인 add를 사용하기 때문이다.

InstrumentedHashSet.addAll이 호출되며 addCount에 3을 더한 후, HashSet의 addAll이 호출된다.
HashSet의 addAll은 내부적으로 각 원소를 add 메서드를 호출해 추가하는데, 이 때 불리는 add는 InstrumentedHashSet에서 재정의한 add 메서드를 각각 불러버린다.
따라서 addCount에 값이 중복해서 더해져, 최종값이 6이된다.addAll로 추가한 원소 하나당 2씩 늘어난다.

self use : 자신의 다른 부분을 사용

hashSet의 addAll이 내부적으로 자신의 다른 부분인 Add를 사용하는 self use 때문에 하위 클래스 InstrumentedHashSet이 오작동한다.

상위 클래스에서 새로운 메서드가 추가되어 하위클래스에서는 허용되지 않은 원소를 상위클래스를 통해 넣을 수 있다.

어떤 보안때문에 컬렉션에 추가된 모든 원소가 특정한 조건을 만족해야만한다는 프로그램을 만들어야 한다.
그래서 컬렉션을 상속하여 원소를 추가하는 add, addAll과 같은 메서드를 재정의해 필요한 보안 조건을 먼저 검사하게 만든다.
그런데 다음 릴리즈에서 상위 클래스에 새로운 메서드가 추가되면서 상위 클래스에 원소를 추가하는 또다른 메서드들이 생겼다. 이 상위클래스의 새로 생긴 메서드때문에 하위클래스에서 보안에 어긋나는 허용되지 않는 원소들을 상위클래스를 통해 추가할 수 있게 되버린다. 즉, 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 허용되지 않은 원소를 추가할 수 있게 된다.

하위 클래스에 새로운 메서드를 추가했을 때 상위클래스에서 같은 시그니처를 가진 메서드가 새로 생길 수 있다.

하위 클래스에서 새로운 메서드를 추가했는데 다음 릴리즈에서 똑같은 시그니처를 가진 상위 클래스의 메서드가 새로 생길 수가 있다. 반환 타입이 다르면 클래스는 컴파일 조차 되지 않는다. 반환타입이 같다면 상위 클래스의 새 메소드를 재정의하는 꼴이 된다.

상속보다는 컴포지션

상속으로 인해 하위 클래스의 캡슐화가 깨지는 문제를 해결하기 위해서 컴포지션을 사용한다.

컴포지션이란, 기존 클래스가 새로운 클래스의 구성요소로 쓰이는 설계.

사용 방법은 새로운 클래스를 만들고, private 필드로 기존 클래스의 인스턴스를 참조한다.

그리고 새로운 클래스에서 기존 클래스의 메서드들을 호출하는 것을 전달 forwarding이라하며, 이러한 새 클래스의 메서드들을 전달 메서드 forwarding method라 한다.

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

//래퍼 클래스 : 다른 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;
    }
}
// 전달 클래스
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(); }

}

java.util.Property extends Hashtable

상속은 다음과 같은 문제가 있다.

  • 캡슐화를 깨트린다
    - self use로 인해 하위 클래스의 오작동이 일어난다.
  • 상위 클래스를 통해서 하위클래스에 허용될 수 없는, 넣어서는 안될 값들을 넣을 수 있다.

Property도 Hashtable을 잘못 상속받아 캡슐화가 깨져버리고 HashTable을 통해 Property에 접근이 가능하게 되었다. 사용자는 매우 혼란스럽게 되었다.

Properties p

...

p.getProPerty(key) //Properties의 동작

p.get(Key) // Hashtable로부터 물려받은 메서드

Properties 타입의 인스턴스 p가 있을 때 p.getProPerty(key) 는 p.get(key)는결과가 다를 수 있다. getProperty(key)는 Properties의 기본동작인데 반해, get(key)는 HashTable의 메서드이기 때문이다.

또 다른 문제는 캡슐화가 깨진다는 것이다.
Properties 타입은 문자열 key, value만 넣을 수 있다.
그런데 상위 클래스인 HashTable을 통해서 문자열 이외의 타입을 넣을 수 있다는 것이다. 그래서 이후에 Properties의 load, store와 같은 Properties API도 사용할 수 없게 되었다.

상속을 하는 경우

하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 사용한다.
클래스 B가 클래스 A와 is-a 관계일때만 클래스 A를 상속한다. "B가 정말 A인가"

ex. Java에서 위반한 클래스 : Stack은 Vector를 확장해서는 안됐다. Properties도 Hashtable을 확장해서는 안됐다.

상속을 사용하는 건 내부구현을 노출시키고, 클라이언트가 노출된 내부에 직접 접근해버린다.
또한 상속은 결함까지도 모두 그대로 전달하지만 컴포지션은 결함 전달을 방어할 수 있다.

참고

이펙티브 자바

자바봄 블로그
https://javabom.tistory.com/20?category=833277

https://stackoverflow.com/questions/28254116/wrapper-classes-are-not-suited-for-callback-frameworks

profile
부족함을 당당히 마주하는 용기

0개의 댓글