본문 바로가기

Kotlin/Kotlin IN ACTION

Kotlin IN ACTION 6장 정리(코틀린 타입 시스템, 어떻게 코틀린은 null처리를 우아하게 할까?)

반응형

null이 될 수 있는 타입과 null 처리 구문

null이 될 수 없는 타입의 장점

null이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 컴파일 시점에 검사하여 예외 발생의 가능성을 줄이는 특징을 갖습니다.

//자바에서 하던 것
int (String str) {
    if(str == null) {
        throw new NumberFormatException("null");
    }
    //...기타 작업
}

자바에서는 메소드의 파라미터로 레퍼런스 타입이 오면 대부분 null인지 아닌지 고민하고 null처리를 별도로 해야할지 고민했습니다.

//코틀린에서 null을 다루는 방법
fun parseInt(str: String):Int {
    // 바로 작업
}

코틀린에서 null이 될 수 없는 타입과 null이 될 수 있는 타입을 구분하기 때문에 메소드의 파라미터로 null이 될 수 없다고 타입을 지정해버리면 자바에서 하던 고민을 하지 않을 수 있습니다. 덕분에 "이 함수가 null로부터 안전하다!"라고 까지 말할 수 있는 것입니다.

null이 될 수 있는 타입을 써야한다면?

String?, Int?, MyType? 처럼 뒤에 물음표(?)를 뭍이면 해당 타입이 null을 참조할 수 있다고 표현할 수 있습니다.

코틀린에서 null이 될 수 있는 타입의 특징으로는 변수.메소드() 이런 식으로 메소드 호출을 할 수 없다는 특징이 있습니다. 그뿐만 아니라 null이 될 수 있는 값을 null이 될 수 없는 타입의 변수에 대입도 못하고, null이 될 수 있는 값을 메소드의 파라미터에 null이 될 수 없는 타입에도 전달을 못합니다.

오로지 null이 될 수 있는 타입은 null에 대한 처리 또는 검증을해야 사용할 수 있습니다.

fun parseInt(str: String?):Int {
    if(str == null){
        return 0;
    }
    //...기타 작업
}

더 우아한 null 처리

위의 방법처럼 여전히 if 로 null을 처리해야한다면 null 처리를 코틀린의 장점이라고 말할 수 없을 것입니다.

더 우아하게 null을 다루는 방법을 소개합니다.

안전한 호출 연산자 ?.

foo?.bar()
//위와 같이 호출했을 때 foo가 null이면, 결과는 null이되며 호출은 무시되고,
//foo가 null이 아니면 foo.bar()가 호출됩니다.

따라서 ?. 호출 연산자를 이용해서 안전하게 메소드를 호출할 수 있습니다. 단, ?. 호출 연산자를 사용하더라도 결과값이 null을 리턴할 수 있다는 것은 유의해야합니다.

엘비스 연산자 ?:

fun foo(s: String?) {
    val t: String = s?:""
}

엘비스 연산자 ?: 를 가운데에 두고 좌항의 값이 null 인지 검사하는 것으로 좌항이 null이면 우항을 리턴하고 좌항이 null이 아니면 좌항을 사용합니다.

안전한 캐스트 as?

foo as? Type
//foo is Type -> foo는 Type으로 캐스팅 됨
//foo !is Type -> null로 캐스팅 됨

캐스트 연산자 as? 를 사용하여 null일 수 있는 변수의 타입을 안전하게 캐스팅할 수 있습니다.

null이 아닐 것이라고 단언하기 !!

fun foo(s: String?) {
    val notNullStr:String = s!!
    println(notNullStr.length)
}

!! 연산자는 null이 아니라고 단언하는 행위를 개발자가 하는 방법입니다.

만약 !! 연산자를 썼음에도 불구하고 해당 변수가 null이라면 KotlinNullPointerException이 발생합니다.

위에 있는 좋은 해결책들이 많으니 웬만하면 !! 연산자를 쓰지 않는게 좋습니다.(필자 생각)


null이 될 수 있는 타입의 값에 어떻게 접근하는지는 알아봤고, 이제 null이 아닌 값만을 파라미터로 받는 함수를 호출하는 방법에 대해서 알아봅니다.

let

foo?.let{
    //해당 블럭(람다)안에서는 it(foo)가 null이 아닌 값이 됩니다.
    //따라서 이 블럭안에서 함수를 호출하면 됩니다.
    bar(it)
}

주석에 적혀있는대로 let 함수를 사용하면 let함수의 람다식 안에서는 it 가 null이 아니라는 것을 보장합니다. 단, 위의 예에서 foo 즉 변수가 null이면 해당 블럭(람다)은 아무일도 일어나지 않습니다.

단점으로는 foo처럼 단 하나의 변수일 때만 유효합니다. 만약 함수의 파라미터가 여러 개라면 let을 중첩으로 사용해야합니다.

let 을 중첩으로 사용하는 것보다 그냥 ifnull검사를 하는게 더 낫습니다.

lateinit

간혹 개발을 하다보면 초기화때부터 항상 모든 클래스의 멤버변수가 초기화되지 않는 경우도 있다.

부득이하게 null 로 초기화를 해야한다면 null이 될 수 있는 타입으로 멤버변수를 선언할 것이다.

또는 무의미한 값으로 초기화하기 위해 val 대신 var 를 써야할 것입니다.

이럴 때 멤버 변수를 null이 될 수 없는 타입으로 선언하려면 어떻게 해야할까?

바로 lateinit 키워드를 변수에 쓰는 것입니다.

이렇게하면 코틀린에서 변수 초기화가 나중에 이뤄질 것이라고 알려주고 null이 될 수 없는 타입으로 선언할 수 있습니다. 단, 여전히 var 를 써야합니다.

