ch4 클래스와 인터페이스 - LenKIM/everyone-is-effective-java-study GitHub Wiki

추상화의 기본 단위인 클래스와 인터페이스는 자바 언어의 심장! 그 설계에 사용하는 강력한 요소가 많이 있다.

이런 요소를 적절히 활용하여 클래스와 인터페이스를 쓰기 편하고, 견고하며, 유연하게 만드는 방법을 안내한다.

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

잘 설계된 컴포넌트란?

클래스 내부 데이터와 내부 구현정보를 외부 컴포넌트로부터 얼마나 잘 숨겼느냐다. (정보은닉, 캡슐화)
이는 곧 구현과 API를 깔끔히 분리함 하게 해준다.

정보 은닉의 장점

  • 시스템 개발 속도를 높인다. 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다.
  • 시스템 관리 비용을 낮춘다. 각 컴포넌트를 더 빨리 파악하여 더비깅할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다.
  • 정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다.
  • 소프트웨어 재사용성을 높인다. 외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 그 컴포넌와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크기 때문이다.
  • 큰 시스템을 제작하는 난이도를 낮춰준다. 시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있기 때문이다.

정보 은닉을 위한 다양한 장치 제공하는데, 그 중 접근 제어 매커니즘은 클래스, 인터페이스, 멤버의 접근성(접근 허용 범위)을 명시. 각 요소의 접근성은 그 요소가 선언된 위치와 접근 제한자로 정해진다.

즉, 접근 제한자를 제대로 활용하는 것이 정보 은닉의 핵심이다.

기본원칙은, 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.

소프트웨어가 올바로 동작하는 한 항상 가장 낮은 접근 수준을 부여해야 한다.

: 이 말의 뜻은 (가장 바깥이라는 의미의)톱레벨 클래스와 인터페이스에 부여할 수 있는 접근 수준은 package-private과 public 두 가지다. 톱레벨 클래스와 인터페이스를 public으로 선언하면 공개 API가 되며, package-private으로 선언하면 해당 패키지 안에서만 이용할 수 있다. 즉, 클라이언트에 아무런 피해 없이 다음 릴리스에서 수정, 교체 제거할 수 있다. 반면 public으로 선언한다면 API가 되므로 하위 호환을 위해 영원히 관리해줘야만 한다.

접근 수준 네 가지

  • private - 멤버를 선언한 톱레벨 클래스에서만 접근
  • package-private(default) - 멤버가 소속된 패키지 안의 모든 클래스에서 접근 할 수 있다.
  • protected - package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
  • public - 모든 곳에서 접근 가능.

권장하는 접근 수준

  • 우리는 기본적으로 모든 public API를 private 로 만들자.
  • 다음에 private 를 pacakge-private로 풀어주자. 권한을 풀어주는 일을 자주하게 된다면 여러분 시스템에서 컴포넌트를 더 분해야 해야 하는 것은 아닌지 다시 고민하게 된다.

주의

  • public 클래스의 인스턴스 필드는 되도록 public이 아니여야 한다.

  • 길이가 0이 아닌 배열은 모두 변경 가능하니 주의하자. 따라서 클래스에서 public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안 된다.

  • 클래스의 공개 API를 세심히 설계한 후, 그외의 모든 멤버는 private으로.

  • 테스트의 목적으로 적당한 수준으로 넓혀도 되지만 공개 API로 만들어서는 안 된다.

  • public 클래스는 상수용 public static final 필드 외에는 어떠한 public 필드도 가져서는 안된다.

  • public static final 필드가 참조하는 객체가 불변인지 확인하라.

자바 9 에서부터 모듈 시스템이라는 개념이 도입되면서 추가된 암묵적 접근 수준 두가지

  • 패키지중 공개(export)할 것들을 선언하게 되는데 공개하지 않으면 public 멤버라도 외부에서 접근X.. 효과가 모듈 내부로 한정되는 변종

  • 9모듈경로가 아닌 애플리케이션의 클래스패스에 두면 그 모듈 안의 모든 패키지는 모듈이 없는 것처럼 행동한다. (모두 밖에서 접근가능)

