코딩은 마라톤

[이펙티브자바] 아이템 16, 17, 18 요약 정리 본문

Language/Java

[이펙티브자바] 아이템 16, 17, 18 요약 정리

anxi 2024. 6. 3. 23:34

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

class Point {
	public double x;
    public double y;
}

 

위와 같은 클래스는 데이터 필드에 직접 접근할 수 있으니 캡슐화의 이점을 제공하지 못한다.

또한 API 수정 없이 내부 표현을 바꿀 수 없으며, 불변식 보장 못하고, 외부에서 필드에 접근할 때 부수 작업을 수행할 수도 없다.

 

따라서 필드들을 private으로 바꾸고 public 접근자 (getter)를 추가한다.

class Point {
	private double x;
    private double y;
    
    public double getX() { return x; }
    public double getY() { return y; }
    
    public void setX(double x) { this.x = x; }
    public void setY(double y) { this.y = y; }
}

 

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

 

별개로 package-private(default) 클래스 혹은 private 중첩 클래스라면 데이터 필드를 노출해도 문제가 없다.

package-private는 클래스를 포함하는 패키지 내부에서만 사용되고,

private 중첩 클래스는 수정 범위가 더 좁아져서 이 클래스를 포함하는 외부 클래스까지만 수정 범위가 제한되기 때문이다.

 

결론 : public 클래스의 필드가 불변이라 할지라도 노출하지 말자. (가변 필드는 절대 노출하지 말자)

         오히려 package-private 클래스나 private 중첩 클래스에선 종종 노출해도 괜찮다. 

         public 클래스에서는 필드 노출하지 말자!


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

불변 클래스 : 그 인스턴스의 내부 값을 수정할 수 없는 클래스

불변 인스턴스의 정보는 고정되어 객체가 파괴되는 순간까지 절대 달라지지 않는다.

 

장점

  • 가변 클래스보다 설계하고 구현하고 사용하기 쉽다.
  • 오류가 생길 여지가 적고 훨씬 안전하다.

불변 클래스 만드는 다섯가지 규칙

  1. 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다. ex) setter
  2. 클래스를 확장할 수 없도록 한다.
    • 상속이 가능하면 하위 클래스에서 객체의 상태를 변하게 할 수 있다.
  3. 모든 필드를 final로 선언한다.
    • 설계자의 의도를 명확히 드러낸다.
    • 다른 쓰레드로 새로 생성된 인스턴스를 건네도 문제없이 동작한다.
  4. 모든 필드를 private으로 선언한다.
    • 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다.
  5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

또한 함수형 프로그래밍을 이용하는 것도 불변이 되는 영역의 비율을 높일 수 있다.

함수형 프로그래밍 : 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴

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 Complex plus(Complex c) {
		return new Complex(re + c.re, im + c.im);
    }
}

 

위와 같이 함수형 프로그래밍을 통해 피연산자 자체는 변하지 않을 수 있다.

 

불변 객체 장점

  • 단순하다.
    • 불변 객체는 생성된 시점의 상태를 파괴될 때까지 그대로 간직한다.
  • 근본적으로 스레드 안전하여 따로 동기화할 필요 없다. (안심하고 공유 가능)
    • 여러 스레드가 동시에 사용해도 절대 훼손되지 않는다. 
    • 자주 쓰이는 값을 상수(public static final)로 제공하여 재활용할 수 있다.
  • 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해주는 정적 팩터리를 제공한다.
    • 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다.
    • 방어적 복사도 필요 없다. (복사해봤자 원본과 똑같으니 복사의 의미가 없다 -> clone 메서드나 복사 생성자 제공하지 않는게 좋다.)
  • 자유롭게 공유할 수 있으며 불변 객체끼리는 내부 데이터를 공유할 수 있다.
public class BigInteger extends Number implements Comparable<BigInteger> {
    final int signum;
    final int[] mag;
    
   /**
     * Returns a BigInteger whose value is {@code (-this)}.
     *
     * @return {@code -this}
     */
    public BigInteger negate() {
        return new BigInteger(this.mag, -this.signum);
    }

 

mag배열은 비록 가변이지만 복사하지 않고 원본 인스턴스와 공유해도 된다. 

그 결과 새로 만든 BigInteger 인스턴스도 원본 인스턴스가 가리키는 내부 배열을 그대로 가리킨다.

