Programming/Kotlin

코틀린(Kotlin) - 널(NULL) 가능성

JunsuKim 2021. 10. 20.
728x90

자바에 비해 코틀린의 타입 시스템은 코드의 가독성을 향상하는 데 도움이 되는 몇 가지 특성을 제공한다.

대표적으로는 널이 될 수 있는 타입(nullalbe type), 읽기 전용 컬렉션, 배열지원이 있다.

널 가능성

널 가능성은 NPE오류(NullPointerException 오류)를 피할 수 있게 도와주는 코틀린 타입 시스템의 특성이다.

널이 될 수 있는지의 여부를 타입 시스템에 추가함으로써 컴파일러가 컴파일 시 오류를 미리 감지해서 실행 시점에 발생할 수 있는 예외 가능성을 줄일 수 있다.

널이 될 수 있는 타입

코틀린과 자바의 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 것이다.

자바에서 다음과 같은 코드가 있다 하자.

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

이 함수에 null을 전달하면 NullPointerException이 발생한다.

이 함수를 코틀린으로 작성할 때 가장 먼저 알아야 할 질문은 "이 함수가 널을 인자로 받을 수 있는가?"이다.

널을 인자로 받을 수 있다는 말은 strLen(null) 처럼 직접 null 리터럴을 사용하는 경우뿐 아니라 변수나 식의 값이 실행 시점에 null이 될 수 있는 경우 모두 포함한다.

널이 인자로 들어올 수 없을 때의 함수를 정의하면 다음과 같다.

fun strLen(s: String) = s.length

이 함수에는 null 또는 널이 될 수 있는 인자를 넘기는 것이 금지된다. 이를 넘겼을 시 컴파일 에러가 발생한다.

 

널을 인자로 받을 수 있게 하려면 타입 이름 뒤에 물음표(?)를 명시하면 된다.

fun strLenSafe(s: String?) = if(s != null) s.length else 0

String?, Int, MyCustomType? 등 어떤 타입이든 타입 이름 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다.

 

널이 될 수 있는 타입의 변수가 있으면, 그에 대해 수행할 수 있는 연산도 제한된다.

예를 들어 널이 될 수 있는 타입인 변수에 대해 변수.메소드()처럼 메소드를 직접 호출할 수 없다.

또한 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없으며, 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다.

이처럼 제약도 많지만, 널이 될 수 있는 타입의 장점도 있다.

널이 될 수 있는 타입과 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할지 명확히 이해할 수 있고, 실행 시점에서 예외를 발생시킬 수 있는 연산을 판단할 수 있다.

안전한 호출 연산자 ?.

호출 연산자 ?.은 코틀린이 제공하는 가장 유용한 도구 중 하나이다.

?.은 null 검사와 메소드 호출을 한 번의 연산으로 수행한다.

if(s != null) s.toUpperCase() else null

s?.toUpperCase()

위 두 문장은 같은 코드이다.

호출하려는 값이 null이 아니라면 ?.은 일반 메소드 호출처럼 작동하고, 호출하려는 값이 null이면 이 호출은 무시되고 null이 결과 값이 된다.

?. 호출의 결과 타입도 널이 될 수 있는 타입이다.

메소드 호출뿐만이 아니라 프로퍼티를 읽거나 쓸 때도 ?.을 사용할 수 있다.

엘비스 연산자 ?:

?:(엘비스 연산자)는 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자이다.

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

?:는 이항 연산자로 좌항을 계산한 값이 널인지 검사한다. 좌항 값이 널이 아니면 좌항 값을 결과로 하며,

널일 경우 우항 값을 결과로 한다.

?:는 객체가 널인 경우 널을 반환하는 ?.와 함께 사용해서 객체가 널인 경우에 대비한 값을 지정하는 경우도 많다.

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

또한 코틀린에서는 return이나 throw 등의 연산도 식이기 때문에 엘비스 연산자의 우항에 넣을 수 있다.

안전한 캐스트 as?

as 연산자는 어떤 값을 지정한 타입으로 캐스트한다. 값을 대상 타입으로 변환할 수 없으면 null을 반환한다.

