[Kotlin] Nullability(널 가능성) - 2
Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
이전 글에서 이어진다.
이전 글 : https://sh1mj1-log.tistory.com/211
지연 초기화할 프로퍼티
실제로는 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