클래스가 다른 클래스를 확장하는 구현 상속의 경우 확장할 목적으로 설계되었고 문서화도 잘 된 클래스(아이템 19)일 때는 안전하지만,

다른 패키지의 구체 클래스를 상속하는 것은 위험하다. (클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터 페이스 상속은 상관 없음)

 

 

상속을 사용하지 않는 이유 중 하나는, 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. 

예를 들어, 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며 , 그 여파로 하위 클래스가 오동작할 수 있다.

 

아래와 같이 HashSet을 상속하여 InstrumentedHashSet을 구현했다고 하자.

package com.example.sypark9646.item18;

import java.util.Collection;
import java.util.HashSet;

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) {
        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;
    }
}

 

이 때, InstrumentedHashSet의 addCount를 직접 접근한 값과 size를 가져오는 것에 대한 결과는 매우 다름을 알 수 있다.

package com.example.sypark9646.item18;

import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class InstrumentedHashSetTest {

    @DisplayName("HashSet addAll override test")
    @Test
    void testInstrumentedHashSetTest() {
        InstrumentedHashSet<String> instrumentedHashSet = new InstrumentedHashSet<>();
        instrumentedHashSet.addAll(List.of("1", "2", "3"));

        Assertions.assertEquals(3, instrumentedHashSet.size());
        Assertions.assertEquals(3, instrumentedHashSet.getAddCount()); // 6
    }
}

 

이러한 결과가 나온 이유는, size의 경우 아래와 같이 map의 사이즈를 그대로 가져오기 때문에 제대로 된 크기가 나오는 것이다.

getAddCount의 경우는 addAll과 add에서 각각 값을 더하고 있다.

그런데, InstrumentedHashSet에서 상속하고 있는 addAll은 add 메서드를 사용하여 구현되어 있기 때문에 중복으로 값을 더하게 된다.

 

이 문제를 피하기 위한 근본적인 방법은 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참 조하는 `컴포지션` 방식이다. 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출 하여 그 결과를 반환(전달, forwarding)하기 때문에, 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나기 때문에 기존 클래스에 새로운 메서드가 추가되더라도 영향 받지 않는다. (ex. Guava는 모든 컬렉션 인터페이스용 전달 메서드를 전부 구현해뒀다)

그렇다면 위 InstrumentedHashSet을 컴포지션과 전달 방식으로 다시 구현해 보자.

package com.example.sypark9646.item18;

import java.util.Collection;
import java.util.Set;

public class InstrumentedHashSet<E> extends ForwardingSet<E> {

    private int addCount = 0;

    public InstrumentedHashSet(Set<E> s) {
        super(s);
    }

    @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;
    }
}

 

여기서 ForwardingSet은 전달 메서드만으로 이뤄진 재사용 가능한 전달 클래스이다.

package com.example.sypark9646.item18;

import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

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();
    }
}
    @DisplayName("HashSet addAll Composition test")
    @Test
    void testCompositionInstrumentedHashSet() {
        InstrumentedHashSet<String> compositionInstrumentedHashSet = new InstrumentedHashSet<>(new TreeSet<>());
        compositionInstrumentedHashSet.addAll(List.of("1", "2", "3"));

        Assertions.assertEquals(3, compositionInstrumentedHashSet.size());
        Assertions.assertEquals(3, compositionInstrumentedHashSet.getAddCount());
    }

위의 ForwardingSet의 addAll은 set을 사용하고 있는데, 디버깅 해 보면 아래와 같이 사용한 Set의 인스턴스(TreeSet)의 addAll을 사용하게 되고, 

addAll이 add 메서드를 사용하더라도 TreeSet의 add 메서드를 사용하게 된다. (ForwardingSet의 add가 사용되는 것이 아님)

따라서 중복으로 addCount에 더해질 일이 없다.

 

다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며,

다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.

2021/02/27 - [책을 읽자/Design Patterns] - Decorator Pattern

 

Decorator Pattern

 

sysgongbu.tistory.com

컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다. (래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우)

래퍼 클래스는 단점이 거의 없다. 단, 래퍼 클래스가 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.

콜백 프레임워크에서 는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 하는데, 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라고 한다.

 

 

