본문은 Effective Java를 읽고 정리한 내용을 기반으로 작성된 글입니다.
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
[메서드 호출과 달리 상속은 캡슐화를 깨뜨린다]
: 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스가 오동작할 수 있다.
☑️ HashSet를 상속받은 클래스 InstrumentedHashSet
public class InstrumentedHashSet<E> extends HashSet<E> {
// 추가된 원소의 수
private int addCount = 0;
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
이 코드에서는 InstrumentedHashSet의 메서드 addAll을 재정의 하고 있음
→ addAll 메서드 내부에서 HashSet의 addAll 메서드를 호출함 → 값이 중복해서 더해져 원하는 결과값을 얻을 수 없다.
이 경우에는, 하위 클래스(InstrumentedHashSet)에서 메서드를 재정의하지 않으면 문제를 고칠 수 있다!
하지만 다음 릴리즈에서도 문제가 없을지는 모른다.
[상속 대신 컴포지션을 사용하자]
: 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자.
☑️ 다음 릴리즈에서 상위 클래스에 새로운 메서드를 추가한다면 어떻게 될까?
ex) 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야 하는 프로그램이 있다.
이 상황에서는,
- 그 컬렉션을 상속하여
- 원소를 추가하는 모든 메서드를 재정의하여
- 필요한 조건을 검사하면 된다.
ex) 하지만 상위 클래스에 또 다른 원소 추가 메서드가 만들어진다면?
- 하위 클래스에서 재정의하지 못한 원소 추가 메서드를 사용할 수 있게 되고
- ‘허용되지 않은’ 원소를 추가할 수 있게 되고
- 보안 구멍들이 발생한다.
→ 모두 메서드 재정의가 원인이다.
☑️ 그렇다면 메서드를 재정의하는 대신 새로운 메서드를 추가하면 괜찮을까?
: 메서드 재정의보다는 훨씬 안전한 방법이지만, 운이 없으면 위험할 수 있다.
ex) 다음 릴리즈에서 메서드가 추가됐는데, 내가 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다른 경우
- 이렇게 되면 컴파일조차 되지 않는다.
- 상위 클래스의 새 메서드를 재정의한 꼴이 되므로 똑같은 문제가 발생한다.
☑️ 그러면 어떻게 하라는걸까?
: 기존 클래스가 새로운 클래스의 구성요소로 쓰이도록 설계하자 ↔ 컴포지션
- 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조함
- 전달 : 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드들을 호출하여 결과를 반환함
- 전달 메서드 : 새 클래스의 메서드들
[컴포지션과 전달 방식]
상속 대신 컴포지션을 사용한 InstrumentedSet 클래스 : HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
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());
}
}
재사용할 수 있는 전달 클래스 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(); }
}
☑️ 임의의 Set에 계측 기능(조건 등)을 덧씌워 새로운 Set로 만드는 것이 이 클래스의 핵심
- Set 인터페이스를 구현함
- Set 인터페이스를 인수로 받는 생성자를 제공함
- 대상 Set 인스턴스를 특정 조건하에서만 임시로 계측 가능
☑️ 래퍼 클래스 (InstrumentedSet)
: 다른 인스턴스(Set)를 감싸고 있다는 뜻, 래퍼 클래스는 콜백 프레임워크에서의 SELF 문제를 제외하면 단점이 거의 없다.
☑️ 데코레이터 패턴
: 다른 Set에 계측 기능을 덧씌운다는 뜻
☑️ 위임
: 컴포지션과 전달의 조합
[상속은 반드시 is-a 관계일 때만 사용해야 한다]
: 클래스 B가 클래스 A와 is-a 관계(B가 정말 A인가?)일 때만 A를 상속해라.
☑️ is-a가 아니라면
: A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 한다. ↔ A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.
☑️ 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.
- API가 내부 구현에 묶임 → 성능도 영원히 제한됨
- 클라이언트가 노출된 내부에 직접 접근할 수 있음 → 클래스의 불변식을 해칠 수 있음
정말 상속을 사용해야겠다면,
확장하려는 클래스의 API에 아무런 결함이 없는가?
이 결함이 내 클래스의 API까지 전파돼도 괜찮은가?
[핵심 정리]
- 상속은 강력하지만 캡슐화를 해치므로, 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다.
- is-a 관계일지라도, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다.
- 상속의 취약점을 피하려면 → 컴포지션과 전달을 사용하자.
- 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.
'Book > 이펙티브 자바' 카테고리의 다른 글
[Effective Java] item 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) | 2023.12.18 |
---|---|
[Effective Java] item 17. 변경 가능성을 최소화하라 (0) | 2023.10.04 |
[Effective Java] item 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2023.09.29 |