다른 사람의 코드를 보다보면 대부분의 클래스들이 죄다 implements가 사용된 것을 볼 수 있다.
여기저기서 합성이 상속보다 좋다고도 말은 하지만 정확하게 어떤 이유에서 상속이 좋지 않은지는 햇갈리곤한다. 예시를 통해 알아보고 더 좋은 설계가 되도록 고쳐보자.
자바 초기버전의 Stack
을 살펴보자.
Stack
은 Vector
의 삭제, 삽입을 재사용하기 위해 Vector
를 상속받아 사용했다. 하지만 Vector
를 상속받아 사용하였기 때문에 Vector
의 요소를 Stack
이 사용가능하게 되었다.
public class Example {
public static void main(String[] args) {
Stack<Integer> stk = new Stack<>();
stk.push(1);
stk.push(2);
stk.push(3);
stk.add(0, 4);
System.out.println(stk.pop());
}
}
Stack
의 동작방식을 고려한다면 답은 4가 되겠지만 3이 출력된다.
단순히 add를 사용하지 않으면 되지 않은가? 라고 생각할 수 있지만 인터페이스는 이상하게 사용하기는 어렵게, 제대로 사용하기에는 쉽게 설계해야한다.
상속보다 합성관계로 바꾸어보자 Vector
를 상속받지 말고 인스턴스 변수로 두어서 사용해보자
public class Stack <T> {
private Vector<T> vector = new Vector<>();
public T puss(T ele) {
vector.addElement(ele);
return ele;
}
public T pop(T ele) {
if(vector.isEmpty()) {
throw new EmptyStackException();
}
return vector.remove(vector.size() - 1);
}
}
이제 Vector
의 오퍼레이션이 포함되지 않아 원하는 방식으로 Stack
이 동작한다.
InstrumentedHashSet
은 HastSet
내부의 저장된 요소의 개수를 새는 기능을 추가한 클래스이다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Overide
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Overide
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll();
}
}
하지만 다음과 같은 코드를 실행하면 문제가 생긴다.
InstrumentedHashSet<String> arr = new InstrumentedHashSet<>();
arr.addAll(Arrays.asList("um", "jun"));
아마 위의 코드에서 카운트는 2가 되는것이 의도된 작동이겠으나 4가 나온다.
Stack
의 예시와 마찬가지로 ashSet
를 내부 인스턴스로 포함시켜 구현하는 방법을 생각할 수 있다.
하지만 InstrumentedHashSe
은 HashSet
이 제공하는 오퍼레이션을 그대로 이용해야 하기 때문에 합성을 사용해준다.
HashSet
과의 결합도는 줄여주고 HashSet
이 제공하는 인터페이스는 유지할 수 있다.
HashSet
은 Set
의 구현체이므로 Set
과 합성하도록 한다.
public class InstrumentedHashSet<T> implements Set<T> {
private int addCount = 0;
private Set<T> set;
public InstrumentedHashSet(Set<T> set) {
this.set = set;
}
@Overide
public boolean add(E e) {
addCount++;
return set.add(e);
}
@Overide
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll();
}
...overide methods...
}
public class Song {
private String singer;
private String title;
public Song(String singer, String title) {
this.singer = singer;
this.title = title;
}
... getter, setter
}
public class PlayList {
private List<Song> tracks = new ArrayList<>();
public void append(Song song) {
getTracks().add(song);
}
public List<Song> getTracks() {
return tracks;
}
}
public class PersonalPlaylist extends PlayList {
public void remove(Song song) {
getTracks().remove(song);
}
}
만약 PlayList
에서 노래의 제목도 관리해야 한다면 PlayList
의 append
만 수정하는 것이 아니라 PersonalPlaylist
의 remove
또한 수정해야 할 것이다.
이 예시는 간단한 편이기 때문에 "그정도 수정이야"라고 생각할 수 있지만 좀 더 복잡한 개발을 하다 보면 변경의 파급효과는 하루종일 수정을 해야할 정도로 엄청나다는 것을 느낄 수 있을 것이다.
이 예시는 합성을 사용하더라도 근본적인 문제는 해결되지 않을 것이다. 하지만 합성을 사용하는것이 PlayList
의 구현을 변경하더라도 파급효과들을 내부로 캡술화할 수 있으므로 합성을 사용하는 것이 더 좋다.
기본적으로 상속은 자식 클래스와 부모 클래스의 결합도를 높인다. 이로 인해 자식 클래스는 필요 없는 세부사항까지 알아야 하고 부모 클래스의 변경사항이 자식 클래스까지 침범하게되는 일이 발생한다.
한마디로 강한 결합도 때문에 문제가 발생하는 것이다.