[Effective Java] Item 17. 변경 가능성을 최소화하라
불변 클래스란
불변 클래스란 그 인스턴스의 내부 값을 수정할 수 없는 클래스를 말한다.
클래스는 꼭 필요한 경우가 아니라면 반드시 불변이어야 한다. 불변 클래스가 가변 클래스보다 무수히 많은 장점들을 갖고 있기 때문이다.
불변 클래스의 예시
ex. Java에서 String, BigInteger, BigDecimal, 기본 타입의 박싱된 클래스(Integer, Double, Long 등)
클래스를 불변으로 만들고 싶다면
1. 객체의 상태를 변경하는 메서드를 제공하지 않는다.
2. 클래스를 확장할 수 없도록 한다.
3. 모든 필드를 final로 선언한다.
4. 모든 필드를 private으로 선언한다.
5. 자신 외엔 내부의 가변적인 컴포넌트에 접근할 수 없도록 한다. (생성자, 접근자, 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 imagenaryPart() { 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 클래스는 불변 클래스가 갖춰야할 5가지 규칙을 모두 만족하고 있다.
함수형 프로그래밍
위의 Complex 클래스의 사칙연산 메서드들이 새로운 Complex 인스턴스를 만들어 반환하는 점에 주목하자. 이 방식을 함수형 프로그래밍이라고 부른다.
함수형 프로그래밍이란 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 말한다.
함수형 프로그래밍에서는 메서드 이름으로 add 같은 동사 대신 plus 같은 전치사를 사용한다. 이는 해당 메서드가 객체의 값을 변경하지 않는다는 사실을 강조하려는 의도다. 이와 달리 절차적 혹은 명령형 프로그래밍은 메서드에서 피연산자인 자신을 수정해 자신의 상태를 변경한다.
함수형 프로그래밍의 장점
코드에서 불변이 되는 영역의 비율이 높아진다.
불변 객체의 장점
1. 불변 객체는 단순하다
불변 객체는 생성된 상태를 파괴될 때까지 그대로 유지한다. 그러므로 가변 객체보다 매우 단순하며 설계, 구현, 사용이 쉽다.
2. 불변 객체는 스레드에서 안전하다.
불변 객체는 스레드에서 안전한 클래스를 만드는 가장 쉬운 방법이기도 하다. 따로 동기화하는 코드가 필요하지 않아 오류가 생길 여지가 적고 훨씬 안전하다.
3. 불변 객체는 안심하고 공유할 수 있다.
불변 클래스라면 한번 만든 인스턴스를 최대한 재활용하는 것이 좋다. 가장 쉬운 재활용 방법은 자주 쓰이는 값들을 상수(public static final)로 사용하는 것이다.
ex. Complex 클래스에서 자주 쓰일 값인 0, 1, 0.1을 상수로 만들기
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);
4. 자주 사용되는 인스턴스를 재활용할 수 있는 정적 팩터리를 제공할 수 있다.
정적 팩터리를 사용해 인스턴스를 공유하면 메모리 사용량과 가비지 컬렉션 비용이 줄어든다. public 생성자 대신 정적 팩터리를 만들어두면 필요에 따라 캐시 기능을 나중에 덧붙일 수 있다.
ex. Java의 박싱된 기본 타입 클래스들(Integer, Double, Long 등), BigInteger
5. 불변 객체는 복사가 의미없다.
불변 클래스는 clone 메서드나 복사 생성자를 제공하지 않는 것이 좋다.
ex. 불변 클래스여야 하는 String 클래스의 복사 생성자는 되도록 사용하지 말자.
(책에 따르면 자바 초창기에 실수로 만들어진 것이라고 한다.)
6. 불변 객체끼리는 내부 데이터를 공유할 수 있다.
ex. BigInteger 클래스의 negate 메서드는 크기가 같고 부호는 반대인 새로운 BigInteger을 생성한다.
BigInteger은 값의 부호에는 int 변수를, 크기에는 int 배열을 사용해 표현한다. negate 메서드로 만들어진 새 인스턴스는 부호만 다르고 원본과 같은 내부 int 배열을 그대로 가리키고 있다.
7. 객체를 만들 때 불변 객체들을 구성요소로 사용하면 불변식을 유지하기 매우 수월하다.
Map의 키는 불변이어야 하고, Set의 원소는 중복되지 않아야 한다. 불변 객체는 불변식을 해제 전까지 유지한다는 점에서 Map의 키와 Set의 원소로 쓰기에 안성맞춤이다.
8. 불변 객체는 그 자체로 실패 원자성을 제공한다.
불변 객체는 상태가 절대 변하지 않으니 잠깐이라도 불일치 상태에 빠질 가능성이 없다.
이렇듯 불변 객체는 장점이 매우 많으므로 단순한 값 객체에 대해서는 꼭 불변으로 만들어 사용하자.
ex. 자바의 java.util.Data, java.awt.Point 등도 사실은 불변이어야 하지만 그렇지 않게 만들어졌다.
불변 클래스의 단점
불변 클래스의 유일한 단점은 값이 다르면 반드시 독립된 객체로 만들어야 한다는 것이다.
ex. BigInteger의 flipBit 메서드로 백만 비트짜리 Biginteger에서 비트 하나 바꾸기
flipBit 메서드는 새로운 BigInteger 인스턴스를 생성하기 때문에 인스턴스의 크기만큼 시공간적 비용이 필요하다. 그러므로 백만 비트짜리 데이터 중 비트 하나를 바꾸고 싶다면 가변 클래스인 BigSet을 사용하는 것이 좋다. 이와 같이 원하는 객체를 완성하기까지 단계가 많고, 중간에 만들어진 객체들이 버려진다면 성능 문제가 발생한다.
불변 객체의 성능 문제 대처 방법 두 가지
1. 흔히 쓰일 다단계 연산들을 예측해 기본 기능으로 제공한다.
자주 쓰일 다단계 연산을 package-private으로 제공하면 각 단계마다 객체를 생성하지 않아도 된다. BigInteger는 모듈러 지수 같은 다단계 연산 속도를 높여주는 가변 동반 클래스를 package-private으로 두고 있다.
ex. String 클래스의 가변 동반 클래스로 StringBuilder가 있다.
많은 String 객체를 연결하면 많은 중간 문자열 객체가 생성되고 메모리 할당과 해제가 자주 발생해 성능이 저하된다. 이 때 고려할 수 있는 수단으로 변경 가능한 문자열을 만들어주는 가변 동반 클래스 StringBuilder가 있다.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append(Integer.toString(i));
}
System.out.println(sb.toString());
StringBuilder 객체를 생성한 다음, append 메서드로 여러 문자열을 연결할 수 있다. 연결이 끝난 결과값을 불변 클래스인 String 인스턴스로 변환하고 싶다면 toString 메서드를 호출한다.
2. 클래스가 불변임을 보장하기 위해서 자신을 상속하지 못하게 해야 한다.
모든 생성자를 private 또는 private-package로 만들고 public 정적 팩터리를 제공하는 방법도 있다. 다음은 Complex 클래스를 이 방식으로 구현한 코드이다.
public final 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);
}
...
}
패키지 바깥에서 바라본 이 불변 객체는 사실상 final이기에 1번 방법보다 훨씬 유연하다. 다른 패키지에서는 이 클래스를 상속해 확장하는 것이 불가능하다.
ex. Integer 박싱 클래스의 valueOf 메서드
public static Integer valueOf(int i) {
final int offset = 128;
if (i >= -128 && i <= 127) { // must cache
return IntegerCache.cache[i + offset];
}
return new Integer(i);
}
그러므로 String과 BigInteger과 같이 무거운 값 객체들도 불변으로 만들 수 있는지 고심해보자. 성능 때문에 어쩔 수 없다면 불변 클래스와 쌍을 이루는 가변 동반 클래스를 public 클래스로 제공하자.
BigInteger과 BigDecimal의 위험성
BigInteger과 BigDecimal의 메서드들은 자바 초기에 만들어져 재정의할 수 있도록 설계되있다. 그리고 하위 호환성의 문제로 인해 현재까지 이 점이 수정되지 않았다. 그러므로 신뢰할 수 없는 출처로부터 BigInteger이나 BigDecimal의 인스턴스를 인수로 받는다면 주의하자. 신뢰할 수 없는 하위 클래스의 인스턴스라고 확인되면 이 인수를 가변이라 가정하고 방어적으로 복사해 사용해야 한다.
public static BigInteger safeInstance(BigInteger val) {
return val.getClass() == BigInteger.class ?
val : new BigInteger(val.toByteArray());
}
어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다.
불변 클래스의 규칙 목록에 따르면 모든 필드가 final이고 어떤 메서드도 그 객체를 수정할 수 없어야 한다. 그러나 성능을 생각하면 이 규칙을 다음처럼 완화할 수 있다.
어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다.
간혹 불변 클래스에서 성능을 최적화하기 위해 계산 비용이 큰 값을 나중에 계산하고 final이 아닌 필드에 캐시하기도 한다. 그리고 똑같은 값을 다시 요청하면 캐시 값을 반환해 비용을 절감하는 것이다. 지연 초기화의 예인 이 기법을 String도 사용하고 있다.
가변 클래스도 변경 가능한 부분은 최소한으로 둔다
불변으로 만들 수 없더라도 변경 가능한 부분은 최소한으로 줄이는 것이 좋다. 객체가 가질 수 있는 상태의 수를 줄이면 객체를 예측하기 쉽고 오류 가능성이 줄어든다. 꼭 변경해야 할 필드를 뺀 나머지 모든 필드는 private final 접근자를 가져야 한다. 생성자는 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다. 확실한 이유가 없다면 생성자와 정적 팩터리 외에는 어떤 초기화 메서드도 public으로 제공되어선 안된다.
그 외..
- 클래스에 게터가 있다고 해서 무조건 세터를 만들지는 말자.
- 객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안된다.
ex. java.util.concurrent 패키지의 CountDownLatch 클래스
CountDownLatch는 어떤 쓰레드가 다른 쓰레드의 작업이 완료될 때까지 기다려야 할 때 사용하는 클래스이다.
CounDownLatch는 인스턴스를 생성하고 카운트가 0에 도달하면 더는 재사용할 수 없게 된다. 비록 가변 클래스지만 가질 수 있는 상태의 수가 많지 않다.
참고자료: