BOOK 6 - 이펙티브 자바(4)

4장 클래스와 인터페이스

클래스와 멤버의 접근 권한을 최소화하라

  • 잘 설계된 컴포넌트는 모든 내부 구현을 완벽히 숨겨, 구현과 API를 깔끔히 분리한다.
    • 오직 API를 통해서만 다른 컴포넌트와 소통하며 서로의 내부 동작 방식에는 전혀 개의치 않는다.

정보 은닉

정보 은닉의 장점

  • 시스템 개발 속도를 높인다.
    • 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다.
  • 시스템 관리 비용을 낮춘다.
    • 각 컴포넌트를 빨리 파악하여 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다.
  • 성능 최적화에 도움을 준다.
    • 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문이다.
  • 소프트웨어 재사용성을 높인다.
  • 큰 시스템을 제작하는 난이도를 낮춰준다.

정보 은닉을 위한 장치

  • 접근 제어 메커니즘
    • 클래스, 인터페이스, 멤버의 접근성을 명시한다.
    • 접근성은 그 요소가 선언된 위치와 접근 제한자(private, protected , public)로 정해진다.
  • 기본 원칙은 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다는 것이다.
  • top-level 클래스와 인터페이스에 부여할 수 있는 접근 수준은 package-privatepublic 두가지다.
    • public 으로 선언한다면 공개 API가 되므로 하위 호환을 위해 영원히 관리해줘야만 한다.
    • 한 클래스에서만 사용하는 package-private 클래스나 인터페이스라면,
      • 이를 사용하는 클래스 안에 private static 으로 중첩시킨다면 이 바깥 클래스 하나에서만 접근할 수 있다.
  • 클래스의 공개 API를 세심히 설계한 후, 그 외의 모든 멤버는 private 으로 만들자.
    • 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private 으로 풀어주자.
    • 권한을 풀어주는 일을 자주 하게 된다면 시스템에서 컴포넌트를 더 분해해야 하는것이 아닌지 다시 고민해보자.

접근 범위

  • 멤버(필드, 메서드, 중첩 클래스, 중첩 인터페이스)에 부여할 수 있는 접근 수준은 네가지다.

  • private : 멤버를 선언한 top-level 클래스에서만 접근할 수 있다.
  • package-private : 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다.
    • 접근 제한자를 명시하지 않았을 때 적용된다.
  • protected : package-private 접근 범위를 포함하여 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
  • public : 모든 곳에서 접근할 수 있다.

멤버 접근성 제약

  • 멤버 접근성을 좁히지 못하게 방해하는 제약이 하나 있다.
  • 상위 클래스의 메서드를 재정의할 때는 그 접근 수준을 상위 클래스보다 좁게 설정할 수 없다는 것이다.
  • 이 제약은 리스코프 치환 원칙을 지키기 위해 필요하다.
  • 클래스가 인터페이스를 구현하는 건 이 규칙의 특별한 예로 볼 수 있고, 이 때 클래스는 인터페이스가 정의한 모든 메서드를 public으로 선언해야 한다.

그 외 주의사항

  • 단지 코드를 테스트하려는 목적으로 클래스, 인터페이스, 멤버의 접근 범위를 넓히려 할 때가 있다.
    • public 클래스의 private 멤버를 package-protected 까지 풀어주는 것은 허용할 수 있지만, 그 이상은 안된다.
  • public 클래스의 인스턴스 필드는 되도록 public 이 아니어야 한다.
    • 필드가 가변 객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 그 필드에 담을 수 있는 값을 제한할 힘을 잃게 된다.
    • public 가변 필드를 갖는 클래스는 그 필드가 수정될 때 다른 작업을 할 수 없게 되므로 일반적으로 스레드 안전하지 않다.

public static final

  • 예외가 하나 있다.
  • 해당 클래스가 표현하는 추상 개념을 완성하는데 꼭 필요한 구성요소로써의 상수라면 public static final 필드로 공개해도 좋다.
  • 이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다.
  • 길이가 0이 아닌 배열은 모두 변경 가능하니 주의해야 한다.
    • 즉, public static final 배열 필드를 두거나 이 필드의 접근자 메서드를 제공해서는 안된다.
public static final Thing[] VALUES = {...};
  • 두가지 방법으로 해결할 수 있다.
// public 배열을 private으로 만들고 public 불변 리스트를 추가하는 방법
private static final Thing[] PRIVATE_VALUES = {...};
public static final List<Thing> VALUES = 
  Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
// 배열을 private으로 만들고 그 복사본을 반환하는 public 메서드를 추가하는 방법 (방어적 복사)
private static final Thing[] PRIVATE_VALUES = {...};
public static final Thing[] values() {
  return PRIVATE_VALUES.clone();
}

