상속을 염두에 두지 않고 설계했고 상속할 때의 주의점도 문서화해놓지 않은 ‘외부' 클래스를 상속할 때의 위험성에 대한 경고임!
상속을 잘못 사용할 시,
- 상위 클래스의 결함을 그대로 승계할 수 있고
- 상위 클래스에서 변화가 생겼을 시, 하위 클래스에도 그대로 오동작이 전파됨
상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자
특히, 래퍼 클래스로 구현할 적당한 인터페이스(예:Set)가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다
상속 = 클래스가 다른 클래스를 확장하는 구현 상속
- 상위 클래스와 하위클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법임
- 확장할 목적으로 설계 되었고 문서화도 잘 된 클래스도 마찬가지로 안전함
하지만 일반적인 구체 클래스를 패키지 경계를 넘어 즉, 다른 패키지의 구체클래스를 상속하는 일은 위험함
상속을 잘못 사용 시 생기는 문제
하위 클래스에서 상위 클래스의 구현에 의존해서 기능 구현을 하기 때문에 상위 클래스의 구현이 바뀌게 되면 하위 클래스가 오동작할 수 있음!
- 메서드 호출과 달리 상속은 캡슐화를 깨뜨림
- 즉, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있음
- 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있고 → 코드 한줄 건드리지 않은 하위 클래스가 오동작 할 수 있게 됨
package effectivejava.chapter4.item18; import java.util.*; // 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽) 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; } public static void main(String[] args) { InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); s.addAll(List.of("틱", "탁탁", "펑")); System.out.println(s.getAddCount()); // 6이 출력됨 } }
- 위 경우, 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있지만, 이는 HashSet의 내부 구현을 알고 해결한 방법이라는 한계를 가짐 ⇒ 다음 릴리스에서 유지될 수 있을지 모름
- 메서드 재정의가 원인이였기에, 클래스 확장 시 메서드 재정의말고 새로운 메서드를 추가하면 괜찮지 않을까..? ⇒ 훨씬 안전하긴 하지만 위험이 전혀 없는 것은 아님
- 만약 내가 새로 정의한 메서드가 상위 클래스에서 생긴다면? 컴파일 에러
- 가상 메서드에 대한 이해가 필요함!! → 객체 지향 핵심
상속 대신, private 필드로 기존 클래스 인스턴스 참조(=컴포지션)
- 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 컴포지션이라 함
- 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환 ⇒ 기존 클래스의 내부 구현 방식의 영향에서 벗어남
- 이를 전달(forwarding)이라 함
- 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부름
package effectivejava.chapter4.item18; import java.util.*; // 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽) 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()); } }
package effectivejava.chapter4.item18; import java.util.*; // 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽) 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 인터페이스를 활용해 설계되어 견고하고, 컴포지션으로 갖고 있는 기존 클래스의 메서드의 결과를 전달해줌
- 만약, InstrumentedSet에서 Set을 필드로 가지면서, Set의 메서드를 바로 호출하게 만든다면, 객체 지향의 특징을 많이 잃어버리게 될 듯함
- Set인터페이스의 메서드와 이름이 같게 오버라이드도 안되고 견고하지가 않을 듯
상속은 언제 쓰냐
- 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야 함
- 클래스 B와 클래스 A의 관계가 is-a 관계일 때
상속 잘못 사용시?
- 그렇지 않으면 A를 private 인스턴스로 두고 A와는 다른 API를 제공해야 하는 상황이 대다수임
- 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴임
- API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한됨
- 예) Properties의 인스턴스인 p가 있을 때, p.getProperty(key)와 p.get(key)는 결과가 다를 수 있음
- p.getProperty가 Properties의 기본 동작인데 반해
- p.get은 상위 클래스인 Hashtable로부터 물려받은 메서드임 (이 내부 구현을 불필요하게 노출) ⇒ 해당 메서드가 Properties의 키와 값에 문자열만 허용하도록 하는 설계를 깨버리게 됨
컴포지션 대신 상속 사용 결정전 마지막 질문
- 확장하려는 클래스의 API에 아무런 결함이 없는가?
- 결함이 있다면, 이 결함이 우리 클래스의 API까지 전파돼도 괜찮은가?
- 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 ‘그 결함까지도’ 그대로 승계함