ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [코틀린 완벽 가이드] 4장 클래스와 객체 다루기 (4.2 널 가능성)
    코틀린 공부/코틀린 2023. 12. 29. 17:31

    4.2 널 가능성

    자바와 마찬가지로 코틀린은 참조 값에는 아무것도 참조하지 않는 경우를 나타내는 특별한 null이라는 값이 있다.  자바에서는 모든 참조 타입의 변수에 null을 대입할 수 있지만, 이때 이 참조 타입에 정의된 메서드나 프로퍼티를 사용하려면 NPE(NullPointerException)이 발생한다.

     

    이 오류가 최악인 이유는 컴파일러가 정적인 타입 정보만으로는 이런 오류를 잡아낼 수 없어서 런타임에 프로그램을 실행해봐야 이 오류를찾을 수 있기 떄문이다. 

     

    코틀린 타입 시스템에는 null 값이 될 수 있는 참조 타입과 없는 타입을 확실하게 구분해주는 큰 장점이 있다. 이 기능은 null 발생 여부를 컴파일 시점으로 옮겨주기 때문에 악명 높은 NullPointerException 예외를 상당 부분 막을 수 있다. 

     

    4.2.1 널이 될 수 있는 타입

    코틀린에서 기본적으로 모든 참조 타입은 널이 될 수 없는 타입이다. 그래서 String 같은 타입은 null 값을 대입할 수 없다.

    fun isLetterString(s:String): Boolean{
        if(s.isEmpty()) return false
        for(ch in s){
            if(!ch.isLetter()) return false
        }
        return true
    }
    println(isLetterString("abc"))
    println(isLetterString(null)) // Null can not be a value of a non-null type String

     

     

    코틀린에서 널이 될 수도 있는 값을 받는 함수를 작성하려면 파라미터 타입 뒤에 물음표(?)를 붙여서 타입을 널이 될 수 있는 타입으로 지정해야 한다. 

    fun isBooleanString(s: String?) = s == "false" || s == "true"
    

     

    코틀린에서 String? 같은 타입은 널이 될 수 있는 타입 (nullable type)이라고 한다. 

     

    타입 시스템용어에서 모든 널이 될 수 있는 타입은 원래 타입의 상위 타입이다.

    널이 될 수 있는 타입의 변수에 항상 널이 될 수 없는 타입의 값을 대입할 수 있고, 하지만 반대로 널이 될 수 없는 타입의 변수에 널이 될 수 있는 타입의 값을 대입할 수는 없다.

    val s:String? = "abc" // OK
    val ss:String = s // Type mismatch.

     

    널이 될 수 있는 타입은 원래 들어있는 어떤 프로퍼티나 메서드도 제공하지 않는다.  널이 될 수 있는 타입은 코틀린의 확장 메커니즘을 활용해 자체적인 메서드와 프로퍼티를 제공한다.

     

    4.2.2 널 가능성과 스마트 캐스트

    널이 될 수 있는 값을 처리하는 가장 직접적인 방법은 조건문을 이용해 null과 비교하는 것이다. 

    fun isLetterString(s:String?): Boolean{
        if(s == null) return false
        if(s.isEmpty()) return false
        for(ch in s){
            if(!ch.isLetter()) return false
        }
        return true
    }

     

    위에처럼 null 검사를 추가하는 것을 스마트 캐스트라는 기능이다. 기복적으로 null에 대한 동등성 검사를 수행하면, 컴파일러는 코드 흐름의 가지 중 한쪽에서는 대상 값이 확실히 널이고 다른 가지에서는 확실히 널이 아니라는 사실을 알 수 있다.  그 후 컴파일러는 이 정보를 사용해 값 타입을 세분화함으로써 널이 될 수 있는 값을 널이 될 수 없는 값으로 타입 변환(cast)한다. 이런 기능을 스마트 캐스트 라고 한다

    (스마트 캐스트는 널 가능성에만 제한되지 않는다)

     

    when에서도 적용 가능.

    fun describeNumber(n: Int?) = when (n) {
        null -> "null"
        in 0..10 -> "small"
        else -> "big"
    }

     

     

    4.2.3 널 아님 단언 연산자

    !! 연산자는 널 아님 단언 이라고 부르는데,  KotlinNullPointerException 예외를 발생시킬 수 있는 연산자다. 이 연산자가 붙은 식의 타입은 널이 될 수 없는 버전이다. 

     

    기본적으로 널 아님 단언은 자바 프로그램의 널 관련 동작, 널 값을 역참조하려 할 때는 예외를 던지는 동작을 부활시킨다. 

    val n = readln().toInt()

     

    일반적으로 널이 될 수 있는 값을 사용하려면 그냥 예외를 던지는 방식보다 더 타당한 응답을 제공해야 하기 때문에 이 연산자를 사용하지 말아야 한다.  하지만 이 연산자 사용을 정당화할 수 있는 경우가 있다. 

    fun nullAssert(){
        var name: String? = null
        fun initialize(){
            name = "John"
        }
        fun sayHello(){
            println(name!!.uppercase())
        }
        initialize()
        sayHello()
    }

     

    이 경우 이름에 널이 될 수 없는 값이 할당된 다음에 sayHello() 함수가 호출되므로 널 아님 단언도 적절한 해법이다.  하지만 이와 같은 경우라도 널을 다룰 때 쓸 수 있는 덜 무딘 도구를 사용하거나 코드 제어 흐름을 고쳐 써서 컴파일러가 스마트 캐스트를 적용할 수 있게 하는 편이 더 낫다. 

    그래도 스마트 캐스트 추천

     

    4.2.4 안전한 호출 연산자

    4.2.2에서 널이 될 수 있는 타입의 값에 대해서는 그의 상응하는 널이 될 수 없는 타입의 값에 있는 메서드를 사용할 수 없다고 이미 설명했다. 하지만 특별한 안전한 호출 연산(safe call)을 사용하면 이런 제약을 피할 수 있다.

     

    안전한 호출 연산자를 사용하면 다음 형태로 코드를 다시 작성할 수 있다. 

    fun readInt() = readLine()?.toInt()

     

    위에 함수는 풀어서 쓰면 아래 함수와 같다. 

    fun readInt(): Int? {
        val tmp = readLine()
        return if (tmp != null) tmp.toInt() else null
    }

     

    안전한 호출 연산자는

    • 널이 아닐경우면 readLine().toInt() 처럼 작동하고 
    • 널이면 null을 돌려준다.

    '수신 객체가 널이 아닌 경우에는 의미 있는 일을 하고, 수신 객체가 널인 경우에는 널을 반환해라'

     

    4.2.5 엘비스 연산자

    널이 될 수 있는 값을 다룰 때 유용한 연산자로 널 복합 연산자(null coalescing operator)인 '?:' 을 들수 있다. 

     

    이 연산자를 사용하면 null 대신 default 값을 지정할 수 있다.  

    fun sayHello(name: String?) {
        println("Hello, ${name ?: "Unknown"}")
    }
    sayHello("John") // Hello, John
    sayHello(null) // Hello, Unknown

     

     

    이 연산자의 결과는 왼쪽 피연산자가 널이 아닐 경우에는 왼쪽 피연산자의 값이고, 널일 경우 오른쪽 피연산자의 값이다. 

    fun sayHello(name: String?) {
        val n = readLine()
    }
    • name != null 이면 n = name
    • name == null 이면 "empty"

    더 간단한 패턴으로, return이나 throw 같은 제어 흐름을 깨는 코드를 엘비스 연산자 오른쪽에 넣는 방법도 있다.

     

    class Name(val firstName: String, val lastName: String?)
    
    class PersonA(val name: Name?) {
        fun describe(): String{
            val currentName = name ?: return "Unknown"
            return "${currentName.firstName} ${currentName.lastName}"
        }
    }
    println(PersonA(Name("John", "Lee")).describe()) // John Lee
    println(PersonA(null).describe()) // Unknown

     

    우선순위 면에서는 엘비스 연산자는 or 등의 중위 연산자와 in, !in 사이에 위치한다. 특히 비교/동등성 연산자나 ||, &&, 대입 보다 우선순위가 높다. 

Designed by Tistory.