이 챕터에서는 우리가 자주 사용하는 extends(상속을 통한 확장) 보다 해당 클래스를 참조하는 private 필드를 선언해서 사용하는 것이 더 좋은 이유에 대해 설명한다.
(이 챕터 내용과 비슷하게 스프링 바이블인 '토비의 스프링' 책에서도 관심사의 분리, 제어의 역전, 콜백 패턴 등을 설명할 때 계승보다는 구성을 사용하는 것이 좋다고 설명한다.)
계승의 문제점
계승은 상위 클래스와 하위 클래스가 밀접한 관계를 가진다. 하위 클래스는 상위 클래스의 구현에 의존할 수밖에 없기 때문에 캡슐화 원칙을 위반한다. 즉 상위 클래스의 구현이 변경되면 하위 클래스는 기존에 구현한 코드가 원하는 방식으로 동작하지 않을 수 있다.
계승을 하기 위해서는 상위 클래스 구현에 대해 잘 알아야 한다. 그렇지 않을 경우 하위 클래스가 원하는 방식으로 동작하지 않을 수 있는데 예를 들어보자.
java.util에서 제공하는 HashSet 클래스를 상속하여 원소가 추가될 때마다 카운팅을 하는 클래스를 아래와 같이 구현했다.
public class CountHashSet<E> extends HashSet<E> {
private int addCount = 0;
public CountHashSet() {
}
public CountHashSet(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);
}
}
심플한 클래스로 잘 동작할 것 같지만 addAll()을 호출할 경우 문제가 발생한다. addAll()에서 super.addAll()은 java.util.AbstractCollection 클래스의 addAll()을 호출하는데 구현 코드는 아래와 같다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
코드를 보면 알겠지만 addAll()에서 다시 add()를 호출한다. 따라서 위에서 구현한 코드는 중복 계산되므로 addAll()을 호출하게 되면 항상 원소의 두 배만큼 증가한다.
해당 버그를 fix 하기 위해 addAll() 재정의 메서드를 제거하더라도 나중에 addAll()에서 add()를 호출하지 않으면 문제가 다시 발생한다. 다른 방법으로 super.addAll()을 호출하지 않고 직접 구현하면 된다. 하지만 상위 클래스의 동작을 위해 직접 구현하기 어려울 뿐 아니라, 접근 불가능한 필드가 있을 경우 구현은 아예 불가능하다.
이외에도, 상위 클래스에 또 다른 원소를 추가하는 메서드가 생겨나게 된다면 하위 클래스에서는 매번 릴리즈 노트를 확인해야 할 것이다.
하위 클래스가 상위 클래스를 망가뜨릴 수 있다. 만약 HashSet에 특정 조건을 만족해야지만 add 할 수 있는 메서드가 있다면 하위 클래스도 add를 재정의할 때 해당 조건을 추가해서 구현해야지 상위 클래스를 만족한다. 하지만 잘못된 사용자는 하위 클래스를 이용하여 해당 조건을 넣지 않아 상위 클래스를 망가뜨릴 수 있다.
계승보다 구성
위와 같은 계승의 문제점은 구성(필드 선언)을 통해 해결할 수 있다. 소스는 아래와 같다.
public class CountHashSetByComposit<E> {
private int addCount = 0;
private Set<E> s;
public CountHashSetByComposit(Set<E> s) {
this.s = s;
}
public boolean add(E e) {
addCount++;
return s.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return s.addAll(c);
}
}
(책에서는 Set 인터페이스를 구현하는 전달 클래스를 하나 만들어 Wrapper 클래스로 예를 들었다.)
위 클래스는 어떤 Set 객체를 인자로 받아 필요한 부분만 사용하고, 추가적인 기능은 직접 구현하였다. 계승 대신 구성을 하게 되면 사용하고자 하는 메서드에 대해서만 이해하면 되고, 해당 클래스의 API의 일부가 변경되어도 구성을 사용한 클래스를 알 필요 없다.
계승은 상위 클래스와 하위 클래스 사이에 IS-A 관계가 확실할 때만 사용하는 것이 좋다. IS-A 관계가 확실하다고 해도 내가 구현한 상위 클래스가 아니라면 계승보다는 구성이 낫다. 즉 계승은 상위 클래스와 하위 클래스 구현을 같은 개발자가 구현한다면 고려해도 된다. 하지만 구성을 하게 되면 하위 클래스보다 견고할 뿐 아니라, 더 강력하다.
'Java > [책] 이펙티브 자바' 카테고리의 다른 글
[이펙티브자바] 규칙20. 태그 클래스는 계층형 클래스로 변경하자 (0) | 2022.06.20 |
---|---|
[이펙티브자바] 규칙19. 상수 인터페이스(Constant Interface)는 안티패턴이다 (0) | 2022.06.20 |
[이펙티브 자바] 규칙15. Immutable(불변) 클래스 (0) | 2022.03.09 |
[이펙티브 자바] 규칙14. getter 메서드 (0) | 2022.02.23 |
[이펙티브 자바] 규칙13. 접근 제어(Access Control)는 최소화하자. (0) | 2022.02.14 |