elevne's Study Note

Effective Java CH4: 클래스와 인터페이스 본문

Backend/Effective Java

Effective Java CH4: 클래스와 인터페이스

elevne 2023. 6. 25. 15:04

아이템 15. 클래스와 멤버의 접근 권한을 최소화하라

 

잘 설계된 컴포넌트들은 모두 내부 구현을 완벽히 숨겨 구현과 API 를 깔끔히 분리하며, 오직 API 를 통해서만 다른 컴포넌트와 소통한다. 정보은닉, 캡슐화라고 하는 이 개념은 소프트웨어 설계의 근간이 된다. 자바는 정보은닉을 위한 다양한 장치를 제공하는데, 접근 제한자가 대표적이며 이를 제대로 활용하는 것이 캡슐화의 핵심인 것이다. 기본 원칙은, 모든 클래스와 멤버의 접근성을 가능한 한 좁히는 것이다.

 

 

탑 레벨의 클래스와 인터페이스에는 public 혹은 package-private (package-private 는 아무런 접근제어자를 붙여주지 않으면 자동으로 설정됨) 를 부여해줄 수 있다. public 으로 선언하면 공개 API 가 되고, package-private 로 선언하면 해당 패키지 안에서만 사용할 수 있다. 서적에서는 public 일 필요없는 클래스의 접근 수준을 package-private 로 설정해주는 것의 중요성을 강조한다. (한편, 다른 곳에서는 이렇게 사용하지 않는 편이 좋다는 의견도 있다..!)

 

링크: https://hyeon9mak.github.io/Java-dont-use-package-private/

 

Java package-private 은 안쓰나요?

 

hyeon9mak.github.io

 

 

멤버에 부여할 수 있는 접근수준은 네 가지다.

 

  • private: 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다
  • package-private: 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다 (접근제어자를 명시하지 않을 때 적용됨. 단, 인터페이스는 public 이 적용됨)
  • protected: package-private 범위를 포함, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다
  • public: 모든 곳에서 접근할 수 있다

 

public 클래스의 인스턴스는 되도록 public 이 아니어야 한다. 필드가 가변객체를 참조하거나, final 이 아닌 인스턴스 필드를 public 으로 선언하면 그 필드에 담을 수 있는 값을 제한할 힘을 잃게된다. 또, 필드가 수정될 때 (락 획득 같은) 다른 작업을 할 수 없게 되므로 public 가변 필드를 갖는 클래스는 일반적으로 Thread-safe 하지 않다고 한다. 클래스에서 public static final 배열을 두거나 이 필드를 반환하는 접근자 메소드를 제공해서도 안된다. 이런 필드나 접근자를 제공하면 클라이언트에서 해당 배열의 내용을 수정할 수 있게된다.

 

 

public static final Thing[] VALUES = { ... };

 

 

위와 같은 멤버를 다루는 해결책은 두 가지가 있다. 첫 번째로는 위 VALUES 를 private 으로 바꾸고 public 불변 List<> 멤버를 추가하는 것이다. 두 번째는 위 배열을 private 으로 만든 뒤, 그 복사본을 반환하는 public 메소드를 추가하는 것이다.

 

 

자바 9 에서는 모듈 시스템이라는 개념이 도입되어, 두 가지 암묵적 접근 수준이 추가되었다. 모듈은 패키지들의 묶음인데, 자신에 속하는 패키지 중 공개할 것들을 선언할 수 있게 된 것이다. (protected 혹은 public 이라고 하더라도 해당 패키지를 공개하지 않았다면 외부 모듈에서는 접근이 불가능한 것) 모듈 시스템을 활용하면 클래스를 외부에 공개하지 않으면서도 같은 모듈을 이루는 패키지 사이에서는 자유롭게 공유할 수 있다.

 

 

 

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

 

