Kotlin 타입 시스템

널 가능성

널 가능성(Nullability)은 NullPointerException을 피하기 위한 코틀린 타입 시스템 중 하나이다.

널이 될 수 있는 타입

코틀린과 자바의 가장 큰 차이점 중 하나는 코틀린은 언어 차원에서 Null이 될 수 있는 타입을 지원한다는 점이다. 이는 Null 호출을 금지함으로써 NullPointerException을 방지한다.

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

이 함수에 null을 넘기면 NullPointerException이 발생할 것이다. 코틀린에서는 이를 Null을 받을 수 있는지 여부를 구분한다. 먼저 Null을 받을 수 없을 경우 다음과 같이 함수를 정의한다.

fun strLen(s: String) = s.length

이 함수는 strLennull이거나 null이 될 수 있는 인자를 넘기는 것은 금지되며 그런 값을 넘길 경우 컴파일 오류가 발생한다.

타입에 ?를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다.

fun strLen(s: String?) = s.length

안전한 호출 연산자: ?.

이 연산자는 호출하려는 값이 null이 아니면 일반 메소드 호출처럼 작동하고 호출하려는 값이 null이면 null이 결과 값이 된다.

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

null이 결과 값이 되는 특징으로 인해 ?. 연산자는 결과 타입이 Nullable하다.

메소드 호출 뿐 아니라 프로퍼티를 읽거나 쓸 때도 ?. 연산자를 사용할 수 있다. 또한 ?. 연산자는 연쇄해서 함께 사용할 수 있다.

val country = this.company?.address?.country
/* if(company != null) {
    if(address != null {
        country
    } else null
} else null */

엘비스 연산자: ?:

엘비스(Elvis) 연산자를 사용하면 null 대신 사용할 디폴트 값을 지정할 때 사용한다.

fun foo(s: String?) {
    val t: String = s ?: ""    //s가 null이면 결과는 빈 문자열("")이다.
}

코틀린은 return이나 throw 등의 연산도 식이다. 따라서 엘비스 연산자의 우항에 이를 사용할 수 있다. 이 경우 엘비스 연산자의 좌항이 null이면 함수가 즉시 값을 반환하거나 즉시 예외를 던지도록 할 수 있다.

val address = person.company?.address ?: throw IllegalArgumentException("No address")

안전한 타입 캐스트: as?

코틀린의 as는 자바와 마찬가지로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생한다. as?는 동일한 상황에 null을 반환한다.

안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤 엘비스 연산자를 사용하는 것이다.

val otherPerson = o as? Person ?: return false

스마트 캐스트는 이런 상황에도 적용되며 타입을 검사한 후 null 값을 거부하면(return, throw 등) 컴파일러는 자동으로 null이 될 수 없는 타입으로 처리한다.

널 아님 단언: !!

널 아님 단언(Non-null assertion)은 어떤 값이든 강제로 널이 될 수 있는 타입으로 바꿀 수 있다. 강제로 바꾸는 것이기 때문에 null에 대해 !!를 적용하면 NPE가 발생한다.

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

ignoreNulls(null) //NPE!!

단순하면서 무식한 도구로 보이지만 의외로 사용할 곳이 있는데, 어떤 함수가 값이 널인지 검사한 다음에 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 내에서 그 값을 안전하게 사용할 수 있음을 인식할 수 없다. 이럴 때는 매번 검사하는 것보다는 !!을 사용할 수 있다.

let

널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우 let 함수를 사용하면 더 쉽게 다룰 수 있다.

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

val email: String? = ...
sendEmailTo(email) // compile error
email?.let { sendEmailTo(it) }

let은 주어진 값이 null이 아닐 경우 코드 블록을 실행하며 null이면 코드 블록을 실행하지 않는다.

let 또한 중첩이 가능하지만 중첩시켜 처리하면 코드가 복잡해진다(람다 중첩과 비슷함). 이런 경우에는 if로 값을 한꺼번에 검사하는 것이 좋다.

lateinit

