본문 바로가기

JAVA

이펙티브 자바 # 3

반응형

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

  • 잘 설계된 컴포넌트의 차이점은 클래스 내부 데이터와 내부 구현 정보를 외부 컴포넌트로부터 얼마나 잘 숨겼는지에 달려있다.
  • 오직 API를 통해서만 다른 컴포넌트와 소통하며, 서로의 내부 동작 방식에는 전혀 개의치 않는다.

정보 은닉 or 캡슐화
장점

  • 시스템 개발 속도(여러 컴포넌트를 병렬로 개발 가능)
  • 시스템 관리 비용 낮춤(각 컴포넌트를 더 빨리 파악하여 디버깅 할수 있고, 다른 컴포넌트로 교체하는 부담이 적음)
  • 정보은닉 자체가 성능을 높여주지 않지만, 성능 최적화에 도움(다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화 가능)
  • 소프트웨어 재사용성을 높임
  • 큰 시스템을 제작하는 난이도를 낮춤(시스템 전체가 완성되지 않는 상태에서도 개별 컴포넌트의 동작을 검증 가능)

정보은닉 장치

  • 접근 제한자(private, protected, public)
  • 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.(낮은 접근 수준)

클래스와 인터페이스

  • public으로 선언하면 공개 API가 되므로 하위 호환을 위해 관리해줘야 한다.
  • 패키지 외부에서 쓸 이유가 없다면 package-private으로 선언한다.
  • private static으로 중첩 클래스는 바깥 클래스 하나에만 접근할수 있다.

멤버

  • private는 멤버를 선언한 톱레벨 클래스에서만 접근 가능
  • package-private는 멤버가 소속된 패키지 안의 모든 클래스에서 접근 가능
  • protected는 package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근 가능
  • public는 모든 곳에서 접근 가능
  1. 클래스의 공개 API를 설계한 후, 그 외의 모든 멤버는 private으로 만든다.
  2. 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여(private 제한자를 제거) package-private으로 풀어준다.
  • public 클래스에서 멤버의 접근 수준을 package-private에서 protected로 변경하는 순간, 그 멤버에 접근할수 있는 대상 범위가 넓어진다.
  • public 클래스의 protected 멤버는 공개 API이므로 영원히 지원해야 한다.
  • public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다. 필드가 가변 객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 그 필드에 담을수 있는 값을 제한할 힘을 잃게 된다.(불변식을 보장할수 없다.)
  • public 가변 필드를 갖는 클래스는 일반적으로 스레드 안전하지 않다. 필드가 final이면서 불변 객체를 참조하더라도 문제는 남아있다.
  • 해당 클래스가 표현하는 추상 개념을 완성하는데 꼭 필요한 구성요소로써의 상수라면 public static final 필드로 공개해도 좋다. 단, 이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다.
  • 가변 객체를 참조한다면 final이 아닌 필드에 적용되는 불이익이 그대로 적용된다. 다른 객체를 참조하지 못하지만, 참조된 객체 자체는 수정될수 있다.
  • 길이가 0이 아닌 배열은 모두 변경 가능 하니 주의한다.(클래스에서 public static final 배열 필드를 두거나 반환하는 접근 메서드를 제공해서는 안된다)
  • 클라이언트에서 그 배열의 내용을 수정할수 있게 된다.
public static final Thing[] VALUES = {...};

방법1

private static final Thing[] PRIVATE_VALUES = {...};
public static fianl List<Thing> VALUES = 
    Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
  • public 배열을 private으로 만들고 public 불변 리스트를 추가하는 방법

방법2

private static final Thing[] PRIVATE_VALUES = {...};
public static final Thing[] values() {
    return PRIVATE_VALUES.clone();
}
  • 배열을 private로 만들고 그 복사본을 반환하는 public 메서드를 추가하는 방법

프로그램 요소의 접근성을 가능한 한 최소한으로 하라.
꼭 필요한 것만 골라 최소한의 public API를 설계한다.
public 클래스는 상수용 public static final 필드 외에는 어떠한 public 필드를 가져서는 안된다.
public static fianl 필드가 참조하는 객체가 불변인지 확인하라.

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

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

변경 가능성을 최소화하라.

불변 클래스란, 그 인스턴스의 내부 값을 수정할 수 없는 클래스다.
불편 인스턴스에 간직된 정보는 고정되어 객체가 파괴되는 순간까지 절대 달라지지 않는다.

  • String, BigInteger, BigDecimal

