프로그래밍/Kotlin

이펙티브 코틀린 1장 안정성

seungdols 2022. 9. 28. 23:31

1장 안정성

아이템 1: 가변성을 제한하라

코틀린은 모듈로 프로그램을 설계 한다. 모듈은 클래스, 객체 ,함수, 타입 별칭, Top-level property 등 다양한 요소로 구성 된다.

아래의 코드를 살펴 봅니다.

class NumberSum {  

    suspend fun sumNumber() {  
        var num = 0  
        coroutineScope {  
            for (i in 1..1000) {  
                launch {  
                    delay(10)  
                    num += 1  
                }  
            }  
        }  

        println(num)  // 실행 마다 다른 값이 나온다.
    }  
}

코루틴은 경량 쓰레드라고 생각하면 되고, 위의 코드에서는 쓰레드 간의 동기화가 되어 있지 않기 때문에, 값을 마음 대로 변경 하게 되는 이슈가 있고, 우선 num 변수 자체가 가변적인 변수라 이를 불변하게 제어 할 필요가 있다.

코틀린에서 가변성 제어

  • 읽기 전용 프로퍼티
    • val keyword
  • 가변 컬렉션과 읽기 전용 컬렉션 구분
    • List를 MutableList로 다운캐스팅을 하지 말것
    • Mutable Collection이 필요하다면, toMutableList()로 변환하여 사용한다.
  • data class의 copy
var name: String = "seungho"  
var nickName: String = "seungdols"  
val fullName  
    get() = "$name $nickName" // custom getter의 경우, 호출시마다 해당 상태값을 반영함.
println(fullName)  
name = "Maja"  
println(fullName)

// seungho seungdols
// Maja seungdols

class GetterExample {  
    val fizz = calculate() // fizz에는 int 값이 담김. 함수 호출은 1번  
    val buzz  
        get() = calculate()  
    private fun calculate(): Int {  
        println("Calculating...")  
        return 42  
    }  
}

var의 경우 get, set 모두 가능 하지만, val의 경우 get만 가능하다. val 로 선언된 프로퍼티를 var 로 오버라이드 가능하다.

val name: String? = "seungho"  
val surname: String = "seungdols"  

val fullName: String?  
    get() = name?.let { "$it $surname"}  
val fullName2: String? = name?.let { "$it $surname"}  

fun test() {  
    if (fullName != null) {  
        println(fullName?.length)  
    }  
    if (fullName2 != null) {  
        println(fullName2.length)  // smart case OK
    }  
}
val list1: MutableList<Int> = mutableListOf()
var list2: List<Int> = listOf()

위의 두 mutable에 대하여 차이를 명확하게 이해하는 것이 좋다.

첫 번째의 경우, mutableList 내부에서의 변경 가능성
두 번째의 경우, 프로퍼티 자체의 변경 가능성

최악의 경우는 아래의 코드이다.

var list: MutableList<Int> = mutableListOf()

결론적으로 mutableList를 쓰는 것보다 프로퍼티를 var로 표현하는 것이 안정성이 더 높고, 방어 로직을 추가하기가 쉽다.


아이템 2: 변수의 스코프를 최소화하라

변수의 스코프를 줄이는 방식이 유리한 이유는 프로그램을 추적하고 관리를 용이하게 만든다.

var user: User
if (hasValue) {
  user = getValue()
} else {
  user = User()
}

val user = if (hasValue) getValue() else User()

코드를 보면 위의 방식보다 if-else 구문을 통한 값 결정을 통해서 변수의 스코프를 좁게 만들 수 있다.

val primes: Sequence<Int> = sequence {
    var numbers = generateSequence(2) { it + 1}
    while(true) {
        val prime = numbers.first()
        yield(prime)
        numbers = numbers.drop(1).filter { it % prime != 0}
    }
}
println(primes.take(10).toList())

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}
    }
}
println(primes.take(10).toList())

Sequence는 실행이 지연 된다는 특성이 있다.
ref. https://kotlinlang.org/docs/sequences.html

아이템 3: 최대한 플랫폼 타입을 사용하지 말라

다른 언어에서 전달 되어 nullable인지 아닌지 알 수 없는 경우, 플랫폼 타입이라 하고 String!를 붙여서 표현한다.

자바와 코틀린을 함께 사용하는 경우라면, @NotNull, @Nullable을 붙여서 사용하는 것이 좋다.

public class User {
    public String nickName() {
        return null;
    }
}
val nickName = User().nickName() // nickName은 플랫폼 타입이 된다.

아이템 4: inferred 타입으로 리턴하지 말라

open class Animal
class Zebra: Animal()

var animal  = Zebra() // zebra 
animal  = Animal() // error