객체의 인스턴스를 일단 생성한 다음에 나중에 초기화해야할 일이 많다(Android의 onCreate에서 Activity 초기화, JUnit의 @Before). 코틀린은 클래스 안의 널이 될 수 없는 프로퍼티를 생성자에서 초기화하지 않고 특별한 메소드에서 초기화시킬 수 없다. 생성자에서 null로 초기화한 후 추후 사용하는 방법이 있지만 이런 경우에는 항상 널 체크나 !!이 따라붙을 것이다.

프로퍼티에 lateinit 변경자를 붙이면 나중에 초기화할 수 있다. val 프로퍼티는 final 필드로 컴파일되며 반드시 생성자 안에서 초기화해야 한다. 따라서 lateinit는 항상 var여야 한다.

private lateinit var myService: MyService

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

lateinit 프로퍼티를 초기화하기 전에 접근하면 "lateinit property has not been initialized"라는 예외가 발생한다.

널이 될 수 있는 타입 확장

널이 될 수 있는 타입에 대한 확장 함수를 작성할 수 있다. 이땐 null 값을 다루는 강력한 도구로 활용될 수 있다. 이런 확장 함수는 ?. 연산자를 사용하지 않고 바로 호출할 수 있다.

fun verifyUserInput(input: Stirng?) {
    if(input.isNullOrBlank()) { //?. 연산자를 사용하지 않아도 된다.
        /* ... */
    }
}
fun String?.isNullOrBlank() : Boolean = 
    this == null || this.isBlank()

널이 될 수 있는 타입의 확장 함수는 그 함수의 내부에서 명시적으로 널 여부를 검사해야 한다. 자바와 달리 코틀린의 this는 널이 될 수 있는 타입의 확장 함수 내부에서 null이 될 수 있다.

let 함수 또한 널이 될 수 있는 값에 대해 호출할 수 있지만 이 경우 람다의 인자는 널이 될 수 있는 타입으로 추론된다.

타입 파라미터의 널 가능성

코틀린의 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다. 따라서 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 물음표가 없더라도 T가 널이 될 수 있는 타입이다.

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

이 경우 타입 파라미터 T에 대해 추론한 타입은 Any? 타입이다. 타입 파라미터가 널이 아님을 확실히 하려면 타입 상한(Upper bound)을 지정해야 한다. 널이 될 수 없는 타입 상한을 지정하면 널이 될 수 있는 값을 거부한다.

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

널 가능성과 자바

코틀린은 자바 상호운용성을 강조한다. 자바와 코틀린의 타입 시스템이 어떻게 상호 운용될까?

먼저 자바 코드에 널 가능성과 관련된 Annotation은 코틀린에서도 활용한다. @Nullable String은 String?과 같고 @NotNull StringString과 같다. 코틀린은 여러 널 가능성 Annotation을 알아보는데, JSR-305 표준(javax.annotation)과 안드로이드(android.support.annotations), JetBrains Annotation(org.jetbrains.annotations) 등을 지원한다.

플랫폼 타입

코틀린이 널 관련 정보를 알 수 없는 타입을 말한다. 이럴 경우 코틀린에서는 어떠한 방법으로 처리해도 된다. 이는 플랫폼 타입에 대해 수행하는 모든 연산의 책임은 사용자에게 있다는 것을 의미한다.

상속

코틀린에서 자바 메소드를 오버라이드할 때 그 메소드의 파라미터와 반환 타입을 결정할 수 있다.

interface StringProcessor {
    void process(String value);
}
class StringPrinter : StringProcessor {
    override fun process(value: String?) { /* ... */ }
}

class StringPrinter : StringProcessor {
    override fun process(value: String) { /* ... */ }
}

코틀린 컴파일러는 널이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 널이 아님을 검사하는 단언문을 만든다. 이로 인해 그 메소드를 사용하지 않더라고 null 값이 들어올 경우 무조건 예외가 발생한다.

코틀린의 원시 타입

원시 타입: Int, Boolean 등

자바는 원시 타입(Primitive type)과 참조 타입(Reference type)이 구분된다. 원시 타입은 그 값이 직접 들어가지만 참조 타입은 메모리상의 객체 위치가 들어간다.