  • 객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다.
    • 불변식을 유지하기 훨씬 수월하다. 
  • 그 자체로 실패 원자성을 제공한다.
    • 실패 원자성 : 메서드에서 예외가 발생한 후에도 그 객체는 여전히 호출 전과 똑같은 유효한 상태여야 한다. 
    • 불변 객체 메서드는 내부 상태를 바꾸지 않으므로 실패 원자성을 만족한다.

불변 객체 단점

  • 값이 다르면 반드시 독립된 객체로 만들어야 한다.
    • BigInteger instance = ...;
      instance = instance.flipBit(0);
      백만 비트 짜리 BigInteger에서 비트 하나를 바꿔야할 때 새로운 BigInteger 인스턴스를 생성해야 한다.
      또한 새로운 instance는 가변이기 때문이다.

      즉, 객체 생성 비용이 높고 상태가 다른 객체를 자주 만들어야 한다면 큰 비용이 들게 된다.
    • 해결책 :
      • 다단계 연산을 기본 기능으로 제공한다.
        • ex) String의 가변 동반 클래스 : StringBuilder(Buffer) 

불변 클래스를 만드는 또 다른 설계 방법 중에 자신을 상속하지 못하게 하는 방법이 있다.

상속 못하게 하는 가장 쉬운 방법은 final 클래스로 선언하는 것이다.

하지만 더 유연한 방법이 있는데 바로 모든 생성자를 private 혹은 default로 만들고 public 정적 팩터리를 제공하는 것이다.

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

 

결론

  • 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다.
    • 게터가 있다고 무조건 세터를 만들지는 말자
  • String과 BigInteger처럼 무거운 값 객체도 불변으로 만들 수 있는지 고심하고, 성능때문에 어쩔 수 없다면 가변 동반 클래스를 public으로 제공하자.
  • 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분은 최소한으로 줄이자.
    • 변경할 필드 제외 모든 필드는 final로 선언하자.
    • 모든 필드는 private final이어야 한다.
  • 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.
    • 생성자, 정적 팩터리 외에는 어떤 초기화 메서드도 public으로 제공해서는 안된다.

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

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

(= 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작 이상이 생길 수 있다.)

 

이러한 이상 문제를 모두 피해갈 수 있는 방식이 있다.

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하면 된다.

기존 클래스가 새로운 클래스의 구성 요소로 쓰인다는 뜻에서 "컴포지션"이라 한다.

  • private 필드로 기존 클래스의 인스턴스 참조
  • 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과 반환 : 전달(forwarding)
  • 새 클래스의 메서드들은 전달 메서드(forwarding method)라 부른다.
    • 새로운 클래스는 기존 클래스의 구현 방식의 영향에서 벗어나며 기존 클래스에 새로운 메서드가 추가되어도 전혀 영향받지 않는다.
  • 래퍼 클래스 :InstrumentedHashSet
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 += c.size();
        return super.addAll(c);
    }
      
    public int getAddCount() {
        return addCount;
    }
 }

 

  • 재사용할 수 있는 전달 클래스 : ForwardingSet
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); }
    
    { ... }
}

 

InstrumentedSet 같은 클래스는 다른 Set 인스턴스를 감싸고 있다는 뜻에서 래퍼 클래스라고 한다.

또한 다른 Set에 계측 기능을 덧씌운다는 점에서 데코레이터 패턴이라고 한다.

 

위 코드들을 토대로 컴포지션을 정리해보면,

새로운 클래스를 만들고 = InstrumentedSet<E> (전체 틀)

private 필드로 기존 클래스의 인스턴스를 참조한다. = ForwardingSet의 s (부분 틀)

즉, ForwardingSet 클래스의 메서드들은 기존 인터페이스(Set)의 메서드에 대응하는 메서드를 호출한다.

그리고 그 메서드들을 이용하는 InstrumentedSet이 되는 것이다. 

결론

  • 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 사용하자
    • 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 사용해야 한다.
  • is-a 관계라도 안심할 수 없기 때문에 컴포지션과 전달을 사용하자.
  • 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.

컴포지션, 래퍼 클래스, 전달 클래스 너무 어렵다. 데코레이터 패턴 너무 어렵다.  
좀 더 알아봐야 겠다.....