모듈의 접근성

  • 자바 9에서 모듈 시스템이라는 개념이 도입되면서 두가지 암묵적 접근 수준이 추가되었다.
    • 모듈은 package들의 묶음이다.
  • 모듈은 자신에게 속한 패키지들 중 공개(export)할 것을 선언한다.
  • protected , public 멤버라도 해당 패키지를 공개하지 않았다면 모듈 외부에서 접근할 수 없다.
  • 모듈 시스템을 활용하면 클래스를 외부에 공개하지 않으면서도 같은 모듈을 이루는 패키지 사이에서는 자유롭게 공유할 수 있다.
  • 추가된 암묵적 접근 수준들은 숨겨진 패키지 안에 있는 protected , public 으로 선언된 멤버와 관련있다.
    • 이 암묵적 접근 수준들은 각각 protected , public 수준과 같으나, 그 효과가 모듈 내부로 한정되는 것이다.
  • 새로운 접근 수준들은 주의해서 사용해야 한다.
    • 모듈의 JAR 파일을 모듈 경로가 아닌 어플리케이션의 classpath에 두면, 그 모듈 안의 모듈 패키지는 마치 모듈이 없는 것처럼 행동한다.
      • 즉, 모듈의 공개 여부와 관련없이 모든 public , protected 멤버를 모듈 밖에서도 접근할 수 있게 된다.

public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라

  • 패키지 외부에서 접근할 수 있는 클래스라면 접근자를 제공함으로써 클래스 내부 표현 방식을 언제든 바꿀 수 있는 유연성을 얻을 수 있다.
  • package-private 클래스 혹은 private 중첩 클래스라면 데이터 필드를 노출한다해도 문제가 없다.
    • 이 방식은 클래스 선언이나 클라이언트 코드 면에서 접근자 방식보다 깔끔하다.
  • public 클래스의 필드가 불변이라면 노출할 때의 단점이 조금 줄어들지만, 여전히 단점이 남아있다.
    • API를 변경하지 않고는 표현 방식을 바꿀 수 없고, 필드를 읽을 때 부수 작업을 수행할 수 없다.

변경 가능성을 최소화하라

  • 불변 클래스란 그 인스턴스의 내부 값을 수정할 수 없는 클래스다.
  • 불변 클래스는 가변 클래스보다 설계, 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적고 훨씬 안전하다.

불변 클래스로 만들기 위한 규칙

  • 객체의 상태를 변경하는 메서드를 제공하지 않는다.
  • 클래스를 확장할 수 없도록 한다.
  • 모든 필드를 final로 선언한다.
    • 새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게끔 보장하는데에도 필요하다.
  • 모든 필드를 private으로 선언한다.
  • 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.
    • 클래스내에 가변 객체를 참조하는 필드가 있다면, 클라이언트가 객체 참조를 가리키게 해서는 안되며, 접근자 메서드가 그 필드를 그대로 반환해서도 안된다.
    • 생성자, 접근자, readObject 메서드 모두에서 방어적 복사를 수행해야 한다.
public final class Complex {
  private final double re;
  private final double im;
  
  public Complex(double re, double im) {
    this.re = re;
    this.im = im;
  }
  
  public double realPart() { return re; }
  public double imaginaryPart() { return im; }
  
  public Complex plus(Complex c) {
    return new Complex(re + c.re, im + c.im);
  }
  
  public Complex minus(Complex c) {
    return new Complex(re - c.re, im - c.im);
  }
  ...
  
  @Override
  public boolean equals(Object o) {
    if (o == this) return true;
    if (!(o instanceof Complex)) return false;
    
    Complex c = (Complex) o;
    return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0;
  }
  
  @Override
  public int hashCode() {
    return 31 * Double.hashCode(re) + Double.hashCode(im);
  }
  
  @Override
  public String toString() {
    return "(" + re + " + " + im + "i)";
  }
}
  • 사칙연산 메서드들이 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 만들어 반환한다.
  • 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍 이라고 한다.
    • 절차적 / 명령형 프로그래밍은 피연산자 자신을 수정해 자신의 상태가 변하게 된다.
    • 함수형 프로그래밍 방식을 사용하면 코드에서 불변이 되는 영역이 많아지는 장점이 있다.

