[Kotlin] 코틀린 심화 Null Safety

Kotlin

목록 보기
2/2
post-thumbnail

본 글은 Kotlin Intermediate : Null safety 페이지를 번역한 내용입니다.

이번 챕터에선 Null Safety의 일반적인 사용 사례와 이를 최대한 활용하는 방법을 다룹니다.

Smart casts and safe casts

코틀린은 경우에 따라 타입을 명시하지 않아도, 컴파일러가 자동으로 타입을 추론하여 적용합니다.

변수나 객체의 타입의 값을 다른 타입으로 바꾸는 행위를 Casting(형변환)이라고 합니다.

타입이 자동으로 변환되는 경우 즉, 추론되는 경우를 Smart Casts(스마트 캐스팅)이라고 합니다.

is and !is oprators

캐스팅이 어떻게 작동하는지 알아보기 전에, 객체가 어떤 타입인지 확인하는 방법을 살펴보겠습니다.
이를 위해 is!is 연산자를 when이나 if 조건식과 함께 사용하는 방법을 알아보겠습니다.

  • is 연산자는 객체에 해당 타입이 있는지 확인하고 Boolean 값을 반환합니다.
  • !is 연산자는 객체에 해당 타입이 없는지 확인하고 Boolean 값을 반환합니다.
fun printObjectType(obj: Any) {
    when (obj) {
        is Int -> println("It's an Integer with value $obj")
        !is Double -> println("It's NOT a Double")
        else -> println("Unknown type")
    }
}

fun main() {
    val myInt = 42
    val myDouble = 3.14
    val myList = listOf(1, 2, 3)
  
    // The type is Int
    printObjectType(myInt)
    // It's an Integer with value 42

    // The type is List, so it's NOT a Double.
    printObjectType(myList)
    // It's NOT a Double

    // The type is Double, so the else branch is triggered.
    printObjectType(myDouble)
    // Unknown type
}

as and as? operators

객체의 타입을 명시적(강제적)으로 적용을 할땐 as 연산자를 사용하면 됩니다.

아래 코드에는 nullable 타입에서 nullable이 아닌 타입으로 형변환 하는 예시입니다.

아래와 같이 형변환이 불가능 하면 프로그램은 런타임 환경에서 오류를 발생시킵니다.

Nullable 상태의 변수는 값이 들어있는지 Null인지 알 수 없어 형변환이 불가능 합니다.

이런 상황을 안전하지 않은 형변환 즉, unsafe cast 라고 부릅니다

val a: String? = null
val b = a as String

// Triggers an error at runtime
print(b)

객체를 Null을 허용하지 않는 타입으로 변환하려고 할 때, 변환이 실패해도 오류를 발생시키지 않고 Null을 반환하도록 하려면 as? 연산자를 사용합니다.
as? 연산자는 형변환이 불가능할 때 오류를 던지지 않고 단순히 Null을 반환하기 때문에,

안전한 캐스팅 연산자(Safe cast operator) 라고 부릅니다

즉, as는 실패 시 오류를 발생시키지만, as?는 실패시 null을 반환합니다

val a: String? = null
val b = a as? String

// Returns null value
print(b)
// null

as? 연산자는 Elvis 연산자와 함께 사용해 코드를 간결하게 만들 수 있습니다.

예를 들어 calculateTotalStringLength() 함수는 문자열과 다른 타입이 섞인 리스트에서 문자열의 총 길이를 계산합니다.

fun calculateTotalStringLength(items: List<Any>): Int {
    var totalLength = 0

    for (item in items) {
        totalLength += if (item is String) {
            item.length
        } else {
            0  // Add 0 for non-String items
        }
    }

    return totalLength
}

위 코드는 아래와 같이 줄일 수 있습니다.

fun calculateTotalStringLength(items: List<Any>): Int {
    return items.sumOf { (it as? String)?.length ?: 0 }
}

이 예제 코드에서는 sumOf() 함수를 사용하며, 각 문자열의 길이를 계산하기 위해 람다 표현식을 전달합니다.

  • 리스트의 각 아이템은 as? 연산자를 이용해 String 타입으로 안전하게(Safe cast) 변경됩니다.

  • 안전 호출 연산자(?.)as?로 형변환된 결과가 null이 아닐 때만 length 속성에 접근합니다.
    만약 null이라면 length를 호출하지 않고 그대로 null을 반환합니다.

  • Elvis 연산자는 앞서 안전 호출 연산자에서 null이 반환된 경우, 대체값(0) 을 반환하도록 합니다.

