Kotlin과 Null | Kotlin Study

hoya·2022년 8월 15일
0

Kotlin Study

목록 보기
3/7
post-thumbnail

🤔 Kotlin의 지향점

  • 코틀린의 타입 시스템은 null 참조의 위험을 제거하는 것을 우선 목표로 한다.
  • 런타임 환경에서 null을 체크했던 기존 언어와는 달리, 코틀린은 컴파일 환경에서 null을 체크하기 때문에 훨씬 안전하다.
🙋🏼‍♂️ 컴파일? 런타임?

컴파일 타임은 작성한 소스 코드를 기계어로 번역하고, 사용 가능한 프로그램으로 만드는 과정을 의미한다.
런타임은 컴파일 타임을 거친 프로그램을 실행하고 있는 시간을 의미한다.

일반적으로 런타임 보다는 컴파일 타임에서 오류를 잡아내는 것이 좋은 디버깅이라고 이야기한다.
앱을 이용하다가 뜬금없이 종료되는 것보다는, 빌드 중 오류를 잡고 넘어가는 것이 더 안전하기 때문이다.
  • 물론, null 참조를 최우선적으로 막는다고 하더라도 어쩔 수 없이 NPE 가 발생하는 경우가 있다.
    • Java 라이브러리를 사용하는 경우, JavaNon-nullable 타입이 없기 때문에 자바 라이브러리를 사용할 경우 어쩔수 없이 nullable 타입으로 변환된다.
    • throw 키워드를 사용, 개발자가 임의로 NPE 를 발생시키는 경우
    • !! 연산자를 이용하고 해당 변수 혹은 함수에 접근할 경우

👀 Non-nullable, Nullable

  • 코틀린은 nullable 타입과, non-nullable 타입을 분리하고 여러 연산자와 스마트 캐스트를 통해 null 관련 문제를 효과적으로 다룰 수 있도록 설계되었다.
  • 우선, 변수 선언부터 알아보자.
    • 코틀린에 대해 기본적으로 알아두어야 할 것은 변수 선언 시 기본적으로 null 을 허용하지 않는다는 것이다.
    • nullable 하게 선언하고 싶다면, ? 기호를 붙여 nullable 한 프로퍼티라는 것을 명시해야 한다.
<// Non-nullable Property
val number: Int = 30
var str: String  = "Hello"

// Nullable Property
val number2: Int? = null
var str2: String? = null

// 컴파일 에러 발생
val number3: Int = null
  • 만약 ? 기호를 명시하지 않고 null 을 할당하려 한다면, 바로 컴파일 에러가 발생하게 된다.
  • null 허용 여부에 따라 서로 다른 자료형임을 확실하게 이해해야 한다.

🔍 Null 처리 기법 알아보기

If-Else 처리

  • 가장 쉬운 방법은, if-else 조건문을 이용하여 분기처리 하는 것이다.
val b: String? = "Kotlin"
if (b != null && b.length > 0) {
    print("String of length ${b.length}")
} else {
    print("Empty string")
}
  • bnull 이면 비어있다는 것을 알리는 단순한 코드이다.

Safe call

  • null 이 할당되어 있을 가능성이 있는 변수를 검사하고, 안전하게 호출하도록 도와주는 연산자이다.
  • 사용할 변수 이름 뒤에 ?. 기호를 붙여 사용한다.
  • null 여부를 체크하여 null 이면 그대로 null을 리턴하고, 아니면 연산을 실시한다.
// safe call
fun main() {
    var str: String? = null

    // checking for null
    val len = if (str != null) str.length else -1
    val len2 = str?.length

    println("str: $str, length: ${len}")
    println("str: $str, length2: ${len2}")
}

/* RESULT
	str: null, length: -1
    str: null, length2: null */
    
  • len 의 경우 if-else 문을 사용하여 null 인지, 아닌지를 체크했지만, len2 의 경우 safe call 을 이용해 체크한 것을 확인할 수 있다.

  • safe call 은, 복잡하게 얽혀져있는 값을 가져올 때도 유용하게 사용할 수 있다.
val name = bob?.department?.head?.name
  • 이렇게, 복잡하게 얽혀져있을 때 하나의 객체라도 null 임이 체크되면, 앞의 요소와 뒤의 요소에 상관없이 곧바로 null을 리턴한다.

  • null 이 아닌 값에 대해서만 연산을 수행할 때는 let 키워드와 함께 사용할 수 있다.
