1. 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
다른 패키지의 구체 클래스를 상속하는 경우
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;
}
}
IstrumentedHashSet은 Java가 제공하는 HashSet을 상속받아서 구현하고 있습니다. 원소하나를 hash에 추가하면서 count를 올리는 add메서드와 여러원소를 한꺼번에 추가하는 addAll메서드가 구현이 되어있습니다. 코드로 보면 별 문제가 없는거 같지만 아래와 같이 3개의 원소를 addAll로 추가해서 count를 결과로 출력하면 6이 나옵니다.
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount()); // 6
}
3이 나와야하는데 결과가 다르게 나오기때문에 addAll을 구현할때 HashSet이 제공하는 addAll의 내부구조를 알아야하고 HashSet에 대한 캡슐화가 지켜지지 않는 문제가 발생합니다. HashSet의 addAll의 내부구조를 파악해서 제대로 구현을 했다고 해도 만약에 HashSet의 addAll의 내부 구현이 바뀐다면 상속받는 InstumentedHashSet의 addAll에도 영향이 미쳐 의도하는대로 작동하지 않게 될가능성이 높습니다. 또한 HashSet에 어떤 메서드가 추가될 경우 HashSet을 상속받는 InstrumentedHashSet은 추가된 메서드를 또 overried해줘야 합니다. 하지만 그 기능이 추가되었다는 것을 알기가 어렵습니다. 따라서 상속으로 구현하면 불안정합니다.
2. 상속대신 컴포지션을 사용
컴포지션을 사용한 ForwardingSet
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 필드로 기존 클래스의 인스턴스를 참조하도록 컴포지션을 사용하도록 구현을 했는데 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라고 합니다. 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 영향을 받지 않습니다.
상속 대신 컴포지션을 사용한 래퍼 클래스
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. 결론
상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 합니다. 클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 정말 A인가?"라고 질문해보면 "그렇다"고 확신할 수 없다면 B는 A를 상속해서는 안됩니다. "아니다"라면 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 사오항이 대다수 입니다. 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴입니다.
'개발관련 서적 정리 > Effective Java' 카테고리의 다른 글
아이템 22. 인터페이스는 타입을 정의하는 용도로만 사용하라 (0) | 2023.02.05 |
---|---|
아이템 20. 추상클래스보다 인터페이스를 우선하라 (0) | 2023.02.05 |
아이템 17. 변경 가능성을 최소화 하라 (1) | 2023.02.05 |
아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2023.02.05 |
아이템 15. 클래스와 멤버의 접근 권한을 최소화하라. (0) | 2023.02.05 |