ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 4-2. 클래스와 객체 다루기 : 널 가능성
    Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 10. 1. 00:12

    4장

    4-1. 클래스와 객체 다루기 : 클래스 정의하기

    4-2. 클래스와 객체 다루기 : 널 가능성

    4-3. 클래스와 객체 다루기 : 단순한 변수 이상의 프로퍼티

    4-4. 클래스와 객체 다루기 : 객체


    4-2. 널 가능성

    코틀린에는 아무것도 참조하지 않는 경우를 나타내는 특별한 null(널)이라는 값이 있다.

    그 어떠한 객체도 가리키지 않는 참조를 뜻한다. 널은 다른 참조와 비슷하게 동작하지 않는다.

    코틀린 타입 시스템에는 널 값이 될 수 있는 참조 타입과 널 값이 될 수 없는 참조 타입을 확실히 구분해주는 장점이 있다.

    이 기능은 널 발생 여부를 컴파일 시점으로 옮겨주기 때문에 악명높은 NullPointerException을 상당부분 막을 수 있다.

     

    1) 널이 될 수 있는 타입

    코틀린에서 기본적으로 모든 참조 타입은 널이 될 수 없는 타입이다.

    따라서 String 같은 타입에 null 값을 대입할 수 없다.

    fun isLetterString(s: String): Boolean{
        if(s.isEmpty()) return false
        for(ch in s){
            if(!ch.isLetter()) return false
        }
        return true
    }
    
    fun main(args: Array<String>) {
        println(isLetterString("xyz")) // true
        
        println(isLetterString(null))
        // compile error: Null can not be a value of a non-null type String
    }

    코틀린에서 널이 될 수도 있는 값을 받는 함수를 작성하려면

    파라미터 타입 뒤에 물음표(?)를 붙여서 타입을 널이 될 수 있는 타입(nullable type)으로 지정해야 한다.

    fun isLetterString(s: String?): Boolean{
        if(s.isNullOrEmpty()) return false // null check 코드 추가 필요
        if(s.isEmpty()) return false
        for(ch in s){
            if(!ch.isLetter()) return false
        }
        return true
    }
    
    fun main(args: Array<String>) {
        println(isLetterString("xyz")) // true
        println(isLetterString(null)) // false
    }

    코틀린에서 String? 같은 타입은  널이 될 수 있는 타입(nullable type)으로 불린다.

    타입 시스템 용어에서 모든 널이 될 수 있는 타입은 원래 타입(?가 붙지 않은 타입)의 상위 타입이다.

    이 말은 널이 될 수 있는 타입의 변수에 항상 널이 될 수 없는 타입의 값을 대입할 수 있다는 뜻 이다.

    하지만 반대로는 불가능하다.

    val s1: String? = "abc"
    val s2: String = s1 //error

    val s1: String = "abc"
    val s2: String? = s1 // OK

    Int나 Boolean 같은 원시 타입도 널 가능 타입이 존재한다.

    하지만 널이 될 수 있는 원시 타입은 항상 박싱한 값만 표현한다.

    val n: Int = 1 // 원시 타입의 값
    val x: Int? = 1 // 박싱한 타입의 값을 참조

    가장 작은 nullable 타입은 Noting? 이다.이 타입은 널 상수를 제외하고 어떤 값도 포함하지 않는다.

    이 값은 null 값 자체의 타입이며 다른 모든 nullable 타입의 하위 타입이다.

     

    가장 큰 nullable 타입은 Any? 타입이다. Any?는 코틀린 타입 시스템 전체에서 가장 큰 타입으로,

    nullable 타입과 널이 될 수 없는 모든 타입의 상위 타입이다.


    nullable 타입은 원래 타입(?가 붙지 않은 Int, String ...)에 들어있는 어떤 프로퍼티나 메서드도 제공하지 않는다.

    멤버 함수를 호출하거나 프로퍼티를 읽는 등의 일반적인 연산이 null에서는 의미가 없기 때문이다.

    위에 작성했던 코드를 다시 확인해보자.

    fun isLetterString(s: String?): Boolean{
        // if(s.isNullOrEmpty()) return false 
        
        if(s.isEmpty()) return false
        // error : Only safe (?.) or non-null asserted (!!.) 
        // calls are allowed on a nullable receiver of type String?
        for(ch in s){
        	// error : Not nullable value required to call an 'iterator()' method on for-loop range
            if(!ch.isLetter()) return false
        }
        return true
    }
    
    fun main(args: Array<String>) {
        println(isLetterString("xyz")) // true
        println(isLetterString(null)) // false
    }

    isLetterString 함수에서 null을 확인하는 문장(s.isNullOrEmpty)을 지우면, 컴파일 오류가 발생한다.

    String? 타입에 iterator() 메서드가 없기 때문에 for 루프를 사용해 nullable 문자열에 대한 이터레이션을 수행할 수 없다.

     

    그럼 위와 같은 함수가 nullable 타입을 제대로 처리하게 바꾸는 내용에 대해 다뤄보겠다.


    2) 널 가능성과 스마트 캐스트

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

    fun isLetterString(s: String?): Boolean{
        if(s.isNullOrEmpty()) return false // null check 
        if(s.isEmpty()) return false
        for(ch in s){
            if(!ch.isLetter()) return false
        }
        return true
    }
    
    fun main(args: Array<String>) {
        println(isLetterString("xyz")) // true
        println(isLetterString(null)) // false
    }

    null check 코드를 추가하면, s 자체의 타입을 바꾸지는 않았지만 코드가 컴파일된다.

    null에 대한 동등성 검사를 수행하면 컴파일러는 null 여부를 확실히 알 수 있다.

    컴파일러는 이 정보를 이용해 값 타입을 세분화함으로써 널이 될 수 있는 값(위 코드에서 s)을

    널이 될 수 없는 값으로 타입 변환 한다(String? > String).

    이런 기능을 스마트 캐스트라고 한다

     

    스마트 캐스트는 when이나 루프 같은 조건 검사가 들어가는 식 안에서도 작동한다.

    또한 | | 나 && 연산의 오른쪽에서도 같은 일이 벌어진다.

    fun describeNumber(n: Int?) = when(n){
        null -> "null"
        in 0..10 -> "small"
        in 11..100 -> "large"
        else -> "out of range"
    }
    
    fun isSingleChar(s: String?) = s != null && s.length == 1

    스마트 캐스트를 실행하려면 대상 변수의 값이 검사 지점과 사용 지점 사이에서 변하지 않는다고 컴파일러가 확신할 수 있어야 한다.

    만약 널 검사와 사용 지점 사이에서 값이 변경되는 경우에는  스마트 캐스트가 작동하지 않는다.

    var s = readLine()
    if(s != null){
        println(s.length) // OK
        
        s = readLine()
        println(s.length) // X
        // error : Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
    }

    가변 프로퍼티에 대해서는 절대 스마트 캐스트를 적용할 수 없다.(언제든 프로퍼티의 값을 null로 변경할 수 있으므로)


    3) 널 아님 단언 선언자

    readLine() 함수와 관련해 !! 연산자를 이미 사용해봤다.

    !! 연산자는 널 아님 단언(not-null assertion)이라고도 불리는데,

    KotlinNullException 예외를 발생시킬 수 있는 연산자이다.

    이 연산자가 붙은 식의 타입은 원래 타입의 널이 될 수 없는 버전이다.

     

    not-null assertion 선언을 한 변수에 null 값을 대입하려 하면 예외를 던진다.

    val n = readLine()!!.toInt()
    // null > throw NumberFormatException

    일반적으로 nullable type을 사용하려면 not-null assertion을 사용하지 않아야 한다.

    (그냥 예외를 던지는 방식보다 더 타당한 응답을 제공해야 하기 때문)

    하지만 이 연산자 사용을 정당화 할 수 있는 경우가 있다.

    다른 후위 연산자와 마찬가지로 널 아님 단언 선언자도 가장 높은 우선순위로 취급된다.


    4) 안전한 호출(safe-call) 연산자

    nullable 타입의 값에 대해서는 그에 상응하는 not-null 타입에 값에 있는 메서드를 사용할 수 없다고 설명했다.

    하지만 safe-call 연산을 사용하면 이런 제약을 피할 수 있다.

    fun readInt() = readLine()?.toInt() 
    // 위 코드는 아래와 같이 작동한다.
    
    fun readInt(): Int?{
        val tmp = readLine()
        return if(tmp != null) tmp.toInt() else null
    }

    safe-call 연산자는 수신 객체(왼쪽 피연산자)가 널이 아닌 경우 일반적인 함수 호출처럼 작동한다.

    하지만 수신 객체가 널이면 safe-call 연산자는 호출을 수행하지 않고 그냥 null을 반환한다.

     

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

    실전에서 꽤 많이 발생한다. 따라서 safe-call을 사용하면 불필요한 if식과 임시 변수의 사용을 줄여서 코드를 단순화할 수 있다. 

    println(readLine()?.toInt()?.toString())

    safe-call이 널을 반환할 수 있기 때문에 이런 연산이 반환하는 값의 타입은 nullable 타입이 된다.

    // var s: String = "" 
    // error
    var s: String? = "" // OK
    s = readLine()?.toInt()?.toString()

    5) 엘비스 연산자

    nullable 값을 다룰 떄 유용한 연산자로 널 복합 연산자(null coalescing operator)인 ?: 를 들 수 있다.

    이 연산자를 사용하면 널을 대신할 디폴트 값을 지정할 수 있다.

    엘비스 프레슐리를 닮았기 때문에 ?: 연산자를 엘비스 연산자라고 부른다고 한다.

    fun sayHello(name: String?){
        println("Hello, " + (name ?: "Unknown"))
    }
    
    fun main() {
        sayHello("김자반")
        // Hello, 김자반
        sayHello(null)
        // Hello, Unknown
    }

    sayHello() 함수는 아래 코드와 같다.

    fun sayHello(name: String?){
        println("Hello, " + (if(name != null) name else "Unknown"))
    }

    안전한 연산과 엘비스 연산자를 조합해서 수신 객체가 널일 때 디폴트 값을 지정하면 유용하다.

     

    return이나 throw 같은 제어 흐름을 깨는 코드를 엘비스 연산자 오른쪽에 넣는 방법이 있다.

    이렇게 하면 이에 상응하는 if 식을 대신할 수 있다.

    class Name(val firstName: String, val lastName: String?)
    
    class Person(val name: Name?){
        fun describe(): String{
            val currentName = name ?: return "Unknown"
            return "${currentName.firstName} ${currentName.lastName}"
        }
    }
    
    fun main() {
        println(Person(Name("김", "자반")).describe())
        // 김 자반
        println(Person(null).describe())
        // Unknown
    }

    다음글

    4-3. 클래스와 객체 다루기 : 단순한 변수 이상의 프로퍼티

    반응형

    댓글

Designed by Tistory.