item 1 : 가변성을 제한하라
상태를 제어하는 것은 양날의 검
- 장점 : 시간의 변화에 따라서 변하는 요소를 표현
- 단점 : 상태를 관리하는 것은 어려움이 따른다
- 프로그램을 이해하고 디버그하기 힘들다
- 가변성이 많아지면 코드의 실행을 추론하기 힘들다
- 동시성에서 충돌이 생길 수 있다
- 테스트하기 어렵다
- 상태 변경에 따라 다른 부분에 알려야하는 경우가 존재
따라서 변할 수 있는 지점은 줄일 수록 좋다.
가변성을 제한하기 위해서
순수 함수형 언어를 사용하는 방법이 있지만 이는 프로그램 작성이 매우 어렵다. 코틀린에서 가변성을 제한하기 위해 property를 변경할 수 없게 하거나 immutable 객체를 만드는 것을 쉽게 할 수 있다.
읽기 전용 프로퍼티 val
- mutable 객체를 가지고 있다면 내부적으로 변할 수 있다.
- 사용자 정의
getter
가var
을 사용한다면 변할 수 있다. immutable
하진 않지만, 레퍼런스 자체를 변경 불가능 함으로 동기화 문제들을 줄일 수 있으므로var
보다 많이 사용한다.- 사용자 정의 getter를 가지지 않는 경우
smart-cast
가능하다.
val (name, surname) = "Márton" to "Braun"
val fullName get() = name?.let { "$it $surname" }
val fullName2 = name?, let { "sit $surname" }
if (fullName != null) println(fullName.length) // 오류
if (fullName2 != null) println(fullName2.length) // OK
가변 collection과 불변 collection
- 왼쪽에 위치한 인터페이스가 Immutable
- 오른쪽에 위치한 인테페이스가 Mutable
불변 collection이라고 실제로 값을 변경할 수 없는 것이 아니지만, 인터페이스 간에서 지원하지 않는 것이므로 불가능하다. 불변하지 않는 collection을 불변하게 쓰도록 규칙을 정해 안정성을 얻는 것이다.
따라서 collection 다운 캐스팅은 이러한 규칙과 추상화를 무시하는 것...if(list is MuatbleList) { ... }
➡️ 플랫폼 마다 List
인터페이스 구현방법이 다를 수 있다.
그래서 위 코드 같이 읽기 전용에서 mutable로 쓰고 싶다면, 다운 캐스팅이 아니라 copy
를 활용하자
val list = listOf(1, 2, 3)
val mutableList = list.toMutableList()
mutableList.add(4)
immutable 불변 객체의 장점
- 한번 정의된 상태를 유지 ➡️ 이해가 쉽다
- 공유해도 충동이 없으므로 병렬 처리에 안전
- 레퍼런스가 변경되지 않음으로 쉽게 캐시 가능
- 방어적 복사본을 만들 필요 없음 == 깊은 복사 안해도 됨
- 다른 객체를 만들때 활용하기 좋다 (StateFlow / MutableStateFlow)
set
또는map
의key
로 사용할 수 있다
immutable 불변 객체의 단점
객체를 변경할 수 없어 자신의 일부를 수정한 객체를 만드는 메서드가 필요
- Int.plus()
Int.minus()
Iterable.map()
Iterable.filter()
- 직접 만드는 immutable 객체 또한 이러한 메서드가 필요
이러한 작업은 귀찮으므로 data
한정자를 활용한다. data
는 copy
메서드를 만들어주는데, 이를 통해 기본 생성자 프로퍼티가 같은 새로운 객체를 만들어 낼 수 있다.
변경 가능 지점 (mutation point)
val listl: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()
list1 += 1 // list1.plusAssign(1)
list2 += 1 // list2 = list2.plus(1)
mutating point가 다르다.
list1 (val + mutable)
: 리스트 내부에서 변경list2 (var + immutable)
: 프로퍼티 자체에서 변경
list1에 경우 멀티스레드 처리의 경우 내부적으로 동기화가 되었는지 장담 불가능. list2가 더 안전하다.
또한 mutable 프로퍼티를 사용하는 경우 getter를 사용해서 변경을 추적할 수 있다.
var names : List<String>
by Delegates.observable() { _, old, new ->
println("$old -> $new")
}
- var + mutable collection은 최악!
mutation point 변경 가능 지점을 숨겨라
- 방어적 복제하기
- 읽기 전용 super 타입으로 업캐스팅
class UserHolder {
private val user: MutableUser()
fun get() = user.copy() // defensive copy
}
class UserRepository {
private val storedUsers = mutableMapOf<Int, String>()
// immutable up-casting
fun loadAll(): Map<Int, String> = storedUsers
}
item 2 : 변수의 스코프를 최소화하라
scope를 좁게 설정하는 것이 좋다.
- 프로그램을 추적하고 관리하기 쉽기 때문
- mutable 프로퍼티의 경우 좁은 scope에 걸쳐 있을 수록 변경 추적이 쉽다.
- scope가 넓다면 휴먼에러로 외부에서 사용할수도.. 후덜덜
변수는 정의할 때 초기화 하자
// not good
lateinit var user: User
if (hasValue) user = getValue()
else user = User()
// better
val user: User = if(hasValue) getValue() else User()
Capturing 캡처링
val primes: Sequence<Int> = sequence {
var numbers = generateSequence(2) { it + 1 }
var prime: Int
while (true) {
prime = numbers.first()
yield(prime)
numbers = numbers.drop(1)
.filter { it % prime != 0 }
}
}
에라토스테네스의 체를 구하는 알고리즘 (소수 구하기)
위 코드는 정상적인 결과를 도출하지 못한다.
Why?
var prime
을 캡처했기 때문. filter
연산이 지연되어서 (sequence lazy evaluation) 최종 prime 값으로만 필터링 된 것이다.
따라서 가변성을 피하고 scope를 좁게 만든다면 이러한 문제를 방지 할 수 있다.
item 3 : 최대한 플랫폼 타입을 사용하지 말라
Java <-> Kotlin
@Nullable
@NotNull
어노테이션을 활용- nullable을 가정하되 확실한 건 unwrap (
!!
) - 자바의 제네릭 타입이면 더 골아프다...
List<List<User>>
- 이렇게 다른 언어에서 넘어온 타입을
platform
타입이라 함 - 이는
!
를 붙여 표기한다. (String!
)
- 이렇게 다른 언어에서 넘어온 타입을
val repo = UserRepo()
val user1 = repo.user // User!
val user2: User = repo.user // User
val user3: User? = repo.user // User?
플랫폼 타입말고 캐스팅해서 쓰자.
item 4 : inferred 타입으로 리턴하지 말라
Inferred 타입이란?
: 타입추론에 의해 지정되는 타입
- 할당 오른쪽의 피연산자에 맞게 설정됨
- super class 또는 interface로 설정되지 않음으로 유의
타입을 명시적으로 지정하면 문제없지만 라이브러리 또는 모듈이라 조작이 불가능 할 수 있다. 그러니까 명시적으로 리턴하고 inferred로 리턴하지 말자. 예측하기 힘드니까
item 5 : 예외를 활용해 코드에 제한을 걸어라
예외를 활용해서 제한을 건다면 안전하게 동작할 수 있다.
Why?
- 문서를 안읽어도 문제를 확인 가능
- 문제되는 동작에 대한 제한 -> exception throw
- 코드 레벨에서 자체 검증 (unit test 까지 안가도 됨)
- smart-cast 기능 활용 -> 형변환 안해도 됨
How?
argument : require
fun factorial(n: Int): Long {
require(n > 0) {
"Cannot calculate factorial of $n" +
"because it is smaller than 0"
}
return if (n <= 1) 1 else factorial(n - 1) * n
}
- 인자 제한에 사용
IllegalArgumentException
state : check
상태와 관련된 제약
fun getUserInfo() {
checkNotNull(token)
...
}
- 어떤 객체가 초기화 되어 있는지? / 세션이 있는지? 등등
IllegalStateException
assert
계열
- 보통 단위 테스트에 씀
- 프로덕션 환경에서는 오류 발생 x
- 근데 그럼 check 쓰지, 테스트 코드도 아닌데 왜 씀?
- 특정 상황(JVM)이 아닌 모든 상황에 대한 테스트
- 싱행 시점에 정확하게 어떻게 되는지 확인 가능
- 실제 코드가 더 빠른 시점에 실패하게 만듦
- 코틀린에서는 딱히 쓰지 않는 것 같다. 양념처럼 쓰자
elvis 연산자 ?:
- nullablitiy를 검증하는 경우
check
나require
도 좋지만 elvis 연산자를 활용 email = person.email ?: return
email = person.email ?: run { throw ... }
item 7 : 결과 부족이 발생할 경 null 과 Failure를 사용하라
결과 부족이란
- 서버로부터 데이터를 인터넷 연결 문제로 읽지 못한 경우
- 조건에 맞는 첫 번째 요소를 찾는데, 요소가 없는 경우
- 텍스트를 파싱해서 객체를 만드는데 형식이 맞지 않는 경우
결과 부족을 처리하기 위해서는
null
또는sealed class
형태의Failure
- throw Exception
Exception은 정보를 전달하는 방법으로 사용되면 안된다.
- 코틀린의 모든 예외는 unchecked 예외이므로 사용자가 예외 처리하지 않을 수 있다.
- try-catch 블록 내부의 코드는 컴파일러의 최적화가 제한된다.
- 명시적 테스트에 비해 빠르지 않다.
null & result sealed class
예상되는 오류를 표현할 때 굉장히 좋다
- 명시적이고, 효율적이고, 간단함
null
을 사용한다면
inline fun <reified T> String.readObjectOrNull(): T? {
if(incorrectSign) return null
...
return result
}
val age = userText.readObjectOrNull<Person>()?.age ?: -1
Result
유니온 타입을 사용한다면
sealed class Result<out T>
class Success<out T>(val result: T): Result<T>()
class Failure(val throwable: Throwable): Result<Nothing>()
inline fun <reified T> String.readObject(): Result<T> {
if(incorrectSign) return Failure(Exception())
...
return Success(result)
}
val person = userText.readObject()
val age = when(person) {
is Success -> person.age
is Failure -> -1
}
- 정보를 전달해야한다면 sealed result를 사용
- 그렇지 않으면 null을 사용하는 것이 일반적이다.
null을 리턴할 때
getOrNull()
처럼 null을 예측할 수 있게 이름에 명시하자
item 8 : 적절하게 null을 처리하라
null이 의미 하는 것
: 값이 부족하다
- property가 null이라면 값이 설정되지 않았거나 제거되었다.
- 함수가 null을 반환한다면
String.toIntOrNull()
: 적절하게 변환할 수 없다Iterable.firstOrNull(predicate)
: 주어진 조건에 맞는 요소가 없다
null을 처리하는 법
?.
(safe call) / smart-casting /?:
Elvis 연산자- exception throw
- nullable 이 나오지 않게 고침
safe call ?.
val printer: Printer? = Printer()
printer?.print
smart-casting
val printer: Printer? = Printer()
if(printer != null) printer.print()
Elvis operator
val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?: throw Error("Printer must be named")
val printerName = printer?.name ?: run { ... }
공격적 vs 방어적 프로그래밍
- defensive
- 모든 가능성을 올바른 방식으로 처리
- ex) null일 때는 출력하기 않기
- 굉장히 안정적이겠지만 이상적
- offensice
- 예상치 못한 상황일 때 개발자에게 이를 알려서 처리
- ex) require, check, assert,
!!
등
Non-null assertion !!
남용하지말자..
- 사용하기 쉬워 남용할 수 있다
- 과연 미래에도 non-null 할까? 확신할 수 없다
nullablity를 피히자
그래도 써야겠다면...
getOrNull
형태의 함수 제공null
대신 empty collectionlateinit
과notNull
delegate
lateinit / notNull delegate
객체를 처음 초기화 해야해서 null을 붙인다면
- 초기화 후에는 non-null이지만 매번 unwrap을 해줘야한다
lateinit
- 프로퍼티를 처음 사용하기 전에 반드시 초기화 될 거라고 예상되는 상황에 사용
Int
,Boolean
같은 기본 타입은 불가능
Delegates.notNull
- lateinit 보다 느림
- 기본 타입도 가능
var doctorId: Int by Delegates.notNull()
- property delegation (무슨말인지 잘 모르겠습니다)
classDoctorActivity : Activity() { private var doctorId: Int by arg(DOCTOR_ID_ARG) private var fromNotification: Boolean by arg(FROM_NOTIFICATION_ARG) }
item 9 : use를 사용하여 리소스를 닫아라
use를 사용하면 Closeable, AutoCloseable을 구현한 객체를 쉽고 안전하게 처리할수있다. 또한 파일을 처리할 때는 파일을 한 줄씩 읽어들이는 useLines를 사용하는 것이 좋다.
item 10 : 단위 테스트를 만들어라
애플리케이션이 진짜로 올바르게 동작하는지 확인하는 것은 중요하다. 이것이 바로 테스트인데 그 중에서 개발과정에서 가장 효율적으로 활용할 수 있는 것은 Unit 테스트.
비즈니스 애플리케이션 등에서는 최소한 몇 개라도 단위 테스트가 꼭 필요하다.
고 합니다.
'Kotlin' 카테고리의 다른 글
[이펙티드 코틀린] 4장 🖼️ 추상화 설계 Abstraction Design (item 26..32) (0) | 2023.06.28 |
---|---|
[이펙티드 코틀린] 3장 ♻️ 재사용성 Reusability (item 19..25) (0) | 2023.06.28 |
[이펙티브 코틀린] 2장 👀 가독성 Readability (item 11..18) (0) | 2023.06.18 |