상속은 반드시 하위 클래스가 상위 클래스의 `진짜` 하위 타입인 상황에서만 쓰여야 한다. (is-a 관계+상위 클래스가 확장을 고려해 설계 되었을 때)
자바 플랫폼 라이브러리에서 스택은 벡터가 아니므로 Stack은 Vector를 확장해서 는 안됐으며, 마찬가지로 속성 목록도 해시테이블이 아니므로 Properties도 Hashtable을 확장해서는 안 됐다. 두 사례 모두 컴포지션을 사용했다면 더 좋았을 것이다.

 

 

아래는 java.util.Stack을 컴포지션을 이용하여 리팩토링 해 본 코드이다.

package com.example.sypark9646.item18;

import java.util.EmptyStackException;
import java.util.Vector;

public class CustomStack<E> extends ForwardingVector<E> {

    public CustomStack(Vector<E> vector) {
        super(vector);
    }

    public E push(E item) {
        addElement(item);

        return item;
    }

    public synchronized E pop() {
        E obj;
        int len = size();

        obj = peek();
        removeElementAt(len - 1);

        return obj;
    }

    public synchronized E peek() {
        int len = size();

        if (len == 0) {
            throw new EmptyStackException();
        }
        return elementAt(len - 1);
    }

    public boolean empty() {
        return size() == 0;
    }

    public synchronized int search(Object o) {
        int i = lastIndexOf(o);

        if (i >= 0) {
            return size() - i;
        }
        return -1;
    }

    // private static final long serialVersionUID = 1224463164541339165L;
}
package com.example.sypark9646.item18;

import java.util.Collection;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Spliterator;
import java.util.Vector;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;

public class ForwardingVector<E> extends Vector<E> {

    private final Vector<E> vector;

    public ForwardingVector(Vector<E> vector) {
        this.vector = vector;
    }

    public synchronized void copyInto(Object[] anArray) {
        vector.copyInto(anArray);
    }

    public synchronized void trimToSize() {
        vector.trimToSize();
    }

    public synchronized void ensureCapacity(int minCapacity) {
        vector.ensureCapacity(minCapacity);
    }

    public synchronized void setSize(int newSize) {
        vector.setSize(newSize);
    }

    public synchronized int capacity() {
        return vector.capacity();
    }

    public synchronized int size() {
        return vector.size();
    }

    public synchronized boolean isEmpty() {
        return vector.isEmpty();
    }

    public Enumeration<E> elements() {
        return vector.elements();
    }

    public boolean contains(Object o) {
        return vector.contains(o);
    }

    public int indexOf(Object o) {
        return vector.indexOf(o);
    }

    public synchronized int indexOf(Object o, int index) {
        return vector.indexOf(o, index);
    }

    public synchronized int lastIndexOf(Object o) {
        return vector.lastIndexOf(o);
    }

    public synchronized int lastIndexOf(Object o, int index) {
        return vector.lastIndexOf(o, index);
    }

    public synchronized E elementAt(int index) {
        return vector.elementAt(index);
    }

    public synchronized E firstElement() {
        return vector.firstElement();
    }

    public synchronized E lastElement() {
        return vector.lastElement();
    }

    public synchronized void setElementAt(E obj, int index) {
        vector.setElementAt(obj, index);
    }

    public synchronized void removeElementAt(int index) {
        vector.removeElementAt(index);
    }

    public synchronized void insertElementAt(E obj, int index) {
        vector.insertElementAt(obj, index);
    }

    public synchronized void addElement(E obj) {
        vector.addElement(obj);
    }

    public synchronized boolean removeElement(Object obj) {
        return vector.removeElement(obj);
    }

    public synchronized void removeAllElements() {
        vector.removeAllElements();
    }

    public synchronized Object clone() {
        return vector.clone();
    }

    public synchronized Object[] toArray() {
        return vector.toArray();
    }

    public synchronized <T> T[] toArray(T[] a) {
        return vector.toArray(a);
    }

    public synchronized E get(int index) {
        return vector.get(index);
    }

    public synchronized E set(int index, E element) {
        return vector.set(index, element);
    }

