Search

Item 18. 상속보다는 컴포지션을 사용하라

생성일
2023/07/27 10:36
챕터
4장 - 클래스와 인터페이스

요약

상속은 두 클래스가 is-a 관계일 때(완전히 하위 타입일 때)만 사용하자.
그렇지 않다면 내부의 private 인스턴스로 클래스를 두고 구현하는 컴포지션을 사용하자.

상속 받은 메서드의 재정의 문제점

메서드 호출과는 달리 상속은 캡슐화를 깨뜨린다. 다시 말해, 상위 클래스의 구현에 따라 하위 클래스 동작에 이상이 생길 수 있다.
public class InstrumentedHashSet<E> extends HashSet<E> { private int addCount = 0; public InstrumentedHashSet() { } @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); } }
Java
복사
위 코드는 잘 구현된 것처럼 보이지만, 실제로 제대로 동작하지 않는다. addAll 메서드로 원소를 3개 더하는 경우, 내부의 addCount 값은 3이 아니라 6이 저장되어 있다.
해당 문제의 원인은 HashSet의 addAll이 각 원소를 add 메서드 호출 하도록 구현되어 있는데, 각 메서드를 호출할 때 InstrumentedHashSet에서 재정의된 add 메서드가 호출되면서 addCount가 중복으로 값이 더해진 것이다.
이 부분은 add 메서드를 재정의 하지 않으면 제대로 동작하지만, 해당 방법은 HashSet이 add 메서드를 정의했다는 전제를 두고 구현된 해결책이라는 한계가 있다. 이렇게 자기사용(self-use)된 메서드를 적용하면 자바의 다음 릴리즈에서 HashSet이 수정될 지 여부를 알 수 없기 때문에 깨지기 쉽다.
위 코드에서 addAll이 부모의 메서드를 호출하는 것이 아니라, 컬렉션을 순회하며 add 메서드를 한번씩만 호출하는 방법은 조금 더 나은 방법이다. 하지만 상위 메서드의 동작을 다시 구현해야 하기 때문에, 어렵고 시간이 많이 들고 오류를 내거나 성능을 떨어뜨릴 수 있다는 단점이 있다. 또한 상위 클래스에 새로운 메서드가 추가된다면 역시 문제가 될 수 있다.

컴포지션으로 구현하기

위 문제는 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 만들어 해결할 수 있다. 이런 방법은 기존 클래스가 새로운 클래스의 구성요소로 사용된다는 뜻에서 컴포지션(composition)이라 한다.
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 lterator<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 retalnAll(Collectlon<?> c) { return s.retainAll(c); ) public Object[] toArray() { return s.toArray(); } public <T> T[] toArray(T[] a) { return s.toArray(a); } @0verride public boolean equals(Object o) { return s.equals(o); } @0verride public int hashCode() { return s.hashCode(); } @0verride public String toString() { return s.toString(); } }
Java
복사
public class InstrumentedHashSet<E> extends ForwardingSet<E> { private int addCount = 0; public InstrumentedHashSet() { } @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); } }
Java
복사
이와 같이 HashSet의 모든 기능을 정의한 Set 인터페이스를 구현하여, 래퍼 클래스로 사용하면 견고하고 유연하게 대처할 수 있다. 이렇게 사용하는 방법을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.
이런 래퍼 클래스에서는 단점이 거의 없지만, 래퍼 클래스를 콜백(callback) 프레임워크에 사용되면 내부 객체는 깜사고 있는 래퍼 클래스의 존재를 모르니 자신(this)의 참조를 넘기고 콜백 때 래퍼 클래스가 아닌 내부 객체를 호출하게 되는 문제가 있다. 이를 SELF 문제라 부른다.

상속과 컴포지션

상속은 반드시 하위 클래스가 상위 클래스의 완전히 하위 타입인 상황에서만 사용되어야 한다. 다시 말해 클래스 A와 클래스 B가 is-a 관계일 때만 B가 A를 상속해야 한다.
그렇지 않다면 클래스 A를 private 인스턴스로 두고 내부 구현하는 컴포지션을 사용하자.