※ 여기서 말하는 '상속'은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다. 클래스 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를
확장하는 인터페이스 상속과는 무관하다.
※ 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스아이템19도 마찬가지로 안전하다.
하지만, 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
[Why]
- 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
- 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
- 클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 정말 A인가?"라고 자문해보고, "그렇다"고 확신할 수 없다면 B는 A를 상속해서는 안 된다. 즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.
- 컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문은 다음과 같다.
- 확장하려는 클래스의 API에 아무런 결함이 없는가?
- 결함이 있다면, 이 결함이 내 클래스의 API까지 전파돼도 괜찮은가?
[When]
문제 1 : 하위 클래스에서 메서드를 오버라이딩할 때
- 다음 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;
}
}
- 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다.
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
- 이제 getAddCount 메서드를 호출하면 3을 반환할 것 같지만, 실제로는 6을 반환한다.
- 왜냐하면 HashSet의 addAll 메서드가 add 메서드를 사용해 구현되었기 때문이다.
- 하위 클래스에서 addAll 메서드를 재정의하지 않으면 당장의 문제는 고칠 수 있다.
- 하지만, 이처럼 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하여 버전이 올라감에도 유지될지는 알 수 없다.
- addAll 메서드를 원소 하나당 add 메서드를 한 번만 호출하는 식으로 재정의할 수도 있다.
- 하지만 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 더 들고, 오류를 내거나 성능을 떨어뜨릴 수 있다.
- 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.
문제 2 : 상위 클래스에 새로운 원소 추가 메서드가 추가될 때
- 하위 클래스에서, 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 특정 조건을 먼저 검사하게끔 했다고 가정하자.
- 만약 상위 클래스에 또다른 원소 추가 메서드가 추가된다면 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 통해 '허용되지 않은' 원소를 추가할 수 있게 된다.
메서드를 재정의하지 않고 아예 새로운 메서드를 추가하면 되지 않겠나 생각할 수 있다. 하지만 재수없게도 상위 클래스에 새로운 메서드가 추가되었는데 하필 똑같은 시그니처에 반환 타입만 다르다면 우리 클래스는 컴파일조차 안 된다.
[How]
해결책
- 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 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 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<E> s)의 대응하는 메서드를 호출해 그 결과를 반환한다.
- 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.
InstrumentedSet 설명
- 다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.
- 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다. - HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
- 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
- 위의 컴포지션 방식은 한번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // 이 메서드에서는 dogs 대신 iDogs를 사용한다.
}
- InstrumentedSet을 이용하면 대상 Set 인스턴스를 특정 조건 하에서만 임시로 계측할 수 있다.
래퍼 클래스의 단점
- 래퍼 클래스가 callback 프레임워크와 어울리지 않는다.
- 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다.
- 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다.
- 이를 SELF 문제라고 한다.
- 실전에서는, 전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리 사용량에 주는 영향이 없다고 밝혀졌다.
- 그러니 귀찮더라도 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다.
'독서찰기(讀書札記) > 이펙티브 자바' 카테고리의 다른 글
[아이템 20] 추상 클래스보다는 인터페이스를 우선하라 (0) | 2022.02.04 |
---|---|
[아이템 19] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라. (0) | 2022.01.25 |
[아이템 17] 변경 가능성을 최소화하라 (0) | 2022.01.09 |
[아이템 16] public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2022.01.08 |
[아이템 15] 클래스와 멤버의 접근 권한을 최소화하라 (0) | 2022.01.08 |