규칙 16.계승하는 대신 구성하라

계승은 코드 재사용을 돕는 강력한 도구지만 항상 최선은 아니다. 계승을 적절히 사용하지 못한 소프트웨어는 깨지기 쉽다. 계승은 상위 클래스와 하위 클래스 구현을 같을 프로그래머가 통제하는 단일 패키지 안에서 사용하면 안전하다. 또한 계승을 고려하여 설계되고 그에 맞는 문서를 갖춘 클래스에 사용하는것도 안전하다. 일반적인 객체 생성 가능 클래스라면 해당클래스가 속한 패키지 밖에서 계승을 시도하는 것은 위험하다.

계승은 캡슐화 원칙을 위반한다.

하위 클래스가 정상 동작하기 위해서는 상의 클래스의 구현에 의존할수밖에 없다. 상위 클래스는 릴리즈가 계속되면서 바뀔 수 있는데 이때 하위클래스는 망가질 수 있다. 또한 하위클래스는 상위클래스의 변화에 발맞춰 진화해야 한다.

HashSet 를 계승하여 요소가 몇개나 추가되었는지에 대한 코드를 작성한다 가정해보자.

default
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class InstrumentHashSet<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;
}

}

위의 코드는 제대로 동작하지 않는다. 가령 다음과같은 코드를 실행한다 해보자.
InstrumentedHashSet s = new InstrumentedHashSet();
s.addAll(Arrays.asList(“A”,”B”,”C”));

addAll은 3을 반환하는것이 아니라 6을 반환한다.

HashSet의 addAll 메소드는 add메서드를 통해 구현되어 있기 때문이다. 이는 HashSet 문서에는 나와있지 않은 내용이다.
하위클래스에서 재정의한 addAll 메서드를 삭제하거나 addAll메서드가 반복하며 add를 호출하도록 하여 해결할 수 있지만 이는 addAll 메소드가 add 위에서 구현되었다는 사실에 의존적이다. 이 또한 add메서드가 private일 경우 사용할 수 없다.
또한 상위 클래스에 새로운 메서드가 추가될 경우 하위클래스의 구현을 망가뜨릴 수 있다.
예를들어 특정 리스트에 데이터를 삽입할 때 “ksh” 라는 문자열이 붙도록 하는 메소드가 어느순간 추가되었을 때 삽입 작업만 호출하는 부모클래스의 메소드만을 호출할 때 ..

즉 InstrumentedHashSet 클래스는 깨지기 쉬운 클래스일수밖에 없다.

구성과 전달을 활용해 위의 문제를 해결하는 방법

기존 클래스를 계승하는 대신, 새로운 클래스에 기존 클래스 객체를 참조하는 privae 필드를 하나 두는것. 이를 구성이라 하며 기존클래스가 새 클래스의 일부가 되는것을 말한다.
새로운 클래스에 포함된 각각의 메서드는 기존 클래스에 있는 메서드 가운데 필요한 것을 호출해서 결과를 반환하면 된다. 이러한 구현 기법을 전달(forwarding)이라고 하고 전달 기법을 사용해 구현된 메소드를 전달메서드(forwarding method)라고 부른다.
구성기법을 통해 구현된 클래스는 기존 클래스의 구현 세부사항에 종속되지 않기 때문에 견고하다.(기존 클래스에 또다른 메서드가 추가되더라도 새로운 클래스에 영향이 없음)

default
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//계승 대신 구성을 사용하는 포장(wrapper) 클래스
public class instrumentedSet<E> extends ForadingSet<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(Coolection<? extends E> c){
addCount += c.size();
return super.addAll(c);
}

public int getAddCount() {
return addCount;
}
}


//재사용 가능한 전달(forwarding) 클래스
public class ForwardingSet<E> implemetns 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);}
.....
}

InstrumentedSet을 이렇게 설계할 수 있는것은 HashSet이 제공해야 할 기능을 규정하는 Set라는 인터페이스가 있기 때문이다. 이런 설계는 안정적일 뿐 아니라 유연성도 아주 높다.
이전의 예제중 계승을 한 것에서는 한 클래스에서만 적용이 가능하고 상위 클래스 생성자마다 별도의 생성자를 구현해야 했다.
하지만 포장 클래스 기법을 쓰면 어떤 Set 구현도 원하는대로 수정할 수 있고 이미 있는 생성자도 그대로 사용할 수 있다.

포장클래스의 단점이 별로 없으나 callback프레임워크와 함께 사용하기에는 적합하지 않다. 역호출 프레임워크에서 객체는 포장 객체에 대해서는 모르기 때문에, 자기 자신에 대한참조를 전달할 것이다. 따라서 역호출 과정에서 포장 객체는 제외된다. 이 문제는 SELF문제로 알려져있다.

요약

계승은 강령하지만 캡슐화 원칙을 침해하므로 문제를 발생시킬 소지가 있다. 상위, 하위 클래스가 IS-A 관계일 때만 사용하는것이 좋고 그렇지 않을때는 구성과 전달기법을 사용하는것이 좋다. 포장 클래스 구현에 적당한 인터페이스가 있다면 더욱 그렇다.
포장 클래스는 하위 클래스보다 견고할 뿐 아니라 더 강력하다.

Share