위 포스팅에서 언급하는 상속은 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.
상속은 코드를 재사용하는 강력한 수단이지만, 잘 못 사용하면 오류를 내기 쉽다.
- 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
- 확장할 목적으로 설계되었고 문서화도 잘 된 클래스(아이템19)도 마찬가지로 안전하다.
- 하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
- 상위 캡슐화를 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
- HashSet의 처음 생성된 이후 원소가 몇개 더해졌는지 알 수 있도록 하기 위해(현재 크기가 아님) 아래와 같이 예시 코드를 작성했다고 가정하자.
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
// 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽)
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());
}
}
- 위 코드를 실행하면
addCount
는 3이 아닌 6이 된다. - 왜냐하면 Set의 addAll메서드는 각 원소를 add 메서드를 호출하여 추가하는데, 이 떄 불리는 add 메서드는 InstrumentedHashSet에서 재정의한 메서드이기에 중복해서 더해진 것이다.
- 이를 해결하기위 addAll 을 호출시 직접 루프를 돌며 add 메서드를 한 번 씩 호출해줄 수 있다. 하지만 이 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능 문제를 야기할 수 있다.
- 또한 하위 클래스에선 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로 구현 자체가 불가능하다.
- 그렇다면 메서드를 재정의하는 대신 새로운 메서드를 추가하는 방식으로 접근하면 어떨까?
- 이 방식이 훨씬 안전하긴 하지만, 위험이 전혀 없는 것은 아니다.
- 다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데 운 없게도 하필 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다르다면 하위 클래스는 컴파일조차 되지 않을 것이다.. 즉 좋은 방법이라 할 수 없다.
해결책: 기존 클래스르르 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자.
- 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(Composition: 구성)이라 한다.
- 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들은 전달 메서드(forwarding method)라 부른다.
- 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나게 되며, 심지어 기존 클래스에 메서드가 추가되더라도 전혀 영향 받지 않는다.
- 먼저 위의 문제가 되는 예시 코드를 개선한 코드를 살펴보자.
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
// 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽)
public class ForwardingSet<E> implements 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); }
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(); }
}
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
// 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽)
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) {
InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
s.addAll(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount());
}
}
- 위의 코드에서 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
- 아래 예시 코드처럼 한 번만 구현해두고 나서 어떠한 Set 구현체라도 계측할 수 잇으며, 기존 생성자들과도 함께 사용할 수 있다.
1
2
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
- 다른 Set인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라 한다.
- 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
- 래퍼 클래스는 단점이 거의 없다. 래퍼 클래스가 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.
Note: 위 처럼 전달 메서드들을 작성하는게 지루하겠지만, 재사용할 수 있는 전달 클래스(코드 18-3)를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다. 좋은 예로 구아바(Guava)는 모든 컬렉션 인터페이스용 전달 메서드를 전부 구현해뒀다.
상속은 반드시 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야 한다.
- 클래스B가 클래스A와
is-a
관계일 때만 클래스 A를 상속해야 한다.
자바 플랫폼 라이버르리에서도 위 원칙을 명히 위반한 클래스들을 찾아볼 수 있다.
1) 스택은 벡터가 아니므로 Stack
은 Vector
를 확장해선 안됐다.
2) 마찬가지로, 속성 목록도 해시 테이블이 아니므로 Properties
도 HashTable
을 확장해선 안됐다.
위의 두 사례 모두 컴포지션을 사용했따면 더 좋았을 것이다.
컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.
- 위처럼 한다면 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다.
- 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.
- 예를 들어,
Properties
의 인스턴스인 p가 있을 때,p.getProperty(key)
와p.get(key)
는 결과가 다를 수 있다.getProperty()
는 Properties의 기본 동작인데 반해,get()
은 HashTable로부터 물려받은 메서드이기 때문이다.- 가장 심각한 문제는 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 사실이다. 예컨대 Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, 상위 클래스인 HashTable의 메서드를 직접호출하면 불변식을 깨버릴 수 있다.
컴포지션 대신 상속을 이용하려 결정하기 전에 마지막으로 자문해야 할 질문은 아래와 같다.
- 확장하려는 클래스의 API에 아무런 결함이 없는가?
- 이 결함이 상속 받은 클래스의 API까지 전파돼도 괜찮은가?
- 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 ‘그 결함까지도’ 그대로 승계한다.
핵심 정리: 상속은 강력하지만, 캡슐화를 해친다. 상속은 상위 클래스와 하위 클래스의 관계가 순수한 is-a 관계일 때만 상속을 사용해야한다. is-a관계일 떄도 안심할 수만은 없는게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다. 상속의 취약점을 피하려면 컴포지션과 전달을 사용하자. 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 컴포지션을 사용해야한다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하기 떄문이다.