ex) JDK 자체가 적극 활용한 사례.

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

class Point{
  public double x;
  public double y;
}
  • 이처럼 퇴보한 클래스는 필드가 public이어서는 안 된다 !
  • 데이터 필드에 직접 접근할 수 있어 캡슐화의 이점 제공하지 못한다.

API를 수정하지 않고는 내부 표현을 바꿀 수 없고, 불변식도 보장할 수 없고, 외부에서 필드에 접근할 때 부수 작업을 수행할 수도 없다.

public 클래스의 필드가 불변인경우, 불변식만 보장할뿐 나머지 단점들은 갖고있게된다.

// 접근자와 변경자 메서드를 활용해 데이터를 캡슐화한다.
// public 클래스라면 이 방식이 확실히 맞다.
class Point{
  private double x;
  private double y;
  
  public Point(double x, double y){
    this.x = x;
    this.y = y;
  }
  
  public double getX() { return x; }
  public double getY() { return y; }
  
  public void setX( double x ) { this.x = x; }
  public void setX( double y ) { this.y = y; }
}

패키지 바깥에서 접근할 수 있는 클래스라면 접근자를 제공함으로써 클래스 내부 표현 방식을 언제든 바꿀 수 있는 유연성을 얻을 수 있다.

package-private 클래스 혹은 private중첩 클래스라면 데이터 필드를 노출한다 해도 하등의 문제가 없다. 추상 개념만 올바르게 표현해준다면.

why? 패키지 바깥 코드는 전혀 손대지 않고도 데이터 표현방식을 바꿀 수 있어서.

(warning) 규칙을 어긴 사례 : java.awt.package 의 Point 와 Dimension. 흉내X 타산지석으로 삼길..

결론

public 클래스는 절대 가변 필드를 직접 노출해서는 안 된다. 불편 필드라면 노출해도 덜 위험하지만 완전히 안심할 수는 없다. 하지만 package-private 클래스나 private 중첩 클래스에서는 종종(불변이든 가변이든) 필드를 노출하는 편이 나을 때도 있다.

item17 - 변경 가능성을 최소화하라

불변 클래스: 인스턴스의 내부 값을 수정할 수 없는 클래스. 객체가 파괴되는 순간까지 절대 달라지지 않음.

ex) String, 기본 타입의 박싱된 클래스들, BigInteger, BigDecimal .

불변 클래스는 가변 클래스보다 설계하고 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적고 훨씬 안전하다.

클래스를 불변으로 만드는 규칙

  • 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.

  • 클래스를 확장할 수 없도록 한다. - 하위 클래스에서 부주의하게 혹은 나쁜 의도로 객체의 상태를 변하게 만드는 상태를 막아준다. 상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이지만, 더 있다.

  • 모든 필드를 final로 선언한다.(https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html)

  • 모든 필드를 private으로 선언한다.

  • 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

클래스를 확장 할 수 없도록 하는 final이 아닌 또 다른 방법

모든 생성자를 private 혹은 package-private으로 만들고public정적 팩터리를 제공하는 방법

public final class Complex {
    private final double re;
    private final double im;