lateinit 키워드를 쓴다고해서 null처리가 되는 것은 아닙니다. 단지 초기화되기 전에 사용하면 어떤 변수가 초기화되지 않았는지 조금 더 명확하게 알려주는 예외가 발생할 뿐입니다.

class NotificationServiceImpl: NotificationService {
    private lateinit var userService: UserService
    //...
}
//사실 스프링 DI를 이용하면 아래처럼 생성자 초기화를 하는게 보통이라 쓸일이 있을지는...
class NotificationServiceImpl:NotificationService(private val userService: UserService) {
    //...
} 

null이 될 수 있는 타입 확장

fun verify(input: String?) {
    if(input.isNullOrBlank()) {
        println("비어있구만")
    }
}

자바에서는 String의 메소드 isEmpty() , isBlank() 와 같은 메소드를 호출할 때도 호출하는 객체가 null인지 아닌지가 중요했습니다.

근데 위의 코틀린 코드에서는 null이 될 수 있는 타입의 파라미터를 썼음에도 불구하고 위에서 배운 null 접근 처리에 대한 코드없이 호출했습니다. 어떻게 한 것일까?

그에 대한 해답은 확장 함수에 있습니다.

null이 될 수 있는 타입의 확장 함수를 정의하고 사용하면 null이 될 수 있는 타입을 파라미터로 갖는 메서드 호출도 자연스럽게 할 수 있습니다.

fun String?.isNullOrBlank():Boolean = this == null || this.isBlank()

this.isBlank() 하는 부분은 앞에서 null에 대한 검사를 했기 때문에 스마트 캐스트까지 이뤄집니다.

위의 작업은 오직 확장함수에서만 가능합니다. 일반 함수에서는 호출하는 객체가 null일 떄는 검사 자체가 불가능합니다.

타입 파라미터의 널 가능성

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

T는 기본적으로 Any? 로 추론되어 제네릭 타입은 항상 널이 될 수 있는 타입입니다.

null이 될 수 없는 타입으로 제네릭 타입을 사용하려면 아래와 같이 해야합니다.

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

자바 null과의 호환

자바에서 애노테이션(@Nullable → Type?, @NotNull → Type)으로 코틀린이 추론할 수 있게 돕는 방법도 있지만 기본적으로 애노테이션이 없을 때 자바의 타입은 코틀린의 플랫폼 타입이 됩니다.

플랫폼 타입은 null 관련 정보를 알 수 없는 타입입니다.

따라서 null 관련 처리를 해도 되고 안 해도 되는...? 타입니다. (필자의 경우, 코틀린 답지는 않은 해결책이라고 생각합니다. 순전히 개발자가 처리하기 나름입니다.)

이 플랫폼 타입을 코틀린에서 직접 선언할 수는 없습니다.

원시 타입(Primitive Type)

코틀린은 Primitive Type과 Reference Type을 구분하지 않습니다.

코틀린이 알아서 효율적으로 사용하고 개발자입장에서는 하나의 타입으로 사용합니다.

숫자 변환

코틀린에서는 명확하게 숫자 관련 타입을 구분합니다.

그래서 숫자 타입간에 변환할 때는 아래 코드와 같이 to~() 메서드를 이용해야합니다.

val i = 1
val l: Long = i //컴파일에러!
val l: Long = i.toLong() //이렇게 써야 맞음

Any, Any?

자바의 Object에 대응됩니다. 모든 타입의 조상 타입입니다. (자바에서는 primitive type은 Object가 조상이 아닙니다.)

Unit

자바에서 void 에 대응됩니다.

Nothing

리턴 값이라는 개념 자체가 의미 없는 함수가 있을 수 있다.

Fail을 의미하는 테스트 코드와 같이 절대 정상적으로 끝날 수 가 없음을 의미하고 싶을 때, Nothing 을 리턴하는 함수를 만들면 된다.

코틀린 컬렉션

null이 될 수 있는 값으로 이루어진 컬렉션 다루기

코틀린에서는 null이 될 수 있는 값으로 이루어진 컬렉션에서 null을 걸러내는 경우가 잦아 filterNotNull 이라는 표준 함수를 제공한다.

fun addValidNumbers(numbers: List<Int?>) {
    val validNumbers = numbers.filterNotNull()
    println("Sum of valid numbers: ${validNumbers.sum()}")
    println("Invalid numbers: ${numbers.size - validNumbers.size}")
}
  • 읽기 전용 컬렉션
    • Collection 인터페이스 (size, iterator(), contains())
    • listOf , setOf , mapOf
  • 가변 컬렉션
    • MutableCollection 인터페이스 (add(), remove(), clear())
    • mutableListOf , arrayListOf
    • mutableSetOf , hashSetOf , LinkedSetOf , sortedSetOf
    • mutableMapOf , hashMapOf , LinkedMapOf , sortedMapOf

MutableCollection이 Collection을 상속받으므로 실질적으로 가변 컬렉션이지만 Collection 인터페이스로 가리킬 수도 있습니다.

따라서 읽기 전용 컬렉션이라고해서 불변 컬렉션일 필요는 없습니다. 그래서 스레드에 안전하지 않으므로 읽기 전용 컬렉션을 사용할 때는 멀티 쓰레드환경에서 동기화가 필요합니다.

반응형
  • 케이 2020.10.06 21:38

    var 로 선언된 옵셔널 변수의 경우 널체크를 한 이후에도 ?나 !! 연산자를 붙여서 호출 해야 합니다. (스마트 캐스트 안됌)
    변수의 변화를 IDE에서 체크할수 없기 때문에 그런것 같습니다.