Kotlin

[Kotlin] Nullability(널 가능성) - 2

sh1mj1 2024. 1. 18. 14:21

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

 

이전 글에서 이어진다. 

이전 글 : https://sh1mj1-log.tistory.com/211

 

[Kotlin] Nullability(널 가능성) - 1

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다. Nullability(널 가능성)은 NPE(`NullPointerException`) 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다. 코

sh1mj1-log.tistory.com

 

지연 초기화할 프로퍼티

실제로는 not-null 인 프로퍼티인데 생성자 안에서 not-null 값으로 초기화할 방법이 없는 경우가 있다.

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

(ex: 안드로이드의 `Activity`은 `onCreate` 메서드 내에서, `JUnit` 은 `@Before` 이 되는 메서드 내에서 지연 초기화한다.)

 

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

프로퍼티가 not-null 타입이라면, 반드시 not-null 값으로 초기화해야 한다.

하지만 nullable 타입을 사용하면, 모든 프로퍼티 접근에 null-check 를 넣거나 `!!` 연산자를 써야 한다.

 

not-null assertion 을 사용해 nullable 프로퍼티에 접근

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

class MyServiceTest {
    private var myService: MyService? = null // null 로 초기화하기 위해 nullable 타입인 프로퍼티 선언

    @BeforeEach
    fun setUp() {
        myService = MyService() // setUp 메서드에서 진짜 초기값 지정
    }
    @Test
    fun testAction() = assert("foo" == myService!!.performAction()) // myService 에 대해 !! 나 ? 를 써야 함
}

이렇게 해결하는 것보다 `lateinit` 또는 `Delegated.notNull` 을 사용해야 한다. 

먼저 `lateinit` 을 알아보자.

 

`myService` 프로퍼티를 지연 초기화(late-initialized)할 수 있다.

`lateint` 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.

 

지연 초기화하는(`lateinit`) 프로퍼티 사용

class MyServiceTest {
    private lateinit var myService: MyService // 지연 초기화 프로퍼티 선언

    @BeforeEach
    fun setUp() {
        myService = MyService() // 프로퍼티 초기화
    }

    @Test
    fun testAction() = assert("foo" == myService.performAction()) // null-check 없이 프로퍼티 사용 가능
}

 지연 초기화하는 프로퍼티는 항상 `var` 이어야 한다.

`val` 프로퍼티는 `final` 필드로 컴파일 되며, 생성자 안에서 반드시 초기화해야 하기 때문이다.

`lateint` 프로퍼티는 DI 프레임워크와 자주 함께 사용한다.
`lateintit` 프로퍼티의 값을 DI 프레임 워크가 외부에서 설정해준다.

Delegated.notNull 

JVM 에서 `Int`,`Long`,`Double`,`Boolean` 과 같은 기본 타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우 `lateinit` 을 사용하지 못한다.

이런 경우 `lateinit` 보다는 약간 느리지만, `Delegated.notNull` 을 사용한다.

안드로이드의 `Activity` 를 상속받는 예시 클래스 `DoctorActivity` 로 설명하겠다.

 

`onCreate` 때 프로퍼티를 초기화하기

class DoctorActivity: Activity() {
    private var doctorId: Int by Delegated.notNull()
    private var fromNotification: Boolean by Delegates.notNull()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        doctorId = intent.extras.getInt(DOCTOR_ID_ARG)
        fromNotification = intent.extras.getBoolean(FROM_NOTIFICATION_ARG)
    }
}

 

프로퍼티 위임을 사용하기

class DoctorActivity: Activity() {
    private var doctorId: Int by arg(DOCTOR_ID_ARG)
    private var fromNotification: Boolean by arg(FROM_NOFITICATION_ARG)
}

 프로퍼티 위임은 다루어야 할 내용이 굉장히 많기 때문에 이런 게 있다고 간단히 보고 넘어가자.

nullable 타입 확장 멤버

nullable 타입에 대해 확장 멤버(프로퍼티/함수)를 만들 수 있다.

 

`isNullOrBlank()` 함수의 구현부