    public static final Complex ZERO = new Complex(0, 0);
    public static final Complex ONE  = new Complex(1, 0);
    public static final Complex I    = new Complex(0, 1);

    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) {  // 동사 add가 아닌 전치사 plus 사용
        // 사칙연산 메서드들이 인스턴스 자신은 수정하지 않고 새로운 Complecx 인스턴스를 만들어 반환하는 모습 주목
        return new Complex(re + c.re, im + c.im);
    }

    // 코드 17-2 정적 팩터리(private 생성자와 함께 사용해야 한다.) (110-111쪽)
    public static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }

    public Complex minus(Complex c) {
        return new Complex(re - c.re, im - c.im);
    }

    public Complex times(Complex c) {
        return new Complex(re * c.re - im * c.im,
                re * c.im + im * c.re);
    }

    public Complex dividedBy(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp,
                (im * c.re - re * c.im) / tmp);
    }

    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Complex))
            return false;
        Complex c = (Complex) o;

        // == 대신 compare를 사용하는 이유는 63쪽을 확인하라.
        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)";
    }
}
  • 피연산자에 함수를 적용해 결과를 반환하지만 피연산자 자체는 그대로인 프로그래밍 = 함수형 프로그래밍

  • 메서드에서 피연산자인 자신을 수정해 상태가 변하는 것 = 절차적, 명령형 프로그래밍

  • 함수형 프로그래밍을 하면 코드에서 불변이 되는 영역의 비율이 증가하는 장점이 있고, 불변 객체는 단순하다.

  • 가변 객체는 임의의 복잡한 상태에 놓일 수 있다.

  • 불변 객체는 근본적으로 스레드 안전하여 따로 동기화할 필요가 없다.

  • 여러 스레드가 동시에 사용해도 절대 훼손되지 않는다.(클래스를 스레드 안전하게 만드는 가장 쉬운방법, 그래서, 불변객체는 안심하고 공유할 수 있다:)

  • 적정 팩터리(야이템1)를 제공할 수 있는데, 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다.

  • 불변 객체를 자유롭게 공유할 수 있다는점은 방어적복사(아이템50)도 필요없다는 결론으로 이어진다.

  • 불변 객체끼리는 내부 데이터를 공유할 수 있다. 불변 객체들을 구성요소로 사용하면 불변식을 유지하기 수월하다는 이점. 불변 객체는 그 자체로 실패 원자성을 제공한다.

단점은?

값이 다르면 반드시 독립된 객체로 만들어야 한다. 값의 가짓수가 많다면 이들은 모두 만드는 데 큰 비용을 치러야 한다. 예컨대 백만 비트짜리 BigInteger에서 비트 하나만 바꿔야 한다고 해보자.

정리

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

  • 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자.

  • 다른 합당한 이유가 없다면 모든 필드는 private final이어야 한다.

  • 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

  • 확실한 이유가 없다면 생성자와 정적 팩터리 외에는 그 어떤 초기화 메서드도 public으로 제공해서는 안 된다.

  • 객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안 된다.
    => 복잡성만 커지고 성능 이점은 거의 없다.

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

참고하면 좋은 사이트 - https://github.com/LenKIM/object-book/tree/master/object-origin-book/chapter10

상속은 보통 코드를 재사용하는 강력한 수단이지만, 최선은 아니다. 오류를 만들어 내기 쉽다.

=> 매서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

→ 상위 클래스가 어떻게 구현되느냐에 따라 하위클래스 동작이 달라질 수 있다.

package effectivejava.chapter4.item18;
import java.util.*;

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

    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount());
    }
}

반환 예상 값 : 3

실제 출력 값 : 6

→ InstrumentedHashSet에서 구현된 addAll()이 HashSet의 addAll() 메서드를 호출하기 때문이다.

/**
     * {@inheritDoc}
     *
     * <p>This implementation iterates over the specified collection, and adds
     * each object returned by the iterator to this collection, in turn.
     *
     * <p>Note that this implementation will throw an
     * <tt>UnsupportedOperationException</tt> unless <tt>add</tt> is
     * overridden (assuming the specified collection is non-empty).
     *
     * @throws UnsupportedOperationException {@inheritDoc}
     * @throws ClassCastException            {@inheritDoc}
     * @throws NullPointerException          {@inheritDoc}
     * @throws IllegalArgumentException      {@inheritDoc}
     * @throws IllegalStateException         {@inheritDoc}
     *
     * @see #add(Object)
     */
    public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for (E e : c)
            if (add(e))
                modified = true;
        return modified;
    }