불변 객체의 장점

  • 불변 객체는 생성된 시점의 상태를 파괴될 때까지 그대로 간직하여 단순하다.

  • 불변 객체는 근본적으로 Thread-safe 하여 따로 동기화할 필요 없다.

    • 불변 객체에 대해서 서로 다른 스레드가 영향을 줄 수 없기 때문에 안심하고 공유할 수 있다.

    • 불변 클래스는 한번 만든 인스턴스를 재활용하는 것이 좋다.

      • 가장 쉬운 재활용 방법은 자주 쓰이는 값들을 public static final 상수로 제공하는 것이다.
      public static final Complex ZERO = new Complex(0, 0);
      public static final Complex ONE = new Complex(1, 0);
      
  • 불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 메모리 사용량과 GC 비용을 줄일 수 있다.

    • 같은 인스턴스를 중복 생성하지 않게 해주는 정적 팩터리를 사용하면 여러 클라이언트가 인스턴스를 공유할 수 있다.
  • 불변 객체는 방어적 복사가 필요없다.

    • 따라서 불변 클래스는 clone 메서드나 복사 생성자를 제공하지 않는 것이 좋다.
  • 불변 객체는 자유롭게 공유할 수 있음은 물론, 불변 객체끼리는 내부 데이터를 공유할 수 있다.

  • 객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 불변식을 유지하기 수월하다.
    • 불변객체는 Map의 키와 Set의 원소로 쓰기에 적절하다.
  • 불변 객체는 그 자체로 실패 원자성을 제공한다.
    • 실패 원자성은 메서드에서 예외가 발생한 후에도 그 객체는 여전히 유효한 상태여야 한다는 성질이다.

불변 객체의 단점

  • 값이 다르면 반드시 독립된 객체로 만들어야 한다.
    • 값의 가짓수가 많으면 이들을 모두 만드는데 큰 비용이 들게 된다.
    • 원하는 객체를 완성하기까지의 단계가 많고, 그 중간 단계에서 만들어진 객체들이 모두 버려진다면 성능 문제는 더 심각할 것이다.

해결 방법

  • 흔히 쓰일 다단계 연산(multistep operation)들을 예측하여 기본 기능으로 제공하는 방법
    • 더이상 각 단계마다 객체를 생성하지 않아도 된다.
    • 예를들어 BigInteger 는 모듈러 지수 같은 다단계 연산 속도를 높여주는 가변 동반 클래스(companion class)를 package-private으로 두고 있다.
  • 가변 동반 클래스를 public 으로 제공하는 방법
    • 클라이언트들이 원하는 복잡한 연산들을 정확히 예측할 수 없는 경우에 사용한다.
    • 예를 들어 String 의 가변 동반 클래스인 StringBuilder 가 있다.

불변 클래스를 만드는 또 다른 설계 방법

  • 클래스가 불변임을 보장하려면 자신을 상속하지 못하게 해야 한다.

  • 상속하지 못하게 하려면 final 클래스로 선언하면 된다.

  • 더 유연한 방법은 모든 생성자를 private 혹은 package-private 으로 만들고 public 정적 팩터리를 제공하는 방법이다.

  • 본래 불변 클래스는 모든 필드가 final 이고 어떤 메서드도 그 객체를 수정할 수 없어야 한다.

    • 이 규칙을 성능을 위해 완화할 수 있다.

      => 어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다.

    • 계산 비용이 큰 필드는 나중에 계산하여 final이 아닌 필드에 캐시해놓고 사용할 수 있다.

정리

  • getter가 있다고 해서 무조건 setter를 만들지는 말자.
  • 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다.

  • 모든 클래스를 불변으로 만들 순 없지만, 변경할 수 있는 부분을 최소한으로 줄이자.
  • 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

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

  • 상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.
  • 확장할 목적으로 설계되었고 문서화도 잘 된 클래스라면 안전하다.
    • 하지만 일반적인 구체 클래스를 패키지 경계를 넘어 상속하는 일은 위험하다.
    • 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다.

상속의 문제점

첫번째 문제점

  • 상속은 캡슐화를 깨뜨린다.
    • 즉, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
    • 상위 클래스가 다음 릴리즈에서 내부 구현을 변경하면, 그 여파로 하위 클래스가 오동작할 수 있다.
public class InstrumentedHashSet<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;
  }
}
  • 변형된 HashSet을 만들어 추가된 원소의 수를 저장하는 변수와 접근자 메서드를 추가했다.
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("a", "b", "c"));
  • 이 후에 getAddCount 메서드를 호출하면 3을 반환할 것으로 기대하지만, 실제로는 6을 반환할 것이다.
  • HashSetaddAll 메서드가 add 메서드를 호출해 원소를 추가하기 때문이다.
    • 이 때 불리는 add 메서드는 InstrumentedHashSet 에서 재정의된 add 메서드이기 때문에 addCount 에 값이 중복되어 더해지게 된다.
  • 이를 해결하기 위해 하위 클래스에서 addAll 메서드를 재정의하지 않는 방법이 있다.
    • HashSetaddAll 메서드가 add 메서드를 호출함을 가정한 해법이라는 한계가 있다.
  • 또 다른 방법은 addAll 메서드를 컬렉션을 순회하며 원소 하나당 add 메서드를 한번씩 호출하도록 재정의하는 방법이다.
    • 하위 클래스에서 접근할 수 없는 private 필드를 써야 하는 상화이라면 이 방식으로는 구현이 불가능하다.

