[Effective Java] item18 - 상속보다는 컴포지션을 사용하라

신민철·2023년 4월 25일
1

Effective Java

목록 보기
18/23
post-thumbnail

우리가 코드를 재사용할때 상속을 썼었다. 그런데 상속이 항상 최선의 방법일까?

같은 패키지 안이거나 확장적인 목적이고 문서화가 잘된 클래스는 문제가 없다.

이런 케이스가 아닌 이상 구체 클래스를 상속하는 것은 위험하다..(인터페이스 간, 인터페이스 상속 제외)

상속을 캡슐화를 깨뜨릴 수 있다. 즉, 상위 클래스의 내부 구현이 바뀌게 되면 하위 클래스는 수정하지 않으면 오류가 생기게 된다.

public class InstrumentedHashSet<E> extends HashSet<E> {
	private int addCount = 0;

	public InstrumentedHashSet() {
	}

	public InstrumentedSet(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("안", "녕", "하세요");

위 코드를 호출하게 되면 3을 반환할 것이다.

틀렸다. 6을 반환하게 된다. HashSet의 addAll 메소드는 add 메소드를 활용해서 구현되어 있다.

그래서 addCount에 3을 더해준 후 super.addAll에서 3이 더 더해진 결과로 6이 나오게 되는 것이다.


이것을 해결하려면 하위 클래스에서 addAll을 재정의하지 않거나, addAll을 다른 식으로 재정의하면 된다. 하지만 상위 클래스의 메소드를 다시 구현하는 방식은 어렵고 시간이 들거나 성능상 안좋을 수 있다는 단점이 있다. 그리고 애초에 private 필드가 있으면 구현 자체가 불가능하다..

만약 상위 클래스에서 새로운 메소드가 추가되면 하위 클래스에서는 그와 맞춰서 에러가 발생할 것이다.

Hashtable과 Vector가 그 예인데, 보안 구멍이 생겨서 수정해야 하는 사태가 벌어졌었다고 한다.


위 문제들은 모두 메소드 재정의와 관련된 문제라 메소드를 재정의하는 대신 새로운 메소드를 추가하면 될거라고 생각이 들 것이다. 이것도 나름 위험이 있는데, 새로 추가한 메소드가 하필 다음 릴리즈에서 상위 클래스가 새로 추가한 메소드와 같고 반환 타입만 다르다면 클래스가 컴파일 자체가 되지 않을 것이다.

그리고 상위 클래스의 규약에 맞지도 않을 가능성이 높다.

대신, 상속보다 새로운 클래스를 만들고 기존 클래스의 인스턴스를 참조하는 private 필드를 두면 이런 문제가 생기지 않을 것이다.
기존 클래스가 새로운 클래스의 구성요소로 쓰여서 컴포지션(composition)이라 한다.


기존 클래스에 대응하는 메소드를 호출하여 결과를 반환하는데 이러한 메소드를 전달 메소드라 한다.

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 int size() { return s.size(); }

	public boolean isEmpty() { return s.isEmpty(); }

	public boolean contains(Object o) { return s.contains(o); }

	public Iterator<E> iterator() { return s.iterator(); }

	public Object[] toArray() { return s.toArray(); }

	public <T> T[] toArray(T[] a) { return s.toArray(a); }

	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 retainAll(Collection<?> c) { return s.retainAll(c); }

	public boolean removeAll(Collection<?> c) { return s.removeAll(c); }

	public void clear() { s.clear(); }

	public int hashCode() { return s.hashCode(); }

	public boolean equals(Object o) { return s.equals(o); }

	public String toString() { return s.toString(); }
}

ForwardingSet은 임의의 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를 쓰게 되는데 이런 클래스를 Wrapper 클래스라고 한다.

다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.


래퍼 클래스는 단점이 없다는데 다만 콜백 프레임워크와는 맞지 않는다고 한다.

→ 객체를 넘겨줄 때(자기 자신)와 다음 호출시(내부 객체)에 대상이 다르게 됨

상속은 반드시 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야 한다. (is-a)

만약 B가 A를 상속받으려고 할 때 정말 A와 관련이 있는지 생각해보고 확신하지 못한다면 A를 상속해서는 안된다. 만약 답이 “아니다”이면 private 인스턴스를 두는 방식을 채택해야 한다.


마지막으로 상속할 때 고려해야 할 상황이 있다.

  • 상위 클래스의 API가 결함이 없는가?
  • 그 결함이 해당 클래스로 전파돼도 괜찮은가?


핵심 정리
상속은 캡슐화를 헤치게 된다. 정말 is-a관계일 때만 상속하고 그러지 않을 경우에는 private 인스턴스로 두고 사용해야 한다. 상속의 취약점을 피하려면 컴포지션과 전달을 사용하자. 또한 래퍼 클래스를 기억하자.

0개의 댓글