책 읽고 정리하기/2022

Effective Java 3/E - 4장 클래스와 인터페이스 - 2

TwinParadox 2022. 1. 23. 12:37
728x90

18. 상속보다는 컴포지션을 사용하라

  • 상속은 코드 재사용의 강력한 수단이지만, 잘못된 사용은 오히려 오류를 유발한다.
  • 다른 패키지의 구체 클래스를 확장하여 사용하는 상속은 위험한 행위가 된다.

 

 

상속은 캡슐화를 깨뜨린다.

상위 클래스의 구현에 따라서, 하위 클래스 동작에 이상이 발생할 수 있다. 상위 클래스는 릴리즈마다 내부 구현이 변경될 수 있는데, 이를 확장한 하위 클래스에서는 어떠한 변경 사항 없이도 동작에 문제가 발생할 수 있다.

 

 

HashSet을 상속하여 사용하는 구조 예시

public class InstrumentedHashSet<E> extends HashSet<E> {
	public 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<String> tmp = InstrumentedHashSet<>();
tmp.addAll(List.of("A", "B", "C")); // 3개 추가
int size = tmp.getAddCount(); // 원하는 값은 3이지만, 6을 받게 된다.

 

이 오동작 이슈의 원인은 HashSet의 addAll의 동작에 있다.

public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    Iterator var3 = c.iterator();

    while(var3.hasNext()) {
        E e = var3.next();
        if (this.add(e)) {
            modified = true;
        }
    }

    return modified;
}
  • HashSet.addAll은 HashSet.add 메서드를 사용하도록 구현되어 있다.
  • InstrumentedHashSet에서는 addAll을 할 때, 원소 갯수만큼 더하고, HashSet.addAll을 호출한다.
  • HashSet.addAll에서 호출하는 add는, InstrumentedHashSet.add이며 addCount++을 수행한다.

 

addAll 메서드를 재정의하지 않는 방법

  • 동작에 문제가 없을 수 있지만, HashSet의 addAll이 add를 이용했을 때의 구현임을 가정하게 된다.
  • self-use는 내부 구현 스펙이고 이게 유지될 것인지에 대한 것은 파악하기 어렵다.

 

addAll 메서드를 재정의하는 방법

  • addAll 메서드를 파라미터로 넘겨 받은 원소들에 대해 하나씩 순회하며 add를 호출하는 방식
  • 상위 클래스의 메서드 동작을 다시 구현한다는 것은 그만한 노력와 위험을 감수해야 한다.
  • 하위 클래스에서는 접근 불가능한 private 필드를 쓰는 경우는 또 이 방법이 불가능하다.

 

깨지기 쉬운 또 하나의 이유 - 상위 클래스에 새로운 메서드가 추가된다면?

검증 등을 위한 요구사항을 맞추기 위해서 모든 메서드를 재정의하는 경우, 현재 릴리즈에 한해서는 문제가 없지만 이후 릴리즈에서 동일한 동작을 하는 메서드가 상위 클래스에 추가된다면 하위 클래스에서 대응할 수가 없다.

 

재정의 말고 새로운 메서드 추가

재정의보다는 안전하다.

문제는, 상위 클래스에서 하위 클래스 구현을 알 턱이 없고, 그렇기 때문에 메서드 시그니처는 같고 반환 타입은 다른 경우, 컴파일조차 못하게 된다. 새로운 메서드가 상위 클래스의 메서드의 요구 사항을 만족하지 못할 수 있다.

 

컴포지션(Composition)을 고려하자.

  • 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스 참조
  • 새 클래스의 인스턴스 메서드가 기존 클래스의 대응하는 메서드를 호출해 결과 반환하는 전달 메서드 구현
  • 기존 클래스가 새로운 클래스의 구성요소로 쓰이도록 하는 방식
  • 기존 클래스 내부 구현 방식의 영향을 벗어날 수 있고, 메서드 추가의 영향도 없다.

 

 

컴포지션 적용 예시

// 재사용 가능한 전달 클래스
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(); }
}

// 래퍼 클래스
public class InstrumentedSet<E> extends ForwardingSet<E> {
	private int addCount = 0;

