Kotlin, null 어렵지 않게 다루기

2023. 8. 20. 23:29Spring/Kotlin

반응형

최근, 코프링을 사용하고 있어서 코틀린 시리즈를 마무리해보려 합니다.

이전 게시글을 다시 작성하는 것도 고려하고 있으니, 참고하시길 바랍니다.

 

 

TL;DR

- 코틀린은 널이 될 수 있는 타입을 지원해 NullPointerException 오류를 컴파일 시점에 감지할 수 있습니다.

- 코틀린의 안전한 호출 ?., 엘비스 연산자 ?:, 널 아님 단언 !!, let 함수 등을 사용하면 널이 될 수 있는 타입을 간결한 코드로 다룰 수 있다.

- as? 연산자를 사용하면 값을 다른 타입으로 변환하는 것과 변환이 불가능한 경우를 처리하는 것을 한꺼번에 편리하게 처리할 수 있다.

- 널이 될 수 있는 원시 타입(Int? 등) 은 자바의 박싱한 원시 타입 (java.lang.Integer 등)에 대응한다.

- Any 타입은 다른 모든 타입의 조상 타입이며, 자바의 Object에 해당한다. Unit 은 자바의 void와 비슷하다.

 

 

코틀린에서는 자바를 사용하며 문제를 겪었던 문제들에 도움이 되는 몇 가지 특성을 새로 제공합니다.

 

✔️ 널이 될 수 있는 타입 nullable type

✔️ 읽기 전용 컬렉션

 

본 포스팅에서는 그 중, 널이 될 수 있는 타입에 대해 다루려고 합니다. 

 

 

 

 

Nullability

적어도 자바 개발자라면, NullPointerException로 고통받아 null에 대한 부정적인 시선이 있을 수도 있습니다.

코틀린 개발자의 입장으로는 null과 type에 대해 다시 한 번 생각해볼 필요가 있습니다.

 

자바에서는 의도치 않은 null 값을 고려하여 코드를 작성해야 했다면,

코틀린에서는 의도적으로 사용할 null 값만 고려하면 됩니다.

 

코틀린은 의도치 않은 null, 정확히는 NullPointerException 을 위해 타입 체크를 컴파일 시점으로 옮깁니다.

컴파일러가 컴파일 시 오류를 미리 감지해서,

실행 시점에 발생 할 수 있는 예외의 가능성을 줄일 수 있습니다.

 

코틀린이 채택한 방식을 보면, 타입을 두 가지로 나눌 수 있습니다.

바로, 널이 될 수 있는 타입널이 될 수 없는 타입입니다.

 

 

 

 

Nullable types

코틀린 타입은 널이 될 수 있는 타입을 명시적으로 표시하게끔 지원합니다.

타입 뒤에 물음표를 명시하는 방식입니다.

 

Type? = Type or null (ex. String? , Int? , CustomType? )
Type = only Type

 

Null과 문자열을 인자로 받을 수 있게 하려면 타입 이름 뒤에 물음표(?)를 명시합니다 : String?

자바 코드와 함께 예시를 확인해봅시다.

 

Java

int strLen(String s) { 
    return s.length();
}

 

Java로 정의된 위 함수는, Kotlin에서는 아래와 동일합니다.

 

Kotlin

fun strLen(s: String?) = /* logic with null handling... */

 

Null을 허용하지 않는 경우와 비교해보겠습니다.

 

fun strLen(s: String) = s.length

 

strLen에 null이거나 널이 될 수 있는 값을 넘기려 할 경우,

컴파일 시 오류가 발생합니다.

 

strLen(null) // ERROR: Null can not be a value of a non-null type String

 

 

 

 

Type vs Type?

중요한 점은 널이 될 수 있는 값을, 널이 될 수 없는 타입대입할 수 없다는 것입니다.

엄격하게 다른 타입으로 구분됩니다.

 

val x: String? = null
var y: String = x
// ERROR: Type mismatch: inferred type is String? but String was expected

 

 