public 클래스를 사용한다면 접근자를 제공함으로써 멤버들을 private 로 사용한다. (반면, package-private, private 중첩클래스라면 데이터 필드를 노출하여도 문제가 생기지 않는다) 그런데 자바 플랫폼 라이브러리에서도 public 클래스의 필드를 직접 노출하지 말라는 규칙을 어기는 사례가 종종 있다. 대표적으로 java.awt.package 패키지의 Point, Dimension 이 있는데, 내부를 노출한 Dimension 클래스의 심각한 성능 문제는 아직도 해결되지 못하고 있다고 한다. public 클래스의 필드가 불변이라면 직접 노출할 때의 단점이 줄어들긴 하지만, 여전히 좋은 방법은 아니라고 한다. API 를 변경하지 않고는 표현 방식을 바꿀 수 없고, 필드를 읽을 때 부수 작업을 수행할 수 없다는 단점이 있다.

 

 

 

아이템 17. 변경 가능성을 최소화하라

 

불변클래스란 그 인스턴스 내부 값을 수정할 수 없는 클래스다. 불변 인스턴스에 간직된 정보는 고정되어 객체가 파괴되는 순간까지 절대 달라지지 않는다. (자바 플랫폼 라이브러리에는 String, BigInteger, BigDecimal 이 그 대표적 예다) 불변클래스는 가변클래스보다 설계하고 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적으며 훨씬 안전하다. 클래스는 아래 다섯 가지 규칙을 통해 불변으로 만들어질 수 있다.

 

  1. 객체의 상태를 변경하는 메소드를 제공하지 않는다
  2. 클래스를 확장할 수 없게한다 (대표적인 방법은 클래스를 final 선언하는 것)
  3. 모든 필드를 final 선언한다
  4. 모든 필드를 private 으로 선언한다
  5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다: 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 그 객체의 참조를 얻을 수 없도록 해야한다. 이런 필드는 절대 클라이언트가 제공한 객체 참조를 가리키게 해서는 안되고, 접근자 메소드가 그 필드를 그대로 반환해서도 안된다. (생성자, 접근자, readObject 모두에서 방어적 복사를 수행한다)

 

이전에 계속 사용한 PhoneNumber 클래스도 위에 해당한다. 이번에는 다른 예시를 한 번 작성해본다. (불변클래스를 상속받지 못하게 설정할 때는 클래스를 final 선언할 수도, 아래와 같이 팩토리 메소드를 사용할 수도 있다)

 

 

public class Complex {

    private final double re;
    private final double im;

    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }

    public double readPart() { 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);
    }

    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;
        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 인스턴스를 반환한다. (이와 같이 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라고 한다. 이와 반대로 자신을 수정하여 자신의 상태가 변하는 것은 절차적/명령형 프로그래밍이라고 한다) 위와 같이 함수형 프로그래밍을 기반으로 작성할 때는 (사칙연산) 메소드 이름을 동사로 짓지 않고 전치사를 사용한다. (divide 대신 dividedBy 와 같이)

 

 

위와 같은 불변객체는 단순하다. 임의의 복잡한 상태에 놓일 수 있는 가변객체보다 다루기가 쉽다. 불변객체는 근본적으로 Thread-safe 하여 따로 동기화할 필요가 없다. 불변객체에 대해서는 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없으니 불변 객체는 안심하고 공유할 수 있다. (상수로 활용될 수 있는 것) 이러한 불변객체에는 방어적 복사도 필요하지 않다. 아무리 복사해도 원본과 똑같으니 의미가 없는 것이다. 그래서 불변 클래스에는 clone 메소드나 복사 생성자를 제공하지 않는 편이 좋다. (String 의 복사생성자는 이 사실을 잘 이해하지 못한 자바 초창기 때 만들어진 것으로 되도록 사용하지 않도록 한다)

 

 

이러한 불변클래스의 단점은, 값이 조금이라도 다르면 반드시 독립된 객체로 만들어져야 하며, 이 때 큰 비용을 치러야 할 수도 있다는 것이다. 이러한 문제에 대처하기 위해 다단계 연산을 예측하여 기본 기능으로 제공할 수 있다. 예를 들어 BigInteger 은 모듈러 지수같은 다단계 연산 속도를 높여주는 동반 클래스를 package-private 로 두고있다. 클라이언트들이 원하는 복잡한 연산들을 정확히 예측할 수 있다면 package-private 의 가변동반 클래스만으로 충분하다. (그렇지 않다면 클래스를 public 으로 제공하는게 최선이다) 자바 플랫폼 라이브러리에서 이에 해당하는 대표적인 예가 String 이다. String 의 가변 동반 클래스는 StringBuilder 과 (책에서 구단다리 전임자라 표현하는) StringBuffer 이 있다.

 

 

