item 18. 상속보다는 컴포지션을 사용해라
상속
- 코드를 재사용하는 강력한 수단. 같은 패키지 안에서 확장을 목적으로 사용된다면 안전한 방법이다. (인터페이스 상속)
- 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됨. 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 안전하지 않을 수 있다. (구현 상속 )
[ 상속을 구현할 때 주의 사항 ]
1. 캡슐화를 깨뜨린다.
- 상위 클래스의 구현에 따라 하위 클래스 동작에 이상이 생길 수 있다.
public class item18 {
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
//정적 팩터리 메소드인 list.of 사용
s.addAll(List.of("틱", "탁", "펑"));
int answer = s.getAddCount();
System.out.println(answer); //answer : 6 (result =3)
/* addAll method가 add method를 사용해서 구현했지 때문에 잘못된 값을 반환했다.*/
}
private static 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) {
// addCnt에 3을 더한 후 HashSet의 addAll 구현을 호출했다.
// Hashset은 각 원소를 add메서드를 호출해 추가한다.
// 이 때 add는 재정의된 메서드이므로, 따라서 ddCount에 값이 중복해 더해지게 된다.
// 따라서 반환되는 값이 원래 값의 두 배가 된다.
addCount = c.size();
return super.addAll(c);
}
//접근자 메서드
public int getAddCount(){
return addCount;
}
}
}
-> 이처럼 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당하며, 다음 릴리스까지도 유지되는지는 알 수 없다. -> 잘못 동작하기 쉽다.
- 잘못 재정의된 메서드는 다른 식으로도 재정의할 수 있지만, 여전히 문제점은 남아있다.
- 메서드를 자칫 잘못 재정의하면,
- 상위 클래스에서 새로운 메서드를 추가해야 할 시, 전 단계 릴리스의 모든 메서드를 다시 재정의해야할 수 있다.
- 다음 릴리스에서 오류가 생겼을 때 하위 클래스에서 재정의하지 못한 새로운 메서드를 이용해 허용되지 않은 동작이 일어날 수 있다.
- 클래스를 확장할 때, 재정의가 아닌 새로운 메서드를 추가한다면?
- 기존 메서드와 타입은 다르고 시그니처가 같아지는 경우, 상위 클래스 메서드가 요구하는 규약을 만족시키지 못할 경우 등 다양한 오류의 변수를 고려해야 한다.
[ 해결 방안 ]
Composition 사용
- 새로운 클래스를 만들고, private field로 기존 클래스의 인스턴스를 참조하여 기존 클래스가 새로운 클래스의 구성 요소가 되게 하는 설계.
- 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 결과를 반환한다. (Forwarding)
- 새 클래스의 메서드들은 전달 메서드(Forwarding method)라 부른다.
[ 장점 ]
- 새로운 클래스는 기존 클래스의 내부 구현 방식에서 벗어난다.
- 기존 클래스에 새로운 메서드가 추가되도 영향을 받지 않는다.
- 상속을 사용함으로써 발생할 수 있는 내부 구현의 불필요한 노출을 방지할 수 있다.
- 상속을 잘못 사용한다면 결과 API가 내부 구현에 묶이고, 클래스 성능도 제한된다.
- 또 클라이언트가 노출된 내부 구현을 직접 건드릴 수 있다.
- 클라이언트가 상위 클래스를 직접 수정해 하위 클래스의 불변식을 해칠 수도 있다.
public class item18 {
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>(new HashSet<>());
//정적 팩터리 메소드인 list.of 사용
s.addAll(List.of("틱", "탁", "펑"));
int answer = s.getAddCount();
System.out.println(answer); // answer :3, result :3
}
// 다른 set 인스턴스를 감싸고 있으므로 래퍼 클래스이다.
// 다른 set에 기능을 덧씌우므로 Decorator pattern이다.
public static class InstrumentedHashSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedHashSet(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 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(); }
}
}
[ 래퍼 클래스의 단점 ]
- callback 프레임워크와 어울리지 않는다. 내부 객체는 래퍼의 존재를 모르니 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출한다.
(callback 프레임워크는 자기 자신의 참조를 다른 객체에 넘겨 다음 호출 때 사용한다.)
재사용 가능한 전달 클래스들을 인터페이스 당 하나씩 만들어두자.
클래스 A와 클래스 A를 상속하려는 클래스 B가 있을 때,
상속은 클래스 B가 클래스 A와 is-a관계일 때만 사용한다.
[상속을 구현할 때 자문해야 할 몇가지 질문들...]
- B가 정말 A인가?
- 확장하려는 클래스의 API에 아무 결함이 없는가?
- 결함이 있다면 이게 내 클래스의 API까지 전파되도 괜찮나?
언제나 '네'라고 대답할 수 있어야 상속을 할 수 있는 것이다.
-> Bad example) Vector을 확장한 Stack, Hashtable을 확장한 Properties.
결론 : 컴포지션과 Forwarding을 사용하자.