불변 클래스

  • 객체의 상태를 변경하는 메서드는 제공하지 않는다.
  • 클래스를 확장할수 없도록 한다.(상속을 막기는 final 선언)
  • 모든 필드를 final로 선언한다.(시스템이 강제하는 수단을 이용해 설계자의 의도를 명확히 드러내는 방법)
  • 모든 필드를 private로 선언한다.(필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다.)
  • 자신 외에는 내부의 가변 컴포넌트에 접근할수 없도록 한다.(클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 그 객체의 참조를 얻을수 없도록 해야 한다.)
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() { retrun re; }
    public double imaginaryPart() { retrun im; }

    public Complex plus(Complex c) {
        retrun new Complex(re + c.re, im +  c.im);
    }

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

    public Complex times(Complex c) {
        retrun 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;
        retrun new Complex((re * c.re - im *  c.im) / tmp,
                                      (re * c.im + im *  c.re) / tmp);
    }    

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

        return Double.compare(c.re, re) == 0
            && Double.compare(c.im, im) == 0;
    }

    public int hashCode(){
        return 31 * Double.hashCode(re) + Double.hashCode(im);
    }

    public String toString() {
        return "("+ re + " + " + im +"i)";
    }
}
  • 이 클래스는 복소수(실수부와 허수부로 구성된 수)를 표현한다.
  • 사칙연산 메서드들이 인스턴스 자신은 수정하지 않고, 새로운 Complex 인스턴스를 만들어 반환한다.
  • 불변 객체는 단순하다. 생성되는 시점의 상태를 파괴될때까지 그대로 간직한다.
  • 스레드 안전하여 따로 동기화할 필요 없다. (여러스레드가 동시에 사용해도 절대 훼손되지 않는다.)
  • 다른 스레드에 영향을 줄수 없으니 불변 객체는 안심하고 공유할수 있다.
  • 불변 클래스라면 한번 만든 인스턴스를 최대한 재활용하기 권한다.
  • public static fianl Complex ZERO = new Complex(0, 0); public static fianl Complex ONE = new Complex(1, 0); public static fianl Complex I = new Complex(0, 1);
  • 불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해주는 정적 팩터리를 제공할수 있다.
  • 정적 팩토리를 사용하면 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다.
  • 불변 클래스는 clone 메서드나 복사 생성자를 제공하지 않는 것이 좋다.
  • 불변 객체는 자유롭게 공유할수 있음은 물론, 불변 객체끼리는 내부 데이터를 공유할수 있다.

또 다른 불변 클래스 만들기

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) {
        retrun new Complex(re, im);
    }
}
  • 바깥에서 볼수 없는 package-private 구현 클래스를 원하는 만큼 만들어 활용할수 있으니 훨씬 유용하다.
  • 패키지 바깥의 클라이언트에서 바라본 이 불변 객체는 사실상 final이다.
  • public이나 protected 생성자가 없으니 다른 패키지에서는 이 클래스를 확장하는게 불가능하기 때문이다.
  • 정적 팩터리 방식은 다수의 구현 클래스를 활용한 유연성을 제공하고, 이에 더해 다음 릴리스에서 객체 캐싱 기능을 추가해 성능을 끌어올리수도 있다.
  • 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다.
  • 모든 클래스를 불변으로 만들순 없다. 불변으로 만들수 없는 클래스라도 변경할수 있는 부분을 최소한으로 줄이자.
  • 객체가 가질수 있는 상태의 수를 줄이면 그 객체를 예측하기 쉬워지고 오류가 생길 가능성이 줄어든다.
  • 변경해야할 필드를 뺀 나머지 모두를 final로 선언하자.
  • 생성자는 불변식 설정이 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.
  • 이유가 없다면 생성자와 정적 팩터리 외에는 그 어떤 초기회 메서드도 public으로 제공해서는 안된다.

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

  • 상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.
  • 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. (상위 클래스가 어떻게 구현하느냐에 따라 하위 클래스의 동작에 이상이 생길수 있다.)
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet() { }

    public InstrumentedHashSet(int intCap, float loadFactor) {
        super(intCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount++;
        retrun super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}
  • 이 클래스의 addAll 메서드로 원소 3개를 더한다면?
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱","탁탁","펑"));
  • 6을반환한다. HashSet의 addAll 메서드가 add 메서드를 사용해 구현되어 있기 때문이다.
  • 이 경우 하위클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠수 있다.
  • 하지만 제대로 동작할지 모르나, HashSet의 addAll이 add 메서드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다..

방법

  • 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다.
  • 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라고 한다.
  • 새 클래스의 인스턴스 메서드들은(private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
  • 이 방식을 전달(forwarding)이라고 하며, 새 클래스의 메서드들을 전달 메서드라 부른다.
  • 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.
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++;
        retrun super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

}
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 addAll(Collection<? extends E> c) { return s.addAll(c); }
    ...

}
  • InstrumentedHashSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
  • 구체적으로는 Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다.
  • 임의의 Set에 꼐측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
  • 상속방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다.
  • 컴포지션 방식은 한번만 구현해두면 어떤 Set의 구현체라도 계측할수 있으며, 기존 생성자들과도 함께 사용할수 있다.
Set<Instant> times = new InstrumentedHashSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedHashSet<>(new HashSet<>(INIT_CAPACITY));
static void walk(Set<Dog> dogs) {
    InstrumentedHashSet<Dog> iDogs = new InstrumentedHashSet<>(dogs);
    // dogs 대신 iDogs를 사용한다.
}
  • 다른 Set 인스턴스를 감싸고(Wrap) 있다는 뜻에서 InstrumentedHashSet 같은 클래스를 래퍼 클래스라고 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 테코레이터 패턴이라고 한다.
  • 컴포지션과 전달의 조합을 넓은 의미로 위임이라고 부른다.
  • 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우에만 위임에 해당한다.
  • 래퍼 클래스는 단점이 거의 없다. 단 래퍼 클래스가 콜백 프레임워크와는 어울리지 않는다는 점만 주의한다.
  • 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. is-a 관계

상속은 강력하지만 캡슐화를 해친다는 문제가 있다.
상속은 상위 클래스와 하위클래스가 순수 is-a 관계일 때만 써야 한다.
상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자.

반응형

'JAVA' 카테고리의 다른 글

이펙티브 자바 #1  (0) 2021.07.27
도메인 모델링 3가지 개념  (0) 2020.09.17