[Effective Java] 아이템18 - 상속보다는 컴포지션을 사용하라

인터페이스 상속이 아닌 클래스가 다른 클래스를 확장하는 구현상속은 여러 문제점이 존재한다.

상속은 캡슐화를 깨뜨린다.

상위 클래스의 내부 구현이 달라질 때마다 그 여파로 하위 클래스가 오동작할 수 있다.

아래 코드는 HashSet을 상속했고, add와 addAll 메소드를 재정의 했다.

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 static void main(String[] args) {
		InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
		s.addAll(List.of("틱", "틱틱", "펑"));

		System.out.println(s.getAddCount()); // 3이 아닌 6이 찍힌다.
	}
}

3개의 원소를 addAll 하고, addAll 메소드에는 그 size를 증가시켰다. 3개의 원소를 넣었으니 3을 반환해야 하지만 6을 반환한다.

그 원인은 상위 클래스인 HashSet의 addAll 메서드가 add 메서드를 사용해서 구현했기 때문이다.

public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    for (E e : c)
        if (add(e)) // 여기서 호출되는 add는 우리가 재정의한 메서드이다.
            modified = true;
    return modified;
}

이렇게 자기 자신의 메소드를 사용하여 구현하는 것을 self-use라고 한다.

상위 클래스를 상속한 하위 클래스는 항상 이를 고려하여 메서드를 재정의해야한다. 또한, 보안 이슈도 있다. 상위 클래스에서 새로운 메서드가 추가되면, 하위 클래스에서 이 메서드를 사용해 “허용되지 않은” 원소를 추가할 수 있게 된다.

상속이 아닌 컴포지션으로 구성하자

상속의 문제점을 해결하기 위해서 컴포지션(composition)으로 구성하는 방법이 있다.

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것이다. (왠지 뷰와 비슷한 느낌 같다.)

새로운 클래스의 메서드들은 기존 클래스의 대응하는 메서드를 호출해 단순히 그 결과를 반환한다. 그 결과, 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나게 된다.

public class ForwardingSet<E> implements Set<E> { // HashSet이 아닌 인터페이스 타입을 구현한다.

    private final Set<E> s; // private 으로 인스턴스 변수를 가지고 생성자에서 매개변수로 받는다.
    public ForwardingSet(Set<E> s) {this.s = s;} // 아무 제너릭 타입 E가 아닌 Set 타입 E를 지정한다.

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

}
  • 인터페이스 Set을 구현한 ForwardingSet은 private final 접근 제어자로 파라미터로 받은 HashSet(기존 클래스)를 필드로 가지고 있는다. -> 컴포지션
  • 매개변수로 받은 HashSet(기존 클래스)의 기능을 그대로 전달한다.
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) {
		Set<String> s = new InstrumentedSet<String>(new HashSet<>());
		s.addAll(List.of("틱", "틱틱", "펑"));

		System.out.println(((InstrumentedSet<String>) s).getAddCount()); // 3 반환
	}

}
  • 새로 구성한 InstrumentedSet은 컴포지션 방식으로 구현한 ForwardingSet을 상속한다.
  • 이렇게 하면 addAll 메서드를 호출했을 때, 새로운 클래스인 InstrumentedSet이 재정의한 add메서드가 호출되는게 아니라 기존 클래스인 HashSet의 addAll이 호출되게 된다.
  • HashSet 뿐만 아니라 어떠한 Set 구현체라도 이용할 수 있어서 유연성이 크다.
    • 이러한 클래스를 래퍼 클래스라고 하며, 다른 Set에 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고도 한다.

private 도우미 메서드 생성

(이 내용은 아이템19에서 말했지만, 이 내용과 이어지므로 여기다가 정리한다.)

재정의 가능 메서드의 코드를 private 도우미 메서드로 옮기고, 클래스에서는 이 도우미 메서드를 호출하도록 수정한다. 그런 다음 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정하면 된다.

이렇게 하면 상속했을 경우 위와 같이 연쇄적으로(?) 메서드들이 잘못 호출되는 일은 없을 것이다.

Summary

ForwardingSet 처럼 전달 메서드들을 작성하는게 지루하지만, 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 래퍼 클래스들을 손쉽게 구현할 수 있다.

이 장에서 래퍼 클래스를 말하는 이유 또한 상속이 상위클래스의 불변식을 깨버릴 수 있다는 우려감 때문이다. 상속을 하게되면 상위 API의 구현이 모두 노출되며, 불변식이 깨질 수 있기 때문이다.

상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자. 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.

댓글남기기