두번째 문제점

  • 기존 하위클래스에서 컬렉션에 추가된 모든 원소가 특정 조건을 만족하도록 검사하고 있었다고 해보자.

  • 상위 클래스에 또 다른 원소 추가 메서드가 생기면 그 새로운 메서드를 사용해 허용되지 않은 원소를 추가할 수 있게 된다.

세번째 문제점

  • 위의 두 문제점과 같이 메서드 재정의가 문제라면, 메서드 재정의 없이 새로운 메서드를 추가하는 방법은 어떨까?
  • 상위 클래스에 하위 클래스의 추가된 메서드와 시그니처가 같고 반환타입만 다른 메서드가 추가된다면, 하위 클래스는 컴파일도 되지 않을 것이다.
    • 반환 타입 마저도 같다면 상위 클래스의 메서드를 재정의한 꼴이니 앞의 두 문제점이 또 발생할 수 있다.
  • 또한, 하위클래스에서 새로 만든 메서드는 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.

해결 방법 - 컴포지션 사용

  • 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다.
  • 새 클래스의 메서드들은 private 필드로 참조하는 기존 클래스의 대응하는 메서드를 호출해 결과를 반환한다.
    • 이런 메서드를 forwarding method 라고 부른다.
  • 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.
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 addAll(Collection<? extends E> c) {
    return s.addAll(c);
  }
  
  public boolean removeAll(Collection<?> c) {
    return s.removeAll(c);
  }
  ...
}
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;
  }
}
  • 다른 Set 인스턴스를 감싸고 있다는 의미에서 InstrumentedSet 같은 클래스를 wrapper class 라고 한다.
    • ForwardingSet 은 전달 클래스라고 한다.
    • 다른 Set 에 계측 기능을 덧씌운다는 의미에서 decorator 패턴이라고 한다.
  • 래퍼 클래스는 단점이 거의 없지만, 콜백 프레임워크와는 어울리지 않는다.
    • 콜백은 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출때 사용하도록 한다.
    • 내부 객체는 자신을 감싸고 있는 래퍼 클래스의 존재를 모르니 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다.
  • 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 쉽게 구현할 수 있다.

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

  • 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
  • 공개된 메서드에서 자신의 또 다른 재정의 가능한 메서드를 호출한다면 그 사실을 API 설명에 적시해야 한다.
  • 즉, 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.
    • 백그라운드 스레드나 정적 초기화 과정에서도 호출이 일어날 수 있다.
  • 클래스를 안전하게 상속할 수 있도록 하려면 내부 구현 방식을 설명해야만 한다.

  • 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 여러개 만들어보면 검증해야 한다.

그 외 주의사항

  • 상속용 클래스의 생성자는 직접/간접적으로 재정의 가능 메서드를 호출하면 안된다.
    • 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 생성되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출된다.
    • 만약 재정의한 메서드가 하위 클래스 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다.
  • Cloneable 이나 Serializable 인터페이스를 구현한 클래스를 상속할 수 있게 설계하는 것은 좋지 않다.
    • clonereadObject 메서드는 생성자와 비슷한 효과를 내기 때문에 역시 재정의 가능 메서드를 호출해서는 안된다.
  • 상속용으로 설계하지 않은 일반적인 구체 클래스는 상속을 금지해야 한다.
  • 상속을 꼭 허용해야겠다면 클래스 내부에서 재정의 가능 메서드를 호출하는 자기 사용 코드를 제거해야 한다.
    • 이렇게 하면 하위 클래스에서 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않아 그리 위험하지 않기 때문이다.

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

  • 자바 8부터 인터페이스도 default method를 제공할 수 있게 되었다.
  • 추상 클래스와 인터페이스의 가장 큰 차이는 추상 클래스를 구현한 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다는 점이다.
    • 자바는 단일 상속만 지원하므로, 추상 클래스 방식은 새로운 타입을 정의하는데 큰 제약이 있다.
  • 반면 인터페이스가 선언한 메서드를 모두 정의하고 규약을 잘 지킨 클래스는 다른 어떤 클래스를 상속했든 같은 타입으로 취급된다.
  • 즉, 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다.

