[아이템 18] 상속보다는 컴포지션을 사용하라

gang_shik·2022년 4월 9일
0

Effective Java 4장

목록 보기
4/11
  • 상속은 코드를 재사용하는 강력한 수단이지만 최선은 아님, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됨

  • 이는 만약 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험함(여기서 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말함, 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관함)

  • 메서드 호출과 달리 상속은 캡슐화를 깨뜨림, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있음

  • 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작 할 수 있음

  • 그래서 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 발맞춰 수정돼야만 함

  • 예를 들어 아래와 같이 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 getAccount() {
				return addCount;
		}
}
  • 하지만 위처럼 잘 구현된 것처럼 보여도 잘 작동하지 않음 addAll 을 통해 원소 3개를 더하면 6을 반환함

  • HashSetaddAll 메서드가 add메서드를 사용해 구현됨 즉 add에서 쓰는 것과 addAll에서 addCount가 중복해서 더해져 6이 된 것임

  • 이 경우에는 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있음 하지만 이 부분은 한계가 있음

  • 이와 같이 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당되고 이 가정에 기대면 깨지기 쉬움

  • 여기서 만약 addAll 메서드를 컬렉션을 순회하며 원소 하나당 add메소드를 한 번만 호출하는 것으로 재정의할 수 있음

  • 하지만 그렇게 해도 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고 오류나 성능이 떨어지거나 만약 private 필드를 써야하는 상황이라면 이 방식으로 구현이 불가능함

  • 그리고 하위 클래스가 깨지기 쉬운 이유가 더 있는데 만약 다음 릴리스에서 상위 클래스에 새로운 메서드를 추가한다면 이 메서드를 재정의해 필요한 조건을 먼저 검사하게끔 해야함

  • 하지만 이게 또 다음 릴리스에서 변할 수도 있고 추가적인 수정을 더 해야함

  • 즉 위의 문제 모두 메서드 재정의가 원인이었고 그래서 이렇게 메서드를 재정의하는 대신 새로운 메서드를 추가하는 것을 생각할 수 있는데 이 역시 새로운 릴리스에서 반환 타입이 다르면 또 문제에 봉착함

컴포지션

  • 위와 같은 문제를 해결하기 위한 묘안 중 하나임

  • 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 방법임

  • 새 클래스의 인스턴스 메서드들은(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 containsAll(Collection<?> c) { return s.containsAll(c); }
		public boolean addAll(Collection<?> 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(); }
}
  • InstrumentedSetHashSet 의 모든 기능을 정의한 ForwardingSet 해당 Set 인터페이스를 활용해 설계되어 견고하고 유연함

  • Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공함 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만든 것임

  • 위와 같은 방식으로는 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있고 기존 생성자들과도 함께 사용할 수 있음

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
  • InstrumentedSet을 이용하면 대상 Set 인스턴스를 특정 조건하에서만 임시로 계측할 수 있음
static void walk(Set<Dog> dogs) {
		InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
		... // 이 메서드에서는 dogs 대신 iDogs를 사용함
}
  • 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 함

  • 컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부름(엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당함)

  • 래퍼 클래스는 콜백 프레임워크와는 어울리지 않음

  • 콜백 프레임워크는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백)때 사용하도록 함

  • 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고 콜백 때는 래퍼가 아닌 내부 객체를 호출함 SELF 문제가 발생함

  • 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 마들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있음

  • 상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 함

  • 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야함 B가 정말 A인가라고 자문해봐야함

  • 그렇다고 확신할 수 없다면 상속을 하면 안되고 아니다라고 한다면 A를 private 인스턴스로 두고 다른 API를 제공해야 하는 상황이 대다수임 A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐임

  • 컴포지션을 써야할 상황에서 상속을 사용하면 내부 구현을 불필요하게 노출하고 API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한됨

  • 그래서 컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 것은 확장하려는 클래스의 API에 아무런 결함이 없는지 결함이 있다면 이 결함이 클래스의 API까지 전파돼도 괜찮은지 컴포지션은 이 결함을 숨기는 새로운 API를 설계할 수 있지만 상속은 그 결함까지도 승계를 함

profile
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선시킬 수도 없다

0개의 댓글