가변성을 제한하라
read-write property인 var
혹은 mutable
객체를 가진다면 상태를 가질 수 있다.
이러한 방식으로 상태를 가진다면, history에도 의존하게 된다.
class BankAccount {
var balance = 0.0
private set
fun deposit(depositAmount: Double) {
balance += depositAmount
}
fun withdraw(withdrawAmount: Double) {
if (balance < withdrawAmount) {
throw InsufficientFunds()
}
balance -= withdrawAmount
}
}
class InsufficientFunds : Exception()
class Item1 {
@Test
fun test() {
val account = BankAccount()
println(account.balance) // 0.0
account.deposit(100.0)
println(account.balance) // 100.0
account.withdraw(50.0)
println(account.balance) // 50.0
}
}
위와 같이 balance 를 var로 지정하고, 값을 시간의 변화에 따라서 변하는 요소를 표현하도록 할 수 있다.
하지만 이렇게 작성한다면, 아래와 같은 문제점이 존재해 상태를 적절히 관리하기 어렵다.
1. 디버그하기 힘듦
: 이러한 상태를 갖는 부분들의 관계를 이해해야하고, 상태 변경이 많아진다면 이를 추적하기 힘들다.
2. 코드의 실행 추론이 어려워짐
: 시점에 따라 값이 달라지므로 한 시점에 확인한 값이 계속 동일하게 유지된다고 확신할 수 없음
3. 멀티스레드 프로그램에서 적절한 동기화 필요
4. 테스트 어려움
: 모든 상태 테스트 필요
5. 상태 변경시 다른 부분에 알려야 하는 경우 존재
: 예를 들어 정렬되어 있는 리스트에 가변 요소를 추가한다면 요소에 변경이 일어날 때마다 리스트 전체를 다시 정렬해야 함
@Test
fun multiThreadTest1() {
var num = 0
for (i in 1..1000) {
thread {
Thread.sleep(10)
num += 1
}
}
Thread.sleep(5000)
print(num) // 1000이 아닐 확률 높음
}
위와 같이 작성한다면 동시성 문제로, 1000이 아닐 확률이 높다. 또한 코루틴을 사용한다면 더 적은 스레드가 관여되므로 충돌 문제는 줄어들지만, 사라지는 것은 아니다. (코루틴은 한 스레드로 여러 루틴 실행)
또한 책에서는 synchronized를 이용해서 동시성 제어 방법을 제시함
코틀린은 이러한 문제를 해결하기 위해 immutable하게 코드를 작성할 수 있도록 설계되어있다. 예로는 아래와 같다.
또한 아래와 같이 var (name) 프로퍼티를 사용하는 val (fullname)은 변경될 수 있다.
class Person {
var name: String = "leah"
var surname = "lim"
val fullName
get() = "$name.$surname"
}
@Test
fun immutableTest() {
val person = Person()
println(person.fullName) //leah.lim
person.name = "lea"
println(person.fullName) //lea.lim
}
val의 값은 변경될 수 있기는 하지만, 레퍼런스 자체는 변경할 수 없어 동기화 문제를 줄일 수 있다. 따라서 일반적으로 var보다는 val을 많이 사용한다.
//getter
val fullName: String?
get() = "$name.$surname"
//불변 프로퍼티
val fullName: String? = "$name.$surname"
위와 같이 getter를 사용하는 경우, 값을 사용하는 시점 다른 결과가 나오므로 null checking 후 스마트캐스트를 사용할 수 없다. 하지만 아래와 같이 쓰이는 경우 스마트 캐스트할 수 있다.
mutable이 붙은 인터페이스는 읽기 전용 인터페이스를 상속받아 변경을 위한 메서드를 추가한다.
그렇다고 읽기 전용 컬렉션이 내부의 값을 변경할 수 없다는 의미는 아니다.
예를 들어, Iterable<T>.map
과 Iterable<T>.filter
함수는 변경할 수 있는 리스트인 ArrayList를 리턴한다. 내부적으로는 immutable하지 않은 컬렉션을 외부적으로는 immutable하게 만든다는 것이다.
이러한 코틀린의 특성은 다운캐스팅(부모가 자식으로 구체화)을 할 때 문제가 발생한다.
val list = listOf(1, 2, 3)
//다운 캐스팅 금지
if (list is MutableList) {
list.add(4)
}
//복제 사용
val mutableList = list.toMutableList()
리스트를 읽기 전용으로 리턴하면, 이를 읽기 전용으로만 사용해야 한다. 만약 읽기 전용에서 Mutable로 변경해야 한다면, 복제를 통해 새로운 mutable을 만들도록 해야한다.
일반적으로 사용하는 String, Int는 내부 상태를 변경하지 않는 immutable 객체를 사용한다. 불변 객체를 사용하면 다음과 같은 장점이 있다.
immutable 객체는 변경할 수 없기에, 자신의 일부를 수정한 새로운 객체를 만들어 내는 메서드를 가져야 한다.
코틀린에서는 이러한 복제하는 기능을 data
클래스로 제공해 준다.
val user = User2("leah", "lim")
user.copy(surname = "lee") //data 클래스에서 제공하는 copy 사용
println(user)
변경할 수 있는 리스트를 만들어야 한다면, (1) mutable 컬렉션, (2) var로 정의 하는 방법이 있다.
val list1: MutableList<Int> = mutableListOf() //(1)
var list2: List<Int> = listOf() //(2)
(1)의 경우, list1.plusAssign(1)
으로 변경되는데, 이는 멀티스레드 처리가 이루어진다면 적절한 동기화가 되어있는지 알 수 없어 위험하다.
(2)는 list2 = list2.plus(1)
로 변경되며, 프로퍼티가 변경지점이기에 멀티스레드 처리의 안정성이 더 좋다고 할 수 있다. 또, Delegates.observable
을 사용해 변경이 있을때 로그를 출력할 수 있어 변경을 추적할 수 있다. (1번 방식도 set을 이용해추적할 수 있지만, 상대적으로 구현 복잡)
(1), (2) 섞는건 최악이다..
class UserRepository {
private val storedUsers: MutableMap<Int, String> = mutableMapOf()
fun loadAll() = storedUsers
}
외부에서 변경이 가능하기에 mutable 객체를 직접 노출하지 말아야 한다.
만약 노출이 필요하다면 data클래스의 copy를 이용해 복제하는 방식을 사용하거나, 읽기 전용으로 업캐스팅 해야한다.