여기서 add() 메서드가 호출될때 위에서 재정의한 메서드가 호출된다.

안전한 방법은?

  1. addAll 메서드를 재정의 하지 않는다 → HashSet의 addAll과 add가 어떻게 동작하는지 안다는 가정.
  2. addAll 메서드를 다른방식으로 재정의 → HashSet의 addAll을 호출하지 않는 방법( 첫번째 방법보다는 나음 )
  3. 매서드를 재정의하기 보다는 새로운 메서드를 추가해서 사용 → 문제가 될 가능성은 여전히 존재 ( 이름이 같은경우, return type 같은 경우)
  4. 확장 대신 새로운 클래스를 생성, private 필드로 기존 클래스의 인스턴스를 참조 ( 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 의미로 이 같은 설계를 composition이라고 함.)
// 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽)
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(); }
}


---

// 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽)
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;
    }

    public static void main(String[] args) {
        InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount());
    }
}
  • 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 wrapper 클래스라고 하고 다른 Set에 측정, 기록 하는 기능을 덧씌운다는 뜻으로 Decorator pattern이라고 한다.

  • 상속은 is-a 관계를 만족하는 두개의 클래스에서만 구현해야 한다. (ex. A와 B 클래스 : B는 A인가? 를 만족)

  • Java에서 이 원칙을 위반한 클래스는 Stack 과 Vector, Properties 와 HashTable 이 있다고 한다.

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

  • 상속용 클래스는 재정의할 수 있는 매서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
/** 
* {@inheritDoc} 
* 
* @implSpec 
* This implementation iterates over the collection looking for the 
* specified element. If it finds the element, it removes the element 
* from the collection using the iterator's remove method. 
* 
* Note that this implementation throws an 
* {@code UnsupportedOperationException} if the iterator returned by this 
* collection's iterator method does not implement the {@code remove} 
* method and this collection contains the specified object. 
* 
* @throws UnsupportedOperationException {@inheritDoc} 
* @throws ClassCastException {@inheritDoc} 
* @throws NullPointerException {@inheritDoc} 
*/

iterator 매서드를 재정의하면 remove 메서드의 동작에 영향을 준다는 설명하는 주석

  • 상속용 클래스를 설계 할때는 직접 하위 클래스를 만들어서 검증이 필요하다.

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

package effectivejava.chapter4.item19;

// 재정의 가능 메서드를 호출하는 생성자 - 따라 하지 말 것! (115쪽)
public class Super {
    // 잘못된 예 - 생성자가 재정의 가능 메서드를 호출한다.
    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}
package effectivejava.chapter4.item19;

import java.time.Instant;

// 생성자에서 호출하는 메서드를 재정의했을 때의 문제를 보여준다. (126쪽)
public final class Sub extends Super {
    // 초기화되지 않은 final 필드. 생성자에서 초기화한다.
    private final Instant instant;

    Sub() {
        instant = Instant.now();
    }

