아이템 18. 상속보다는 컴포지션을 사용하라. - ksw6169/effective-java GitHub Wiki
상속은 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.
상위/하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 마찬가지로 안전하다.
하지만 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
상속은 캡슐화를 깨뜨린다.
메소드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말하면 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작 할 수 있다.
다음 코드는 HashSet을 상속하여 객체가 생성된 후에 추가된 원소의 수를 계산할 수 있는 Set이다.
/** * 잘 구현된 코드 같지만 상속을 잘못 사용하였다. */publicclassInstrumentedHashSet<E> extendsHashSet<E> {
// 추가된 원소의 수privateintaddCount = 0;
publicInstrumentedHashSet() { }
publicInstrumentedHashSet(intinitialCapacity, floatloadFactor) {
super(initialCapacity, loadFactor);
}
@Overridepublicbooleanadd(Ee) {
addCount++;
returnsuper.add(e);
}
@OverridepublicbooleanaddAll(Collection<? extendsE> c) {
addCount += c.size();
returnsuper.addAll(c);
}
publicintgetAddCount() {
returnaddCount;
}
}
이 클래스는 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다. 이유는 HashSet의 addAll 메소드가 각 원소를 추가할 때 add 메소드를 호출하기 때문이다. 이 문제는 하위 클래스에서 addAll 메소드를 재정의하지 않으면 문제를 고칠 수 없다.
InstrumentedHashSet<String> s = newInstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
// 3을 반환하지 않고 6을 반환하여 테스트 실패assertEquals(3, s.size());
해결책 1 - 하위 클래스에서 addAll 메소드를 재정의한다.
addAll 메소드를 재정의하면 당장은 제대로 동작할지 모르나 HashSet의 addAll이 add 메소드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다.
자신의 다른 부분을 사용하는 '자기사용(self-use)` 여부는 해당 클래스의 내부 구현에 해당하며, 다음 릴리즈에서도 유지될지는 알 수 없으므로 이런 가정에 깃댄 InstrumentedHashSet도 깨지기 쉽다.
상위 클래스의 addAll 메소드 동작을 다시 구현하는 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수도 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 사용해야 하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.
또한 다음 릴리즈에서 상위 클래스에 새로운 메소드가 추가된다면 하위 클래스에서 미처 재정의하지 못한 새로운 메소드를 사용해 또 다른 문제를 야기할 수 있다.
해결책 2 - addAll을 재정의하지 않고 새로운 메소드를 추가한다.
이 방식은 좀 나아보이지만, 여전히 문제를 발생시킨다. 예컨대 다음 릴리즈에서 상위 클래스에 새 메소드가 추가됐는데 내가 하위 클래스에 추가한 메소드와 시그니처가 같고 반환 타입은 다르다면 클래스가 컴파일조차 되지 않는다. 혹 반환 타입 마저 같다면 상위 클래스의 메소드를 재정의한 꼴이니 앞서의 문제와 똑같은 상황에 부닥친다.
또한 새로 만든 메소드가 상위 클래스의 메소드가 요구하는 규약을 만족하지 못할 가능성도 크다.
진정한 해결책 - 기존 클래스를 확장하는 대신 새로운 클래스를 멤버로 추가한다.
앞의 해결책들에서 발생한 문제를 모두 피해가는 묘안이 있다. 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하는 것이다.
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition: 구성) 이라 한다.
새 클래스의 인스턴스 메소드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메소드들은 전달 메소드(forwarding method)라 부른다.
결과적으로 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향받지 않는다.
예제 - 상속 대신 컴포지션을 사용한 래퍼 클래스
상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다. 하지만 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존의 생성자와도 함께 사용할 수 있다.
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern) 이라고 한다.
// 재사용 할 수 있는 전달 클래스publicclassForwardingSet<E> implementsSet<E> {
privatefinalSet<E> s;
publicForwardingSet(Set<E> s) {
this.s = s;
}
// 전달 메소드(forwarding method)publicvoidclear() { s.clear(); }
publicbooleancontains(Objecto) { returns.contains(o); }
publicbooleanisEmpty() { returns.isEmpty(); }
publicintsize() { returns.size(); }
publicIterator<E> iterator() { returns.iterator(); }
publicbooleanadd(Ee) { returns.add(e); }
publicbooleanremove(Objecto) { returns.remove(o); }
publicbooleancontainsAll(Collection<?> c) { returns.containsAll(c); }
publicbooleanaddAll(Collection<? extendsE> c) { returns.addAll(c); }
publicbooleanremoveAll(Collection<?> c) { returns.removeAll(c); }
publicbooleanretainAll(Collection<?> c) { returns.retainAll(c); }
publicObject[] toArray() { returns.toArray(); }
public <T> T[] toArray(T[] a) { returns.toArray(a); }
publicbooleanequals(Objectobj) { returns.equals(obj); }
publicinthashCode() { returns.hashCode(); }
publicStringtoString() { returns.toString(); }
}
래퍼 클래스 주의 사항
래퍼 클래스는 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.
콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 그런데 래퍼 클래스에서 감싸고 있는 내부 객체는 자신을 감싸고 있는 래퍼가 무엇인지 알 수가 없다. 따라서 내부 객체는 콜백을 위해 자기 자신(this)의 참조를 전달하게 되는데 이 경우 래퍼가 콜백으로 수행되는 것이 아니라, 래퍼가 감싸고 있는 내부 객체가 콜백으로 실행되게 된다. 따라서 콜백이 의도한대로 동작하지 못하게 되는데 이 문제를 SELF 문제라 한다.