정리하자면, getter 이 있다고 해서 setter 을 무작정 만들지 않는다. 클래스는 꼭 필요한 경우가 아니라면 불변이어야 하기 때문이다. 불변클래스는 장점이 많으며, 단점이라곤 특정 상황에서의 잠재적 성능 저하 뿐이다. 불변으로 만들 수 없는 클래스더라도 변경할 수 있는 부분을 최소한으로 줄이고, 다른 합당한 이유가 없다면 모든 필드는 private final 로 설정한다. 또, 불변 객체의 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야한다.

 

 

 

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

 

메소드 호출과 달리 상속은 캡슐화를 깨뜨린다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다는 뜻이다. 또, 상위 클래스가 변경되었을 때 하위 클래스가 오작동할 수도 있다. 아래와 같은 예를 살펴본다.

 

 

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

}

 

 

원소가 3 개 들어있는 리스트를 만들어 위 addAll 메소드를 호출한다고 생각해본다. 그 다음 getAddCount() 를 사용하면 3 을 반환해야할 것 같지만, 실제로는 6 을 반환한다. 그 원인은 HashSet 의 addAll 메소드가 add 메소드를 사용해 구현된 데이 있다. (이러한 내부 구현 방식은 HashSet 문서에는 쓰여있지 않다) 이처럼 상위 클래스의 내부 구현 방식을 자세히 알아내기 어렵다는 점과, 상위 클래스가 변경되어 새로 만든 해당 클래스를 상속받는 자식 클래스가 깨지기 쉽다는 점이 단점이다. 위와 같이 상속을 사용하는 대신에 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는, 컴포지션 방식을 사용할 수 있다.

 

 

새 클래스의 인스턴스 메소드들은 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다. (이 방식을 Forwarding 이라고 한다) 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향을 받지 않는다.

 

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> {

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

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

}

 

 

위 InstrumentedSet 은 HashSet 의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 유연하다. 위 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체더라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.

 

 

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>());

 

 

다른 Set 인스턴스를 감싸고 있다는 뜻에서 위와 같은 클래스를 래퍼클래스라고 하며, 다른 Set 에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. 이러한 래퍼 클래스는 단점이 거의 없다고 한다. 다만, 콜백 프레임워크와는 어울리지 않는다고 한다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다고 한다.

 

 

상속은 반드시 하위클래스가 상위클래스의 "진짜" 하위 타입일 때만 사용한다. 컴포지션을 써야할 상황에서 상속을 사용하는 것은 내부 구현을 불필요하게 노출하며, API 가 내부구현에 묶이고 그 클래스의 성능도 제한된다. (더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다) 상속을 결정하기 전에는 항상 확장하려는 클래스의 결함 여부를 확인한다. 결함이 있다면 컴포지션으로는 이를 숨길 수 있지만, 상속은 상위클래스의 결함까지도 승계하게 된다.

 

 

 

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

 

상속용 클래스는 재정의할 수 있는 메소드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야한다. 자바 8 이후부터는 @implSpec 을 사용하여 조금 더 간단하게 자바독으로 문서화할 수 있다. 하지만 내부 매커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다. 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정에 끼어들 수 있는 훅(Hook)을 잘 선별하여 protected 메소드 형태로 공개해야할 수도 있다. 하위클래스에서는 protected 로 공개된 메소드를 사용하여 조금 더 좋은 성능의 메소드를 개발할 수 있을 것이다.

 

 

이러한 상속용 클래스를 시험하는 방법은 직접 하위클래스를 만들어보는 것이 유일하다. 꼭 필요한 protected 멤버를 놓쳤다면 하위클래스를 작성할 때 그 빈자리가 확연히 드러나지만, 하위클래스를 여러 개 만들 때까지 전혀 사용하지 않는 protected 멤버는 private 이었어야할 가능성이 큰 것이다.

 

 