    // 재정의 가능 메서드. 상위 클래스의 생성자가 호출한다.
    @Override public void overrideMe() {
        System.out.println(instant);
    }

    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}
  • 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일' 하다.
  • 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.
  • 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
  • 비슷한 효과를 내는 Cloneable, Serializable을 구현해야 할 때도 마찬가지이다.
  • 클래스를 확장해야할 이유가 명확하지 않다면 상속을 금지해라.
  • 금지하는 방법으로는 final 선언, 생성자 모두를 외부에서 접근 할 수 없도록 변경한다.

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

  • 인터페이스와 추상클래스의 가장 큰 차이는 추상클래스를 구현한 하위클래스는 반드시 추상클래스의 하위 타입이 되어야한다는 것

  • 반면에, 인터페이스가 선언한 메서드를 모두 정의하고 그 일반 규약을 잘 지킨 클래스라면 다른 어떤 클래스를 상속했든 같은 타입으로 취급. 즉, 기존 클래스에도 손쉽게 새로운 언터페이스를 구현해 넣을수 있다.
    ex) Comparable, Iterable, AutoCloseable

  • 인터페이스는 믹스인(mixin) 정의에 적합하다.

  • mixin : 클래스가 구현할 수 있는 타입으로, 원래의 "primary type" 외에 특정 부가적인 행위를 제공하고 선언하는 효과를 준다(?)

    ex) Comparable 인터페이스는 자신을 구현한 클래스의 인스턴스끼리의 순서를 정할 수 있다고 선언하는 mixin interface

    대상 타입의 주된 기능에 선택적 기능을 '혼합(mixed in)' 한다고 해서 믹스인이라 부른다.

  • 인터페이스를 이용해서 계층구조가 없는 타입 프레임워크를 만들 수 있다.

  • 현실적으로 계층을 엄격히 구분하기 어려울때 인터페이스를 사용하면 좋음 (ex singer, songwriter)

  • 인터페이스는 기능을 향상시키고 안전하고 강력한 수단이 된다. 타입을 추상 클래스로 정의해두면 그 타입에 기능을 추가하는 방법은 상속뿐이다. 상속해서 만든 클래스는 래퍼클래스는 래퍼클래스보다 활용도가 떨어지고 깨지기는 더 쉽다.

public interface Singer {
  AudioClip sing(Song s); 
}  

public interface Songwriter {
  Song compose(boolean hit); 
}  

public interface SingerSongwriter extends Songwriter, Singer{
  AudioClip strum();
  void actSensitive();
} 
  • 인터페이스와 추상 골격 구현(skeletal implementation) 클래스를 함께 제공하여 2개의 장점을 모두 취하는 방법도 있다. 인터페이스로는 타입을 정의하고, 필요하면 디폴트 메서드 몇 개도 함께 제공하자. 그리고 골격 구현 클래스는 나머지 메서드들까지 구현한다.
  • 관계상 인터페이스 이름이 Interface 라면 그 골격 구현 클래스의 이름은 AbstractInterface 로 짓기. (ex, AbstractCollection, AbstractSet, AbstarctList, AbstarctMap 각각이 바로 핵심 컬렉션 인터페이스의 골격 구현)
// 코드 20-1 골격 구현을 사용해 완성한 구체 클래스 (133쪽)
public class IntArrays {
    static List<Integer> intArrayAsList(int[] a) {
        Objects.requireNonNull(a);

        // 다이아몬드 연산자를 이렇게 사용하는 건 자바 9부터 가능하다.
        // 더 낮은 버전을 사용한다면 <Integer>로 수정하자.
        return new AbstractList<>() {
            @Override public Integer get(int i) {
                return a[i];  // 오토박싱(아이템 6)
            }

            @Override public Integer set(int i, Integer val) {
                int oldVal = a[i];
                a[i] = val;     // 오토언박싱
                return oldVal;  // 오토박싱
            }

            @Override public int size() {
                return a.length;
            }
        };
    }

    public static void main(String[] args) {
        int[] a = new int[10];
        for (int i = 0; i < a.length; i++)
            a[i] = i;

        List<Integer> list = intArrayAsList(a);
        Collections.shuffle(list);
        System.out.println(list);
    }
}
  • 골격 구현 클래스의 아름다움은 추상 클래스처럼 구현을 도와주는 동시에, 추상 클래스로 타입을 정의할 때 따라오는 심각한 제약에서는 자유롭다는 점.
