- 코틀린은 모듈로 프로그램을 설계하는데 모듈은 클래스, 객체, 함수, 타입 별칭, 톱레벨 프로퍼티 등 다양한 요소로 구성
- 이러한 요소 중에 일부는 상태를 가질 수 있는데 프로퍼티 var를 사용하거나 mutable 객체를 사용하면 상태를 가질 수 있음
- 코틀린은 프로퍼티나 객체에 아래와 같이 상태를 가질 수 있다
var a = 10
var list: MutableList<Int> = mutableListOf()
- 이렇게 요소가 상태를 갖는 경우, 해당 요소의 동작은 사용 방법뿐만 아니라 히스토리에도 의존하게 됨
상태를 가지는 클래스 예시
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()
fun main() {
val account = BankAccount()
println(account.balance)
account.deposit(100.0)
println(account.balance)
account.withdraw(50.0)
println(account.balance)
}
- 상태를 갖게 하는 것은 양나르이 검인데, 시간의 변화에 따라서 변하는 요소를 표현하는 것은 유요하지만, 상태를 적절하게 관리하는 것은 생각보다 어려운 문제
상태를 가지는 경우 발생할 수 있는 문제점
- 프로그램을 이해하고 디버그하기 힘들어짐
- 클래스 이해도 어렵고 그에 따라 수정하기도 힘듬
- 가변성이 있으면 코드의 실행을 추론하기 어려워짐
- 시점에 따라 값이 달라질 수 있으므로, 현재 어떤 값을 갖고 있는지에 따라 실행을 예측할 수 있음
- 멀티스레드 프로그램일 때는 적절한 동기화가 필요
- 변경이 발생하는 부분은 충돌 문제가 항상 있음
- 테스트하기 어려움
- 모든 상태를 테스트 해야하므로 변경이 많으면 더 많은 조함을 테스트 해야함
- 상태 변경이 일어날때 관련된 다른 부분에 알려줘야 하는 경우가 있음
- 정렬되어 있는 리스트에 가변 요소가 추가되면 전체 다시 정렬을 해야함
멀티스레드 환경에서의 상태 프로퍼티의 문제점
// 멀티 스레드
private fun multiThread() {
var num = 0
for (i in 1..1000) {
thread {
Thread.sleep(10)
num += 1
}
}
Thread.sleep(5000)
println(num) // 1000이 아닐 확률이 매우 높음
}
// 코루틴
private suspend fun coroutine() {
var num = 0
coroutineScope {
for (i in 1..1000) {
launch {
delay(10)
num += 1
}
}
}
println(num)
}
suspend fun main() {
multiThread()
multiThread()
coroutine()
coroutine()
}
- 멀티스레드를 활용해 프로퍼티를 수정하는 경우 충돌로 인해 일부 연산이 이루어지지 않음, 코루틴의 경우 더 적은 스레드가 관여되어 충돌문제가 줄어들기는 하나 문제가 사라지지 않음
- 즉 프로퍼티가 상태에서 멀티 스레드를 활용하게 된다면 충돌이 되지 않도록 적절하게 동기화를 구현해야함
- 그러나 동기화를 잘 구현하는 것은 굉장히 어려운 일
private fun locking() {
val lock = Any()
var num = 0
for (i in 1..1000) {
thread {
Thread.sleep(10)
synchronized(lock) {
num += 1
}
}
}
Thread.sleep(100)
println(num)
}
- 이렇듯 가변성은 생각보다 단점이 많아 가변성을 완전하게 제한하는 언어도 등장했을 정도로 가변성은 프로그램에서 중요한 문제
- ex) Haskell → 매우 어렵고 코드 작성이 까다로움
- 즉 프로그램을 구현할때에 가변성을 염두해두고 변경이 일어나야 하는 부분을 신중하고 확실하게 결정하고 사용해야함
코틀린에서 가변성 제한하기
- 코틀린은 가변성을 제한할 수 있게 설계되어져 있어서 immutable 객체를 만들거나 프로퍼티를 변경할 수 없게 막는 것이 굉장히 쉬움
코틀린에서 대표적으로 가변성을 제한하는 방법
- 읽기 전용 프로퍼티 (val)
- 가변 컬렉션과 읽기 전용 컬렉션 구분
- 데이터 클래스의 copy
읽기 전용 프로퍼티 (val)
- 읽기 전용 프로퍼티를 활용해 마치 값처럼 동작하며 일반적인 방법으로는 변하지 않음
val a = 10
a = 20 // 오류
- 읽기 전용 프로퍼티가 mutable한 객체를 담고 있다면, 내부적으로 변할 수 있음
val list = mutableListOf(1,2,3)
list.add(4)
- 읽기 전용 프로퍼티는 다른 프로퍼티를 활용하는 사용자 정의 게터로도 정의할 수 있습니다. 이렇게 var프로퍼티를 사용하는 val 프로퍼티는 var프로퍼티가 변할 때 변할 수 있습니다.
var name: String = "Marcin"
var surname: String = "Mkskala"
val fullName
get() = "$name $surname"
- 코틀린의 프로퍼티는 기본적으로 캡슐화되어 있고, 추가적으로 사용자 정의 접근자를 가질 수 있음
- 이러한 특성으로 코틀린은 API를 변경하거나 정의할 때 유연성을 가짐
- var는 게터와 세터를 모두 제공하지만 val은 게터만 제공하므로 val을 var로 오버라이드 할 수 있음
interface Element {
var active: Boolean
}
class ActualElement: Element {
override var active: Boolean = false
}
- val은 읽기 전용 프로퍼티지만 변경할 수 없음을 의미하지 않음
- 만약 완전히 변경할 필요가 없다면, final 프로퍼티를 시용하는 것이 좋음
- val은 정의 옆에 상태가 바로 적히므로, 코드의 실행을 예측하는 것이 훨씬 간단하고 스마트 캐스트(smart cast)등의 추가적 인 기능을 활용할 수 있음
val name: String? = "Marton"
val surname: String = "Braun"
val fullName: String?
get() = name?.let { "$it $surname" }
val fullName2: String? = name?.let { "$it $surname" }
fun main() {
if (fullName != null) {
println(fullName.length) // 오류
}
if (fullName2 != null) {
println(fullName2.length)
}
}
- fullName은 게터로 정의했으므로 스마트 캐스트를 할 수 없음
- 게터를 활용하므로, 값을 사용하는 시점의 name에 따라서 다른 결과가 나올 수 있기 때문
- 반면 fullName2는 지역 변수가 아닌 프로퍼티가 final 이고, 사용자 정의 게터를 갖지 않음
가변 컬렉션과 읽기 전용 컬렉션 구분하기
https://anhristyan.medium.com/kotlin-collections-inside-part-1-2ad270586f4a
- 코틀린은 읽기 전용 컬렉션과 읽고 쓸 수 있는 컬렉션으로 구분됨
- 읽기 전용 : Iterable, Collection, Set, List 인터페이스
- 읽고 쓸 수 있는 : MutableInterable, MutableCollection, MutableSet, MutableList 인터페이스
- 읽기 전용 컬렉션이 내부의 값을 변경할 수 없다는 의미는 아니고 대부분의 경우에는 변경할 수 있지만 읽기 전용 인터페이스가 이를 지원하지 않으므로 변경할 수 없음
- Iterable<T>.map 과 Iterable<T>.filter 함수는 ArrayList를 리턴한다
Iterable map
inline fun<T, R> Iterable<T>.map(
transformation: (T) -> R
): List<R> {
val list = ArrayList<R>()
for (elem in this){
list.add(transformation(elem))
}
return list
}
- 이러한 컬렉션을 진자로 불변하게 만들지 않고, 읽기 전용으로 설계한 것이 중요한 부분
- 코틀린은 내부적으로 immutable하지 않은 컬렉션을 외부적으로 immutable하게 보이게 만들어서 얻어지는 안정성
다운캐스팅 문제
val list = listOf(1, 2, 3)
if (list is MutableList) {
list.add(4)
}
// 만약 변경이 필요하다면 아래와 같이 새로운 mutable list를 만들어서 리턴
val list = listOf(1,2,3)
val mutableList = list.toMutableList()
mutableList.add(4)
- 리스트를 읽기 전용으로 리턴하면, 이를 읽기 전용으로만 사용해야하는데, 이는 단순한 규약의 문제인데 다운 캐스팅은 이러한 계약을 위반하고 추상화를 무시하는 행위이기 때문에 이러한 코드는 안전하지 않고 예측하지 못한 결과를 초래함
- 위 결과는 플랫폼에 따라 다르며 JVM에서 listOf는 자바의 List 인터페이스을 구현한 ArrayList 인스턴스를 리턴 그러나 1년 뒤에 이것이 어떻게 동작하는지 보장할 수 없음
- 즉 읽기 전용 컬렉션을 mutable로 다운 캐스팅을 하는 것은 굉장히 좋지 않고 만약 변경이 필요하다면 copy를 통해 새로운 mutable 컬렉션을 만들어서 활용하는 것이 좋음
데이터 클래스의 Copy
- Immutable 객체의 장점
- 한 번 정의된 상태가 유지되므로 코드 이해가 쉬움
- 공유했을 때도 충돌이 따로 이루어지지 않으므로, 병렬 처리를 안전하게 할 수 있음
- 객체에 대한 참조는 변경되지 않으므로 쉽게 캐시가 가능
- 방어적 복사본을 만들 필요가 없음
- 다른 객체를 만들 때 활용하기 좋고 실행을 더 쉽게 예측가능
- set, map 키로 사용할 수 있는데 mutable 객체는 이러한 것으로 사용할 수 없다.
- 세트와 맵인 내부적으로 해시 테이블을 사용하고 해시 테이블은 처음 요소를 넣을때 요소의 값을 기반으로 버킷을 결정하기 때문에 요소의 값이 수정이 되면 해시 테이블 내부에서 요소를 찾을 수 없게되기 때문
val names: SortedSet<FullName> = TreeSet() val person = FullName("AAA", "AAA") names.add(person) names.add(FullName("Jordan", "Hansen")) names.add(FullName("David", "Blanc")) println(names) println(person in names) // true person.name = "ZZZ" println(names) println(person in names) // false
- immutable 객체는 위와 같은 장점을 가지고 있으나 객체를 변경할 수 없다는 단점이 있는데 만약 수정하고자 하면 새로운 객체를 만들어 내는 메서드를 가져야 함
class User(
val name: String,
val surname: String,
) {
fun withSurname(surname: String) = User(name, surname)
}
var user = User("Nathan", "Hong")
user = user.withSurname("Jo")
print(user)
- 다만 모든 프로퍼티를 대상으로 이런 함수를 하나하나 만드는 것은 굉장히 귀찮은 일이기 때문에 data 한정사를 사용해 copy 메소드를 활용하면 기본 생성자 프로퍼티가 같은 새로은 객체를 만들어낼 수 있음
data class(
val name: String,
val surname: String,
)
var user = User("Nathan", "Hong")
user = user.copy(usrname = "Jo")
print(user)
다른 종류의 변경 가능 지점
- 변경할 수 있는 리스트는 아래 두 가지 방식으로 만들 수 있음
val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()
list1.add(1)
list2 = list2 + 1
list1 += 1 // list1.plusAssign(1)
list2 += 1 // list2 = list2.plus(1)
- 위 두 가지 방식 비교
- list1 : mutable 컬렉션 사용
- 구체적인 리스트 구현 내부에 변경 가능 지점이 있음
- 멀티스레디 처리가 이루어지는 경우 내부적으로 적절한 동기화가 되어 있는지 확실하게 알 수 없음
- list2 : immutable 컬렉션 사용 & 프로퍼티 자체가 변경 가능
- 객체 변경을 제어하기 더 쉬움
- 사용자 정의 세터 혹은 이를 사용하는 델리케이트를 활용해 변경을 추적할 수 있음
var list = listOf<Int>() for(i in 1..1000) { thread { list = list + i } } Thread.sleep(1000) println(list.size) var names by Delegates.observable(listOf<String>()) {_, old, new -> println("Names changed from $old to $new") } names += "Fabio" names += "Bill"
- mutable 컬렉션도 관찰할 수 있게 만들려면 추가적 구현이 필요하기 때문에 mutable 프로퍼티에 읽기 전용 컬렉션을 넣어 사용하는게 쉬움
- list1 : mutable 컬렉션 사용
- 최악의 방식
- mutable 컬렉션과 mutable 프로퍼티 동시에 사용
- 프로퍼티, 컬렉션 모두 변경 가능 지점
var list3 = mutableListOf<Int>()
- mutable 컬렉션과 mutable 프로퍼티 동시에 사용
- 위 두 가지 방식 모두 상태를 제어 및 이해하는 비용이 들어가므로 가변성을 제한하는 것이 가장 좋음
변경 가능 지점 노출하지 말기
- 상태를 나타내는 mutable 객체를 외부에 노출하는 것은 굉장히 위험
data class User(
val name: String
)
class UserRepository {
private val storedUsers: MutableMap<Int, String> = mutableMapOf()
fun loadAll(): MutableMap<Int, String> {
return storedUsers
}
}
val userRepository = UserRepository()
val storedUsers = userRepository.loadAll()
storedUsers[4] = "Kiraill"
- 위와 같은 코드는 돌발적인 수정이 일어나면 위험할 수 있으므로 아래 두 가지 방식 중 하나로 적절하게 처리해야함
- 리턴되는 객체를 복제
- user.copy()
- 읽기 전용 슈퍼 타입으로 업캐스팅하여 가변성을 제어
- fun loadAll(): Map<Int, String>
- 리턴되는 객체를 복제
정리
- 가변성을 제한한 immutable 객체를 사용하는 것이 좋은 이유에 대한 내용이고 코틀린이 가변 지점을 제어하기 위해 사용되는 다양한 도구를 소개
- var 보다는 val을 사용하는 것이 좋음
- mutable 프로퍼티보다는 immutable 프로퍼티를 사용하는 것이 좋음
- mutable 객체와 클래스보다는 immutable 객체와 클래스를 사용하는 것이 좋음
- 변경이 필요한 대상을 만들어야 한다면, immutable 데이터 클래스로 만들고 copy를 활용하는 것이 좋음
- 컬렉션에 상태를 저장해야 한다변, mutable 컬렉션보다는 읽기 전용 컬렉션을 사용하는 것이 좋음
- 변이 지점을 적절하게 설계하고, 불필요한 변이 지점은 만들지 않는 것이 좋음
- mutable 객체를 외부에 노출하지 않는 것이 좋음
'Reading Record > 이펙티브 코틀린' 카테고리의 다른 글
[이펙티브 코틀린] Item6. 사용자 정의 오류보다는 표준 오류를 사용하라 (0) | 2022.04.06 |
---|---|
[이펙티브 코틀린] Item5. 예외를 활용해 코드에 제한을 걸어라 (0) | 2022.04.06 |
[이펙티브 코틀린] Item4. inferred 타입으로 리턴하지 말라 (0) | 2022.04.06 |
[이펙티브 코틀린] Item3. 최대한 플랫폼 타입을 사용하지 마라 (0) | 2022.04.06 |
[이펙티브 코틀린] Item 2. 변수의 스코프를 최소화하라 (0) | 2022.04.06 |