Study/kotlin

[코틀린 프로그래밍] Chapter.06 오류를 예방하는 타입 안정성

에디개발자 2021. 4. 20. 07:00
반응형

나를 닮았다고 한다...

Any와 Nothing 클래스

Any

Java의 Object와 대응되는 클래스지만 같지는 않습니다. Any는 to(), let(), run(), apply(), also()와 같은 확장함수를 가지고 있습니다. 이러한 메소드에 대해서는 뒤에 자세히 다루겠습니다.

 

Nothing

함수가 아무것도 리턴하지 않는 경우이다. Java의 void가 떠오르지만 다른 개념입니다. 코틀린은 표현식이 리턴을 하지 않을 때에는 Unit을 사용합니다. 그러나 정말 아무것도 리턴하지 않는 경우에는 Nothing 사용합니다.

fun computeSqrt(n: Double): Double {
    if (n >= 0) {
        return Math.sqrt(n)
    } else {
        throw RuntimeException()  // Nothing 타입
    }
}

 

Null 가능 참조

코틀린이 매력적이라고 느끼는 것 중 하나가 NPE를 피할 수 있는 것입니다. Java에서는 null은 항상 오류를 발생시킬 수 있는 잠재적인 녀석입니다. 이를 보완하기 위해 Java8부터 Optional 이 추가되었지만 개발자가 작성해야하고 null을 리턴하는 메소드가 있어도 컴파일 레벨에서는 오류가 발생하지 않습니다. 하지만 코틀린은 컴파일 레벨에서 null을 잡아줍니다.

fun nickName(name: String):String {
    if (name == "Tom") {
        return "Coffee"
    }
    return null   // 컴파일 에러 발생..
}
println(nickName(null)) // 여기도 컴파일 에러 발생

코틀린은 null 자체를 허용하지 않습니다. 또한 개발자가 놓칠 수 있는 NPE를 잡아준다.

Nullable로 선언하는 방법
타입 뒤에 '?'를 붙혀서 선언합니다.
fun nickName(name: String?): String? {
    if (name == "Tom") {
        return "Coffee"
    }
    return name.reversed();  // Error!!
}

 

세이프 콜 연산자

위 코드를 null이 나지 않도록 수정해보겠습니다.

fun nickName(name: String?): String? {
    if (name == "Tom") {
        return "Coffee"
    }

    if (name != null) {
        return name.reversed()
    }
    return null
}

수정은 되었으나 코드가 지저분하고 전혀 코틀린스럽지가 못합니다. 개선방법에 대해서 알아보겠습니다.

 

개선1. safe-call 연산자

fun nickName(name: String?): String? {
    if (name == "Tom") {
        return "Coffee"
    }

    return name?.reversed()  // safe-call 연산자
}

null이 발생할 수 있는 변수에 ? 를 붙혀서 사용할 수 있습니다. 만약 변수의 값이 null이면 reversed()를 작동하지 않고 null을 반환합니다.

 

개선2. 엘비스 연산자

safe-call 연산자를 이용해 toUpperCase도 적용하는 코드입니다.

fun nickName(name: String?): String? {
    if (name == "Tom") {
        return "Coffee"
    }

    val result = name?.reversed()?.toUpperCase()
    return if (result == null) "Joker" else result
}

 

적용하고 나니 코드가 지저분해 보입니다. 이럴 때 엘비스 연산자(?:)를 사용합니다. 

fun nickName(name: String?): String? {
    if (name == "Tom") {
        return "Coffee"
    }

    return name?.reversed()?.toUpperCase() ?: "Joker"
}

값이 null이 아니면 뒤에 선언한 메소드나 결과값을 리턴하고 null이면 null을 반환합니다.

!! 이런 연산자가 있습니다. null이 안날꺼라고 개발자가 확신할 때 사용하는 연산자입니다.
NPE 나길 바라는 마음이라면 사용하자. 쓰지말자 삭제..

 

위 코드를 최종적으로 when을 사용하여 개선해보겠습니다.

fun nickName4(name: String?) = when (name) {
    "Tom" -> "Coffee"
    null -> "Joker"
    else -> name.reversed().toUpperCase()
}

 

