-
이펙티브 자바 Item 18 - 상속보다는 컴포지션을 사용하라언어/Effective Java 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)이 있다면 더 그렇다 래퍼 클래스는 하위 클래스보다 견고하고, 강력하다.!
'언어 > Effective Java' 카테고리의 다른 글
이펙티브 자바 Item 20 - 추상클래스보다는 인터페이스를 우선시하라 (0) 2024.05.31 이펙티브 자바 Item 19 - 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) 2024.05.31 이펙티브 자바 Item 17 - 변경 가능성을 최소화하라 (0) 2024.05.30 이펙티브 자바 Item 16 - public 클래스에서는 public필드가 아닌 접근자 메서드를 사용하라 (0) 2024.05.30 이펙티브 자바 Item15 - 클래스와 멤버의 접근 권한을 최소화하라 (0) 2024.05.29