// 코드 20-2 골격 구현 클래스 (134-135쪽)
public abstract class AbstractMapEntry<K,V>
        implements Map.Entry<K,V> {
    // 변경 가능한 엔트리는 이 메서드를 반드시 재정의해야 한다.
    @Override public V setValue(V value) {
        throw new UnsupportedOperationException();
    }
    
    // Map.Entry.equals의 일반 규약을 구현한다.
    @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());
    }

    // Map.Entry.hashCode의 일반 규약을 구현한다.
    @Override public int hashCode() {
        return Objects.hashCode(getKey())
                ^ Objects.hashCode(getValue());
    }

    @Override public String toString() {
        return getKey() + "=" + getValue();
    }
}
  • 단순 구현은 골격 구현의 작은 변종으로, AbstractMap.SimpleEntry 가 좋은 예다. 단순 구현도 골격 구현과 같이 상속을 위해 인터페이스를 구현한 것이지만, 추상 클래스가 아니란 점이 다르다.

참고자료

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

  • 자바8 이전에는 인터페이스에 메서드를 추가하면 보통 컴파일 오류가 발생했다. 왜냐면, 구현 클래스들에서 구현을 하지 않았기 때문
  • 자바8 부터는 인터페이스에 디폴트 메서드를 추가할 수 있지만 *<생각할 수 있는 모든 상황에서 불변식을 해치지 않는 디폴트 메서드를 작성하는 방법>*은 어렵다.
  • 디폴트 메서드는 구현 클래스에 대해 아무것도 모른 채 합의 없이 무작정 '삽입'될 뿐이다.

ex) apache commons의 SynchronizedCollection 에서 removeIf() 메서드를 호출 할 경우.

  • 기존 인터페이스에 디폴트 메서드로 새 메서드를 추가하는 일은 꼭 필요한 경우가 아니라면 피하도록 하자.

  • 디폴트 메서드는 (컴파일에 성공하더라도) 기존 구현체에 런타임 오류를 일으킬 수 있다.

  • 디폴트 메서드라는 도구가 생겼더라도 인터페이스를 설계할 때는 여전히 세심한 주의를 기울여야 한다.


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

  • 인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할.
  • 클래스가 어떤 인터페이스를 구현한다는 것은 자신의 인스턴스로 무엇을 할 수 있는지를 말해주는 것. 인터페이스는 오직 이 용도로만 사용 할 것

ex) 잘못 사용하는 예 (상수 인터페이스 - public static final 필드들만 있는 인터페이스)

package chapter4.item22.constantinterface;

// 코드 22-1 상수 인터페이스 안티패턴 - 사용금지! (139쪽)
public interface PhysicalConstants {
    // 아보가드로 수 (1/몰)
    static final double AVOGADROS_NUMBER   = 6.022_140_857e23;

    // 볼츠만 상수 (J/K)
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;

    // 전자 질량 (kg)
    static final double ELECTRON_MASS      = 9.109_383_56e-31;
}

클래스에서 사용하는 상수는 외부 인터페이스가 아니라 내부 구현에 해당. 아무런 의미가 없고 혼란만 준다. 더 심하게는 클라이언트 코드가 내부 구현에 해당하는 이 상수들에 종속되게 한다. (무슨말이지...?)

상수를 공개할 목적이라면, Integer.MAX_VALUE, enum type, 정적 유틸리티 클래스 등에서 사용하는것과 같이 연관된 특정 클래스나 인터페이스에 해당 상수를 추가해서 사용하는 것이 훨씬 좋다. 좀 더 명확해진다.

package chapter4.item22.constantutilityclass;

// 코드 22-2 상수 유틸리티 클래스 (140쪽)
public class PhysicalConstants {
  private PhysicalConstants() { }  // 인스턴스화 방지

  // 아보가드로 수 (1/몰)
  public static final double AVOGADROS_NUMBER = 6.022_140_857e23;

  // 볼츠만 상수 (J/K)
  public static final double BOLTZMANN_CONST  = 1.380_648_52e-23;

  // 전자 질량 (kg)
  public static final double ELECTRON_MASS    = 9.109_383_56e-31;
}

인터페이스는 타입을 정의하는 용도로만 사용해야 한다. 상수 공개용 수단으로 사용하지 말자.

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

  • 두가지 이상의 의미를 표현할 수 클래스의 경우 클래스 계층 구조를 활용해라.