	public InstrumentedSet(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; }
}

래퍼 클래스 InstrumentedSet

HashSet의 모든 기능을 정의한 Set 인터페이스를 기반으로 하여 견고하면서 유연하다.

Set을 구현하고, 이를 인스턴스로 받는 생성자를 제공하여서 임의의 Set에다가 계측 기능을 래핑한 새로운 Set을 만들기 때문에, 어떤 Set이더라도 계측이 가능하고 기존 생성자들과 함께 사용이 가능하다.

 

래퍼 클래스의 문제 - SELF

래퍼 클래스는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출에 사용하도록 하는 콜백 프레임워크와는 어울리지 않는다. 내부 객체는 자신을 감싸는 래퍼 클래스를 모르기 때문에, 자신의 참조를 넘기고 래퍼가 아닌 내부 객체를 호출하게 된다.

 

결론적으로, 상속은 'is-a' 관계에만 적용해야 한다.

상속에 대한 확신이 없다면, 컴포지션을 적용해야 한다. 상속이 필요하다 생각되면, 아래 내용을 고려하자.

  • 정말 하위 타입관계인가?
  • 확장하려는 클래스의 API에 결함이 없는지, 결함이 있어도, 전파되어도 문제가 없는지?

 

 

19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.

이전 내용에서 다뤘던 대로, 상속을 고려하지 않은 설계와 문서화되지 않은 내부 구현은 외부 클래스가 상속하는 과정에서는 많은 위험이 발생한다.

 

상속용 클래스는 재정의할 수 있는 메서드들에 대한 문서화가 필요하다.

클래스의 API로 공개된 메서드에서 자신의 또 다른 메서드를 호출할 수도 있는데, 이것이 재정의 가능한 메서드(public, protected)인 경우, 이에 대한 내용을 문서화해야 한다.

문서화 내용에는 호출 순서와 결과, 호출 결과가 이어지는 처리가 주는 영향도 등이 포함된다.

문서화는 @implSpec 태그를 붙여서 사용하자.

 

상속용으로 설계한 클래스는 배포 전 하위 클래스를 만들어 검증하라.

protected 멤버나 메서드를 결정하기 위해서는, 설계 시점에 고려해야 할 규약 같은 건 없다. 접근 제한자를 결정하는 가장 좋은 방법은 이를 직접 상속하는 하위 클래스를 만들어서 테스트하는 것이 최선이다.

하위 클래스는 3개 이상을 생성해보는 것이 좋고, 최소한 하나 이상은 제 3자가 작성해야 한다.

 

상속용 클래스의 생성자는 직간접적으로 재정의 가능한 메서드를 호출하며 안 된다.

상위 클래스의 생성자가 하위 클래서의 재정의한 메서드를 호출하는 상황이 오면, 그 메서드의 동작에 따라 프로그램에 오동작을 발생시킬 수 있다. private, final, static은 재정의가 불가능하여 이 이슈에 해당하지 않는다.

Cloneable의 clone, Serializable의 readObject는 생성자처럼 새로운 객체를 만드는 형태라서, 이 또한 직간접적으로 재정의 가능 메서드를 호출하지 않아야 한다.

Serializable을 구현하는 경우, readResolve, writeReplcae는 protected로 선언해서, 하위 클래스에서 무시되지 않게 한다.

 

 

결론은, 상속용으로 설계하지 않은 클래스는 상속을 금지시켜야 한다.

클래스를 final로 선언하거나, 모든 생성자를 private, package-private으로 선언하고 정적 팩토리를 사용하자.

 

 

 

20. 추상 클래스보다는 인터페이스를 우선하라.

자바 8부터는 인터페이스에서도 디폴트 메서드를 제공할 수 있다.

추상 클래스가 정의한 타입을 구현한 클래스는 무조건 그것의 하위 클래스가 되어야 하는 제약이 생긴다. 반면, 인터페이스는 선언한 메서드를 모두 정의하고 규약을 잘 지킨 클래스라면 어떤 클래스를 상속하더라도 같은 타입으로 취급한다.

 

 