믹스인 인터페이스

  • 인터페이스는 mixin 정의에 안성맞춤이다.
  • mixin이란, 클래스가 구현할 수 있는 타입으로, 믹스인을 구현한 클래스에 원래의 ‘주된 타입’ 외에도 특정 선택적 행위를 제공한다고 선언하는 효과를 준다.
    • 예를 들어, Comparable 은 이를 구현한 클래스의 인스턴스들끼리는 순서를 정할 수 있다고 선언하는 믹스인 인터페이스다.
    • 주된 기능에 선택적 기능을 혼합한다고해서 믹스인이라고 부른다.
  • 추상클래스에는 기존 클래스에 추상 클래스를 끼워넣기 어렵기 때문에 믹스인을 정의할 수 없다.
    • 클래스는 두 부모를 둘 수 없고, 클래스 계층 구조에 믹스인을 삽입하기에 합리적인 위치가 없기 때문이다.

계층 구조가 없는 타입

  • 인터페이스로 계층 구조가 없는 타입 프레임워크를 만들 수 있다.
public interface Singer {
  AudioClip sing(Song s);
}
public interface Songwriter {
	Song compose(int chartPosition);
}
  • 작곡과 가수를 함께 하는 경우도 있다.
    • 가수 클래스가 SingerSongwriter 를 모두 구현해도 전혀 문제가 되지 않는다.
  • SingerSongwriter 를 모두 확장하고 새로운 메서드를 추가한 제 3의 인터페이스를 정의할 수도 있다.
public interface SingerSongwriter extends Singer, Songwriter {
  AudioClip strum();
  void actSeneitive();
}

디폴트 메서드

  • 인터페이스의 메서드 중 구현 방법이 명확한 것이 있다면, 그 구현을 디폴트 메서드로 제공할 수 있다.

디폴트 메서드 제약

  • 많은 인터페이스가 equals , hashCode 와 같은 Object 의 메서드를 정의하고 있지만, 이를 디폴트 메서드로 정의해서는 안된다.
  • 인터페이스는 인스턴스 필드를 가질 수 없고, public 이 아닌 정적 멤버도 가질 수 없다.

추상 골격 구현 클래스

  • 인터페이스와 추상 골격 구현 클래스 (skeletal implementation) 를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취하는 방법도 있다.
  • 인터페이스로 타입을 정의하고, 몇 가지 필요한 디폴트 메서드를 제공한다.
  • 골격 구현 클래스는 나머지 메서드들까지 구현한다.
  • 단순히 골격 구현 클래스를 확장하는 것만으로 이 인터페이스를 구현하는데 필요한 일이 대부분 완료되게 된다.
  • 이를 템플릿 메서드 패턴이라고 부른다.
  • 예시로 컬렉션 프레임워크의 AbstractCollection , AbstractSet , AbstractList , AbstractMap 각각이 핵심 컬렉션 인터페이스의 골격 구현 클래스이다.
static List<Integer> intArrayAsList(int[] a) {
  Objects.requireNonNull(a);
  return new AbstractList<Integer>() {
    @Override
    public Integer get(int i) {
      return a[i];
    }
    
    @Override
    public Integer set(int i, Integer val) {
      int oldVal = a[i];
      a[i] = val;
      return oldVal;
    }
    
    @Override
    public int size() {
      return a.length;
    }
  }
}
  • int 배열을 받아 AbstractList 를 활용하여 List 구현체를 반환하는 정적 팩터리 메서드이다.
  • 익명 클래스 형태를 사용했다.
  • 골격 구현 클래스는 추상 클래스처럼 구현을 도와주는 동시에, 추상 클래스로 타입을 정의할 때 따라오는 심각한 제약에서는 자유롭다는 장점이 있다.
  • 구조성 골격 구현을 확장하지 못하는 경우라면, 두가지 방법이 있다.
    • 인터페이스를 직접 구현한다.
    • 인터페이스를 구현한 클래스에 해당 골격 구현 클래스를 확장한 private 내부 클래스를 정의하고, 각 메서드 호출을 내부 클래스의 인스턴스에 전달한다.
      • 이 방식을 시뮬레이트한 다중 상속(simulated multiple inheritance) 이라고 한다.

