[Kotlin/코틀린] Kotlin - Nullable

Nullable에 대해서 java와는 어떤 차이가 있는지 비교하면서 알아보도록 하겠습니다.

Java에서 일반적으로 NullPointException은 런타임에 발생하는 예외입니다. 그래서 NullPointException을 피하기 위해 Null 검사를 하거나 Null Object Pattern 등을 사용하곤 하는데요, 하지만 Kotlin에서는 Type System에서 Null을 관리 하도록 설계되어 있습니다.

다음과 같이 간단하게 표현됩니다.

Type? = Type + null

Question mark ?

val notAllowedNullString: String = ""
val allowedNullString: String? = null

위 코드에서 ?가 붙지 않은 타입의 변수는 null이 될 수 없습니다. (compile time error)

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

// 자바의 경우, s 가 null이라면 NullPointException이 발생합니다.

//kotlin
fun strLen(s: String) = s.length

strLen(null)
// 기본적으로 strLen은 파라미터로 null을 받을 수 없음, 컴파일 타임에 에러가 납니다.


// Nullable
fun strLenNullable(s: String?): Int = if (s != null) s.length else 0

// 코틀린은 파라미터 타입에 ?를 붙여주므로써 null을 파라미터의 인자로 전달할 수 있습니다.

안전한 호출 연산자: ?.

  • ?. 는 메소드 호출과 널 체크를 동시에 하는 연산자입니다. 다음 예제를 한번 보겠습니다.
if (s != null) s.length else 0 --> s?.length

fun strLenNullable2(s: String?): Int? = s?.length

// 만약 s가 null이면 호출은 무시되고 null을 리턴합니다.
// 반환값이 null이 될 수 있기 때문에 리턴 타입에도 ?가 필요합니다.
  • ’?.’을 이용해서 별다른 널 체크 구문 없이 다음과 같은 연속 호출이 가능합니다.
class Address(val streetAddress: String, val zipCode: Int, val city: String, val country: String)

class Company(val name:String, val address: Address?)

class Person(val name: String, val company: Company?)

fun Person.countryName(): String {
    val country = this.company?.address?.country
    return if (country != null) country else "Unknown"
}
// company가 null이므로 contry는 null 값을 갖는다.

fun main() {
    val person = Person("Dmitry", null)
    println(person.countryName())
}

>> Unkown

java에서는 어떻게 구현 했는었는지와 비교해보면 kotlin이 훨씬 간단하다는 것을 느낄 수 있습니다.

public String getCountryName() {
    String country = null;
    String company = this.getCompany();
    if (company != null) {
        Address address = company.getAddress();
        if (address != null) {
            country = address.getCountry();
        }
    }
    return (country != null) ? country : "Unkown";
}

엘비스 연산자 ?:

Java의 3항 연산자랑 그 쓰임은 비슷하지만 쓰는 형태가 다릅니다. Kotlin에서의 사용 방법은 변수 ?: (리터럴) 입니다. 변수가 null이면 리터럴 값을 아니면 변수의 값이 리턴 됩니다.

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


//java
public int strLenthSafe(String s) {
    return s != null ? s.length() : 0
}

s?.length ? : 0         // ?와 : 사이에 어떤것도 있으면 안됩니다.
// java 처럼 3항 연산자로 쓸 수가 없다. 쓰고 싶다면 if-else 식으로 사용

with 키워드

with 키워드는 일종의 네임스페이스와 비슷한 역할을 하는 키워드입니다.

fun printShippingLabel(person: Person) {
    val address = person.company?.address ?: throw IllegalArgumentException("No address");
    with(address) {
        println(streetAddress)
        println("$zipCode $city, $country")
    }
}
public void printShippingLabel(Person persion) {
    ....
    System.out.println(address.streetAddress);
    System.out.println(address.getZipCode() + " " + address.getCity() + ", " + address.getCountry());
}

안전한 캐스트: as?

is와 비슷하지만, as는 비교하는 instance의 타입이 맞으면 바로 캐스팅한 인스턴스를 리턴해줍니다.

equals 메소드를 구현하는 예제를 통해 알아보겠습니다.

@Override
public boolean equals(Object obj) {
    if (!(obj instanceof Person)) return false;
    
    Person otherPerson = (Person) obj;

    return otherPerson.firstName == this.firstName && otherPerson.lastName == this.lastName
}

public static void main(String[] args) {
    print(p1.equals(p2));        
}
class Person(val name: String, val lastName: String) {
    override fun equals(o: Any?): Boolean {
        val otherPerson = o as? Person ?: return false;
        
        return otherPerson.firstName == this.firstName && otherPerson.lastName == this.lastName
    }
}

val p1 = Person("Dmitry", "Jemerov")
val p2 = Person("Dmitry", "Jemerov")
print(p1 == p2) 

  • Kotlin에서는 java와 다르게 == 연산자를 사용하면 equals 메소드를 호출합니다.

