data class에서 프로퍼티를 수정하는 방법이 고민됩니다.
프로퍼티 선언 시val
선언 후copy
로 수정할 수도 있고,
프로퍼티를var
로 선언하여 기존의 객체를 유지하면서 프로퍼티만 수정할 수도 있습니다.
val
로 선언 후copy
로 수정하면 가변성을 제한할 수 있지만, 새로운 인스턴스를 생성해야 합니다. 두 가지 방법 중 어느 것이 더 좋은 방법이라고 생각하시나요?
프로퍼티를 var
로 선언하면 위험하다고 생각하지만, 그 생각에 근거를 찾지 못해 알아보았다.
var
는 읽고 쓸 수 있는 프로퍼티(read-write property)를 선언하는 키워드이다.
클래스에서는 다음과 같이 선언할 수 있다.
class BankAccount(
var amount: Int = 0, // 잔액
)
위처럼 var
로 프로퍼티를 선언하면 계속해서 변하는 상태(Ex: 계좌의 잔액, 사람의 신장 등)를 표현하기 좋다.
하지만 다음과 같은 단점들 또한 존재한다:
상태가 의도한 대로 변하지 않으면 클래스가 예상치 못한 상황에 처할 수 있다.
class Person(var age: Int) {
fun `떡국 먹은 횟수 자랑하기`() {
println("떡국 ${age}번 먹었어요.")
}
}
fun main() {
val gio = Person(20)
// 누군가가 아래와 같은 짓을 한다면..?
gio.age = -1
gio.`떡국 먹은 횟수 자랑하기`()
}
결과:
떡국 -1번 먹었어요.
상태 변경이 다양한 곳에서 일어날 수 있으므로 코드의 실행을 추론하기 어렵다.
class Person {
var hp: Int = 400
fun sleep() {
hp += 50
}
fun eat() {
hp += 30
}
fun drinkSoju() {
hp -= 70
}
val isHealthy: Boolean get() = hp > 400
}
fun main() {
val gio =
Person().apply {
sleep()
eat()
eat()
drinkSoju()
eat()
eat()
drinkSoju()
drinkSoju()
sleep()
}
println(gio.isHealthy)
}
멀티스레드 프로그램의 경우 적절한 동기화가 없으면 충돌이 발생할 수 있다.
suspend fun main() {
var count = 0
coroutineScope {
repeat(1_000) {
launch {
delay(50)
count += 1
}
}
}
println(count)
}
fun main() {
val list1 = mutableListOf(1, 2, 3)
val list2 = mutableListOf(1, 2, 3)
println("list1 == list2: ${list1 == list2}")
list1.add(4)
println("list1 == list2: ${list1 == list2}")
}
list1
, list2
가 불변으로 선언되었는데도 비교 결과가 달라졌다.
list1
이 add()
를 통해 갖고 있는 요소가 달라졌기 때문이다.
위에서 확인할 수 있듯, val
로 선언된 프로퍼티가 변경 가능한 객체를 가질 경우, 값이 변할 수 있다.
따라서 val
은 불변보다, 재할당 불가 프로퍼티 또는 읽기 전용 프로퍼티라고 표현하는 것이 맞을 것 같다.
Use the
val
keyword to declare variables that are assigned a value only once.
val
은 읽기 전용 프로퍼티지만, 변경할 수 없음(불변, immutable)을 의미하는 것은 아니라는 것을 기억하기 바랍니다.
- Effective Kotlin
val
로 선언된 프로퍼티의 단점은 무엇이 있을까? 바로 변경하기 귀찮다는 것이다…
class PersonWithVar(
val name: String,
var age: Int,
val email: String,
val phoneNumber: String,
) {
fun `1살 더 먹기`() {
age++
}
}
fun main() {
val gio =
PersonWithVar(
"Gio",
20,
"giovannijunseokim@gmail.com",
"010-1234-5678",
)
gio.`1살 더 먹기`()
println(gio.age)
}
결과: 21
class PersonWithVal(
val name: String,
val age: Int,
val email: String,
val phoneNumber: String,
) {
fun `1살 더 먹기`(): PersonWithVal =
PersonWithVal(
name,
age + 1,
email,
phoneNumber,
)
}
fun main() {
var gio =
PersonWithVal(
"Gio",
20,
"giovannijunseokim@gmail.com",
"010-1234-5678",
)
gio = gio.`1살 더 먹기`()
println(gio.age)
}
결과: 21
이름만 수정하는 메서드, 나이만 수정하는 메서드, 이메일만 수정하는 메서드, 전화번호만 수정하는 메서드까지 만든다면 코드가 매우 길어질 것이다. 이는 귀찮음의 문제를 넘어 가독성의 문제로도 넘어간다.
하지만 위는 하나의 함수를 사용하면 훨씬 간단해질 수 있다.
class PersonWithVal(
val name: String,
val age: Int,
val email: String,
val phoneNumber: String,
) {
fun `1살 더 먹기`(): PersonWithVal = this.copy(age = this.age + 1)
fun `이름 변경하기`(newName: String): PersonWithVal = this.copy(name = newName)
fun `이메일 변경하기`(newEmail: String): PersonWithVal = this.copy(email = newEmail)
fun `전화번호 변경하기`(newPhoneNumber: String): PersonWithVal = this.copy(phoneNumber = newPhoneNumber)
fun copy(
name: String = this.name,
age: Int = this.age,
email: String = this.email,
phoneNumber: String = this.phoneNumber,
): PersonWithVal = PersonWithVal(name, age, email, phoneNumber)
}
공통된 부분을 추출하여 copy
메서드를 생성했다. 이 메서드는 프로퍼티의 현재 값을 파라미터의 기본값으로 제공한다.
놀랍게도 코틀린은 data 클래스를 사용하면 직접 적지 않고도 이 코드를 생성할 수 있다.
data class PersonDataClass(
val name: String,
val age: Int,
val email: String,
val phoneNumber: String,
)
class PersonNormalClass(
val name: String,
val age: Int,
val email: String,
val phoneNumber: String,
) {
fun copy(
name: String = this.name,
age: Int = this.age,
email: String = this.email,
phoneNumber: String = this.phoneNumber,
): PersonNormalClass = PersonNormalClass(name, age, email, phoneNumber)
}
두 클래스를 디컴파일해보면 다음과 같다.
public final class PersonNormalClass {
...
@NotNull
public final PersonNormalClass copy(@NotNull String name, int age, @NotNull String email, @NotNull String phoneNumber) {
Intrinsics.checkNotNullParameter(name, "name");
Intrinsics.checkNotNullParameter(email, "email");
Intrinsics.checkNotNullParameter(phoneNumber, "phoneNumber");
return new PersonNormalClass(name, age, email, phoneNumber);
}
...
}
public final class PersonDataClass {
...
@NotNull
public final PersonDataClass copy(@NotNull String name, int age, @NotNull String email, @NotNull String phoneNumber) {
Intrinsics.checkNotNullParameter(name, "name");
Intrinsics.checkNotNullParameter(email, "email");
Intrinsics.checkNotNullParameter(phoneNumber, "phoneNumber");
return new PersonDataClass(name, age, email, phoneNumber);
}
...
}
메모리 관점에서 좋은 지적이다. var
를 사용했다면 추가적인 객체 생성이 발생하지 않는다.
결국 val
를 사용하는 것과 var
를 사용하는 것은 각각의 장단점이 있기에 상황에 맞게 선택해야 한다.
나는 var
로 인해 생기는 문제는 유지보수에 영향을 주고, val
로 인해 생기는 문제는 메모리에 영향을 준다고 생각한다. 그리고 둘을 비교하자면, 현 상황에서는 유지보수를 선택해야 한다고 생각한다. 인건비가 메모리보다 비싸기 때문이다.
끝으로 무어의 법칙을 소개한다.
무어의 법칙은 반도체 집적회로의 성능이 24개월마다 2배로 증가한다는 법칙이다. 경험적인 관찰에 바탕을 두고 있다. 인텔의 공동 설립자인 고든 무어가 1965년에 내 놓은 것이다.
- 무어의 법칙 - 위키백과
var
로 선언된 프로퍼티는 여러번 값을 할당할 수 있으므로 클래스가 예상치 못한 상황에 처하기 쉽고, 코드 실행 예측이 어려우며, 멀티스레드 환경에서는 적절한 동기화를 요한다.val
은 불변이 아닌, 재할당이 불가능한 읽기 전용 프로퍼티이기에 위 문제가 어느정도 해결된다.copy
메서드를 만들면 특정한 프로퍼티 값만 수정된 객체를 쉽게 얻을 수 있다.copy
메서드를 자동으로 생성해주고, 이를 통해 val
프로퍼티를 사용하는 것이 수월해진다.
이해가 쏙쏙 되네요~