인터페이스의 장점

기존 클래스에도 쉽게 새로운 인터페이스를 구현해 넣을 수 있다.

인터페이스가 요구하는 메서드를 추가하고, 선언에 implements를 추가하는 것으로 대응이 가능해진다.

 

믹스인(mixin) 정의에 안성맞춤이다.

대상 타입의 주된 기능에 선택적 기능을 혼합하는 것

자신을 구현한 클래스의 인스턴스 간 순서를 정하는 Comparable은 믹스인 인터페이스에 해당한다.

추상 클래스는 클래스를 덧씌우는 구조를 가질 수 없어서 이런 것이 불가능하다.

 

계층 구조가 없는 타입 프레임워크를 만들 수 있다.

추상 클래스에서의 상속의 개념은 계층 구조가 생긴다.

여러 인터페이스를 구현하는 방식의 설계를 상속을 활용하는 클래스 기반으로 진행하려면 모든 조합을 고려해야 하는, 조합 폭발 문제가 발생할 수 있다. 이를 회피하기 위해 모든 내용을 담는 거대한 클래스를 만드는 것도 좋지 않다.

 

인터페이스는 기능 향상시키는 안전하고 강력한 수단이다.

 

 

디폴트 메서드의 제약

  • equals나 hashCode 같은 메서드는 디폴트 메서드로 제공하면 안 된다.
  • 인터페이스는 인스턴스 필드와 public이 아닌 정적 메서드는 가질 수 없다.
  • 직접 만들지 않은 인터페이스에는 디폴트 메서드를 추가할 수 없다.

 

인터페이스와 추상 클래스의 장점을 모두 취하는 방법

  • 인터페이스와 추상 골격 구현(Skeleton Implmentation) 클래스를 함께 사용하는 방법
  • 인터페이스로 타입 정의 및 디폴트 메서드 제공하고, 구현 클래스에 나머지 메서드들을 구현하는 클래스
  • 구현한 클래스를 확장하는 것만으로도 인터페이스 구현에 필요한 부분을 시킬 수 있음
    • 템플릿 메서드(Template Method) 패턴

 

 

21. 인터페이스는 구현하는 쪽을 생각해 설계하라

Java 8에서 디폴트 메서드는 기존 구현체를 깨뜨리지 않고 인터페이스에 메서드를 추가할 수 있지만, 범용적인 상황에 적용하기 위해서 모든 상황에서 불변식을 해치지 않는 그런 메서드를 생성하기가 어렵다.

디폴트 메서드의 컴파일이 성공해도 기존 구현체에서 사용할 때, 런타임 오류가 발생할 수 있다.

따라서, 기존 인터페이스에 새로운 디폴트 메서드를 추가하는 것은 신중해야 한다.

 

결론은...

  • 디폴트 메서드가 있더라도, 인터페이스 설계에는 세심함이 필요하다.
  • 인터페이스를 릴리즈하기 전에 철저히 테스트해야 한다.

 

 

22. 인터페이스는 타입을 정의하는 용도로만 사용하라

인터페이스를 구현한 클래스의 인스턴스로 무엇을 할 수 있는지를 알려주는 용도로만 사용해야 한다.

 

잘못된 사용의 대표적인 예시 - 상수 인터페이스

// 상수 인터페이스 - 안티 패턴
public interface PhysicalConstants {
    static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    static final double ELECTRON_MASS = 9.109_383_56e-31;
}

static final 필드로만 가득찬 인터페이스를 상수 인터페이스라고 하는데, 완벽한 안티패턴이다.

클래스 내부에서 사용하는 상수는 내부 구현에 해당하기 떄문에 인터페이스 하나에 몰아 놓으면 안 된다.

이런 필드를 공개하는 것이 목적이면, 그 클래스나 인터페이스 자체에 추가해야 한다.

// 상수 유틸리티 클래스
// 이 클래스를 정적 임포트해서 사용
public class PhysicalConstants {
    private PhysicalConstants() { } // 인스턴스화 방지

    public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    public static final double ELECTRON_MASS = 9.109_383_56e-31;
}

 

 

 

 

728x90
728x90