이펙티브 자바:: 아이템 18 <상속보다는 컴포지션을 사용하라>
🙈

이펙티브 자바:: 아이템 18 <상속보다는 컴포지션을 사용하라>

Created
Aug 22, 2024 01:39 AM
Last edited time
Last updated August 22, 2024
Tags
Language
Language
Java
URL

Intro::

이펙티브 자바 정리본입니다.
 

결론

  • 상속은 강력하지만 캡슐화를 해칩니다.
  • 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일때만 사용해야 합니다.
  • is-a관계더라도, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제될 수 있습니다.
  • 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자.
 

컴포지션을 지향해야하는 이유

  • 메서드 호출과 달리 상속은 캡슐화를 깨뜨립니다.
    • 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있습니다.
  • 상속의 경우 하위 클래스가 깨지기 쉽다.
    • 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야하는 프로그램에서, 상위 클래스에 새로운 메서드가 추가된 경우
 
// 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽) 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) { System.out.println("InstrumentedHashSet.add"); addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { System.out.println("InstrumentedHashSet.addAll"); addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; } public static void main(String[] args) { InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); s.addAll(List.of("틱", "탁탁", "펑")); System.out.println(s.getAddCount()); } }
InstrumentedHashSet.addAll InstrumentedHashSet.add InstrumentedHashSet.add InstrumentedHashSet.add 6
HashSet의 addAll 메서드가 내부적으로 add 메서드를 사용해 구현되어 있고, 이때 재정의된 add 메서드를 사용하기 때문에 의도와는 다르게 동작하게 됩니다.
 
자기 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당하며, 자바 플랫폼 전반적인 정책인지, 그래서 다음 릴리스에서도 유지될지는 알 수 없습니다.
때문에 위의 예시에서 다음과 같은 방식들은 완벽한 해결책이 될 수 없습니다.
  • addAll 메서드를 재정의하지 않는다.
  • addAll 메서드를 주어진 컬렉션을 순회하며 원소 하나당 add 하는 방식
    • 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고 비효율적입니다.
    • 하위 클래스에서는 접근할 수 없는 private필드를 사용해야 한다면 구현자체가 불가능합니다.
  • 새로운 메서드를 추가하는 방식
    • 이전 방식들보다는 훨씬 안전하지만, 실수로 재정의할 여지가 남아있습니다.
    • 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 있습니다.
 

컴포지션 구현 방식

새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 방식을 말합니다.
새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환합니다. 이 방식을 forwarding이라 하며, 새 클래스의 메서드들을 전달 메서드라 부릅니다.
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) { System.out.println("InstrumentedHashSet.add"); addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { System.out.println("InstrumentedHashSet.addAll"); addCount += c.size(); return super.addAll(c); } public int getAddCount() { return addCount; } public static void main(String[] args) { InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); s.addAll(List.of("틱", "탁탁", "펑")); System.out.println(s.getAddCount()); } }
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 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 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(); } }
 
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 합니다.
컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부릅니다. 단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당합니다.
 

래퍼 클래스의 단점

  • 콜백(callback) 프레임워크와는 어울리지 않는다고 합니다.

질문

“컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부릅니다. 단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당합니다.”가 이해가 안감.

  1. 컴포지션과 전달만 있는 경우
    1. class B { void doSomething() { System.out.println("B is doing something"); } } class A { private B b = new B(); // 컴포지션 void performAction() { b.doSomething(); // 전달 } }
  1. 위임이 포함된 경우
    1. class B { private A a; B(A a) { this.a = a; // A의 참조를 받음 } void doSomething() { System.out.println("B is doing something with A's help"); a.help(); // A의 메서드를 사용할 수 있음 } } class A { private B b = new B(this); // 자신의 참조를 넘김 void performAction() { b.doSomething(); // 전달이자 위임 } void help() { System.out.println("A is helping B"); } }
말 그대로 자기 자신의 참조를 전달하는 것이 위임이고, 컴포지션과 전달 메서드만 구현한 경우에는 넓은 의미에서의 위임이라고 생각하면 됩니다.
 

References::

이펙티브 자바 / 조슈아 블로크 지음 (프로그래밍 인사이트)

Loading Comments...