Not-null assertion: !!

fun ignoreNulls(s: String?) {
    val sNotNull: String = s!!
    println(sNotNull.length)        // 여기서 NullPointException이 발생하지만, stack trace에 안내되는 라인은 !!가 있는 줄입니다.
}

s: Stirng = null!! 
// 위 라인도 컴파일은 되지만, NPE가 발생합니다.

만약 s가 null이어서 Exception이 발생한다면, !!가 있는 줄에서 exception이 발생했다고 가리킵니다.

let 함수

let 함수는 다음과 같은 경우에 사용하는데요.

  • 널이 될 수 있는 값널이 아닌 값만 파라미터로 받는 메소드에 넘기는 경우
  • 실제로는 널이 될 수 없다는 것을 로직적으로 알고 있는 경우 사용
fun sendEmailTo(receiver: String) {println(receiver)}
val receiver: String? = null
sendEmailTo(receiver)           //compile error

receiver?.let { receiver -> sendEmailTo(receiver) }
receiver?.let { sendEmailTo(receiver) }

>>
>>
// 아무것도 찍히지 않는다.

lateinit(나중에 초기화할 프로퍼티)

lateinit의 경우는 다음과 같은 경우에 사용하게 됩니다.

  • 실제로는 널이 될 수 없는 프로퍼티지만, 생성자 안에서 널이 아닌 값으로 초기화할 방법이 없는 경우
  • 널이 될 수 있는 값(ex String?)으로 초기화 하게 되면, 모든 프로퍼티 접근에 널 체크!! 연산자를 사용해야 합니다.

AS-IS

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

class MyTest {
    private var myService: MyService? = null
    
    @Before 
    fun setUp() {
        myService = MyService()
    }
    
    @Test
    fun testAction() {
        println(myService!!.performAction())        // 이런식으로 !! 연산자를 사용해야 함
    }
}

TO-BE

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

class MyTest {
    private lateinit var myService: MyService       // lateinit 변경자를 붙이면 ! 
    
    @Before 
    fun setUp() {
        myService = MyService()
    }
    
    @Test
    fun testAction() {
        println(myService.performAction())
    }
}
  • lateinit 변경자를 사용해서 나중에 초기화를 한 상태에서, 초기화 되기 전에 접근하면 lateinit property myService has not been initalized라는 Exception이 발생한다. 이는 단순한 NullPointException보다는 훨씬 좋다.

널이 될 수 있는 타입 확장

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) {
        println("Please fill in the required fields")
    }
}
verifyUserInput(" ")
verifyUserInput(null)
>> Please fill in the required fields
>> Please fill in the required fields
  • 널이 될 수 있는 타입의 확장 함수는 ?, !! 를 사용하지 않아도 호출이 가능합니다.
fun String?.isNullOrBlank(): Boolean = this == null || this.isBlank()
// 두 번째 this의 경우, String?로 smart cast가 적용되어 있음  
  • java와의 차이점

java에서 method 안의 this는 호출된 수신 객체를 가리키는 키워드이기 때문에 null이 될 수 없지만, kotlin에서는 null이 될 수 있습니다.

타입 파라미터의 널 가능성 (유일한 예외)

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

fun <T: Any> printHashCodeUpperBound(t: T) {
    println(t.hashCode())
}
printHashCodeUpperBound(null) // compile error
  • 타입 파라미터 T를 메소드나 클래스안에서 타입이름으로 사용하면 물음표가 없더라도 T는 널이 될 수 있는 타입입니다.
  • 널이 아님을 확실히 하려면 타입 상한(upper bound)를 지정하면 됩니다.

널 가능성과 자바

@Nullable String s;         // = String?
@NotNull String s2;         // = String

플랫폼 타입

Java코드로 작성된 클래스 등이 kotlin에서 사용될 때, 이 플랫폼 타입으로서 사용됩니다.

  • Platform Type = Type? + Type
  • 플랫폼 타입은 선언 불가, 오직 자바코드에서 가져온 타입만 플랫폼 타입이 됩니다.
//java class
public class Person {
    private final String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}
fun yellAt(person: Person) {
    println(person.name.toUpperCase())
}

fun yellAtSafe(person: Person) {
    println((person.name ?: "Anyone").toUpperCase())
}

//둘 모두 사용 가능하고, 아래와 같이 사용할 수도 있습니다.

val s: String? = person.name
val s1: String = person.name        // 이 경우 person.name이 null이라면 exception이 발생합니다.

상속의 경우

