-
Composition vs extendsCS/Java (with Effective) 2022. 3. 2. 15:08
해당 글은 Effective Java Item18을 기반으로 합니다.
상속보다는 컴포지션을 사용하라.
해당 글에서 상속은 인터페이스의 상속(implements)은 포함하지 않습니다.
'상속(extends)은 메서드 호출과 달리 캡슐화를 깨트린다.' 즉, 상위 클래스가 어떻게 구현되는냐에 따라 하위 클래스가 오작동 할 수 있다는 뜻입니다. 상위 클래스 구현이 변경되는 경우 하위 클래스 또한 변경해야할 수도 있는 것이죠.
따라서, 이러한 문제점을 방지하기 위해 composition의 사용을 제안하고 있는데요. composition이 무엇인지 살펴보고, 상속시 발생할 수 있는 문제점, 그리고 어떤 경우에 composition 또는 extends를 사용하는지에 대해 알아보겠습니다.
Composition
기존 클래스를 확장하는 대신 새로운 클래스를 만들고, private 필드로 기존 클래스의 인스턴스를 참조시키는 방법
두 방식을 코드로 비교해보면 아래와 같습니다.
대상 클래스 (Car.java)
class Car { private int number; private String brand; public Car(int number, String brand) { this.number = number; this.brand = brand; } public int getNumber() { return number; } public String getBrand() { return brand; } }
extends
class Sonata extends Car { private String model; private int cost; public Sonata(int number, String brand, String model, int cost) { super(number, brand); this.model = model; this.cost = cost; } public String getModel() { return model; } public int getCost() { return cost; } }
composition
class Sonata { private Car car; // composition private String model; private int cost; public Sonata(Car car, String model, int cost) { this.car = car; this.model = model; this.cost = cost; } public Car getCar() { return car; } public String getModel() { return model; } public int getCost() { return cost; } }
코드로 보니 명확하게 차이가 보입니다. extends는 상위 클래스로 접근하는 코드가 있기 때문에 의존성이 있네요. (해당 코드가 잘못된 상속 관계라는 뜻은 아닙니다.)
HashSet 클래스를 상속하는 경우 이러한 문제점을 명확하게 발견할 수 있는데요. 책의 예제로 살펴보겠습니다.
InstrumentedHashSet.java
import java.util.Arrays; import java.util.Collection; import java.util.HashSet; public class Main { public static void main(String[] args) throws Exception{ InstrumentedHashSet<String> set = new InstrumentedHashSet<>(); set.addAll(Arrays.asList("가", "나", "다")); System.out.println(set.getAddCount()); // 6 출력 } } class InstrumentedHashSet<E> extends HashSet<E> { private int addCount = 0; public InstrumentedHashSet() {} public InstrumentedHashSet(int initCapacity, float loadFactor) { super(initCapacity, loadFactor); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> collection) { addCount += collection.size(); return super.addAll(collection); } public int getAddCount() { return addCount; } }
위의 코드는 3을 출력해야함에도 불구하고 6을 출력하게됩니다. 그 원인은 HashSet addAll() 메서드가 add() 메서드를 통해 구현되었기 때문입니다.
HashSet addAll()
public boolean addAll(Collection<? extends E> c) { boolean modified = false; for (E e : c) if (add(e)) // InstrumentedHashSet add 사용 modified = true; return modified; }
addCount += collection.size(); 를 통해 3이 더해지고, 상위 클래스 로직을 타게 되면서 HashSet의 addAll()이 호출이 되는데요. 이때 addAll()에서 호출되는 add()는 InstrumentedHashSet에서 재정의된 add()입니다. 따라서 addCount를 중복으로 연산하게되죠.
위의 문제는 addAll()을 재정의 하지 않는 방법, addAll() 대신 add() 호출로 바꾸는 방법, 새로운 메서드를 추가하는 방법 등 대안은 많지만 각각의 한계 또한 존재합니다. 이러한 복잡한 문제를 composition을 사용하면 해결할 수 있으니 composition 사용을 지향하는 것이 좋겠네요.
composition을 이용한 구현
import java.util.*; public class Main { public static void main(String[] args) throws Exception{ Set<String> set = new InstrumentedHashSet<>(new HashSet<>(10)); Set<Integer> time = new InstrumentedHashSet<>(new TreeSet<>()); } } class InstrumentedHashSet<E> extends ForwardingSet<E> { private int addCount = 0; public InstrumentedHashSet(Set<E> set) { super(set); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> collection) { addCount += collection.size(); return super.addAll(collection); } public int getAddCount() { return addCount; } } class ForwardingSet<E> implements Set<E> { private final Set<E> set; // composition 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(); } }
Set을 감싸고있는 InstrumentedHashSet -> Wrapper class
임의의 Set에 지시, 제어, 기록의 기능을 덧씌워 새로운 Set을 만드는 것이 해당 클래스의 핵심입니다. 이렇게 유연하게 설계하고, 컴포지션 방식을 적용한 경우 어떤 Set 구현체도 코드와 같이 사용할 수 있습니다.
컴포지션을 사용하면, 확장하려는 클래스의 API에 결함이 존재한다 하더라도, 이를 숨기는 새로운 API를 설계할 수 있지만 상속은 그렇지 못합니다.
또한, 상속이 필요한 B extends A 인 경우 반드시 B와 A가 is-a 관계인지 확인해야합니다. 그렇지 않은 경우 또는, 구분이 잘 되지 않는 경우엔 상속을 쓰지 않는다고 판단하는 것이 좋습니다.
참고 자료
Effective Java - 조슈아 블로크
반응형'CS > Java (with Effective)' 카테고리의 다른 글
Generic과 raw-type 그리고 비검사 경고 (0) 2022.04.28 HashCode (0) 2022.02.27 Singleton (0) 2022.02.08 Builder (0) 2022.02.06 Static Factory Method (0) 2022.01.13