var animal: Animal  = Zebra()  
animal  = Animal() // ok

할당 시점에 inferred 타입은 정확하게 오른쪽에 있는 피연산자에 설정 되기 때문에, 위와 같은 이슈가 발생한다. 그렇기 때문에 리턴 타입들은 명시적으로 표기하는 것이 오류 발생을 줄일 수 있다.

아이템 5: 예외를 활용해 코드에 제한을 걸어라

확실하게 어떤 형태로 동작해야 하는 코드가 있다면 제한을 설정 하는 것이 좋다.

  • require
    • argument 제한
      • 해당 조건을 만족하지 못하면, IllegalArgumentException을 throw
  • check
    • 상태와 관련한 동작 제한
      • 지정 된 예측을 만족하지 않을 때, IllegalStateException을 throw
  • assert
    • 어떤 것이 true인지 확인, test mode에서만 동작
      • JVM 옵션의 -ea로 활성화 해야 동작
  • return|throw + Elvis operator
class Calculator(val number1: Int, val number2: Int, val op: String) {  
    fun minus(): Int {  
        require(number1 > 0 && number2 > 0) {  
            "Negative value"  
        }  
        check(op.isNotEmpty()) { "Input op value"}  
        assert(op == "-")  
        return number1 - number2  
    }  
}

kotlin에서 require, check 블록으로 해당 조건이 true가 나오면 해당 조건은 그 이후에도 true로 가정합니다.

require(person.outfit is Dress)
val dress: Dress = person.outfit // smart cast

require(user.name != null)

val name = user.name 

// null 체크 
requireNotNull()
checkNotNull() 

return 혹은 throw와 함께 elvis operator 활용

val name = user.name ?: return 
val name = user.name ?: run {
    log("name is null")
    return 
}

아이템 6: 사용자 정의 오류보다는 표준 오류를 사용하라

inline function과 reified에 대해서 이해할 필요가 있다.

    fun hello(block: () -> Unit) {  
        println("hello")  
        block() // 자주 호출 될수록 비용이 높다.   
    }  

LoggingFactory.getLogger(XXX.class); // java style
inline fun <reified T> T.logger(): Logger {
  return LoggerFactory.getLogger(T::class.java)
}

ref.

inline fun <reified T> String.readObject(): T {
    if (incorrectSign) {
        throw JsonParsingException()
    }
    // 
    ///....
    return result
}

표준 라이브러리에 있는데 사용자 정의 오류를 사용하는 것보다는 표준 라이브러리에 있는 오류를 재사용하는 것이 코드 보존에 유리하다는 측면입니다.

  • IndexOutOfBoundsException
  • ConcurrentModificationException
  • UnsupportedOperationException
  • NoSuchElementException
  • IllegalArgumentException

아이템 7: 결과 부족이 발생할 경우 null과 Failure를 사용하라

해당 챕터를 공부하기 전에 알아야 할 코틀린 지식

ref.

함수가 원하는 결과를 만들어 낼 수 없을 때가 있습니다.

  • 서버 응답 불능
  • 조건에 맞는 요소가 없는 경우
  • 파싱 오류
  • ...

이런 상황에서는 두 가지의 선택지가 있습니다.

  1. null 또한 Failure라는 클래스를 만들어서 리턴
  2. 예외를 throw

예외는 정보를 전달하는 방법으로 사용해서는 안되고, 잘못된 특별한 상황을 나타내야 하고, 처리 되어야 한다.

  • 많은 개발자가 예외가 전파되는 과정을 제대로 추적하지 못한다.
  • 코틀린의 모든 예외는 unchecked 예외이다.
  • 예외는 예외적인 상황을 처리하기 위해서 만들어졌기에 명시적인 테스트만큼 빠르게 동작하지 않을 수 있다.
  • try-catch 블록 내부에 코드를 배치하면, 컴파일러 최적화가 제한된다.

그렇기 때문에, 첫번째 방법이 명시적으로 효율적이며 간단한 방법으로 처리할 수 있다.

예상 하능한 범위의 오류는 null과 Failure를 사용하고, 예측하기 어려운 예외는 throw를 통해 적절하게 처리하는 것이 좋다.

class SealedClassExample {  
    //    inline fun <reified  T> execute(a: T, b: T): Result<T> {  
    fun <T> execute(a: T, b: T): Result<T> {  
        if (a == -1) {  
            return Failure(NegativeNumberException())  
        }  

        return Success(a)  
    }  
}  

class NegativeNumberException : Exception()  

sealed class Result<out T>  
class Success<out T>(val result: T) : Result<T>()  
class Failure(val throwable: Throwable) : Result<Nothing>()

null 값과 sealed result 클래스의 차이점

  • 추가적인 정보 전달은 sealed class 사용
  • 그렇지 않다면, null