원시 타입을 사용하면 값을 효율적으로 저장하고 여기저기 전달할 수 있지만 그런 값에 대해 메소드를 호출하거나 컬렉션에 원시 타입을 담을 수 없다. 자바는 이런 원시 타입을 감싸는 래퍼 타입(Wrapper type)을 사용해 왔다. 코틀린은 원시 타입과 래퍼 타입을 구분하지 않기 때문에 항상 같은 타입을 사용한다.

코틀린은 실행 시점에 숫자 타입은 가능한 한 효율적인 방식으로 표현된다. 대부분은 원시 타입으로 컴파일 되며 원시 타입을 사용하지 못하는 제네릭 등은 래퍼 타입으로 컴파일 된다.

널이 될 수 있는 원시 타입: Int? Boolean? 등

널이 될 수 있는 원시 타입은 자바의 원시 타입에 대응되지 않으므로 항상 래퍼 타입으로 컴파일된다. 널이 될 수 있는 원시 타입은 널 가능성으로 인해 두 값을 직접 비교할 수 없다. 먼저 두 값 모두 널이 아닌지 검사해야 한다.

숫자 변환

코틀린은 숫자의 자동 변환이 불가능하다. 대신 직접 변환 메소드를 호출하여 변환할 수 있다.

val i = 1
val l: Long = i //wrong
val l: Long = i.toLong()

Boolean을 제외한 모든 원시 타입에 대해 변환 함수를 제공한다. 양방향으로 모두 제공되며 toByte, toShort, toChar와 같은 이름을 가진다.

원시 타입 리터럴

코틀린은 다음과 같은 원시 타입 리터럴을 허용한다.

  • L : Long 타입 리터럴(123L)
  • 표준 부동소수점 표기법 : Double 타입 리터럴(0.12, 2.0, 1.2e10)
  • f, F : Float 타입 리터럴(123.4f, .456f, 1e3f)
  • 0x, 0X : 16진수 리터럴(0xCAFEBABE)
  • 0b, 0B : 2진 리터럴(0b01000101)

숫자 리터럴을 타입이 알려진 변수에 대입하거나 함수에 인자로 넘기면 컴파일러가 필요한 변환을 자동으로 넣어준다. 또한 산술 연산자는 적당한 타입의 값을 받아들일 수 있게 이미 오버로드돼 있다.

문자열을 숫자로 변환하기

문자열을 원시 타입으로 변환하는 toInt, toByte, toBoolean 등을 제공한다. 문자열의 내용을 숫자로 변환하지 못하는 경우에는 NumberFormatException이 발생한다.

최상위 타입: Any, Any?

자바의 Object가 클래스 계층의 최상위 타입이듯 코틀린에서는 Any 타입이 모든 널이 될 수 없는 타입의 조상 타입이다. 자바와 다른 점은 원시 타입은 Object 계층에 들어가 있지 않지만 코틀린은 원시 타입을 포함한 모든 타입의 조상 타입이다.

자바와 마찬가지로 코틀린에서도 원시 타입 값을 Any 타입 변수에 대입하면 자동으로 박싱된다.

val answer: Any = 42 // 42가 박싱된다.

Any 타입 변수는 null이 들어갈 수 없다. 코틀린에서 null을 포함한 모든 값을 대입할 변수를 선언하려면 Any?를 사용한다.

내부적으로 Any 타입은 java.lang.Object에 대응한다. 정확하게는 플랫폼 타입인 Any!로 취급한다.

모든 코틀린 클래스에는 toString, equals, hashCode가 포함되어 있다. 그러나 java.lang.Object에 포함된 wait이나 notify 등은 포함되어 있지 않기 때문에 그런 메소드를 호출하려면 java.lang.Object 타입으로 캐스팅해야 한다.

코틀린의 void: Unit

코틀린의 Unit 타입은 자바의 void와 같은 기능을 한다. 관심을 가질 만한 내용을 반환하지 않는 함수의 반환 타입으로 Unit을 쓸 수 있다.

fun f(): Unit { ... }

이는 반환 타입 선언이 없는 함수와 같다.

fun f() { ... }

Unitvoid는 기능적으로는 비슷해 보이지만 큰 차이점이 있다. Unit은 모든 기능을 갖는 일반적인 타입이다. 따라서 타입 인자로 쓸 수 있다.

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