가령, 아래와 같은 함수를 정의한다고 해봅시다.

 

fun strLenSafe(s: String?) = s.length()

 

그럼, 다음과 같은 오류가 발생합니다.

 

ERROR: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type kotlin.String?

 

 

 

Kotlin → Java

실제 Kotlin 를 Java 코드에 대응해보면 아래와 같이 변환됩니다.

아래 코드는 Kotlin → Bytecode → Java Decompile 한 형태입니다.

 

 

좌) Nullability Type   우) Type

 

사실, 실행 시점에 널이 될 수 있는 타입이나 널이 될 수 없는 타입객체는 같습니다.

컴파일 시점의 검사를 통해 둘을 구분합니다.

즉, 널이 될 수 있는 타입을 처리하는 데 별도의 실행 시점 부가 비용이 들지 않습니다.

 

 

 

 

 

 

Null Safety

코드를 작성하다 보면  널이 될 수 있는 타입을 널이 될 수 없는 타입의 객체를 변환해야 할 때가 많습니다.

코틀린은 null을 위한 특별한 연산자를 제공합니다.

 

아래의 특별한 네 개의 연산자와 let 함수, 그리고 lazyinit 키워드를 통해

null 값을 조작하는 방식을 알아보겠습니다.

 

- Safe call operator — ?.

- Elvis operator — ?:

- Safe casts — as?

- Not-null assertions — !!

- let function

- Late-initialized properties

 

 

 

 

Safe calls - ?.

: nullability 변수가 null이 아닐 경우에만 이후 체인 호출

 

해당 연산자는 Type이 null이 아니라면, 해당 속성을 호출하고, null이라면 호출을 하지 않습니다.

 

val a = "Kotlin"
val b: String? = null
println(b?.length)
println(a?.length) // Unnecessary safe call

 

b가 null 이기 때문에 ?. 연산자는 length를 호출하지 않고, 

a는 Nullability 변수가 아니기 때문에 kotlin comiler가 주의를 내보냅니다.

 

 

 

해당 연산자는 특히 연쇄 호출 (chain 호출) 시에 유용합니다.

예제를 통해 확인해보겠습니다.

 

Person는 Company를 포함하고, Company는 Address를 포함합니다.

주목할 점은, 두 속성 모두 Nullable하다는 것입니다.

 

class Address(val streetAddress: String, val zipCode: Int, val city: String, val country: String)

class Company(val name: String, val address: Address?) 

class Person(val name: String, val company: Company?)

 

위와 같은 클래스가 정의되어 있을 때, 

Person 객체가 갖는 Company의 국가를 조회해본다고 해봅시다.

 

// Example #1
fun Person.countryName(): String { 
    val country = this.company?.address?.country      // ?. 연산자로 안전한 연쇄 호출
        return if (country != null) country else "Unknown"
}

 

이 때, Country를 null로 주게 되면 어떻게 진행될지 예상해보고,

아래 결과와 비교해보시길 바랍니다.

 

val person = Person("Dmitry", null)
println(person.countryName()) // Unknown

 

 

 

 

 

Elvis operator - ?:

: null 대신 사용할 디폴트 값 지정

 

엘비스 연산자는 널 복합null coalescing 연산자라고도 합니다.

 

fun foo(s: String?) = s ?: "" // "s"가 null이면 결과는 빈 문자열

foo("abc")	// "abc"
foo(null)	// ""

 

다른 예시로, ?. 연산자에서 다룬 'Example #1' 함수를 다시 보도록 하겠습니다.

 

fun Person.countryName(): String { 
    val country = this.company?.address?.country
        return if (country != null) country else "Unknown"
}

 

위 함수에서 country가 null이면 "Unknown"을 반환합니다. 

이를 엘비스 연산자를 통해 아래와 같이 단축할 수 있습니다.

 

fun Person.countryName() =
         this.company?.address?.country ?: "Unknown"

 

 

혹은, 함수의 전제 조건 pre-condition 을 검사하는 경우 특히 유용합니다.

 