타입 체크와 캐스팅

equals 기능에 대해서 Java 코드와 비교해보겠습니다.

// java code
// 먼저 타입을 비교하고 캐스팅을 한 후 비교가 가능하다.

@Override
public boolean equals(Object other) {
    if (other instanceof Animal) {
        return age == ((Animal) other).age;
    }

    return false;
}

 

Smart Cast

코틀린에서는 캐스팅을 할 필요가 없습니다. 조건문에서 이미 확실한 판정을 받았기 때문입니다. is를 사용하여 적용해보겠습니다.

override fun equals(other: Any?): Boolean {
    return if (other is Animal) age == other.age else false;
}

other가 Animal 객체라면 캐스팅이 필요없고 Animal 객체로 확신하고 뒤에 로직을 실행합니다.

 

위 코드를 리펙토링...

override fun equals(other: Any?) = other is Animal && age == other.age

 

앞 장에서 나온 코드에 대해서 Smart Cast를 적용해보겠습니다.

적용전

fun WhatToDo1(dayOfWeek: Any) = when (dayOfWeek) {
    "Saturday", "Sunday" -> "Relax"
    in listOf("Monday", "TuesDay", "Wednesday", "Thursday") -> "Work hard"
    in 2..4 -> "Work hard"
    "Friday" -> "Party"
    is String -> "What???"
    else -> "No clue"
}

 

적용후

fun WhatToDo2(dayOfWeek: Any) = when (dayOfWeek) {
    "Saturday", "Sunday" -> "Relax"
    in listOf("Monday", "TuesDay", "Wednesday", "Thursday") -> "Work hard"
    in 2..4 -> "Work hard"
    "Friday" -> "Party"
    is String -> "What, .... length ${dayOfWeek.length}"  // string smart cast
    else -> "No clue"
}

 

명시적 타입 캐스팅

컴파일러가 타입을 확실하게 결정할 수 없을 경우 스마트 캐스팅을 하지 못하는 경우에만 사용합니다.

fun fetchMessage(id: Int): Any =
    if (id == 1) "Record found" else StringBuilder("data not found")

// error 발생 코드.
// StringBuilder는 String으로 캐스트되지 않는다.
for (id in 1..2) {
    println("Message length: ${(fetchMessage(id) as String).length}")
}

 

위 코드를 안전하게 리펙토링..

for (id in 1..2) {
    println("Message length: ${(fetchMessage(id) as? String)?.length ?: "---"}")
}
as보다는 as? 를 사용하여 안정성을 보장받자!
가능한 스마트 캐스트를 사용하자.

 

제네릭: 파라미터 타입의 가변성과 제약사항

타입 불변성

List<T>로 예를 들어보겠습니다. 먼저 2개의 클래스가 있고 Dog extends Animal이 있습니다. List<Animal>을 변수로 받는다고 해서 List<Dog>는 불가하다.

open class Fruit

class Banana : Fruit()
class Orange : Fruit()
fun receiveFruitsMutable(fruits: Array<Fruit>) {
    println("Number of fruits: ${fruits.size}")
}

val bananas: Array<Banana> = arrayOf()
receiveFruitsMutable(bananas) // ERROR type이 맞지않다.

 

공변성 

아래 코드와 같은 경우 파라미터의 값을 읽기만 하기 때문에 Fruit 하위 클래스가 전달되더라도 문제가 없습니다. 이런 것을 타입이나 파생 타입에 접근하기 위한 파라미터 타입의 공변성이라고 합니다. 공변성을 사용하면 컴파일러에게 자식 클래스를 부모 클래스의 자리에 사용할 수 있게 요청합니다.

fun receiveFruit(fruits: Array<out Fruit>) {  // 공변성
    println("Number of fruits: ${fruits.size}")
}

receiveFruit(bananas)  // 가능하다.

 

반공변성

공변성과는 반대로 상위 클래스가 전달되어도 문제가 없습니다. 이것을 반공변성이라고 합니다. 반공변선을 사용하면 받을 수만 있고 다른곳으로 리턴은 불가합니다.