안전한 캐스트를 사용할 때의 일반적인 패턴: 캐스트 수행 -> 엘비스 연산자 사용

이 패턴을 사용하면

  • 파라미터로 받은 값이 원하는 타입인지 쉽게 검사하고, 캐스트 할 수 있다.
  • 타입이 맞지 않으면 쉽게 false를 반환할 수 있다.
  • 이 모든 동작을 한 식으로 해결 가능하다.

라는 장점이 있다.

널 아님 단언 !! 연산자

!! 연산자를 사용하면 어떤 값이든 널이 될 수 없는 타입으로 바꿀 수 있다.

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

* !!은 한 줄에 함께 쓰는 일이 없도록 주의해야 한다.

let 함수

let 함수를 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있다.

let 함수를 안전한 호출 연산자(?.)와 같이 사용하면 원하는 식을 평가한 후 결과가 널인지 검사한다. 후에 그 결과를

변수에 넣는 작업을 간단한 식을 사용해 한번에 처리 가능하다.

예제를 보자.

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

이 함수에는 파라미터 뒤에 ?가 붙지 않아 널이 될 수 있는 타입의 값을 넘길 수 없다.

인자를 넘기기 전에 주어진 값이 널인지 검사를 해야 한다.

if(email != null) sendEmailTo(email)

하지만 let 함수를 사용하면 let 함수가 자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다.

널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입을 인자로 받는 람다를 let에 전달한다.

나중에 초기화할 프로퍼티

객체 인스턴스를 일단 생성한 다음 나중에 초기화하는 프레임워크가 많다.

안드로이드에서는 onCreate에서 액티비티를 초기화하고, 제이유닛에서는 @Before로 에노테이션된 메서드 안에서 초기화 로직을 수행해야 한다.

 

코틀린에서는 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메소드 안에서 초기화할 수는 없고, 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다.

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

이러한 초기화 값을 제공할 수 없다면 널이 될 수 있는 타입을 사용할 수밖에 없다.

class NyService {
    fun performAction(): String = "foo"
}

class MyTest {
    private var myService: MyService? = null
    
    @Before
    fun setUp() {
        myService = MyService()
    }
    
    @Test
    fun testAction() {
        Assert.assertEquals("foo", myService!!.performAction())
    }
}

코드를 보면 myService 프로퍼티에 null을 넣고, setUp 함수에서 진짜 초기화 값을 지정한다.

따라서 myService를 사용할 때마다 !! 또는 ?를 사용해야 하여 코드가 더 못생겨진다.

 

이를 해결하기 위해 myService 프로퍼티를 나중에 초기화할 수 있다.

이를 위한 방법은 lateinit 변경자를 붙이면 된다.

class MyService {
   fun performAction(): String = "foo"
}

class MyServiceTest {

   private lateinit var myService:MyService

   @BeforeEach
   fun setUp() {
       myService = MyService()
   }

   @Test
   fun test() {
       assertEquals("foo", myService.performAction())
   }
}

나중에 초기화하는 프로퍼티는 항상 var이어야 한다.

val 프로퍼티는 final 필드로 컴파일되며, 생상자 안에서 반드시 초기화해야 하므로 나중에 초기화하는 프로퍼티는 항상 var이어야 하는 것이다.

널이 될 수 있는 타입 확장

널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있다.

어떤 메소드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해

메소드를 호출해도 확장 함수인 메소드가 알아서 널을 처리해준다.

이는 확장 함수에서만 가능하고, 일반 멤버 호출은 객체 인스턴스를 총해 디스패치되어 인스턴스가 널인지 여부를

검사하지 않는다.

타입 파라미터의 널 가능성

코틀린에서는 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다.

널이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있기 때문에

타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 될 수 있는 타입이다.

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

2행에서 안전한 호출(?.)을 사용한 이유는 t가 null이 될 수 있기 때문이다.

타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한을 지정해야 한다.

이를 하게 되면 널이 될 수 있는 값을 거부하게 된다.

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

댓글