이펙티브 자바!
계승하는 대신 구성하자!
계승(상속)은 코드 재사용을 돕는 강력한 도구지만, 항상 최선이라고는 할 수 없다.
계승(상속)을 적절히 사용하지 못한 소프트웨어는 깨지기 쉽다.
한 클래스가 다른 클래스를 상속(extends) 한다는 소리이다. 인터페이스의 상속을 말하는 것은 아니다. 또한 인터페이스가 인터페이스를 상속하는 것도 포함되지 않는다.
메서드 호출과 달리 계승(상속)은 캡슐화 원칙을 위반한다. 하위 클래스가 정상 동작하기 위해서는 상위 클래스의 구현에 의존할 수 밖에 없다.
상위 클래스의 구현이 릴리즈가 거듭되면서 자주 바뀌는데 그러다 보면 하위 클래스 코드는 수정된 적이 없어도 망가질 수 있다.
따라서 상위 클래스 작성자가 계승(상속)을 고려해 클래스를 설계하고 문서가지 만드렁 놓지 않았다면 하위 클래스는 상위 클래스의 변화에 발맞춰 진화해야 한다.
예로 HashSet을 보자
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;
}
}
위와 같이 우리는 HashSet을 계승(상속) 받아서 구현을 했다.
딱히 이상한점은 찾을 수 없다.
하지만 이 클래스는 제대로 동작하지 않는다.
다음과 같이 addAll을 호출해서 실제 요소를 삽입한 횟수를 출력해보자
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println(s.getAddCount());
우리는 3개는 넣었으니 3개를 기대할 것이다. 하지만 기대 했던거와 달리 6이 나온다.
사실 HashSet의 addAll 메서드는 add메서드를 통해 구현되어 있다. 그리고 HashSet 문서에는 그런 사실이 명시 되어 있지 않다.
InstrumentedHashSet에 정의된 addAll 메서드는 addCount에 3을 더하고 상위 클래스인 HashSet의 addAll 메서드를 호출 하는데 이 메서드는 InstrumentedHashSet에 재정의한 add 메서드를 삽입할 원소마다 호출 하게 된다. 각각의 add 메서드가 호출될 때마다 addCount는 1씩 증가함에 따라 총 6이 나오게 된다.
사실 하위 클래스에서 재정의한 addAll 메서드를 삭제하면 이 문제를 해결 할 수 있는데 이 클래스가 정상 작동한다는 것은 HashSet의 addAll 메서드가 add 위에서 구현되었다는 사실에 의존한다.
우리는 다음과 같이 해결 할 수 있다. 재사용이 가능한 전달 클래스?(위임을 말하는 듯?) 의 두 부분으로 나뉜다. 전달 클래스는 모든 전달 메서드를 포함하고 다른 것은 없다.
다음과 같이 계승 대신 구성을 사용하는 포장 클래스를 살펴보자
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;
}
}
class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s){
this.s = s;
}
@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.retainAll(c);
}
@Override
public void clear() {
s.clear();
}
}
InstrumentedSet과 같은 클래스를 포장 클래스라고 부르는데 다른 Set 객체를 포장하고 있기 때문이다. 또한 이런 구현 기법은 장식자(데코래이터) 패턴이라고 부르는데 기존 Set 객체에 기능을 덧붙여 장식하는 구실을 하기 때문이다. 그런데 기술적으로 보자면 포장 객체가 자기 자신을 포장된 객체에 전달하지 않으면 위임이 라고 부를 수 없다.
이 포장 클래스에는 단점이 별로 없으나 역호출(콜백) 프레임워크와 함께 사용하기에는 적합하지 않다. 객체는 자기 자신에 대한 참조를 다른 객체에 넘겨, 나중에 필요할 때 콜백 하도록 요청한다. 포장된 객체는 포장 객체에 대해서는 모르기 때문에 자기 자신에 대한 참조를 전달할 것이다. 따라서 그 과장에서 포장 객체는 제외된다.
요약하자면 계승은 강력한 도구 이지만 캡슐화 원칙을 침해하므로 문제를 발생시킬 소지가 있다는 것이다.
상위 클래스와 하위 클래스 사이에 IS-A? 관계가 있을 때만 사용하는 것이 좋다.
그것이 아니라면 구성과 전달 기법을 사용하는 것이 좋다. 포장 클래스 구현에 적당한 인터페이스가 있다면 더더욱 그렇다.
포장 클래스는 하위 클래스보다 견고할 뿐 아니라 더 강력하다.
이것이 답은 아니지만 되도록이면 포장 클래스를 쓰라는 말 같다.