골격 구현 작성

  • 인터페이스를 잘 살펴 다른 메서드들의 구현에 사용되는 기반 메서드들을 선정한다.
    • 이 기반 메서드들은 골격 구현에서는 추상 메서드가 될 것이다.
  • 기반 메서드들을 사용해 직접 구현할 수 있는 메서드를 디폴트 메서드로 제공한다.
  • 기반 메서드나 디폴트 메서드로 만들지 못한 메서드가 남아있다면, 골격 구현 클래스를 만들어 남은 메서드를 작성한다.
    • 인터페이스 메서드 모두가 기반 메서드와 디폴트 메서드로 된다면 골격 구현 클래스를 별도로 만들 이유는 없다.
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
  
  @Override
  public V setValue(V value) {
    throw new UnsupportedOperationException();
  }
  
  @Override
  public boolean equals(Object o) {
    if (o == this) return true;
    if (!(o instanceof Map.Entry)) return false;
    
    Map.Entry<?,?> e = (Map.Entry) o;
    return Objects.equals(e.getKey(), getKey())
      && Objects.equals(e.getValue(), getValue());
  }
  
  @Override
  public int hashCode() {
    return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
  }
  
  @Override
  public String toString() {
    return getKey() + "=" + getValue();
  }
}
  • getKeygetValue 는 기반 메서드이다.
  • Object 메서드들은 디폴트 메서드로 제공해서는 안되므로, 해당 메서드들은 모두 골격 구현 클래스에 구현한다.
    • 이 메서드들은 기반 메서드를 사용해 구현했다.

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

  • 자바 8부터는 기존 인터페이스에 메서드를 추가할 수 있도록 디폴트 메서드를 추가했다.
  • 디폴트 메서드를 선언하면 해당 인터페이스를 구현한 후 디폴트 메서드를 재정의하지않은 모든 클래스에서 디폴트 구현이 쓰이게 된다.
  • 기존 인터페이스에 메서드를 추가하는 길이 생겼지만, 모든 기존 구현체들과 매끄럽게 연동되리라는 보장은 없다.
    • 생각할 수 있는 모든 상황에서 불변식을 해치지 않는 디폴트 메서드를 작성하기란 어렵기 때문이다.
  • 자바 8의 Collection 인터페이스에 추가된 removeIf 메서드를 예로 살펴보자.
default boolean removeIf(Predicate<? super E> filter) {
  Objects.requireNonNull(filter);
  boolean result = false;
  for(Iterator<E> it = iterator(); it.hasNext();) {
    if(filter.test(it.next())) {
      it.remove();
      result = true;
    }
  }
  return result;
}
  • 주어진 boolean 함수 (predicate)가 true를 반환하는 모든 원소를 제거한다.
  • 이 코드가 모든 Collection 구현체와 잘 어우러지는 것은 아니다.
    • 예를 들어 아파치 커먼즈 라이브러리의 SynchronizedCollection 은 모든 메서드에서 주어진 락 객체로 동기화한 후 내부 컬렉션 객체에 기능을 위임하는 래퍼 클래스이다.
    • SynchronizedCollectionremoveIf 메서드를 재정의하지 않고 있고, 따라서 이 클래스를 자바 8과 함께 사용한다면 모든 메서드 호출을 알아서 동기화해주지 못하게 된다.
      • 즉, SynchronizedCollection 인스턴스를 여러 스레드가 공유하는 환경에서 한 스레드가 removeIf 를 호출하면 예기치 못한 오류나 결과가 발생할 수 있다.

정리

  • 디폴트 메서드는 컴파일에 성공하더라도 기존 구현체에 런타임 오류를 일으킬 수 있다.
  • 기존 인터페이스에 디폴트 메서드로 새 메서드를 추가하는 일은 꼭 필요한 경우가 아니면 피해야 한다.

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

  • 인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할을 한다.
    • 인터페이스는 오직 이 용도로만 사용해야 한다.
  • 이 지침에 맞지 않는 예로 상수 인터페이스라는 것이 있다.
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 필드로만 구성된 인터페이스를 뜻한다.
  • 상수 인터페이스 안티패턴은 인터페이스를 잘못 사용한 예이다.
    • 클래스 내부에서 사용하는 상수는 외부 인터페이스가 아니라 내부 구현에 해당한다.
    • 따라서 상수 인터페이스를 구현하는 것은 이 내부 구현을 클래스의 API로 노출하는 행위다.
  • 클래스가 어떤 상수 인터페이스를 사용하든 사용자에게는 아무런 의미가 없다.

상수를 공개할 다른 방법

  • 특정 클래스나 인터페이스와 강하게 연관된 상수라면, 그 클래스나 인터페이스 자체에 추가해야 한다.
    • 대표적으로 IntegerDouble 에 선언된 MIN_VALUEMAX_VALUE 가 그 예이다.
  • 인스턴스화할 수 없는 유틸리티 클래스에 담아 공개할 수도 있다.
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;
}