Null values and collections

코틀린에서 컬렉션을 다룰 때는 null 값 처리불필요한 요소 필터링이 자주 필요합니다

이러한 작업을 간결하고 효율적으로 수행할 수 있도록, 코틀린은 List, Set Map 등 다양한 컬렉션 타입에서 사용할 수 있는 유용한 내장 함수들을 제공합니다.

컬렉션에서 null 값을 필터링을 할땐, filterNotNull() 함수를 사용하면 됩니다.

val emails: List<String?> = listOf("alice@example.com", null, "bob@example.com", null, "carol@example.com")

val validEmails = emails.filterNotNull()

println(validEmails)
// [alice@example.com, bob@example.com, carol@example.com]

리스트를 생성할 때부터 null 값을 제외하고 싶다면, listOfNotNull() 함수를 사용하면 됩니다.

이 함수는 전달된 인자 중 null이 아닌 값들만 포함한 리스트를 생성합니다.

val serverConfig = mapOf(
    "appConfig.json" to "App Configuration",
    "dbConfig.json" to "Database Configuration"
)

val requestedFile = "appConfig.json"
val configFiles = listOfNotNull(serverConfig[requestedFile])

println(configFiles)
// [App Configuration]

위 두 함수는 모든 항목이 null인 경우 결과로 빈 리스트가 반환됩니다.

코틀린은 컬렉션에서 값을 찾는 데 사용할 수 있는 함수도 제공합니다.

값을 찾지 못하면 오류를 발생시키는 대신 null 값을 반환합니다.

  • maxOrNull() 은 컬렉션 안에서 가장 큰 값을 찾는 함수입니다. 값을 찾을 수 없으면 null을 반환합니다.
  • minOrNull() 은 컬렉션 안에서 가장 작은 값을 찾는 함수입니다. 값을 찾을 수 없으면 null을 반환합니다.
// Temperatures recorded over a week
val temperatures = listOf(15, 18, 21, 21, 19, 17, 16)

// Find the highest temperature of the week
val maxTemperature = temperatures.maxOrNull()
println("Highest temperature recorded: ${maxTemperature ?: "No data"}")
// Highest temperature recorded: 21

// Find the lowest temperature of the week
val minTemperature = temperatures.minOrNull()
println("Lowest temperature recorded: ${minTemperature ?: "No data"}")
// Lowest temperature recorded: 15

이 예제에서는 Elvis 연산자를 사용하여, 함수가 null 값을 반환할 경우 "No data"를 출력합니다.

maxOrNull()minOrNull() 함수는 null 값을 포함하지 않는 컬렉션에서 사용하도록 설계되었습니다.
그렇지 않다면, 함수가 원하는 값을 찾지 못한 것인지 아니면 null 값을 찾은 것인지 구분할 수 없게 됩니다.

또한 singleOrNull() 함수를 람다 표현식과 함께 사용하여 특정 조건에 맞는 단 하나의 요소를 찾을 수 있습니다.

해당 조건에 맞는 요소가 존재하지 않거나, 여러 개가 존재할 경우 이 함수는 null 값을 반환합니다.

// Temperatures recorded over a week
val temperatures = listOf(15, 18, 21, 21, 19, 17, 16)

// Check if there was exactly one day with 30 degrees
val singleHotDay = temperatures.singleOrNull{ it == 30 }
println("Single hot day with 30 degrees: ${singleHotDay ?: "None"}")
// Single hot day with 30 degrees: None

singleOrNull() 함수는 null 값을 포함하지 않는 컬렉션에서 사용하도록 설계되었습니다.

일부 함수는 람다 표현식(lambda expression)을 사용해 컬렉션을 변환하지만, 변환할 수 없는 경우 null 값을 반환합니다.

컬렉션을 변환하면서 null이 아닌 첫 번째 값을 반환하고 싶다면 firstNotNullOfOrNull() 함수를 사용할 수 있습니다. 이 함수는 람다 표현식을 통해 각 요소를 변환하며, 변환 결과 중 가장 처음으로 null이 아닌 값을 반환합니다.

