wanna be dev 🧑‍💻

Cool 하고 Sick한 개발자가 되고 싶은 uzun입니다

A.K.A. Kick-snare, hyjhyj0901, h_uz99 solvedac-logo

Kotlin

[이펙티트 코틀린] 1장 🏰 안정성 Safety (item 01..10)

Kick_snare 2023. 6. 18. 16:32
728x90

by Sammy Wong

item 1 : 가변성을 제한하라

상태를 제어하는 것은 양날의 검

  • 장점 : 시간의 변화에 따라서 변하는 요소를 표현
  • 단점 : 상태를 관리하는 것은 어려움이 따른다
    1. 프로그램을 이해하고 디버그하기 힘들다
    2. 가변성이 많아지면 코드의 실행을 추론하기 힘들다
    3. 동시성에서 충돌이 생길 수 있다
    4. 테스트하기 어렵다
    5. 상태 변경에 따라 다른 부분에 알려야하는 경우가 존재

따라서 변할 수 있는 지점은 줄일 수록 좋다.

가변성을 제한하기 위해서

순수 함수형 언어를 사용하는 방법이 있지만 이는 프로그램 작성이 매우 어렵다. 코틀린에서 가변성을 제한하기 위해 property를 변경할 수 없게 하거나 immutable 객체를 만드는 것을 쉽게 할 수 있다.

읽기 전용 프로퍼티 val

  • mutable 객체를 가지고 있다면 내부적으로 변할 수 있다.
  • 사용자 정의 gettervar을 사용한다면 변할 수 있다.
  • 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 불변 객체의 장점

  1. 한번 정의된 상태를 유지 ➡️ 이해가 쉽다
  2. 공유해도 충동이 없으므로 병렬 처리에 안전
  3. 레퍼런스가 변경되지 않음으로 쉽게 캐시 가능
  4. 방어적 복사본을 만들 필요 없음 == 깊은 복사 안해도 됨
  5. 다른 객체를 만들때 활용하기 좋다 (StateFlow / MutableStateFlow)
  6. set또는 mapkey로 사용할 수 있다

immutable 불변 객체의 단점

객체를 변경할 수 없어 자신의 일부를 수정한 객체를 만드는 메서드가 필요
- Int.plus() Int.minus() Iterable.map() Iterable.filter()
- 직접 만드는 immutable 객체 또한 이러한 메서드가 필요

이러한 작업은 귀찮으므로 data 한정자를 활용한다. datacopy 메서드를 만들어주는데, 이를 통해 기본 생성자 프로퍼티가 같은 새로운 객체를 만들어 낼 수 있다.

변경 가능 지점 (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 변경 가능 지점을 숨겨라

  1. 방어적 복제하기
  2. 읽기 전용 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?

  1. 문서를 안읽어도 문제를 확인 가능
  2. 문제되는 동작에 대한 제한 -> exception throw
  3. 코드 레벨에서 자체 검증 (unit test 까지 안가도 됨)
  4. 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를 검증하는 경우 checkrequire도 좋지만 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 collection
  • lateinitnotNull 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 테스트.

비즈니스 애플리케이션 등에서는 최소한 몇 개라도 단위 테스트가 꼭 필요하다.
고 합니다.

728x90