fun String?.isNullOrBlank(): Boolean =  // nullable String 의 확장
    this == null || this.isBlank()      // 두번째 this 는 smart cast 가 적용되어 not-null 이다.

`this == null` 에서의 `this` 는 `String?` 이다(nullable).

`this.isBlank()` 에서의 `this` 는 `String` 이다(not-null).

`this == null` 에서 이미 null-check 가 되어 두번째 `this` 는 `String` 으로 스마트 캐스팅된다.

 

일반 함수 `nullOrBlank()`

fun nullOrBlank(input: String?): Boolean =
    input == null || input.isBlank()

 위 함수에서도 똑같이 `input.isBlank()` 의 `input` 은 스마트 캐스트가 된다.

왜냐하면 `input == null` 에서 이미 null-check 가 되기 때문이다.

 

그런데 두 함수는 호출부에서 차이가 발생한다.

아래는 nullable 한 객체에 대한 함수를 호출하는 호출부 코드이다.

 

 nullalbe 객체를 인자로 받는 일반 함수 호출하기

fun verifyUserInputNormal(input: String?) {
    if (nullOrBlank(input)) {
        println("Please fill in the required fields")
        return
    }
    
    println("input length: ${input.length}") // null-check 를 새로 해야 함.
    // [ERROR] Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
}

fun nullOrBlank(input: String?): Boolean {
    return input == null || input.isBlank()
}

 이 때 if 문의 바디 바깥에서는 `input` 에 대해서 다시 null-check 를 해야 하는 것을 볼 수 있다.

`input.length` 등과 같은 `input` 에 대한 멤버를 호출하려면, `input?.length` 이나 `input!!.length` 처럼 호출해야 한다.

 

nullable 한 수신 객체에 대해 확장 함수 호출하기

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) { // safe call 할 필요 없음
        println("Please fill in the required fields")
        return
    }
    println("input length: ${input.length}") // safe call 할 필요 없음
}

verifyUserInput(null) // 예외 발생 안 함
// print /* Please fill in the required fields */

 확장 함수를 호출했을 때는 if 문의 바디 바깥에서 `input` 이 이미 스마트 캐스트가 되어 있다.

그래서 `input` 에 대해 safe call 할 필요가 없다.

 

이렇게 동작하는 이유는 아래와 같다.

기본적으로 스마트 캐스트는 로컬적이다.

위에 예에서는 null-check 가 수행된 블록 안에서는 not-null 이 되지만, 그 블록 바깥에서는 smart cast 가 되지 않는다.

타입 확인을 위해 모든 함수 본문을 검사하면 컴파일 시간에 큰 영향을 미치기 때문에 이렇게 동작한다.

 

그렇다면 확장 함수인 `isNullOrBlank` 에서도 `if` 문 바깥에서 `input` 에 대한 null-check 가 필요한 것 아닐까?

kotlin contract

비밀은 kotlin contract 에 있었다. (이에 대해 궁금했는데 친절히 답변해준 StackOverFlow 에 감사함당)

 

내가 사용하는 코틑린 버전(1.9)에서는 `isNullOrBlank` 는 아래와 같이 최상위 함수로 구현되어 있다.

`isNullOrBlank()` 구현부

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

    return this == null || this.isBlank()
}

 여기서 `contract` 라는 코드가 눈에 띈다.

kotlin contract  함수의 precondition 및 postcondition 을 명시적으로 지정할 수 있게 해주는 기능이다.
이를 통해 함수의 동작에 대한 가정을 개발자가 코드 수준에서 명시할 수 있다.
Contracts는 Returns 영역, Ensures 영역(postcondition), Requires 영역(precondition)으로 세 가지 핵심 요소로 구성된다:
Contracts는 주로 성능 최적화와 코드 분석에 활용될 수 있다.
코틀린 1.9 버전에서는 아직 실험 기능이다. 현재는 최상위 함수에서만 contract 를 가질 수 있다.

그래서 `verifyUserInput` 함수에서 if 문의 바깥에서는 이미 `input` 이 not-null And not-blank 가 되어 있는 것이다.

 

만약 우리가 직접 정의한 일반 함수 `nullOrBlank` 에서 아래처럼 정의한다면?

