이펙티브 자바 아이템 18 - 상속보다는 컴포지션을 사용하라 - 핵심 정리
아이템 18 - 상속보다는 컴포지션을 사용하라 - 핵심 정리
이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.
패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험하다
주의할 점은 인터페이스 상속이 아니라 구체적인 클래스 를 상속할 때의 이야기이다.
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()); // 3이 아닌 6이 나온다.
}
}
자바 제공하는 HashSet 을 상속해 구현한 InstrumentedHashSet 클래스를 작성한다.
addAll
이나 add
메서드 실행시 addCount 를 증가시키도록 오버라이딩했다.
s.addAll(List.of("틱", "탁탁", "펑"));
메서드를 실행 후 addCount
를 출력하면 3이 아니라 6이 나오게된다.
우리가 상위 클래스에 있는 내부 구현을 알고 코드를 작성해야하기 때문에 캡슐화가 되지 않는다.
// HashSet 에 있는 addAll
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
HashSet 에 있는 addAll
메서드에서 add
메서드를 호출한다.
때문에 addCount
의 값이 6이 나오게되는 것이다.
만약 상위 클래스의 구현이 바뀐다면 거기에 따라 하위 클래스의 구현이 바뀌어야한다.
이런 현상이 발생한다는 것은 캡슐화 가 잘되어있지 않다는 것이다.
다른 경우로는 HashSet 에 나중에 어떤 요소들이 추가하는 기능이 생겼다고 가정해보자.
우리는 모든 요소를 추가할때마다 addCount
를 계산하고 싶기때문에 상위 클래스에 요소를 추가하는 새로운 메서드가 생길때마다 오버라이딩을 해주어야한다.
문제는 우리가 해당 기능이 추가되었는지 확인하기가 힘들다.
떄문에 나중에 프로그램에 구멍이 생길 가능성이 있다.
하위 클래스에서 새로 정의한 메서드가 상위 클래스에서 다시 정의 되는 경우도 있다.
이에대한 대안으로 컴포지션 을 제안한다.
컴포지션 (Composition)
컴포지션은 기존 클래스를 확장하는게 아니라 기능을 사용하거나 재사용하고 싶은 클래스를 private 필드 로 참조한다.
그 뒤 모든 메서드들이 해당 필드를 통해가도록 정의하는 것이다.
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(); }
}
위의 ForwardingSet 클래스는 컴포지션을 사용한 대표적인 예이다.
모든 메서드들이 private 필드 인 Set 타입의 멤버를 통해 전달된다.
그래서 전달 클래스 , 포워딩 클래스 , wrapper 클래스 라고도 부른다.
이 자체를 데코레이터 패턴 이라고 볼 수도 있다.
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()); // 3이 나온다.
}
}
위의 클래스처럼 ForwardingSet 을 상속해 사용하면 된다.
컴포지션 구조에서는 addAll
을 호출했을 때 사이드 이펙트가 발생하지 않는다.
HashSet 의 내부 구현이 바뀐다 하더라도 인터페이스 규격에 맞춰 구현만 된다면 코드는 안정적으로 동작한다.
캡슐화 가 완벽히 보완이된다.
새로운 메서드가 추가되더라도 HashSet 의 내부에 추가되면 클라이언트 코드는 변하지 않는다.
ForwardingSet 클래스는 Set 인터페이스를 implements 하고 있기 때문에
구현체인 HashSet 내부에 기능이 추가되어도 안전하다.
하지만 Set 인터페이스에 추가가 된다면 ForwardingSet 클래스는 구현하지 않은 메서드가 생기기 때문에 구현이 깨졌다는 걸 알 수 있다.