상속을 허용하는 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안된다. 상위클래스의 생성자가 하위클래스의 생성자보다 먼저 실행되므로 하위클래스에서 재정의한 메소드가 하위클래스의 생성자보다 먼저 호출된다. 이 때 그 재정의한 메소드가 하위클래스의 생성자에서 초기화하는 값에 의존하다면 의도대로 동작하지 않게 되는 것이다.

 

 

CloneableSerializable 은 상속용 설계의 어려움을 한 층 더한다. 둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이라고 한다. clone 과 readObject 메소드는 생성자와 비슷한 효과를 낸다 (새로운 객체를 생성). 따라서 상속용 클래스에서 Cloneable 이나 Serializable 을 구현할지 정해야한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점에 주의한다. (clone 과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안된다. readObject 의 경우 하위클래스의 상태가 미처 다 역직려로하되기 전에 재정의한 메소드부터 호출하게된다. clone 의 경우 하위클래스의 clone 메소드가 복제본 상태를 수정하기 전에 재정의한 메소드를 호출한다. 또, Serializable 을 구현한 상속용 클래스가 readResolve 나 writeReplace 를 갖는다면 이 메소드들은 private 이 아닌 protected 로 선언해야 한다 (private 로 선언하면 하위클래스에서 무시))

 

 

위와 같이 클래스를 상속용으로 설계하기 위해서는 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당하다. 상속용으로 설계하지 않은 클래스는 상속을 금지(클래스 final 선언 혹은 팩토리메소드 방식)하고, 필요할 때는 컴포지션 방식을 사용하는 것이다.

 

 

 

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

 

자바는 인터페이스와 추상클래스, 두 가지의 다중 구현 메커니즘을 제공한다. 자바 8 부터는 인터페이스도 디폴트 메소드를 제공할 수 있어 두 메커니즘 모두 인스턴스 메소드를 구현 형태로 제공할 수 있다. 차이는, 추상클래스가 정의한 타입을 구현하는 클래스는 반드시 추상클래스의 하위클래스가 된다는 것이다. (자바는 단일상속) 반면 인터페이스가 선언한 메소드를 모두 정의하고 그 일반규약을 잘 지킨 클래스라면 다른 어떤 클래스를 상속했든 같은 타입으로 취급한다. (기존 클래스에도 손쉽게 새로운 인터페이스를 구현할 수 있다) 이러한 인터페이스를 사용하면 계층구조가 없는 타입 프레임워크를 만들 수 있으며, 래퍼클래스 관용구와 함께 사용하면 인터페이스 기능을 향상시키는 안전하고 강력한 수단이 된다. 반면 추상클래스를 상속해 사용하는 것은 래퍼클래스보다 사용도가 떨어지고 깨지기는 더 쉽다.

 

 

한편, 인터페이스와 추상 골격 구현 클래스를 함께 제공하는 식으로 인터페이스와 추상클래스의 장점을 모두 취하는 방식도 있다. 인터페이스로는 타입을 정의 (필요하면 디폴트 메소드도) , 골격 구현 클래스는 나머지 메소드들까지 구현한다. 이렇게 해두면 단순히 골격 구현을 확장하는 것만으로 이 인터페이스를 구현하는데 필요한 일이 대부분 완료된다. 이를 템플릿 메소드 패턴이라고 한다. 관례상 인터페이스 이름이 A 라면, 그 골격 구현 클래스의 이름은 AbstractA 로 짓는다.

 

 