fun printShippingLabel(person: Person) {
	val address = person.company?.address
    	?: throw IllegalArgumentException("No address") // 주소가 없으면 예외를 발생
	with(address) {
		println("$streetAddress \n$zipCode $city, $country")
	}
}
val address = Address("Elsestr. 47", 80687, "Munich", "Germany")
val jetbrains = Company("JetBrains", address)
val person = Person("Dmitry", jetbrains)

/*   Success Case   */
printShippingLabel(person)
// Elsestr. 47
// 80687 Munich, Germany

/*   Failure Case   */
printShippingLabel(Person("Alexey", null))
// java.lang.IllegalArgumentException: No address

 

 

 

 

 

Safe casts - as?

: 특정 값을 지정한 타입으로 캐스트하고, 변환할 수 없으면 null을 반환

 

대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생합니다.

as를 사용할 때마다 is를 통해 미리 as로 변환 가능한 타입인지 검사해볼 수도 있습니다.

 

가령, 아래 People 클래스의 equals 정의를 확힌해보면,

People 클래스와의 비교를 위한 코드를 확인할 수 있습니다.

 

class People(
    val firstName: String,
    val lastName: String) {

    override fun equals(o: Any?): Boolean {
        val other = o as? People ?: return false  // 타입이 일치하지 않으면 false 반환
        return other.firstName == firstName &&    // 안전한 캐스트 후, other이 Person 타입으로 스마트 캐스트
                    other.lastName == lastName
    }

    override fun hashCode(): Int
        = firstName.hashCode() * 37 + lastName.hashCode()
}

 

인자로 들어온 o 를 People 로 캐스트하고,

그 값이 없다면 null을 반환하기 때문에

엘비스 연산자를 통해 false를 반환합니다.

 

이 때, Kotlin의 스마트 캐스트 덕분에 아래에서 firstName을 그대로 불러올 수 있습니다.

스마트 캐스트가 아니었다면, Unresolved reference: firstName 를 발생시켰을 것입니다

 

 

 

 

 

Not-null assertions - !!

: 어떤 값이든 널이 될 수 없는 타입으로 (강제로) 변경 가능

 

근본적으로 !!는 컴파일러에게 "나는 이 값이 null이 아님을 잘 알고 있다. 내가 틀리면 예외가 발생해도 감수하겠다" 라고 말하는 것입니다.

 

즉, 널에 대해 !!를 적용하면 NPE가 발생합니다.

실제로 Kotlin 공식 페이지에서는 해당 연산자를 'The option is for NPE-lovers'로 소개합니다. 

 

 

TMI. 

일상 채팅에서 !! 표시는 종종 소리지르는 모습이거나 조금 떼쓰는 표시가 될 수 있죠.

코틀린 설계자들은 컴파일러가 검증할 수 없는 단언 말고,

더 나은 방법을 찾아보라고 !!라는 못생긴 기호를 택했다고 합니다.

 

!! 기호는 마치 컴파일러에게 소리를 지르는 것 같은 느낌이 든다. 사실 이는 의도한 것이다.
검증할 수 없는 단언 말고, 더 나은 방법을 찾아보라고 !! 라는 못생긴 기호를 택했다.

 

 

fun ignoreNulls(s: String?) {
	val sNotNull: String = s!!
	println(sNotNull.length)
}

 

결과는 다음과 같습니다.

 

ignoreNulls("not null!") // not null!
ignoreNulls(null) // Exception in thread "main" kotlin.KotlinNullPointerException

 

가장 주의할 점은, 아래와 같이 여러 !! 을 한 줄에 함께 쓰는 일을 피해야 합니다.

 

person.company!!.address!!.country

 

 

 

 

 

let function

: let 함수는 오직 null이 아닌 값에 대해서만 실행

 

Type?을 Type만 인자로 받는 함수에 넘기려면 어떻게 해야 할까요?

안전하지 않기 때문에 컴파일러는 해당 호출을 허용하지 않습니다.