fun receiveFruit(fruit: Array<in Fruit>) {
    println("Number of fruits: ${fruit.size}")
}

val anythings = Array<Any>(3) { _ -> Fruit() }
receiveFruit(anythings)

 

where를 사용한 파라미터 타입 제한

너무 유연한 제네릭에 제약조건을 부여할 수 있습니다. 간단한 예제 코드로 알아보겠습니다. 

 

모든 객체가 close 메서드를 가지고 있는 것은 아니다.

fun <T> useAndClose(input: T) {
    input.close();
}

위와 같은 메소드는 close 메소드를 가지고 있지 않는 클래스를 넘기면 에러가 발생합니다.

 

제약조건을 적용하여 AutoCloseable을 상속받은 클래스만 받도록 구현해보겠습니다.

// 제약조건을 사용한다.
fun <T : AutoCloseable> useAndClose(input: T) {
    input.close()
}

val stringWriter = java.io.StringWriter()
stringWriter.append("hello")
useAndClose(stringWriter)  // 정상

 

만약 제약조건이 2개 이상이라면 where를 사용합니다.

fun <T> useAndClose(input: T) where T : AutoCloseable, T : Appendable {
    input.close()
    input.append("there")
}

 

스타 프로젝션

  • java의 ?와 비슷합니다.
  • 스타 프로젝션을 사용하면 읽기만 가능하고 쓰기는 불가능합니다.
  • * = out T
fun printValues(values: Array<*>) {
    for (value in values) {
        println(value)
    }
}

printValues(arrayOf(1, 2))

 

구체화된 타입 파라미터

Java에서 제네릭을 사용할 때 Class<T>를 함수에 파라미터로 전달해야하는 경우가 있습니다. 제네릭 함수에서 특정 타입이 필요하지만 Java의 타입 이레이저 때문에 타입 정보를 잃어버릴 경우 필수적으로 작성합니다. 하지만 코틀린은 구체화된 타입 파라미터를 이용하여 코드를 한결 깨끗하게 도와줍니다. 간단한 예제를 살펴보겠습니다.

 

abstract class Book(val name: String)

class Fiction(name: String) : Book(name) 
class NonFiction(name: String) : Book(name)
val books: List<Book> = listOf(Fiction("Moby Dick"), NonFiction("Learn to Code"), Fiction("LOTR"))

 

자바스럽게 코드를 작성하자면 변수에 Class를 넘겨하는 불편함이 있습니다.

fun <T> findFirst(books: List<Book>, ofClass: Class<T>): T {
    val selected = books.filter { book -> ofClass.isInstance(book) }  // 타입 체크
    if (selected.isEmpty()) {
        throw RuntimeException("Not found");
    }
    return ofClass.cast(selected[0])
}

println(findFirst(books, NonFiction::class.java).name)

 

하지만 reifeid(구체화)를 선언하여 위 코드를 리펙토링 할 수 있습니다. 함수의 바디가 함수 호출하는 부분에서 확장되기 때문에 타입 T는 컴파일 시간에 확인되는 실제 타입으로 대체됩니다.

inline은?
inline으로 선언하면 reified는 inline 선언한 메서드 내에서만 사용가능
inline fun <reified T> findFirst(books: List<Book>): T {
    val seleted = books.filter { book -> book is T }
    if (books.isEmpty()) {
        throw RuntimeException("Not found")
    }
    return seleted[0] as T
}

 

정리

- nullable 레퍼런스 타입을 non-nullable 레퍼런스 타입에서 분리해서 컴파일러는 메모리 오버헤드 없이 안정적인 타입 안정성을 지님
- nullable 레퍼런스로부터 쉽고 유연하게 객체를 가지고 올 수 있는 몇가지 연산자도 제공
- 스마트 캐스트 기능은 불필요한 캐스팅을 할 필요 없게 하면서 코드의 복잡성을 줄여 줌
- 제네릭 함수와 클래스를 사용할 때파라미터 타입을 조정하여 타입 안정성과 유연성을 제공
- reified 타입 파라미터는 컴파일 시간 타입 안정성을 강화해서 코드의 클러터와 오류를 제거

반응형