불변 클래스란 그 인스턴스 내부 값을 수정할 수 없는 클래스를 말한다.
불변 인스턴스에 간직된 정보는 객체가 파괴되는 순간까지 절대 달라지지 않는다.
자바 플랫폼 라이브러리의 String, 기본 타입 박싱 클래스들, BigInteger, BigDecimal이 여기 속한다.
불변 클래스는 설계하고 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적고 안전하다.
📌 구현 규칙
불변 클래스를 생성하기 위해 다섯 가지 규칙을 따르면 된다.
- 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.
- getter를 만들었다고 무조건 setter를 만들지는 말자.
- 클래스를 확장할 수 없도록 한다.
- 하위 클래스에서 객체의 상태를 변하게 만드는 상태를 방지한다.
- 대표적인 방법은 final로 선언하는 것이지만, 기본 생성자를 private로 막고 정적 팩토리 메서드만을 지원해주어도 구현할 수 있다.
- 모든 필드를 final로 선언한다.
- 새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없다. (Thread-safe)
- 모든 필드를 private로 선언한다.
- 필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해서 수정할 수 없도록 막는다.
- 기술적으로는 기본 타입 필드나 불변 객체를 참조하는 필드를 public final로만 선언해도 불변 객체가 되긴 하나, 한 번 공개된 필드는 다음 릴리즈에서 내부 표현을 바꾸지 못하므로 권장되는 방식은 아니다.
- 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.
- 가변 객체를 참조하는 필드가 있다면, 클라이언트에서 해당 객체의 참조를 얻을 수 없도록 해야 한다.
- 접근자 메서드가 그 필드를 그대로 반환해서도 안 된다.
- 생성자, 접근자, readObject 메서드 모두에서 방어적 복사를 수행하라
📌 불변 클래스 (함수형 프로그래밍)
✒️ 함수형 프로그래밍
선언형 프로그래밍이라고도 하며, 'How'보다 'What'에 초점을 둔다.
함수는 input에 대한 output을 보장하면서, 함수 외부의 값을 변경해서는 안 된다.
이러한 방식은 불변성을 보장하고, 안전성을 높일 수 있다.
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 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 divdedBy(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 인스턴스를 만들어 반환하고 있다.
피연산자에 함수를 적용하여 결과를 반환하긴 하지만, 피연산자 자체는 그대로인 프로그래밍 패턴이다.
또한, 메서드 이름을 add같은 동사가 아니라 plus처럼 전치사를 사용하여, 해당 메서드가 객체 값을 변경하지 않음을 강조하고 있다.
이러한 프로그래밍은 사용자가 별다른 노력을 하지 않아도 해당 클래스는 영원히 불변으로 남는다.
📌 불변 객체의 장점
1️⃣ 불변 객체는 근본적으로 Thread-safe하여 따로 동기화할 필요 없다.
불변 객체에 대해서 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없다. 따라서 안심하고 공유할 수 있다.
불변 클래스는 한 번 만든 인스턴스를 최대한 재활용하는 것이 좋다.
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);
위의 예시처럼 자주 쓰이는 값들을 상수로 제공하면 쉽게 재활용 가능하다.
여기서 한 걸음 더 나아가, 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않도록 정적 팩터리를 제공할 수 있다.
박싱 기본 타입 클래스 전부와 BigInteger가 여기에 속한다.
이러한 정적 팩터리를 사용하면, 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다.
또한, 불변 객체를 자유롭게 공유할 수 있다는 것은 방어적 복사가 필요없다는 반증이다.
아무리 복사해봐야 원본과 같으므로 복사 자체가 의미가 없다.
그러니 불변 클래스는 clone 메서드나 복사 생성자를 제공하지 않는 것이 좋다.
2️⃣ 불변 객체는 자유롭게 공유할 수 있으며, 불변 객체끼리는 내부 데이터를 공유할 수 있다.
public class BigInteger extends Number implements Comparable<BigInteger> {
final int signum;
final int[] mag;
BigInteger(int[] magnitude, int signum) {
this.signum = (magnitude.length == 0 ? 0 : signum);
this.mag = magnitude;
if (mag.length >= MAX_MAG_LENGTH) {
checkRange();
}
}
public BigInteger negate() {
return new BigInteger(this.mag, -this.signum);
}
...
}
BigInteger 클래스는 내부에서 값의 부호(sign)와 크기(magnitude)를 따로 표현한다.
negate()는 크기가 같고 부호만 반대인 새로운 BigInteger를 생성하는데, 배열이 가변임에도 복사하지 않고 원본 인스턴스와 공유해도 상관 없다.
그 결과 새로 생성한 BigInteger 인스턴스도 원본 인스턴스가 가리키는 내부 배열을 그대로 가리킨다.
3️⃣ 객체 생성 시, 다른 불변 객체들을 구성 요소로 사용하면 이점이 많다.
구조가 아무리 복잡하더라도 값이 바뀌지 않는 구성요소로 이루어진 객체는 불변식을 유지하기 훨씬 수월하다.
Map의 key나 Set의 원소로 사용해도, 안에 담긴 값이 바뀔 일이 없으므로 불변식이 무너질 걱정이 없다.
4️⃣ 불변 객체는 그 자체로 실패 원자성을 보장한다.
실패 원자성은 메서드에서 예외가 발생하더라도, 그 객체는 여전히 유효한 상태여야 한다는 성질이다.
불변 객체 메서드는 내부 상태를 바꾸지 않으므로 마찬가지로 자명한 사실이다.
📌 불변 객체의 단점
값이 다르면 반드시 독립된 객체로 만들어야만 한다.
값의 수가 많다면, 이들을 모두 만드는데 큰 비용이 들어간다.
이는 원하는 객체를 완성하기 까지 단계가 많고, 그 중간 단계에서 만든 객체들이 모두 버려진다면 성능 문제는 더 커진다.
이 문제를 해결하는 방법은 다단계 연산을 수행하는 가변 동반 클래스를 작성하는 것이다.
불변 객체는 내부적으로 아주 영리한 방식으로 구현할 수 있다.
BigInteger에는 package-private로 여러 클래스들이 존재하고,
String에는 public으로 제공되는 StringBuilder와 StringBuffer(구닥다리 전임자)가 있다.
클라이언트들이 원하는 복잡한 연산들을 정확히 예측할 수 있다면 package-private 가변 동반 클래스만으로 충분하다.
만약, 그렇지 않다면 이 클래스를 public으로 제공하는 것이 최선이다. (하지만 가변 동반 클래스를 사용하는 것은 불변 클래스보다 힘들다.)
✒️ 가변 동반 클래스
가변 동반 객체는 기존의 불변 객체와는 별개의 객체로서 불변 객체를 보완하거나, 보완하는 가변 기능을 제공한다.
따라서 불변 객체와는 독립적으로 존재하며, 불변 객체를 가지고 생성된다.
StringBuilder를 예로 들면, 문자열을 변경하기 위한 가변 동반 클래스이다.
새로운 문자열 객체를 생성하지 않고, 기존의 문자열 객체를 변경한다.
StringBuilder 클래스는 내부적으로 문자 배열을 유지하고, 문자열을 변경할 때 이 배열을 수정한다.
String 클래스의 인스턴스를 가지고 있긴 하지만, StringBuilder 클래스를 통해 문자열을 변경하더라도, 원래의 String 인스턴스는 변경되지 않는다.
이렇게 함으로써, 기존의 불변 객체를 보호하면서 가변 기능을 제공할 수 있다.
@Override
public StringBuilder append(Object obj) {
return append(String.valueOf(obj));
}
@Override
@HotSpotIntrinsicCandidate
public StringBuilder append(String str) {
super.append(str);
return this;
}
@Override
@HotSpotIntrinsicCandidate
public String toString() {
// Create a copy, don't share the array
return isLatin1() ? StringLatin1.newString(value, 0, count)
: StringUTF16.newString(value, 0, count);
}
📌 불변 클래스를 만드는 설계 방법
"클래스를 확장할 수 없도록 한다"라는 규칙은 final 클래스로 선언해도 되지만, 더 유연한 방법은 모든 생성자를 private 혹은 package-private로 만들고 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);
}
...
}
이렇게 구현해도 클라이언트 입장에선 사실상 final이다.
public이나 protected 생성자가 없어 다른 패키지에서 확장을 못하도록 막음과 동시에,
정적 팩터리 방식은 다수의 구현 클래스를 활용한 유연성을 제공하고, 다음 릴리즈에서 객체 캐싱 기능을 추가해 성능 개선도 가능하다.
"모든 필드가 final이고 어떤 메서드도 그 객체를 수정할 수 없어야 한다"라는 규칙은 좀 과한 감이 있다.
그래서 성능을 위해 살짝 완화하여 "어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다"로 보는 게 좋을 때도 있다.
어떤 불변 클래스는 계산 비용이 큰 값을 나중에(처음 쓰일 때) 계산하여 final이 아닌 필드에 캐싱해두기도 한다.
똑같은 값을 다시 요청하면 캐시해둔 값을 반환하여 계산 비용을 줄이는 것이다.
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
private int hash; // hash는 final이 아니다.
private static final long serialVersionUID = -6849794470754667710L;
...
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
hash = h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
}
return h;
}
이 방법은 순전히 String이 불변 객체이므로, 몇 번을 계산해도 항상 같은 결과가 만들어짐을 보장하기 때문이다.
📌 Summary
- 대부분의 경우 불변 클래스로 만들어야 한다.
- getter 만들었다고 무조건 setter를 구현하진 말자.
- 성능 저하로 불변 클래스 구현이 힘들다면, 가변 동반 클래스를 이용하자.
- 불변 클래스 구현이 불가능하더라도 변경 가능 부분을 최소한으로 줄이자.
- 꼭 변경해야 할 필드를 제외한 나머지 모두 final로 선언하라.
- 다른 합당한 이유가 없다면 모든 필드는 private final이어야 한다.
- 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.
- 확실한 이유가 없다면 객체의 상태를 초기화 하는 메서드는 생성자, 정적 팩터리 메서드 이외에는 없어야 한다.
- 객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안 된다.