태그 달린 클래스보다는 클래스 계층구조를 활용하라

  • 두 가지 이상의 의미를 표현할 수 있으며, 그 중 현재 표현하는 의미를 태그 값으로 알려주는 클래스가 종종 있다.
class Figure {
  enum Shape { RECTANGLE, CIRCLE };
  
  // 태그 필드 : 현재 모양을 나타낸다.
  final Shape shape;
  
  double length;
  double width;
  
  double radius;
  
  Figure(double radius) {
    shape = Shape.CIRCLE;
    this.radius = radius;
  }
  
  Figure(double length, double width) {
    shape = Shape.RECTANGLE;
    this.length = length;
    this.width = width;
  }
  
  double area() {
    switch(shape) {
      case RECTANGLE:
        return length * width;
      case CIRCLE:
        return Math.PI * (radius * radius);
      default:
        throw new AssertionError(shape);
    }
  }
}

태그 달린 클래스의 단점

  • 열거 타입 선언, 태그 필드, switch 문 등 쓸데 없는 코드가 너무 많다.
  • 여러 구현이 한 클래스에 혼합되어 있어서 가독성도 나쁘다.
  • 또 다른 의미를 추가하려면 코드를 수정해야 한다.
    • 새로운 의미를 추가할 때마다 모든 switch 문을 찾아 새 의미를 처리하는 코드를 추가해야 하는데, 하나라도 빠뜨리면 런타임에 오류가 생길 것이다.

subtyping

  • 타입 하나로 다양한 의미의 객체를 표현하기 위해 태그 달린 클래스 대신 클래스 계층구조를 활용하는 서브타이핑을 사용할 수 있다.

태그 달린 클래스를 클래스 계층구조로 바꾸는 방법

  • 계층구조의 루트가 될 추상클래스를 정의한다.
  • 태그 값에 따라 동작이 달라지는 메서드들을 루트 클래스의 추상 메서드로 선언한다.
    • Figure 클래스의 area 메서드가 이에 해당한다.
  • 태그 값에 상관없이 동작이 일정한 메서드들을 루트 클래스에 일반 메서드로 추가한다.
  • 모든 하위 클래스에서 공통으로 사용하는 데이터 필드들도 전부 루트 클래스로 올린다.
  • 루트 클래스를 확장한 구체 클래스를 의미별로 하나씩 정의한다.
    • 각 하위 클래스에는 각자의 의미에 해당하는 데이터 필드들을 넣는다.
    • 루트 클래스가 정의한 추상 메서드를 각자의 의미에 맞게 구현한다.
abstract class Figure {
  abstract double area();
}

class Circle extends Figure {
  final double radius;
  
  Circle(double radius) { this.radius = radius; }
  
  @Override
  double area() {
    return Math.PI * (radius * radius);
  }
}

class Rectangle extends Figure {
  final double length;
  final double width;
  
  Rectangle(double length, double width) {
    this.length = length;
    this.width = width;
  }
  
  @Override
  double area() {
    return length * width;
  }
}
  • 이러한 클래스 계층 구조는 쓸데 없는 코드가 사라지고, 관련 없는 데이터 필드를 모두 제거하여 살아 남은 필드들은 모두 final 로 선언하게 된다.
  • 루트 클래스의 코드를 건드리지 않고도 독립적으로 계층 구조를 확장하고 함께 사용할 수 있다.
  • 타입 사이의 자연스러운 계층 관계를 반영할 수 있어서 유연성은 물론 컴파일 타임 타입 검사 능력을 높여준다는 장점도 있다.

멤버 클래스는 되도록 static으로 만들라

  • 중첩 클래스란 다른 클래스 안에 정의된 클래스를 말한다.
  • 중첩 클래스의 종류는 정적 멤버 클래스, (비정적) 멤버 클래스, 익명 클래스, 지역 클래스 네 가지다.
    • 정적 멤버 클래스를 제외한 나머지는 내부 클래스에 해당한다.

정적 멤버 클래스

  • 다른 클래스 안에 선언되고, 바깥 클래스의 private 멤버에도 접근할 수 있다는 점만 제외하고는 일반 클래스와 똑같다.
  • 흔히 바깥 클래스와 함께 쓰일 때만 유용한 public 도우미 클래스로 쓰인다.
    • 예를 들어, 계산기 Calculator 가 지원하는 연산 종류를 정의하는 열거 타입 OperationCalculator 의 public 정적 멤버 클래스로 선언한다.