@OptIn(ExperimentalContracts::class)
fun nullOrBlank(input: String?): Boolean {
    contract{
        returns(false) implies (input != null)
    }
    return input == null || input.isBlank()
}

 그렇다면 `verifyUserInputNormal` 함수에서 if 문 바깥에서의 `input` 도 not-null And not-blank 가 된다.

fun verifyUserInputNormal(input: String?) {
    if (nullOrBlank(input)) {
        println("Please fill in the required fields")
        return
    }
    println("input length: ${input.length}") // 이제 null-check 필요 없음
}
만약 직접 확장 함수를 작성한다면, 처음에는 확장 함수를 not-null 타입으로 정의하라.
나중에 nullable 타입을 가지고 해당 확장 함수를 사용해야 할 필요가 생긴다면,
그 때 확장함수의 receiver 타입을 바꾸고, 확장 함수 내에서 null 처리를 추가해주어도 된다.

타입 파라미터의 nullability

코틀린의 모든 타입 파라미터는 기본적으로 nullable 이다.

즉, 타입 파라미터 `T` 뒤에 `?` 가 없더라도 `T` 는 nullable 이다.

 

nullable 타입 파라미터 다루기

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

printHashCode(null) // 여기서 T 의 타입은 Any? 로 추론된다.
// print /* null */

타입 파라미터가 not-null 을 확실히 하려면 타입 상한(upper bound) 를 지정하여 null 이 될 수 없도록 해야 한다.

 

타입 파라미터에 대해 not-null 인 upper bound 설정

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

printHashCode(null) // [Compile ERROR] Null can not be a value of a non-null type TypeVariable(T)
코틀린 1.3 부터는 null 값에 대해서도 `hashCode` 를 호출할 수 있다. 
이 때 null 의 `hashCode` 는 0 이다.
fun <T> printHashCode(t: T){ 
    println(t.hashCode())
} 

printHashCode(null) // print /* 0 */​

Nullability & Java

자바에서는 코틀린과 완전히 대응하는 nullability 정보를 제공하는 기능이 없다.

대신 애노테이션을 이용할 수 있다. `@Nullable String` <=> `String?`  `@NotNull String` <=> `String`

Platform(플랫폼) 타입

코틀린은 자바 등의 다른 프로그래밍 언어에서 넘어온 타입들을 특수하게 다룬다.

이러한 타입을 플랫폼 타입이라고 한다.

 

만약 자바 타입에 Nullability 애노테이션이 없다면, 플랫폼 타입이된다.

platform 타입은 코틀린이 null 관련 정보를 알 수 없는 타입이다. 

컴파일러는 platform 타입에 대해 nullability 여부에 상관없이 연산을 적용할 수 있다.

  • null-check 을 여러 번 검사해도 경고를 띄우지 않음.
  • 이미 우리가 platform 타입이 not-null 인 것을 안다면 not-null 타입을 사용하는 것처럼 사용 가능
    • 물론 우리의 예측이 틀렸다면 NPE 발생
    • 자바를 코틀린과 함께 사용할 때 자바 코드를 직접 조작할 수 있다면, 가능한 `@Nullable` 과 `@NotNull` 어노테이션을 붙여서 사용하라 (Effective kotlin item3)
  • 표기는 `!` 가 타입 뒤에 붙음 (ex: `String!`)
  • 코틀린에서 직접 플랫폼 타입을 정의할 수는 없다. (단지 컴파일러 오류 메시지에서만 플랫폼 타입을 볼 수 있다.)
    • ex: ERROR: Type mismatch: inferred type is String! but Int was expected

Null 애노테이션이 없는 자바 클래스 `Person`

public class Person {
    private final String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

 

null-check 없이 자바 클래스에 접근

fun yellAt(person: Person) {
    println(person.name.toUpperCase() + "!!!")
}

yellAt(Person(null))

/* 런타임 에러 
"java.lang.IllegalArgumentException: Parameter specified as non-null is null: method toUpperCase, paramter $receiver"
*/