//java interface
interface StringProcessor {
    void process(String value);
}
class StringPrinter : StringProcessor {
    override fun process(value: String) {
        println(value)
    }
}
//or
class NullableStringPrinter : StringProcessor {
    override fun process(value: String?) {
        if (value != null) {
            println(value)
        }
    }
}
  • 코틀린 컴파일러는 위 두 구현 모두를 받아들입니다.
  • 코틀린 컴파일러는 널이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 널이 아님을 검사하는 !!를 만들어 주고, 만약 자바코드가 그 메소드에 널값을 넘기면 예외가 발생합니다.

타입 (Type)

원시 타입

  • kotlin에는 원시 타입, 레퍼런스 타입이 없습니다.
Byte, Short, Int, Long
Float, Double,
Char
Boolean
val i: Int = 1
val list: List<Int> = listOf(1, 2, 3)

java의 경우

위와 같은 형태로 원시타입과 참조 타입의 리터럴 값을 참조하는 방법을 달리하고 있습니다.

kotlin의 경우

런타임에 자바의 원시 타입에 대해서는 가능한한 가장 효율적인 방식으로 표현하고, Collection 과 같이 primitive 타입을 저장할 수 없는 경우를 제외하고 대부분 primitve type을 사용합니다.

숫자 변환

val i = 1
val l: Long = i // compile error

val i = 1
val l: Long = i.toLong()
  • 코틀린의 모든 원시 타입은 변환 메소드를 제공합니다.
val x = 1
val list = listOf(1L, 2L, 3L)
x in list // compile error
x.toLong() in list // true
// in 키워드는 내부적으로 contains method를 사용하는 것. 
// 따라서, List의 contains의 구현에 따라 비교를 하게 된다.
val b: Byte = 1
val l = b + 1L // 이 경우 + 연산자 오버로딩이 되어 있어 Byte와 Long을 인자로 받을 수 있음
  • 리터럴 값을 직접 입력하는 경우, 알아서 변환해줌. (ex Long 변수에 대해 123 -> 123L )

Any, Any?: 최상위 타입

Java의 Object와 같은 역할을 하는 것이 Any 타입입니다. 역시 Any는 널이 될 수 없고, Any?는 널이 될 수 있습니다. 차이점으로는 Java의 Object는 다르게 wait(), notify() 등의 메소드는 없습니다.

package kotlin

public open class Any {
    
    public open operator fun equals(other: Any?): Boolean
    
    public open fun hashCode(): Int

    public open fun toString(): String
}

Unit 타입: 코틀린의 void

Unit은 함수형 프로그래밍 언어에서 단 하나의 인스턴스만 갖는 타입을 의미합니다. 차이점은 Unit 함수의 파라미터 타입으로 사용될 수 있습니다.

interface Processor<T> {
    fun process(): T
}

class NoResultProcessor : Processor<Unit> {
    override fun process() {
        // Unit Type을 리턴하지만, 타입을 지정할 필요는 없다.
        // 컴파일러가 return Unit을 넣어준다.
    }
}

Nothing 타입: 이 함수는 결코 정상적으로 끝나지 않는다 라는 것을 알려주는 타입

  • 함수가 정상적으로 끝나지 않는 경우, 예를 들어 무한루프를 도는 함수, 무조건 예외를 던지는 함수 등에 사용합니다.
fun fail(message: String): Nothing {
    throw IllegalStateException(message)
}

컬렉션과 배열

kotlin에서도 간단하게 컬렉션과 배열을 사용할 수 있는데요, 다음 예제를 통해서 알아보곘습니다.

val list = ArrayList<Int>()
val list2 = ArrayList<Int?>()

val list3 = listOf(1, 2, 3, null, 4, 5, 6)
val list4 = listOf(null, null, null, null)
printListCustom(list3)
println()
printListCustom(list4)

>>123null456
>>123456
>>nullnullnullnull


fun printListCustom(list: List<Int?>?) {
    list?.forEach { print(it) }
    val newList = list?.filterNotNull() // newList는 List<Int>
    println()
    newList?.forEach { print(it) }
}

읽기 전용과 변경 가능한 컬렉션

  • 코틀린은 데이터에 접근하는 인터페이스와 데이터를 변경하는 인터페이스를 분리했습니다. (kotlin.collections.Collection, kotlin.collections.MutableCollection 두 인터페이스로 구분되어 있다.)

분리를 통해 각 리스트의 용도를 확실히 구분 할 수 있다는 장점이 있습니다. 다만 읽기 전용이라고 해서, thread-safe 한 것은 아닙니다. (해당 리스트 객체를 참조하는 변수가 mutable일 수도 있다.)

kotlin의 MutableCollection들은 java의 컬렉션들을 그대로 옮겨놓은 것이고, 읽기와 관려된 기능들은 읽기 전용 인터페이스를 상속받아서 사용합니다.

// kotlin standard library collections package
/*
 * Copyright 2010-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license
 * that can be found in the license/LICENSE.txt file.
 */

@file:Suppress("ACTUAL_WITHOUT_EXPECT") // for building kotlin-stdlib-minimal-for-test