val listWithNulls: List<String?> = listOf("Kotlin", null)
for (item in listWithNulls) {
    item?.let { println(it) } // prints Kotlin and ignores null
}

엘비스 연산자(Elvis operator)

  • 위에서 이야기한 safe call과 함께 사용할 때 굉장히 유용한 연산자이다.
  • nullable 한 변수 b 를 사용할 때, bnull 이 아니라면 어떤 값을 할당하고, null 이면 -1을 할당하길 원한다면 어떻게 해야할까?
    • 위에서 이야기했던, if-else 조건식을 활용해 할당하는 방법이 있다.
    • 하지만 코틀린의 경우 엘비스 연산자를 활용해 매우 간결하게 null을 체크할 수 있다.
  • 엘비스 연산자는 변수 뒤에 ?: 기호를 붙여 사용한다.
fun main() {
    var str: String? = null

    // checking for null
    val len = if (str != null) str.length else -1
    val len2 = str?.length ?: -1

    println("str: $str, length: ${len}")
    println("str: $str, length2: ${len2}")
}

/* RESULT
	str: null, length: -1
    str: null, length2: -1 */
  • 위에서 봤던 코드에서 ?: 만 추가했을 뿐인데, null 여부에 따라 다른 값을 출력하는 것을 확인할 수 있다.
  • 만약 strnull 이 할당되어 있지 않았다면 str 의 길이를 출력했을 것이다.

  • throw, return 와 같은 모든 표현식이 허용되기 때문에, 필요한 상황에는 이런 식으로도 사용할 수 있다.
fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

Not-nullable 단정 연산자 (!!)

  • 단정 연산자는 개발자가 논리적으로 절대 null 이 발생할 가능성이 없다고 확신할 때 사용할 수 있다.
  • 변수 뒤에 !! 기호를 붙여 사용한다.
fun main() {
    val age: Int? = 26
    val age2: Int = age // 에러 발생
    val age3: Int = age!! // 에러 발생 X
}
  • nullable 을 처리하는 가장 간단한 방법이지만, 좋은 해결 방법은 절대 아니다.
    • 현재 null 이 아니라고 해서, 미래에도 null 이 아닐까?
    • 코드를 변경하다가 어떤 문제가 발생할 수도 있다.
  • 정말 확실한 경우라고 판단되는 것이 아니면, !! 은 최대한 사용하지 않는 것이 좋다.
    • safe call, 엘비스 연산자를 적극적으로 이용하도록 하자.

Safe casts

  • 세이프 캐스트는 형변환 때 자주 일어나는 ClassCastException 오류 발생을 방지하기 위해 사용한다.
fun main() {
    val str: String? = "ABC"

    val strToInt: Int? = str as Int // Error

    println(strToInt)
}
  • 위의 코드 전개와 같이 String 으로 선언된 strInt 로 강제 형변환을 일으키려 하면, 곧바로 ClassCastException 오류가 발생한다.
  • 하지만, 세이프 캐스트를 사용하면 형변환을 안전하게 진행할 수 있다.
fun main() {
    val str: String? = "ABC"
    val strToInt: Int? = str as? Int
    val strToInt2: Int? = str as? Int ?: 0

    println(strToInt)
    println(strToInt2)
}

/* RESULT
     null
     0    */
  • 이렇게, 애초에 캐스팅이 불가능한 경우면 null 을 리턴하는 것을 확인할 수 있다.
  • strToInt2 의 경우처럼 엘비스 연산자를 함께 활용하면 유용하게 사용할 수 있다.

지연 초기화

  • 현재는 사용하지 않지만 나중에 초기화가 필요한 경우가 있다.
  • 초기에 값을 세팅할 때 null 을 세팅하는 방법이 있지만, 의미없이 메모리를 낭비시킬 뿐이다.
    • 또한, 사용 시 매번 null 여부를 체크해야 하므로 개발자에게 번거롭다.
  • 이럴 때, 지연 초기화를 사용하면 매끄러운 코드 전개가 가능하다.
class Test() {
    lateinit var dao: String // 지연 초기화

    fun init() {
        dao = "Dao"
    }
}

fun main() {
    val test = Test()
    test.init()
    print(test.dao)
}
  • 지연 초기화에는 lateinitby lazy 키워드가 있는데, 이에 대한 자세한 글은 여기에 있다.

참고 및 출처

코틀린 공식 문서

profile
즐겁게 하자 🤭

0개의 댓글