티스토리 뷰
data class Cash(val dollars: Int) {
fun mul(factor: Int): Cash {
return Cash(dollars * factor)
}
}
우아한테크코스에서 수업을 들으며, "불변 객체"에 대해 학습했다.
불변 객체의 특징과 장점은 아래와 같다.
- 생성자를 통해서만 초기화한다.
- 변경할 수 없기 때문에 동시에 접근해도 항상 같은 값을 보장한다.
- 실패 원자성을 보장한다. (메서드를 수행하다가 예외가 발생하는 경우에도 메서드 수행 전 상태와 동일함을 보장)
- 도메인의 불변 객체를 값 객체(VO)라고도 한다.
이러한 불변 객체에도 단점이 있다.
값을 변경할 때마다 인스턴스를 생성하기 때문에 메모리 측면에서 비효율적일 수 있다는 점이다.
(하지만 불변 객체는 메모리를 걱정하는 것보다 훨씬 더 많은 이점을 준다고, 썬 마이크로가 말했다...)
어쨌든 불변 객체의 인스턴스 생성이 걱정된다면, value class를 생성하면 된다.
@JvmInline
value class Cash(val dollars: Int) {
fun mul(factor: Int): Cash {
return Cash(dollars * factor)
}
}
fun main() {
val cash = Cash(3)
}
value class의 생성자를 호출하면, 인스턴스가 생성된 것처럼 보이지만 실제로는 인스턴스가 생성된 것이 아니다.
생성자를 호출했는데 인스턴스가 생성되지 않았다니, 이게 무슨 소리일까?
자바로 디컴파일한 코드를 보자.
public final class Cash {
private final int dollars;
public final int getDollars() {
return this.dollars;
}
private Cash(int dollars) {
this.dollars = dollars;
}
public static int constructor_impl(int dollars) {
return dollars;
}
public static final Cash box_impl(int v) {
return new Cash(v);
}
}
int cash = Cash.constructor-impl(3);
(디컴파일 코드 중 일부만 가져왔다.)
실제 Cash 클래스는 생성자가 private으로 제한되어있다.
main()에서 Cash 인스턴스를 생성하는 코드는
new Cash(3);이 아닌 Cash.constructor-impl(3);이라는 코드로 디컴파일 되었다.
constructor-impl() 함수는 인스턴스를 생성하지 않고, 생성자 파라미터로 들어온 값을 바로 반환하기만 한다.
즉, 코틀린 코드에서는 Cash 인스턴스처럼 보이지만 실제로는 값 자체를 사용한다.
디컴파일된 Cash 클래스를 보면, 값 자체를 반환하는 constructor-impl()와 생성자를 호출하여 실제 인스턴스를 반환하는 box_impl() 함수가 있다.
코틀린에서 일반적으로 생성자를 호출하면 constructor-impl()이 호출되는 것을 확인했다.
그러면 box_impl()는 언제 호출될까?
@Test
fun test() {
val cash1 = Cash(3)
val cash2 = Cash(3)
assertThat(cash1).isEqualTo(cash2) // passed
assertThat(cash1).isSameAs(cash2) // failed
}
생성자를 호출하면(실제로는 생성자가 아닌 constructor-impl) 값 자체가 바로 반환되기 때문에
cash1과 cash2는 항상 같을 거라고 예상할 수 있다.
하지만 cash1과 cash2끼리 동일성(주소값) 비교를 하면, 테스트가 통과되지 않는다.
Int 값 3과 3을 비교하는 건데, 왜 주소값이 같지 않을까?
@org.junit.jupiter.api.Test
public final void test() {
int cash1 = Cash.constructor-impl(3);
int cash2 = Cash.constructor-impl(3);
Assertions.assertThat(Cash.box-impl(cash1)).isEqualTo(Cash.box-impl(cash2));
Assertions.assertThat(Cash.box-impl(cash1)).isSameAs(Cash.box-impl(cash2));
}
위 테스트 코드를 디컴파일 해보면 위와 같은 코드가 나온다.
cash1과 cash2에는 constructor-impl()이 호출되어 값 자체가 저장되지만,
assertThat(), isEqualTo(), isSameAs()의 인자로 들어갈 때는 box-impl()가 호출됨을 알 수 있다.
즉, 실제 인스턴스가 생성된 것이다.
public SELF isEqualTo(Object expected)
public SELF isSameAs(Object expected)
실제 인스턴스가 왜 생성되었는지는, 함수의 시그니처를 보면 알 수 있다.
바로 함수의 인자가 Object 타입이기 때문이다.
Object, 즉 Reference Type이 필요하기 때문에 실제 인스턴스를 생성한 것이다.
인스턴스를 새로 생성하여 주소값이 달랐기 때문에 동일성을 비교하는 테스트는 통과하지 않았다.
@Test
fun test() {
val cash1 = Cash(3)
val cash2 = Cash(3)
assertThat(cash1).isEqualTo(cash2) // passed
assertThat(cash1).isSameAs(cash2) // failed
}
사실 이 코드를 value class가 아닌 일반 값 객체를 사용했다면, 인스턴스는 두 번밖에 생성되지 않았을 것이다. (cash1, cash2)
그런데 value class를 사용하면서 함수의 인자로 전달될 때마다 인스턴스가 생성되어, 인스턴스는 총 네 번 생성되었다. (box-impl 네 번 호출)
이처럼 value class를 사용해서 오히려 더 비효율적인 경우가 있을 수 있으니 경우에 따라 적절히 사용해야겠다.
추가적으로 value class에 대해 간단히 알아본 내용을 정리해본다.
*
@Test
fun test() {
val cash1 = Cash(3)
val cash2 = Cash(3)
assertThat(cash1 === cash2).isTrue() // error
// Identity equality for arguments of types Cash and Cash is forbidden
}
value class의 인스턴스끼리의 === 비교는 컴파일 단계에서 금지한다.
*
생성자 프로퍼티에 단 하나의 프로퍼티만 존재해야 한다.
var이 아닌 val이어야 한다.
*
data class는 equals(), toString(), hashCode(), copy(), componentN()를 자동 생성해준다.
반면에 value class는 equals(), toString(), hashCode()만 자동 생성해준다.
*
backing field를 사용할 수 없다.
참고
'kotlin' 카테고리의 다른 글
[kotlin/코틀린] nested class와 inner class (0) | 2024.03.28 |
---|---|
[kotlin/코틀린] companion object (0) | 2024.02.25 |
[코틀린/kotlin] 클래스 내에서 프로퍼티에 접근하면 커스텀 접근자가 호출될까? (1) | 2024.02.24 |
[코틀린/kotlin] DTO와 VO의 차이 (1) | 2023.11.01 |
[코틀린/kotlin] 영역 함수 (scope function) also, let, run, with, apply (1) | 2023.11.01 |