package kotlin.collections

@SinceKotlin("1.1") public actual typealias RandomAccess = java.util.RandomAccess


@SinceKotlin("1.1") public actual typealias ArrayList<E> = java.util.ArrayList<E>
@SinceKotlin("1.1") public actual typealias LinkedHashMap<K, V> = java.util.LinkedHashMap<K, V>
@SinceKotlin("1.1") public actual typealias HashMap<K, V> = java.util.HashMap<K, V>
@SinceKotlin("1.1") public actual typealias LinkedHashSet<E> = java.util.LinkedHashSet<E>
@SinceKotlin("1.1") public actual typealias HashSet<E> = java.util.HashSet<E>

listOf(), setOf(), mapOf()의 경우, 자바 표준 라이브러리에 속한 클래스의 인스턴스를 반환합니다, 즉 내부적으로는 변경이 가능한 클래스라는 것입니다. 따라서 java.util.Collection을 파라미터로 받는 자바 메소드가 있다면, Collection이나 MutalbleCollection 값을 인자로 넘기는 것이 가능합니다.

다음 예제를 통해서 알아보겠습니다.

import java.util.List;

public class CollectionUtils {
    public static void upperCaseAll(List<String> items) {
        for (int i = 0; i < items.size(); i++) {
            items.set(i, items.get(i).toUpperCase());
        }
    }
    
    public static void nullizeAll(List<String> items) {
        for (int i = 0; i < items.size(); i++) {
            items.set(i, null);
        }
    }
}
fun printInUpperCase(list: List<String>) {
    CollectionUtils.upperCaseAll(list)
}

val readOnlyList = listOf("a", "b", "c", "d", "e")
val mutableList = mutableListOf("a", "b", "c", "d", "e")

println("readOnlyList that is passed by java method: ")
printInUpperCase(readOnlyList)              // 읽기 전용이어도 java 메소드 파라미터로 받을 수 있음
println(readOnlyList)
println("mutableList: ")
printInUpperCase(mutableList)
println(mutableList)

>> readOnlyList that is passed by java method: 
>> [A, B, C, D, E]
>> mutableList: 
>> [A, B, C, D, E]
  • Nullable Collection을 자바 메소드로 전달할 때도 이러한 문제가 발생합니다. (널이 아닌 원소로 이루어진 컬렉션에 널을 넣는 경우)
val list: List<String> = listOf("1", "2", "3", "4")
CollectionUtils.nullizeAll(list)

println(list)
>> [null, null, null, null]

Java 클래스 메소드를 kotlin에서 오버라이드하려는 경우 확인 사항

  • 컬렉션이 널이 될 수 있는지
  • 컬렉션의 원소가 널이 될 수 있는지
  • 오버라이드하는 메소드가 컬렉션을 변경할 수 있는지
class FileIndexer : FileContentProcessor { 
    override fun processContents(path: File, binaryContents: ByteArray?, textContent: List<String>?) {
        //...
        // 이 경우 collection 파라미터를 List<String>? 으로 했으므로 list는 읽기 전용이고, 널이 될 수 있습니다.
        // 하지만 list의 원소는 널이 될 수 없습니다.
    }
}

class PersonParser : DataParser<Person> {
    override fun parseData(input: String, output: MutableList<Person>, errors: MutableList<String?>) {
        //...
        // 이 경우 output이라는 list는 널이 될 수 없고, list의 원소 또한 널이 될 수 없다, 하지만 변경은 가능합니.
        // errors list의 경우 널이 될 수 없고, list의 원소는 널이 될 수 있다, 그리고 변경이 가능합니다.

객체의 배열과 원시 타입의 배열

val letters = Array(26) {i -> ('a' + i).toString()}
println(letters.joinToString(""))
>> abcd....
val strings = listOf("a", "b", "c")
println("%s/%s/%s".format(*strings.toTypedArray()))     // *string.toTypedArray() vararg를 넘기기 위함
>> a/b/c

Array()는 기본적으로 박싱된 참조 타입의 배열이 됩니다.

val fiveZeros = IntArray(5)
val fiveZerosToo = intArrayOf(0,0,0,0,0)

val squares = IntArray(5) {i -> (i + 1) * (i + 1)}
println(squares.joinToString())
  • int[], byte[], char[] 등으로 컴파일하기 위해서는 IntArray, ByteArray, CharArray를 사용해야 합니다.
  • toIntArray를 활용해서 컬렉션이나 박싱된 값이 들어있는 배열을 넌박싱 배열로 바꿀 수 있습니다.
  • 원시 타입인 원소로 이루어진 배열에도 확장 함수를 사용할 수 있습니다.
args.forEachIndexed {index, element -> println("Argument $index is: $element")}

대부분의 내용은 📎 Kotlin In Action 6장에서 가져왔습니다.
감사합니다.