    public synchronized boolean add(E e) {
        return vector.add(e);
    }

    public boolean remove(Object o) {
        return vector.removeElement(o);
    }

    public void add(int index, E element) {
        vector.insertElementAt(element, index);
    }

    public synchronized E remove(int index) {
        return vector.remove(index);
    }

    public void clear() {
        vector.removeAllElements();
    }

    public synchronized boolean containsAll(Collection<?> c) {
        return vector.containsAll(c);
    }

    public boolean addAll(Collection<? extends E> c) {
        return vector.addAll(c);
    }

    public boolean removeAll(Collection<?> c) {
        return vector.removeAll(c);
    }

    public boolean retainAll(Collection<?> c) {
        return vector.retainAll(c);
    }

    public boolean removeIf(Predicate<? super E> filter) {
        return vector.removeIf(filter);
    }

    public synchronized boolean addAll(int index, Collection<? extends E> c) {
        return vector.addAll(index, c);
    }

    @Override
    public synchronized boolean equals(Object o) {
        return vector.equals(o);
    }

    @Override
    public synchronized int hashCode() {
        return vector.hashCode();
    }

    @Override
    public synchronized String toString() {
        return vector.toString();
    }

    public synchronized List<E> subList(int fromIndex, int toIndex) {
        return vector.subList(fromIndex, toIndex);
    }

    public synchronized ListIterator<E> listIterator(int index) {
        return vector.listIterator(index);
    }

    public synchronized ListIterator<E> listIterator() {
        return vector.listIterator();
    }

    public synchronized Iterator<E> iterator() {
        return vector.iterator();
    }

    public synchronized void forEach(Consumer<? super E> action) {
        vector.forEach(action);
    }

    public synchronized void replaceAll(UnaryOperator<E> operator) {
        vector.replaceAll(operator);
    }

    public synchronized void sort(Comparator<? super E> c) {
        vector.sort(c);
    }

    public Spliterator<E> spliterator() {
        return vector.spliterator();
    }
}

 

위 CustomStack을 테스트 해 본 결과는 아래와 같다.

package com.example.sypark9646.item18;

import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import java.util.EmptyStackException;
import java.util.Stack;
import java.util.Vector;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class CustomStackTest {

    @DisplayName("stack 동작 비교 테스트")
    @Test
    void testCustomStack() {
        CustomStack<String> customStack = new CustomStack<>(new Vector<>());
        Stack<String> stack = new Stack<>();

        customStack.push("1");
        customStack.push("2");
        customStack.push("3");

        stack.push("1");
        stack.push("2");
        stack.push("3");

        Assertions.assertAll(
            () -> Assertions.assertEquals(stack.search("3"), customStack.search("3")),
            () -> Assertions.assertEquals(stack.search("10"), customStack.search("10")),
            () -> Assertions.assertEquals(stack.peek(), customStack.peek()),
            () -> Assertions.assertEquals(stack.pop(), customStack.pop()),
            () -> Assertions.assertEquals(stack.pop(), customStack.pop()),
            () -> Assertions.assertEquals(stack.pop(), customStack.pop())
        );
    }

    @DisplayName("empty stack exception 테스트")
    @Test
    void testCustomStack_WhenEmptyStackCallsPeek_ThrowException() {
        CustomStack<String> customStack = new CustomStack<>(new Vector<>());

        Assertions.assertTrue(customStack.empty());
        assertThatThrownBy(customStack::peek).isInstanceOf(EmptyStackException.class);
    }
}

많은 클래스가 하나 이상의 자원에 의존한다.(컴포지션)

이 의존하는 클래스를 정적 유틸리티 클래스(아이템 4)로 구현하게 되면 유연하지 않고 테스트 하기 어려운 구조가 된다.

 

  • 정적 유틸리티의 안좋은 예- 유연하지 않고 테스트 어렵다.

public class SpellChecker {

  private final Lexicon dictionary = ...;

  private SpellChecker() { }
  public static SpellChecker INSTANCE = new SpellChecker();

  public static boolean isValid(String word) { ... }
  public static List<String> suggestions(String typo) { ... }
}

 

  • 싱글톤의 안좋은 예- 유연하지 않고 테스트 어렵다.