 단순 NPE 가 아닌, `toUpperCase()` 가 수신 객체($reciever)로 null 을 받을 수 없다는 더 자세한 예외가 발생한다.

코틀린 컴파일러는 코틀린 함수의 파라미터, 수신 객체가 not-null 이면 먼저 null-check 을 한다.

그래서 함수 내부에서 파라미터를 사용하는 시점이 아니라, 함수 호출 시점에 값을 검사하여, 더 빠르게 예외를 확인할 수 있다.

 예외 발생도 개발자들의 요청에 따라, 버전에 따라 변경이 된다. 
코틀린 1.9 버전으로 코드를 똑같이 작성해보았을 때는 
"java.lang.NullPointerException: getName(...) must not be null" 에러가 발생한다.
1.4 버전부터 `IllegalArgumentException` 이 발생하는 것보다 NPE 가 발생하는 것이 예외를 보고 장애를 파악하기 편해서 바뀌었다.

 

이처럼 자바 API 를 다룰 때는 조심하자.

플랫폼 타입은 안전하지 않으므로 최대한 빨리 제거하자

간단한 예제 코드

public class JavaClass {
    public String getValue() {
        return null;
    }
}

 

`JavaClass` 의 `getValue` 를 사용하는 코틀린 함수

fun statedType() {
    // value 의 타입을 not-null 로 명시해주고 있다.
    val value: String = JavaClass().value // java.lang.NullPointerException: getValue(...) must not be null
    println("foo")
    println(value.length)
}

fun platformType() {
    // value 타입이 여전히 platform 타입이다.
    val value = JavaClass().value
    println("foo")
    println(value.length) // java.lang.NullPointerException: Cannot invoke "String.length()" because "value" is null
}

` statedType` 함수에서는 `value` 의 타입을 `String` 으로 명시해준다.

함수를 호출하면 함수 바디의 첫번째 줄, 즉, 자바에서 값을 가져오는 위치에서 NPE 가 발생한다.

 

`platformType` 함수에는 `value` 의 타입을 명시하고 있지 않다.

함수를 호출하면 함수의 바디의 마지막 줄, 즉, 값을 활용할 때 NPE 가 발생한다.

 

이렇게 플랫폼 타입은 위험하다. 

상속

코틀린에서 자바 메서드를 오버라이드할 때 그 메서드의 파라미터, 리턴의 타입을 not-null 로 할지 nullable 로 할지 정해야 한다.

 

`String` 파라미터가 있는 자바 인터페이스

public interface StringProcessor {
    void process(String value);
}

 

자바 인터페이스를 코틀린으로 구현

class StringPrinter : StringProcessor {
    override fun process(value: String) {
        println(value)
    }
}

class NullableStringPrinter : StringProcessor {
    override fun process(value: String?) {
        if (value != null) {
            println(value)
        }
    }
}

 두 구현 모두 가능하다.

코틀린 컴파일러는 not-null 타입으로 선언한 모든 파라미터에 대해 not-null 임을 검사하는 단언문을 만들어준다.

자바 코드가 그 메서드에게 null 값을 넘기면 런타임에 이 단언문이 발동되어 예외가 발생한다.

 

또 다른 예를 보자.

인터페이스에서 플랫폼 타입 사용

interface UserRepo {
    fun getUserName() = JavaClass().value // 리턴타입은 String! 인 platform 타입
}

class UserRepoImpl : UserRepo {
    override fun getUserName(): String? = null
}

val repo: UserRepo = UserRepoImpl()
val text: String = repo.getUserName()
println("User name length is ${text.length}") // NPE 발생

`UserRepo` 의 `getUserName` 은 플랫폼 타입이어서 클라이언트 코드에서 null 관련 컴파일 오류를 표시해주지 않는다.

`JavaClass` 에서의 플랫폼 타입이 `UserRepo` 라는 인터페이스와 `UserRepoImpl` 를 거쳐서 클라이언트 코드까지 전파되었다.

이렇게 플랫폼 타입이 전파되는 일은 굉장히 위험하다.

 

 

참조

kotlin contracts

https://www.baeldung.com/kotlin/contracts

 

stackoverflow

https://stackoverflow.com/questions/77831685/questions-about-kotlins-extension-function-and-null-related-casting-kotlin-in/77831780#77831780