static List<Integer> intArrayAsList(int[] a) {
    Objects.requireNonNull(a);
    return new AbstractList<Integer>() {
        @Override
        public Integer get(int index) {
            return a[index];
        }

        @Override
        public Integer set(int i, Integer val) {
            int oldVal = a[i];
            a[i] = val;
            return oldVal;
        }

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

 

 

위 메소드는 골격 구현의 힘을 잘 보여주는 예시이다. 골격 구현 클래스는 추상클래스처럼 구현을 도와주는 동시에, 추상클래스로 타입을 정의할 때 따라오는 제약에서는 자유롭다. 위와 같이 골격 구현 작성을 하기 위해서는 먼저 인터페이스를 살펴 다른 메소드들의 구현에 사용되는 기반 메소드들을 선정한다. 이 기반 메소드들은 골격 구현에서는 추상 메소드가 된다. 그 다음으로, 기반 메소드들을 사용해 직접 구현할 수 있는 메소드를 모두 디폴트 메소드로 제공한다. (단, equals 나 hashCode 같은 Object 의 메소드는 디폴트 메소드로 제공하면 안된다) 기반 메소드나 디폴트 메소드로 만들지 못한 메소드가 남아있다면, 이 인터페이스를 구현하는 골격 구현 클래스를 하나 만들어 남은 메소드들을 작성해 넣는다. (골격 구현은 상속해서 사용하는 것을 가정하므로 아이템 19 의 지침들을 따라야한다) 

 

 

 

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

 

생각할 수 있는 모든 상황에서 불변식을 해치지 않는 인터페이스 디폴트 메소드를 작성하는 것은 어려운 일이다. 디폴트 메소드는 컴파일에 성공하더라도, 기존 구현체에 런타임 오류를 일으킬 수 있다. 기존 인터페이스에 디폴트 메소드로 새 메소드를 추가하는 일은 꼭 필요한 경우가 아니면 피한다.

 

 

 

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

 

인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할을 한다. 인터페이스는 오직 이 용도로만 사용되어야 한다고 한다. 이 지침에 맞지 않는, 상수 인터페이스라는 것이 있다. 상수 인터페이스란 메소드 없이, 상수를 뜻하는 static final 필드로만 가득 찬 인터페이스를 말한다.

 

 

public interface PhysicalConstants {
    static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    static final double BOLTMAMM_CONSTANT = 1.380_648_52e-23;
}

 

 

상수 인터페이스 안티패턴은 인터페이스를 잘못 사용한 예라고 한다. 클래스 내부에서 사용하는 상수는 외부 인터페이스가 아니라 내부 구현에 해당한다. 따라서 상수 인터페이스를 구현하는 것은 이 내부 구현을 클래스의 API 로 노출하는 행위다. 상수 인터페이스를 사용하는 것은 사용자에게 혼란을 주기도 하며, 더 심하게는 클라이언트 코드가 내부 구현에 해당하는 이 상수들에 종속되게 한다. 그래서 다음 릴리스에서 이 상수들을 더는 쓰지 않게 되더라도 바이너리 호환성을 위해 여전히 인터페이스를 구현하고 있어야한다.

 

 

상수를 공개할 목적이라면, 특정 클래스나 인터페이스와 강하게 연결된 상수라면 그 클래스나 인터페이스 자체에 추가해야한다. (Integer, Double 의 MIN_VALUE, MAX_VALUE 처럼) enum 타입으로 나타내기 적합하다면 enum 으로 만들어 공개한다. 아래와 같이 바꿔 작성하는 것이다.

 

 

public class PhysicalConstant {
    private PhysicalConstant() {}
    public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    public static final double BOLTMAMM_CONSTANT = 1.380_648_52e-23;
}

 

 

자바 7 부터는 위와 같이 숫자 리터럴에 밑줄(_)을 사용할 수 있다. 이는 값에는 아무런 영향도 주지 않으면서, 읽기는 더 편하게 해준다.

 

 

 

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

 

두 가지 이상의 의미를 표현하는, 태그가 달린 클래스를 볼 수 있다.

 

 

public class Figure {
    enum Shape {RECTANGLE, CIRCLE}

    private Shape shape;

    private double length;
    private double width;

    private double radius;

    public Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    public Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }

    private double area() {
        switch (shape) {
            case RECTANGLE:
                return length + width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

 

 

태그 달린 클래스는 단점이 많다. 열거타입 선언, 태그 필드, switch 문 등 쓸데없는 코드가 많다. 이는 너무 장황하고, 오류를 내기 쉬우며 비효율적이다. 책에서는 태그 달린 클래스는 클래스 계층구조를 어설프게 흉내낸 아류일 뿐이라 표현한다. 위 코드를 아래와 같이 나누어 작성해보는 것이다.

 

 

abstract class Figure {
  abstract dobule 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 이다. 이러한 방식으로는 실수도 적어지며, 다른 프로그래머들이 독립적으로 계층구조를 확장하고 함께 사용할 수 있다. 타입이 의미별로 따로 존재하니 변수의 의미를 명시하거나 제한할 수 있으며, 특정 의미만 매개변수로 받을 수도 있다. 태그 달린 클래스를 사용해야하는 경우는 거의 없다.

 

 

 

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

 

중첩클래스란 다른 클래스 안에 정의된 클래스로, 중첩클래스는 자신을 감싼 바깥 클래스에서만 쓰여야한다. (그 외의 쓰임새가 있다면 톱레벨 클래스로 만든다) 중첩클래스는 정적 멤버 클래스, (비정적) 멤버 클래스, 익명 클래스, 지역 클래스 네 종류로 나뉜다. 이 중 첫 번째를 제외한 나머지는 내부 클래스이다.

 

 

  1. 정적 멤버 클래스: 흔히 바깥 클래스와 함께 쓰일 때만 유용한 public 도우미 클래스로 쓰인다. 멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면 static 을 붙여서 정적 멤버 클래스로 만든다. static 을 생략하면 바깥 인스턴스로의 숨은 외부참조를 갖게되고, 이 참조를 저장하려면 시간과 공간이 소비된다. 더 심각하게는 가비지 콜렉션이 바깥 클래스의 인스턴스를 수거하지 못하는 메모리 누수가 발생할 수 있다는 점이다. private static 멤버 클래스는 흔히 바깥 클래스가 표현하는 객체의 한 부분을 나타낼 때 사용된다. 예를 들어 키와 값을 매핑하는 Map 인스턴스에서, 많은 Map 구현체는 각각의 키-값 쌍을 표현하는 엔트리 객체를 가진다. 모든 엔트리가 맵과 연관되어 있지만, 엔트리의 메소드들은 맵을 직접 이용하지는 않는다. 따라서 이를 비정적 클래스로 표현하는 것은 낭비이며 private 정적 멤버 클래스가 가장 알맞은 것이다.
  2. 비정적 멤버 클래스: 바깥 클래스의 인스턴스와 암묵적으로 연결된다. 정규화된 this (클래스명.this 형태로 바깥 클래스의 이름을 명시하는 용법) 를 사용해 바깥 인스턴스의 메소드를 호출하거나 바깥 인스턴스의 참조를 가져올 수 있다. 따라서 개념상 중첩 클래스 인스턴스가 바깥 인스턴스와 독립적으로 존재할 수 있다면 정적 멤버 클래스로 만들어야한다. 비정적 멤버 클래스는 바깥 인스턴스 없이는 생성할 수 없기 때문이다. 비정적 멤버 클래스는 어댑터를 정의할 때 자주 사용된다. 어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용한다는 뜻이다. 예를 들어 Map 인터페이스의 구현체들은 보통 자신의 콜렉션 뷰를 구현할 때 비정적 멤버 클래스를 사용한다.
  3. 익명 클래스: 이는 멤버와 달리 쓰이는 시점에 선언과 동시에 인스턴스가 만들어진다. 비정적인 문맥에서 사용될 때만 바깥 클래스의 인스턴스를 참조할 수 있고, 정적 문맥에서라도 상수변수 외의 정적 멤버는 가질 수 없다. 익명 클래스는 여러 인터페이스를 구현할 수 없고, 인터페이스를 구현하는 동시에 다른 클래스를 상속할 수도 없다. 표현식 중간에 등장하므로 짧지 않으면 가독성이 떨어진다.
  4. 지역 클래스: 넷 중 가장 드물게 사용된다. 지역변수를 선언할 수 있는 곳이라면 어디서든 선언할 수 있으며, 유효범위도 지역변수와 같다.

 

 

 

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

 

톱레벨 클래스를 여러 개 선언하더라도 자바 컴파일러에서 문제삼지 않으나, 이렇게 하면 한 클래스가 여러가지로 정의될 수 있으며 그 중 어느 것을 사용할지는 어느 소스파일을 먼저 컴파일하느냐에 따라 달라진다. 소스파일 하나에 톱레벨 클래스를 하나만 담는다면, 컴파일러가 한 클래스에 대한 정의를 여러 개 만들어내는 일은 사라진다. 소스파일을 어떤 순서로 컴파일하든 바이너리 파일이나 프로그램의 동작이 달라지는 일은 일어나지 않을 것이다.

 

 

 

 

 

 

 

Reference:

Effective Java