만약 그런 값이 존재하지 않으면 null을 반환합니다.

data class User(val name: String?, val age: Int?)

val users = listOf(
    User(null, 25),
    User("Alice", null),
    User("Bob", 30)
)

val firstNonNullName = users.firstNotNullOfOrNull { it.name }
println(firstNonNullName)
// Alice

컬렉션의 각 항목을 순차적으로 처리하면서 누적된 결과를 생성하고,

만약 컬렉션이 비어 있다면 null 값을 반환하도록 하려면 reduceOrNull() 함수를 사용합니다.

reduceOrNull() 함수는 컬렉션의 첫 번째 요소를 초기값으로 사용하고, 이후 람다 표현식을 통해 다음 요소들을 순서대로 합산합니다. 컬렉션이 비어 있을 경우 reduce()와 달리 예외를 던지지 않고 null을 반환한다는 점이 다릅니다.

// Prices of items in a shopping cart
val itemPrices = listOf(20, 35, 15, 40, 10)

// Calculate the total price using the reduceOrNull() function
val totalPrice = itemPrices.reduceOrNull { runningTotal, price -> runningTotal + price }
println("Total price of items in the cart: ${totalPrice ?: "No items"}")
// Total price of items in the cart: 120

val emptyCart = listOf<Int>()
val emptyTotalPrice = emptyCart.reduceOrNull { runningTotal, price -> runningTotal + price }
println("Total price of items in the empty cart: ${emptyTotalPrice ?: "No items"}")
// Total price of items in the empty cart: No items

이 예제에서도 Elvis 연산자를 사용하여, 함수가 null 값을 반환할 경우 "No items”를 반환하도록 합니다.

reduceOrNull() 함수는 null 값을 포함하지 않는 컬렉션에서 사용하도록 설계되었습니다. 이 함수는 컬렉션이 비어 있을 때 null을 반환하므로, Elvis 연산자와 함께 사용하면 안전한 코드를 작성할 수 있습니다.

더 많은 기능을 배우고 싶다면, 코틀린의 표준 라이브러리를 읽어보세요.

Early returns and the Elvis oprator

초급 튜토리얼에서, 함수의 실행을 특정 지점에서 조기 반환 시키는 방법을 배웠습니다.

Elvis 연산자를 함께 사용하면 함수 안에서 조건을 간단하게 확인하고 바로 종료할 수 있습니다.

data class User(
    val id: Int,
    val name: String,
    // List of friend user IDs
    val friends: List<Int>
)

// Function to get the number of friends for a user
fun getNumberOfFriends(users: Map<Int, User>, userId: Int): Int {
    // Retrieves the user or return -1 if not found
    val user = users[userId] ?: return -1
    // Returns the number of friends
    return user.friends.size
}

fun main() {
    // Creates some sample users
    val user1 = User(1, "Alice", listOf(2, 3))
    val user2 = User(2, "Bob", listOf(1))
    val user3 = User(3, "Charlie", listOf(1))

    // Creates a map of users
    val users = mapOf(1 to user1, 2 to user2, 3 to user3)

    println(getNumberOfFriends(users, 1))
    // 2
    println(getNumberOfFriends(users, 2))
    // 1
    println(getNumberOfFriends(users, 4))
    // -1
}

조기 반환를 하지 않으면 코드가 더 짧아 보일 수도 있습니다.

하지만 이렇게 작성하면 users[userId]null을 반환할 가능성이 있기 때문에, 여러 번의 안전 호출 연산자를 사용해야 합니다

fun getNumberOfFriends(users: Map<Int, User>, userId: Int): Int {
    // Retrieve the user or return -1 if not found
    return users[userId]?.friends?.size ?: -1
}

이 예제에서는 엘비스 연산자를 한 번만 사용해 조건을 검사하지만, 필요하다면 여러 개의 조건을 추가해 중요한 예외 상황을 모두 처리할 수도 있습니다.

Elvis 연산자를 이용한 조기 반환은 프로그램이 불필요한 작업을 수행하지 않도록 막고, null 값이나 잘못된 상황이 발견되면 즉시 실행을 중단해 코드를 더 안전하고 안정적으로 만들어 줍니다.

0개의 댓글