이런 상황에서 도움이 될 수 있는 함수가 바로 let { ... } 입니다.

 

val listWithNulls: List<String?> = listOf("Kotlin", null, "Java")
for (item in listWithNulls) {
    item?.let { println(it) } // prints Kotlin, Java and ignores null
}

 

for 문의 Kotlin과 Java는 출력이 되지만, null에 대해서는 실행되지 않습니다.

 

fun sendEmailTo(email: String) { /*...*/ }

 

가령, 위와 같은 함수가 정의되어 있을 때, 아래와 같은  결과를 출력합니다.

 

// ① null이 아닌 경우 - let 함수 실행
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) }
// Sending email to yole@example.com

// ② null일 경우 - let 함수는 실행되지 않음
email = null
email?.let { sendEmailTo(it) }

 

 

 

 

 

Late-initialized properties

: lateinit 변경자로 정의한 프로퍼티는 늦은 초기화 허용

 

코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 합니다.

또, 타입이 널이 될 수 없는 타입이라면, 반드시 널이 아닌 값으로 해당 프로퍼티를 초기화해야 합니다.

 

초기화 값이 없다면 널이 될 수 있는 타입을 사용할 수 밖에 없는데,

널이 될 수 있는 타입을 사용하면 null 검사를 넣거나 !! 연산자를 써야 하는 문제가 또 생깁니다.

 

하지만, 실생활에서는 인스턴스 생성 후 초기화하는 프레임워크가 많습니다.

kotlin은 이런 "나중에 초기화하는 프로퍼티"를 위해 "lateinit" 키워드를 통해 초기화 시점을 늦출 수 있게 지원합니다.

당연한 얘기지만, 나중에 초기화하는 프로퍼티는 항상 var여야 합니다.

 

 

좌) !! 연산자 사용   우) lateinit으로 정의

 

만약, lateinit을 정의했지만, 값을 할당하지 않는다면 어떻게 될까요?

정답은, Compile에서 오류를 내기 때문에 실행 시점에서의 not-null을 보장할 수 있습니다.

 

class MyTest {
    private lateinit var myService: MyService

    fun setUp() {
        myService = MyService()
    }

    fun testAction() {
        Assert.assertEquals("foo", myService.performAction())
    }
}

val myTest = MyTest()
println(myTest.testAction()) // Compile Error
// UninitializedPropertyAccessException: lateinit property myService has not been initialized

 

 

 

 

 

Nullable receiver

: 널이 될 수 있는 타입도 확장 메서드를 가질 수 있음

 

String? 타입의 확장 메서드에는 isNullOrEmpty이나 isNullOrBlank 메소드가 있습니다.

즉, 널이 될 수 있는 수신 객체에 대해 호줄할 수 있는 메서드를 정의하고 사용할 수 있습니다.

 

kotlin-stdlib-common 에 포함된 Strings.kt 에는 String 확장 메서드들이 정의되어 있습니다.

그 중 하나인 isNullOrEmpty 메소드는 다음과 같이 정의되어 있습니다.

 

public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.length == 0
}

 

실제로, String? 타입에 대해 아래와 같이 사용할 수 있습니다.

 

fun verifyUserInput(input: String?) {
        if (input.isNullOrBlank()) {  // 따로 안전한 호출을 하지 않아도 됨
        println("Please fill in the required fields")
}

verifyUserlnput(" ")
// Please fill in the required fields

verifyUserlnput(null) // 예외가 발생하지 않음
// Please fill in the required fields

 

 

 

 

 

Nullability of type parameters

흔히 T로 정의되곤 하는 Type Parameter는 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입으로 간주됩니다.

 

fun <T> printHashCode(t: T) { 
    println(t?.hashCode())
}

 

제네릭 T는 Any? 타입으로 추론됩니다.

 

printHashCode(null) // 0

 

 

 

 

 

 

 

 

| Reference |

Kotlin in Action, Dmitry Jemerov and Svetlana Isakova

Kotlin Official - Null Safety

 

 

 

반응형