비정적 멤버 클래스

  • 비정적 멤버 클래스의 인스턴스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다.
  • 비정적 멤버 클래스의 인스턴스 메서드에서 정규화된 this 를 사용해 바깥 인스턴스의 메서드를 호출하거나 바깥 인스턴스의 참조를 가져올 수 있다.
    • 정규화된 this클래스명.this 형태로 바깥 클래스의 이름을 명시하는 용법을 의미한다.
  • 개념상 중첩 클래스의 인스턴스가 바깥 인스턴스와 독립적으로 존재할 수 있다면, 이는 정적 멤버 클래스로 만들어야 한다.
    • 비정적 멤버 클래스는 바깥 인스턴스 없이는 생성할 수 없기 때문이다.
  • 비정적 멤버 클래스의 인스턴스와 바깥 인스턴스의 관계는 멤버 클래스가 인스턴스화될 때 확립되며, 더 이상 변경할 수 없다.
    • 이 관계 정보는 비정적 멤버 클래스의 인스턴스 안에 만들어져 메모리 공간을 차지하며, 생성 시간도 더 걸린다.
  • 어댑터를 정의할 때 자주 쓰인다.
    • 즉, 어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용하는 것이다.
public class MySet<E> extends AbstractSet<E> {
  ...
    
  @Override
  public Iterator<E> iterator() {
    return new MyIterator();
  }
  
  private class MyIterator implements Iterator<E> {
    ...
  }
}
  • Map, Set, List 같은 컬렉션 인터페이스 구현체들은 자신의 컬렉션 뷰나 반복자를 구현할 때 비정적 멤버 클래스를 사용한다.

  • 멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면 무조건 static을 붙여서 정적 멤버 클래스로 만들자

    • 비정적 멤버 클래스로 만들면 바깥 인스턴스로의 숨은 참조가 생겨 이 참조를 저장하기 위한 시간과 공간이 소비된다.

private 정적 멤버 클래스

  • 흔히 바깥 클래스가 표현하는 객체의 한 구성요소를 나타낼 때 쓴다.
  • 예를 들어 Map 구현체의 각각의 키-값 쌍을 표현하는 Entry 가 있다.
    • 엔트리는 맵과 연관되어 있지만, 엔트리의 메서드들(getKey , getValue , setValue )이 맵을 직접 사용하지는 않는다.
    • 엔트리를 비정적 멤버 클래스로 표현하는 것은 모든 엔트리가 바깥 맵으로의 참조를 갖게 되어 시간, 공간 낭비이므로 private 정적 멤버 클래스가 가장 알맞다.

익명 클래스

  • 익명 클래스는 바깥 클래스의 멤버가 아니다.
  • 비정적 멤버 클래스와 다르게 쓰이는 시점에 선언과 동시에 인스턴스가 만들어진다.
  • 오직 비정적인 문맥에서 사용될 때만 바깥 클래스의 인스턴스를 참조할 수 있다.
  • 정적 문맥에서라도 상수 변수 이외의 정적 멤버는 가질 수 없다.

익명 클래스의 제약

  • 선언한 지점에서만 인스턴스를 만들 수 있다.
  • instanceof 검사나 클래스의 이름이 필요한 작업은 수행할 수 없다.
  • 여러 인터페이스를 구현할 수 없고, 인터페이스를 구현한 동시에 다른 클래스를 상속할 수 없다.
  • 표현식 중간에 등장하므로 짧지 않으면 가독성이 떨어진다.

지역 클래스

  • 지역 변수를 선언할 수 있는 곳이면 어디서든 선언할 수 있고, 유효 범위도 지역 변수와 같다.
  • 멤버 클래스처럼 이름이 있고 반복해서 사용할 수 있다.
  • 익명 클래스처럼 비정적 문맥에서 사용될 때만 바깥 인스턴스를 참조할 수 있다.
  • 정적 멤버는 가질 수 없으며, 가독성을 위해 짧게 작성해야 한다.

정리

  • 멤버 클래스의 인스턴스 각각이 바깥 인스턴스를 참조한다면 비정적으로, 그렇지 않으면 정적으로 만들자.
  • 중첩 클래스가 한 메서드 안에서만 쓰이면서 그 인스턴스를 생성하는 지점이 단 한 곳이고 해당 타입으로 쓰기에 적합한 클래스나 인터페이스가 이미 있다면 익명 클래스로 만들고, 그렇지 않으면 지역 클래스로 만들자.

톱레벨 클래스는 한 파일에 하나만 담으라

  • 소스 파일 하나에 톱레벨 클래스 여러 개를 선언하더라도 컴파일 상 문제는 없지만 심각한 위험을 감수해야 할 수도 있다.
  • 이렇게 하면 한 클래스를 여러 가지로 정의할 수 있으며, 그 중 어느 것을 사용할지는 어느 소스 파일을 먼저 컴파일하느냐에 따라 달라지기 때문이다.