언어/Effective Java
이펙티브 자바 Item 18 - 상속보다는 컴포지션을 사용하라
now0204
2024. 5. 31. 10:35
1. 구체 클래스 상속의 위험성
- 다른 패키지의 구체 클래스를 상속하는 일은 위험하다 (인터페이스 상속말고, 구현 상속)
- 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
- 상위 클래스에 따라 하위클래스의 동작에 문제가 생길 수 있기 때문이다.
하위클래스가 깨지기 쉬운 이유
- 상위 클래스의 메서드를 재정의 하여 하위 클래스의 로직을 방어한다.
- 상위 클래스의 메서드를 동작을 다시 구현하는게 어렵다.
- 오류나 성능을 떨어뜨릴 수도 있다.
- 하위 클래스에서 접근 불가한 private 클래스를 써야한다면 구현이 불가능하다.
- 상위 클래스 릴리즈에서 새로운 메서드를 추가했을 때를 고려해야함
- 하위 클래스에서 허용되지 않은 원소를 추가할 지도 모른다!!
- 하위 클래스에 추가한 새 메서드가, 상위 클래스 다음 릴리즈에서 같은 시그니처를 가질 때
- 시그니처가 같고 반환 타입이 다르다면 컴파일조차 안된다. -> 부모를 다 따져야함 개열받음
2. 컴포지션 설계
- 새로운 클래스를 만들고 private필드로 기존 클래스의 인스턴스를 참조한다.
- 컴포지션(composition) : 기존 클래스를 확장하는 대신에 새로운 클래스를 만들고, private필드로 기존 클래스의 인스턴스 참조하게 하는 설계
- 전달(forwarding) : 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환
- 전달 메서드(forwarding method) : 새 클래스의 메서드
public class InheritSet<E> extends HashSet<E> {
private int addCount = 0;
@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;
}
}
//아래는 테스트를 위한 코드 4을 예상했지만 결과는 8이다.
InheritSet<String>stringInheritSet = new InheritSet<>();
stringInheritSet.addAll(Arrays.asList("한방", "두방", "세방", "네방"));
assertEquals(8, stringInheritSet.getAddCount());
- HashSet을 상속받은 InhefitSet 클래스의 객체를 사용하여, 원소가 몇 개 더해졌는지 알기 위한 addCount Field를 추가하고 add(), addAll() 메서드를 사용할 때마다 증가시키기로 했다.
- 테스트가 실패한 이유를 찾아보자, HashSet은 위와 같은 상속 구조를 가지고 있다.
- 예제 코드에서 호출하는 addAll() 메소드는 AbstractCollection 클래스의 메서드인데, 내부적으로 add()를 사용하는 방식으로 구현되어 있다.
- 따라서 addAll()을 호출하면 addCount에 원서 size를 더하고, super.addAll()이 add()를 호출하는데, 이 또한 오버라이딩 되어 있음으로, 현재 클래스그의 add를 호출한다. 결과적으로 4번이 중복되어 8이라는 결과를 얻는다.
2.1 컴포지션 사용
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> set){
super(set);
}
@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> set;
public ForwardingSet(Set<E> set) {this.set = set;}
@Override
public int size() {return set.size();}
@Override
public boolean isEmpty() {return set.isEmpty();}
@Override
public boolean contains(Object o) {return set.contains(o);}
@Override
public Iterator<E> iterator() {return set.iterator();}
@Override
public Object[] toArray() {return set.toArray();}
@Override
public <T> T[] toArray(T[] a) {return set.toArray(a);}
@Override
public boolean add(E e) {return set.add(e);}
@Override
public boolean remove(Object o) {return set.remove(o);}
@Override
public boolean containsAll(Collection<?> c) {return set.containsAll(c);}
@Override
public boolean addAll(Collection<? extends E> c) {return set.addAll(c);}
@Override
public boolean retainAll(Collection<?> c) {return set.retainAll(c);}
@Override
public boolean removeAll(Collection<?> c) {return set.removeAll(c);}
@Override
public void clear() {set.clear();}
@Override
public boolean equals(Object o) {return set.equals(o);}
@Override
public int hashCode() {return set.hashCode();}
}
- InstrumentedSet 클래스는 ForwardingSet 클래스를 확장하여 add와 addAll 메서드를 ForwardingSet의 메서드를 사용한다. ForwardingSet은 Set 인터페이스를 구현했다.
- ForwardingSet은 HashSet을 private final 필드로 참조한다. 이를 Composition이라고 할 수 있다.
- ForwardingSet 클래스는 Set 인터페이스의 모든 메소드를 재정의하고 있는데, 재정의 내용은 기존의 Set 인터페이스의 메소드를 그냥 반환하고 있다. (전달 메소드이다.)
- 기능의 확장을 일궈내면서, 상위 클래스에 기능 변경에 영향을 받지 않으며, 기존 클래스와 기능을 똑같이 사용
- InstrumentedSet 클래스는 Set에 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 부른다.
- 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 부른다.
- Composition과 Forwarding 조합은 넓은 의미로 위임이라고 한다.
3. 데코레이터 패턴
- 원본의 행위 인터페이스 정의
- 이를 구현한 원본객체와 추상 데코레이터 클래스 정의
- 추상 데코레이터에 원본 객체를 연관관계로 가지고, 원본 행위 + 추가 행위 메서드 정의 (추가 행위는 추상 메서드)
- 이를 구현한 데코레이터 콘크리트 생성
interface Component{
void operation(); // 공통 책임
}
class ConcreteComponent implements Component{
public void operation(){
//원본의 행위
}
}
abstract class Decorator implements Component{
//구체 장식 책임 중 origin의 operation 호출까지만
// 장식 클래스는 변화 가능, 구체 클래스에 느슨한 의존관계를 위해..
Component origin;
Decorator(Component origin){
this.origin = origin;
}
public void operation(){
origin.operation;
}
public void extraop();
}
- 생성자는 콘크리트에서 호출하려고 만들어 두었다.
class ComponentDecorator1 extends Decorator{
ComponentDecorator1(Component co){
super(co); //부모생성자 호출로 origin초기화
}
public void operation() {
super.operation();
extraop();
}
public void extraop(){
//장식할 행위
}
}
- 콘크리트는 Component를 받아서 필드에 넣고, 추가 행위만 재정의
핵심은 원본과 같은 인터페이스 구현 후 원본을 필드로 받아서, 원본 행위 + 추가 행위 구현한다는 것
정리
- 상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 한다.
- 클래스 A를 상속하는 클래스 B가 정말 A인가를 생각해보자
- 확장하려는 클래스의 API에 아무런 결함이 없는가? -> 결함이 하위 클래스까지 전파돼도 괜찮은가?
- 상속은 상위 클래스의 API를 그 결함까지도 승계한다.
- 될 수 있으면 컴포지션을 사용하자, 특히 래퍼 클래스로 구현할 적당한 인터피이스 (Set)이 있다면 더 그렇다 래퍼 클래스는 하위 클래스보다 견고하고, 강력하다.!