ex) tagged class

// 코드 23-1 태그 달린 클래스 - 클래스 계층구조보다 훨씬 나쁘다! (142-143쪽)
class Figure {
    enum Shape { RECTANGLE, CIRCLE };

    // 태그 필드 - 현재 모양을 나타낸다.
    final Shape shape;

    // 다음 필드들은 모양이 사각형(RECTANGLE)일 때만 쓰인다.
    double length;
    double width;

    // 다음 필드는 모양이 원(CIRCLE)일 때만 쓰인다.
    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);
        }
    }
}
  • 단점 : 여러 구현이 한 클래스에 혼합되어 있어 가독성도 나쁘고 사용하지 않는 필드들도 초기화 해야한다. 즉, 장황하고, 오류를 내기 쉬우며 비효율 적이다.
abstract class Figure {
  abstract double area(); 
} 

class Circle extends Figure {
  final double radius;
  Circle(double radius) {
    this.radius = radius; 
  }
  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;
  }
  double area() {
    return length * width; 
  } 
}
  • 간결, 명확해짐. 확장성 있는 형태로 변경
  • 태그 달린 클래스의 경우 클래스 계층을 이용해서 리팩토링하는 방법을 고민해라

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

  • 중첩클래스(nested class)란 클래스 안에 정의된 클래스를 말한다. nested class는 자신을 감싼 바깥 클래스에서만 쓰여야 한다.

  • 종류는 정적 멤버 클래스, 맴버 클래스, 익명 클래스, 지역 클래스 가 있다.

  • 맴버 클래스의 인스턴스 각각이 바깥 클래스의 인스턴스에 접근할 일이 없다면 무조건 static을 붙여서 정적 멤버 클래스로 만든다. 아니면 비정적으로 만든다.

  • 비정적의 경우 바깥 인스턴스에 대한 숨은 참조가 가능한데 이는 시간과 공간에 대한 비용이 들어가고 이 참조 때문에 GC가 인스턴스를 제때 수거하지 못하는 메모리 누수가 생길 수도 있다고 한다.

Item 25. 톱 레벨 클래스는 한 파일에 하나만 담아라

  • 소스 파일 하나에 여러개의 톱레벨 클래스가 선언되더라도 자바 컴파일러는 문제를 삼지 않는다.
  • 하지만 컴파일러가 한 클래스에 대한 정의를 여러개 만들 수 있고 바이너리 파일이나 프로그램의 동작이 순서에 따라 달리질 수 있기 때문에 한 파일에는 하나의 톱 레벨 클래스만 담자.

프로그램 동작에 문제가 되는 코드

public class Main {
  public static void main(String[] args) {
    System.out.println(Utensil.NAME + Dessert.NAME);
  }
}
//Utensil.java
class Utensil {
  static final String NAME = "pan";
}

class Dessert {
  static final String NAME = "cake";
}
// 코드 25-2 두 클래스가 한 파일(Dessert.java)에 정의되었다. - 따라 하지 말 것! (151쪽)
class Utensil {
    static final String NAME = "pot";
}

class Dessert {
    static final String NAME = "pie";
}

이런 식의 코드가 있을 때, 컴파일 방식에 따른

  • javac Main.java Dessert.java: ?

  • javac Main.java or javac Main.java Utensil.java : ?

  • javac Dessert.java Main.java : ?

정답은 단순하다. 소스를 분리하면 그만이다! 그러나, 굳이 여러 톱레벨 클래스를 한파일에 담고 싶다면 정적 멤버 클래스를 사용하는 방법을 고민해볼 수 있다.

// 코드 25-3 톱레벨 클래스들을 정적 멤버 클래스로 바꿔본 모습 (151-152쪽)
public class Test {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }

    private static class Utensil {
        static final String NAME = "pan";
    }

    private static class Dessert {
        static final String NAME = "cake";
    }
}
⚠️ **GitHub.com Fallback** ⚠️