상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다.
상속
합성
코드 작성 시점에 결정된 상속 관계는 런타임에 변경이 불가하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문에
상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다.
코드의 재사용을 위해 상속을 남용하는 경우 발생하는 문제점은 아래와 같다.
상속을 이용한 Stack에서는 부모클래스에서 제공하는 퍼블릭 인터페이스 때문에 Stack의 규칙을 위반할 수 있다.
Vector는 임의의 위치(index)에서 요소를 조회하고, 추가하고, 삭제할 수 있는 get, add, remove 오퍼레이션을 제공한다.
Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.add(0, "4th");
assertEquals("4th", stack.pop()); //에러
기존 상속을 이용하던 Stack을 합성으로 바꿔보았다.
public class Stack<E> {
private Vector<E> elements = new Vector<>();
public E push(E item) {
elements.add(item);
return item;
}
public E pop() {
if(elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size()-1);
}
}
이제 Vector의 퍼블릭 인터페이스는 Stack에 포함되지 않는다.
클라이언트는 더 이상 임의의 위치에 요소를 추가하거나 삭제할 수 없다. 따라서 Stack의 규칙을 깰 수 있는 요소가 없게 된다.
메서드 오버라이딩의 오작용 문제: InstrumentedHashSet
상속을 이용한 기존 InstrumentedHashSet
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@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);
}
}
기존에 상속을 이용하던 InstrumentedHashSet을 합성으로 바꿔보았다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@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);
}
}
위 과정까지는 위의 Stack의 예제와 같다.
하지만 InstrumentedHashSet은 기존의 HashSet의 퍼블릭 인터페이스까지 제공을 해야한다.
이를 해결하기 위해 아래와 같이 코드를 작성하였다.
public class InstrumentedHashSet<E> {
private int addCount = 0;
private Set<E> set;
public InstrumentedHashSet(Set<E> set) {
this.set = set;
}
public boolean add(E e) {
addCount++;
return set.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
public int getAddCount() {
return addCount;
}
@Override public boolean remove(E e) {return set.remove(e);}
@Override public void clear() {return set.clear();}
@Override public boolean equals(E e) {return set.equals(e);}
@Override public int hashCode() {return set.hashCode();}
@Override public Spliterator<E> spliterator() {return set.spliterator();}
@Override public boolean isEmpty() {return set.isEmpty(e);}
@Override public boolean contains(E e) {return set.contains(e);}
@Override public Iterator<E> iterator() {return set.iterator();}
@Override public E[] toArray(E e) {return set.remove(e);}
}
위와 같이 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달하는 것을 알 수 있다.
이를 포워딩(forwarding) 이라 부르고 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드(forwarding method) 라고 부른다.
포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경하고 싶은 경우에 사용하는 유용한 기법이다.
상속으로 인한 조합의 폭발적인 증가
상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 증가할 수 있다.
일반적으로 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우이다.
하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.
위의 이미지와 같이 상속의 남용으로 모든 케이스에 대한 조합을 클래스로 표현하는 경우를 가리켜 클래스 폭발(class explosion)이라 한다. 또는 조합의 폭발이라 한다.
클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계로 인해 발생하는 문제다.
클래스 폭발 문제는 새로운 기능을 추가하는 경우에는 많은 코드를 변경해야 할 뿐만 아니라 기능을 추가하는 경우에도 기하급수적으로 클래스가 생성될 수 있다.
합성 관계로 변경하기
상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다.
따라서 여러 기능을 조합해야하는 설계에 상속을 이용하면 모든 조합 가능한 경우별로 클래스를 추가해야한다.
하지만 합성을 사용하면 컴파일 관계를 런타임 관계로 변경함으로써 이 문제를 해결한다.
합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있다.
따라서 합성을 사용하면 구현 시점에 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있다.
컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점이다.