이번장의 핵심은...
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다
잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다
안전한 상속과 위험한 상속이란?
안전한 상속
상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서 상속을 뜻한다
확장할 목적으로 설계되었고 문서화도 잘 된 클래스이다
위험한 상속
일반적인 구체 클래스를 패키지 경계를 넘어,
즉 다른 패키지의 구체 클래스를 상속하는 일을 뜻한다
메서드 호출과 달리 상속은 캡슐화를 깨뜨린다
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다
확장을 충분히 고려하지 않으면 하위 클래스는 상위 클래스의 변화에 발맞춰 수정돼야만 한다
예제) HashSet을 만들어 추가된 원소의 수를 저장하는 변수와 접근자 메서드를 추가
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;
}
}
이 클래스는 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다
예를 들어, addAll 메서드로 원소 3개를 더했을 때,
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
getAddCount() 호출 시, 3을 기대하겠지만, 실제로는 6을 반환한다
c.size()가 3인데, 왜 6을 반환할까?
그 이유는 HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있다
InstrumentedHashSet의 addAll은 각 원소를 add 메서드를 호출해 추가하는데,
이때 불리는 add는 InstrumentedHashSet에서 재정의한 메서드이다
따라서 addCount에 값이 중복해서 더해져 최종적으로 6으로 늘어나는 것이다
addAll을 상위 HashSet의 addAll을 호출하지 않고 별도로 구현하여 이를 방지할 수 있을 것이다
그러나 이러한 방식은 어렵고, 시간도 더 소요될뿐더러 자칫 오류를 내거나 성능을 저하시킬 수 있다
위의 문제는 재정의에서 문제가 발생하였다
그렇다면 재정의 대신 새로운 메서드를 추가하면 괜찮을까?
훨씬 안전한 방식은 맞지만, 위험은 여전히 있다
다음 릴리스에서 상위 클래스에 새 메서드가 추가되었는데,
우연히도 하위 클래스에 추가한 메서드와 동일하지만 반환 타입이 다르다면 컴파일조차 되지 않을 것이다
이 외에도 다양하게 추가한 메서드는 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다
컴포지션
컴포지션이란 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻이다
- 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고, private 필드로 기존 클래스의 인스턴스를 참조하게 하자
- 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다 (이러한 방식을 전달이라고 하며, 새 클래스의 메서드들을 전달 메서드라 부른다)
- 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다
위의 예제를 InstrumentedHashSet을 컴포지션과 전달 방식으로 다시 구현해보자
하나는 집한 클래스 자신이고, 다른 하나는 전달 메서드만으로 이뤄진 재사용 가능한 전달 클래스이다
래퍼 클래스
래퍼 클래스란 상속 대신 컴포지션을 사용하는 것이다
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 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(); }
}
InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다
구체적으로는 Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다
임의의 Set의 계측 기능을 덧씌어 새로운 Set을 만드는 것이 이 클래스의 핵심이다
상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다
하지만, 지금 선보인 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다
래퍼 클래스 / 데코레이션 패턴
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라고 한다
다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이션 패턴이라고 한다
래퍼 클래스는 단점이 거의 없지만,
한 가지로 래퍼 클래스가 콜백 프레임워크와는 어울리지 않는다는 점만 주의하자
그 이유는 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한 콜백 프래임워크에서 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 되기 때문이다
상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다
클래스 A를 상속하는 클래스 B를 작성하려고 한다면 "B가 정말 A인가?"를 자문해보자
컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막 자문해야 할 질문
1. 확장하려라는 클래스의 API에 아무런 결함이 없는가?
2. 결함이 있다면, 이 결함이 여러분 클래스의 API까지 전파돼도 괜찮은가?
컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 그 결함까지도 그대로 승계한다
'Java > Effective Java 3E' 카테고리의 다른 글
[이펙티브자바 3판] ITEM20. 추상 클래스보다는 인터페이스를 우선하라 (0) | 2020.09.26 |
---|---|
[이펙티브자바 3판] ITEM19. 상속을 고려해 설계하고 문서화하라. 그러지 않았으면 상속을 금지하라 (0) | 2020.09.23 |
[이펙티브자바 3판] ITEM17. 변경 가능성을 최소화하라 (0) | 2020.09.19 |
[이펙티브자바 3판] ITEM16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2020.09.18 |
[이펙티브자바 3판] ITEM15. 클래스와 멤버의 접근 권한을 최소화하라 (0) | 2020.09.16 |
댓글