public class SpellChecker {

  private final Lexicon dictionary = ...;

  private SpellChecker() { }
  public static SpellChecker INSTANCE = new SpellChecker();

  public static boolean isValid(String word) { ... }
  public static List<String> suggestions(String typo) { ... }
}

 

  • SpellChecker가 여러 사전을 사용할 수 있도록 하기 위해서 좋지 않은 방법 - 멀티스레드 환경X
public class SpellChecker {

  private Lexicon dictionary = ...;

  public static void changeDictionary(){
		this.dictionary = ...
  }
}

 

클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준 다면 싱글턴과 정적 유틸리티 클래스는 사용하지 않는 것이 좋다. 

이 자원들을 클래스가 직접 만들게 해서도 안 된다. 

 

 

대신 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식을 사용한다.(불변 보장)

이는 의존 객체 주입의 한 형태로, 객체를 생성할 때 의존 객체를 주입해주면 된다. => c.f. Strategy Pattern

public class SpellChecker {

  private final Lexicon dictionary;

  public SpellChecker(Lexicon dictionary) { // 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨준다
    this.dictionary = dictionary;
  }

  public static boolean isValid(String word) { ... }
  public static List<String> suggestions(String typo) { }

}

 

 

이 패턴의 변형으로, 생성자에 자원 팩터리를 넘겨주는 방식이 있다.(Factory Method Pattern)

팩터리란 호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체를 말한다.

 

ex) Supplier<T> interface(java8)

Supplier<T>를 입력으로 받는 메서드는 일반적으로 한정적 와일드카드 타입을 사용해 팩터리의 타입 매개변수를 제한해야 한다. 

이 방식을 사용해 클라이언트는 자신이 명시한 타입의 하위 타입이라면 무엇이든 생성할 수 있는 팩터리를 넘길 수 있다. 

// 클라이언트가 제공한 팩터리가 생성한 타일(Tile)들로 구성된 모자이크 (Mosaic)를 만드는 메서드
Mosaic create(Supplier<? extends Tile> tileFactory) { ... }

 

예를 들어 어떤 사람의 이름과 생일을 입력해두고 getAge()로 나이를 가져오는 Person 클래스를 만든다고 하자.

package com.example.sypark9646.item5;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public class Person {

	final String name;
	private final LocalDate dateOfBirth;
	private final LocalDate currentDate;

	public Person(String name, LocalDate dateOfBirth) {
		this(name, dateOfBirth, LocalDate.now());
	}

	public Person(String name, LocalDate dateOfBirth, LocalDate currentDate) {
		this.name = name;
		this.dateOfBirth = dateOfBirth;
		this.currentDate = currentDate;
	}

	long getAge() {
		return ChronoUnit.YEARS.between(dateOfBirth, currentDate);
	}

	public static void printAge(PersonSupplierConstruct person) {
		System.out.println(person.name + " is " + person.getAge());
	}
}

위 방법의 경우, getAge ()는 현재 날짜가 아닌 Person 객체가 생성 된 시기를 기반으로 한다.

이 문제는 Supplier <LocalDate>를 사용하면 해결된다. 현재 시간을 Supplier를 이용하여 주입하는 것이다.

package com.example.sypark9646.item5;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.function.Supplier;

public class PersonSupplierConstruct {

	final String name;
	private final LocalDate dateOfBirth;
	private final Supplier<LocalDate> currentDate;

	public PersonSupplierConstruct(String name, LocalDate dateOfBirth) {
		this(name, dateOfBirth, LocalDate::now);
	}

	public PersonSupplierConstruct(String name, LocalDate dateOfBirth, Supplier<LocalDate> currentDate) {
		this.name = name;
		this.dateOfBirth = dateOfBirth;
		this.currentDate = currentDate;
	}

	public long getAge() {
		return ChronoUnit.YEARS.between(dateOfBirth, currentDate.get());
	}

	public static void printAge(PersonSupplierConstruct person) {
		System.out.println(person.name + " is " + person.getAge());
	}
}

 

 

용어

  • 한정적 와일드카드 타입(bounded wildcard type, 아이템 31)

+ Recent posts