class NoResultProcessor : Processor<Unit> {
    override fun process() {

    }
}

제네릭 타입을 Unit으로 하면 void 함수인 것처럼 동작한다.

정상적으로 끝날 수 없는 함수: Nothing

값을 성공적으로 돌려주지 못해 ‘반환 값’이라는 개념이 의미 없는 함수가 있을 수 있다. 이럴 때 Nothing이라는 특별한 반환 타입을 사용한다.

fun fail(message: String) : Nothing {
    throw IllegalStateException(message)
}

Nothing 타입은 아무런 값도 포함하지 않기 때문에 타입 파라미터 외의 용도로 쓰는 것은 의미가 없다.

Nothing을 반한하는 함수를 엘비스 연산자 우항에 사용해서 전제조건(precondition)을 검사할 수 있다.

val address = company.address ?: fail("No address")
println(address)

컬렉션과 배열

널 가능성과 컬렉션

컬렉션에서 널 가능성을 사용하는 여러가지 경우를 예시를 통해서 살펴보도록 하자.

List<Int?>

List<Int?>Int? 타입의 값을 저장할 수 있다 -> Int 또는 null을 저장할 수 있다.
리스트 자체는 null이 될 수 없지만 리스트에 들어있는 각 원소가 널이 될 수 있다.

List?

List<Int>?Int 타입의 값을 저장할 수 있다. 또한 리스트 자체가 null이 될 수 있다.
리스트를 가리키는 변수에는 널이 들어갈 수 있지만 리스트 안에는 널이 아닌 값만 들어갈 수 있다.

경우에 따라 널이 될 수 있는 값으로 이뤄진 널이 될 수 있는 리스트를 정의할 때도 있다. List<Int?>?로 정의할 수 있다. 이 경우 리스트가 널이 아닌지 검사한 뒤 그 리스트에 속한 값도 널이 아닌 지 검사하면 된다.

Mutable Collection

코틀린의 컬렉션은 컬렉션의 데이터에 접근하는 인터페이스와 컬렉션의 데이터를 변경하는 인터페이스를 분리했다. 이는 코틀린 컬렉션의 가장 기초적인 인터페이스 kotlin.collections.Collection으로부터 시작한다. 여기에는 size, iterator, contains가 포함되어 있다. 원소를 추가하거나 삭제하는 등의 변경 메소드는 포함되어 있지 않다.

kotlin.collections.MutableCollection 인터페이스를 이용하여 변경 동작을 수행할 수 있다. MutableCollectionCollection을 확장하면서 add, remove, clear 메소드를 제공한다.

valvar의 관계랑 비슷하게 컬렉션도 기본적으로는 읽기 전용을 사용하되 변경할 필요가 있을 때만 변경 가능한 컬렉션을 이용하는 것이 좋다.

읽기 전용 컬렉션이라도 수정될 수 있다

컬렉션 인터페이스를 사용할 때 항상 염두해 둘 내용은 읽기 전용 컬렉션이라고 해서 변경이 불가능한 컬렉션은 아니라는 점이다. 같은 인스턴스를 가리키는 변경 가능한 인터페이스의 참조가 있을 수 있다.

val mutableList = mutableListOf(1, 2, 3)
val list : List<Int> = mutableList

list.add(4) //compile error
mutableList.add(4)
println(list) // 1, 2, 3, 4
글 작성 시점 kotlinx.collections.immutable 패키지로 불변 컬렉션이 Pre-release 버전으로 개발되고 있는 중이다. 

이런 특성으로 인해 멀티스레드 환경에서 동시에 컬렉션의 변경이 일어날 수 있고 ConcurrentModificationException등 여러 오류가 발생할 수 있다. 즉 스레드 안전(thread-safe)하지 않다.

코틀린 컬렉션과 자바

모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스이다. 다만 코틀린은 모든 자바 컬렉션 인터페이스마다 읽기 전용 인터페이스와 변경 가능한 인터페이스라는 두 가지 표현(Representation)을 제공한다.

