Item18. 상속보다는 컴포지션을 사용하라!
- 상속은 코드를 재사용하는 강력한 수단이나, 항상 최선은 아니다.
- 상속을 상위, 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서 사용하거나, 확장할 목적으로 설계되었고 문서화도 잘 된 클래스에서 사용한다면 안전한 방법이다.
메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
- 상위 클래스의 구현에 따라 하위 클래스 동작에 이상이 생길 가능성이 존재한다.
- 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있음. -> 하위 클래스의 오작동 유발 가능성 내재
- 상위 클래스 설계자가 확장을 충분히 고려하고 문서화하지 않으면 하위 클래스는 상위 클래스 변화에 발맞춰 수정해야만 함.
상속을 잘못 사용한 예 1
- 처음 생성 이후 몇 개의 원소가 더해졌는지 알 수 있는 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
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;
}
}
- 아래 코드 실행 후
getAddCount()
를 호출하면 3이 출력될 것을 기대한다.
1
2
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("용", "용용", "용용용"));
- 하지만 6이 출력된다.
addAll
내부에서add
메서드를 호출하고 있기에 중복 카운팅이 된다.
해결 방법
- 이 예제에서의 문제 해결을 위해서는,
addAll
메서드의 재정의를 하지 않거나addAll
메서드를 다른 식으로 재정의하는 방법이 있다.
addAll
메서드를 재정의 하지 않는 경우HashSet
의addAll
이add
메서드를 이용해 구현했음을 가정한 해법이라는 한계- 이러한 가정에 의존하는 경우, 내부 구현의 구조가 변경되면 문제 발생 여지가 있다.
addAll
메서드를 다른 식으로 재정의 하는 경우- 예로, 주어진 컬렉션을 순회하며 원소 하나당
add
메서드를 한 번만 호출하는 방식으로 해결한다. - 이러한 방법도 여전히 문제가 발생한다. 상위 클래스의 메서드 동작을 다시 구현하는 일이기에, 어렵고, 시간이 많이 소요되고, 오류를 유발할 가능성이 있고, 성능 하락의 가능성이 있다. 또 하위 클래스에서 접근 불가능한
private
필드 사용 시 이 방식 사용은 불가하다.
- 예로, 주어진 컬렉션을 순회하며 원소 하나당
상속을 잘못 사용한 예 2
- 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만 하는 프로그램을 구성 시
- 컬렉션을 상속해 원소 추가하는 모든 메서드를 재정의해 필요 조건 검사를 수행하게 한다?
- 이러한 방식은 상위 클래스에 추가 메서드가 만들어 질 경우, 허용되지 않은 원소 추가가 가능해져 보안 구멍이 생길 수 있다.
- 컬렉션을 상속해 원소 추가하는 모든 메서드를 재정의해 필요 조건 검사를 수행하게 한다?
문제의 원인
- 메서드 재정의
- 그럼 재정의 대신 새로운 메서드 생성해 사용하면 되는가?
- 훨씬 안전하지만, 위험이 없다고 볼 수 없다.
- 새로 생성해서 사용하던 중, 상위 클래스에
시그니처(메서드 이름, 매개변수 리스트)
는 같은데 반환 타입만 다른 메서드 추가 시 컴파일 에러가 나게 된다. - 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.
문제의 해결 방법 : 컴포지션
컴포지션
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 부른다.
전달(forwarding)
- 새 클래스의 인스턴스 메서드들은
private
필드로 참조 중인 기존 클래스에 대응하는 메서드를 호출해 결과를 반환하는데, 이 방식을전달
이라 한다. - 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.
예) 래퍼 클래스
- 상속 대신
컴포지션
을 사용했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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; }
}
- 재사용할 수 있는 전달 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ForwardingSet<E> implements Set<E> {
// 기존 클래스를 Private 인스턴스로 선언
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
// Set methods -> 기존 클래스에 대응하는 메서드를 호출
@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.removeAll(c); }
@Override public void clear() { s.clear(); }
}
InstrumentedSet
은HashSet
의 모든 기능을 정의한Set
인터페이스를 활용해 설계되어 견고하고 아주 유연하다.- 구제적으로
Set
인터페이스를 구현,Set
의 인스턴스를 파라미터로 받는 생성자를 제공한다.- 임의의
Set
에 카운팅 기능 넣어 새로운Set
으로 만드는 것이InstrumentedSet
클래스의 핵심
- 임의의
- 상속 방식과는 달리 한 번만 구현해두면 어떠한
Set
구현체라도 카운팅 가능하며 기존 생성자들과도 함께 사용이 가능하다.
래퍼 클래스(Wrapper class)
- 다른
Set
인스턴스를 감싸고 있다는 뜻에서InstrumentedSet
같은 클래스를래퍼 클래스
라 부른다. - 다른
Set
에 카운팅 기능을 덧씌운다는 뜻에서데코레이터 패턴
이라고 한다.
컴포지션의 단점
- 단점이 거의 없다.
- 콜백 시
SELF 문제
를 겪을 수 있다.- 콜백 프레임워크에서는 자신의 참조를 다른 객체에 넘겨 다음 호출 때 사용하도록 한다.
- 내부 객체는 래퍼의 존재를 모르니 자신(this)의 참조를 넘기고, 콜백 시 래퍼가 아닌 내부 객체를 호출하게 된다.
핵심 정리
- 상속은 강력하지만 캡슐화를 해친다는 문제가 있다.
- 상속은 상위 클래스와 하위 클래스가 순수한
is-a
관계일 때만 써야 한다.is-a
관계라도 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장 고려 없이 설계된 경우 문제가 될 수 있다. - 상속의 취약점을 피하기 위해 컴포지션, 전달을 사용하자.
- 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 컴포지션을 사용하자. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.
Comments powered by Disqus.