보통 함수를 두 가지 방식으로 작성하는 것이 명확하고 좋다. nullable을 리턴하는 것은 좋지 않다.

  • get
    • sealed class result 를 리턴
  • getOrNull
    • sealed class result or null 리턴

아이템 8: 적절하게 null을 처리하라

null은 최대한 명확한 의미를 갖는 것이 좋다. 이는 nullable 값을 처리해야 하기 때문인데, 이를 처리하는 사람은 API 사용자기 때문이다.

기본적으로 nullable 타입은 세 가지 방법으로 처리 한다.

  1. ?., smart casting, Elvis operator
  2. 오류를 throw
  3. 함수 또는 프로퍼티를 리팩터링해서 nullable 타입이 나오지 않게 바꾼다.
val printerName = printer?.name ?: "Unnamed" // or return 

contract feature

오류 throw 하기

문제가 발생할 경우에는 오류를 강제로 발생시켜 주는 것이 좋다. 오류를 강제로 발생시킬 때는 throw, !!, requireNotNull, checkNotNull을 이용하는 것도 방법이다.

fun process(user: User) {
    requireNotNull(user.name)
    val context = checkNotNull(context)
    val networkService = getNetworkService(context) ?: throw NoInternetConnection()
}

not null assertion 문제

  • !!은 사용하기 쉽지만, 좋은 해결 방법은 아닙니다. 예외가 발생할 때, 어떤 설명도 없는 제네릭 예외가 발생합니다.
  • !!은 nullable이지만, null이 나오지 않는다는 거의 확실한 상황에서 사용이 된다. 하지만, 미래에는 그 확실함이 사라질 수 있다.
  • 일반적으로 사용을 피하는 것이 좋다

nullability 피하는 몇 가지 방법

  • get/getOrNull 함수를 제공 하거나, null 혹은 Failure를 반환하는 방법
  • 어떤 클래스 생성 이후에 확실하게 설정 된다는 보장이 있다면, lateinit 혹은 notNull Delegate를 사용한다.
  • 빈 컬렉션 대신 null을 리턴 하지 말라
  • nullable enum과 None enum 값은 완전히 다른 의미입니다.
    • null enum은 별도로 처리 해야 되나, None enum 정의에 없으므로 필요한 경우에 사용하는 쪽에서 추가해서 활용할 수 있다는 의미로 이해 하면 된다.

아이템 9: use를 사용하여 리소스를 닫아라

더 이상 필요하지 않을 때, close 메소드를 사용해서 명시적으로 닫아야 하는 리소스가 있다.

  • InputStream/OutputStream
  • java.sql.Connection
  • java.io.Reader(FileReader, BufferedReader,. CSSParser)
  • java.new.Socket/java.util.Scanner

위와 같은 리소스들은 AutoCloseable을 상속 받는 Closeable 인터페이스를 구현하고 있다. 그래서 개발자가 누락하더라도 GC에서 해당 처리를 하지만, 쉽제 처리를 하지 못하기 때문에 개발자가 명시적으로 리소스 사용을 끝내도록 설정 해야 한다.

fun countCharactersInFile(path: String): Int {
    BufferedReader(FileReader(path)).use {
        reader -> return reader.lineSequence().sumBy { it.length }
    }
}

kotlin에서는 use라는 함수를 통해서 자동으로 리소스를 close하도록 설계된 함수를 제공하고 있다.

파일에서 내용을 읽어서 처리 해야 할 경우가 있는데, kotlin에서는 useLines 라는 함수도 제공하는데 메모리에 파일의 내용 한 줄씩만 유지하기 때문에, 대용량 처리도 적절하게 처리 할 수 있다.

아이템 10: 단위 테스트를 만들어라

사용자 관점에서 애플리케이션 외부적으로 제대로 동작하는지 확인 하는 테스트는 개발 단계에서 빠르게 피드백을 얻기가 어렵다.

그래서 단위 테스트가 필요로 하다. 단위 테스트는 아래의 내용을 확인한다.

  1. 일반적인 use case (happy path) 확인
  2. 일반적인 오류 케이스, 잠재적인 문제 부분 테스트
  3. Edge case, 잘못된 argument 테스트

단위 테스트의 장점

  • 테스트가 잘 된 요소는 신뢰할 수 있는 코드가 된다.
  • 리팩터링이 쉽다.
  • 수동으로 테스트 하는 것보다 단위 테스트가 더 빠르다.

단위 테스트의 단점

  • 단위 테스트를 만드는 데 시간이 걸린다.
  • 테스트를 할 수 있도록 코드를 조정해야 한다.
  • 좋은 단위 테스트를 만드는 작업이 꽤 어렵다.
반응형