코틀린의 컬렉션 인터페이스는 java.util 패키지의 자바 컬렉션 인터페이스의 구조를 그대로 옮겨 놓았다. 추가로 변경 가능한 인터페이스는 각각 대응하는 읽기 전용 인터페이스를 확장한다.

컬렉션 생성 함수

컬렉션 타입 읽기 전용 타입 변경 가능 타입  
List listOf mutableListOf, arrayListOf  
Set setOf mutableSetOf, hashSetOf, linkedSetOf, sortedSetOf  
Map mapOf mutableMapOf, hashMapOf, linkedMapOf, sortedMapOf </figure>

코틀린 컬렉션을 자바 메소드에 넘길 때 주의할 점은 자바는 컬렉션의 변경 가능성을 고려하지 않기 때문에 코틀린의 읽기 전용 컬렉션도 자바에서는 컬렉션의 내용을 변경할 수 있다.

컬렉션과 플랫폼 타입

위에서 설명했듯이 자바 코드에서 정의한 타입을 코틀린에서는 플랫폼 타입으로 본다. 플랫폼 타입인 컬렉션은 기본적으로 변경 가능성에 대해 알 수 없기 때문에 코틀린에서는 읽기 전용 또는 변경 가능 컬렉션 어느 쪽으로든 다룰 수 있다. 코틀린에서 플랫폼 타입 컬렉션을 사용하려면 다음을 고려해야 할 것이다.

  • 컬렉션이 널이 될 수 있는가(List<String>?)?
  • 컬렉션의 원소가 널이 될 수 있는가(List<String?>)?
  • 오버라이드하는 메소드가 컬렉션을 변경할 수 있는가(MutableList<String>)?

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

코틀린의 배열은 타입 파라미터를 받는 클래스이다. 배열의 원소 타입은 그 타입 파라미터에 의해 결정된다. 다음은 코틀린에서 배열을 만드는 방법들이다.

  • arrayOf 함수에 원소를 넘긴다.
  • arrayOfNulls 함수에 정수 값을 인자로 넘기면 인자로 넘긴 값 크기의 모든 원소가 null로 채워진 배열을 만들 수 있다.
  • Array 생성자는 배열 크기와 람다를 인자로 받아서 람다를 호출해 배열 인자를 초기화한다. arrayOf를 사용하지 않고 각 원소가 널이 아닌 배열을 만드려면 이 방법을 사용한다.
val letters = Array<String>(26) { i -> ('a' + i).toString() }

toTypedArray()를 사용하여 컬렉션을 배열로 변환할 수 있다.

val strings = listOf("a", "b", "c")
println("%s/%s/%s".format(*strings.toTypedArray())

Array의 타입 인자도 항상 객체 타입이 된다. Array<Int>java.lang.Integer[]와 같다. 원시 타입의 배열이 필요하다면 원시 타입을 위한 특별한 배열 클래스를 사용한다.

코틀린은 원시 타입 배열을 표현하기 위해 각 원시 타입마다 별도 클래스를 제공한다(Int타입의 배열은 IntArray). ByteArray, CharArray 등은 각각 자바 원시 타입 배열인 byte[], char[] 등으로 변환된다. 원시 타입의 배열을 만드는 방법은 다음과 같다.

  • 각 배열 타입의 생성자는 size 인자를 받아서 해당 원시 타입의 디폴트 값(보통 0)으로 초기화된 size 크기의 배열을 반환한다.
  • 팩토리 함수(IntArray를 생성하는 intArrayOf 등)은 여러 값을 가변 인자로 받아서 그 값이 들어간 배열을 반환한다.
  • 크기와 람다를 인자로 받는 생성자를 사용한다.
  • 박싱된 값이 들어있는 컬렉션이나 배열을 toIntArray 등의 변환 함수를 사용한다.
val fiveZeros = IntArray(5)
val fiveZerosToo = intArrayOf(0, 0, 0, 0, 0)
val squares = IntArray(5) { i -> (i + 1) * (i + 1) }

배열 또한 컬렉션 함수(filter, map 등)가 잘 작동한다. 배열에 대한 컬렉션 함수의 결과물은 리스트이다.


© 2021. All rights reserved.

Powered by Hydejack v9.1.6