Kotlin - 06. 코틀린 타입 시스템

코틀린 타입 시스템 Type System?

JavaKotlin 의 큰 차이점 중 하나는 변수 타입을 널이 될 수 있는 타입널이 될 수 없는 타입 으로 나눌 수 있다는 점이다. 그리고, 컬렉션 Collection 을 다룰 때에도 읽기 전용 컬렉션변경 가능 컬렉션 으로 나눠진다.

이 두 가지 특성은 Kotlin 프로그래밍에 안정성과 가독성을 향상시키는 데 도움이 될 것 같다.

이번 6장에서는 아래 항목을 소개하고 있다.

  • 널이 될 수 있는 Nullability 타입과 널 처리 방식
  • 코틀린 원시 타입
  • 코틀린 컬렉션

널이 될 수 있는 Nullability 타입과 널 처리 방식

Java 프로그래밍을 하면 범하기 쉬운 실수 중 하나는 분명 NullPointerException (NPE) 처리일 것이다. 매번 if절을 통해 null을 확인하는 개발 로직은 피로감을 높아질 수 밖에 없다. Kotlin에서는 이런 NPE를 최대한 개발자가 자연스럽게 처리할 수 있도록 도와주고 있다.

널이 될 수 있는 타입

fun strLenSafe(s: String?): String {
    return s?.length
}
  • Type? : 타입 뒤에 ? 를 붙이면, 해당 변수의 타입은 널이 될 수 있는 타입 이 된다.
  • Type? = Type or null

s 변수의 타입이 그냥 String 으로만 되어있고, null이 전달된다면, s.length 실행 시 NPE 에러가 발생할 것이다. 하지만 Kotlin 에서는 널이 될 수 있는 타입 을 통해서 안전하게 변수의 확장함수를 호출하여 NPE를 피할 수가 있게 된다.

널이 될 수 있는 타입 이 무조건 null 를 모두 포함하기 때문에 항상 널이 될 수 있는 타입 으로 선언하는 것은 좋은 방법이 아니다. 최대한 널이 될 수 없는 타입 으로 선언한 후에 필요시에만 변경하여 사용하는 것을 권장하고 있다.

안전한 호출 연산자 : ?.

안전한 호출 연산자 는 함수 호출 전에 null 검사와 함수 호출을 한 번의 연산으로 수행하는 연산자이다.

fun strUpper(s: String?) = s?.toUpperCase()
fun strUpper(s: String) = if (s != null) s.toUpperCase() else null

안전한 호출 연산자

엘비스 연산자: ?:

엘비스 연산자null 대신 사용할 Default 값을 지정할 때 편리하게 사용할 수 있는 연산자이다.

fun strLen(s: String?): Int {
    val t: String = s ?: ""
    return t.length
}

엘비스 연산자

Kotlin 에서는 return 이나 throw 등의 연산도 식이기 때문에, 엘비스 연산자 의 우항에 returnthrow 등의 연산을 넣을 수 있다.

fun printShippingLabel(person: Person) {
    val address = person.company?.address ?: throw IllegalArgumentException("No Address")
    ...
}

안전한 캐스트 : as?

안전한 캐스트 는 대상 타입으로 변환할 수 없다면 null을 반환한다.

class Person(val firstName: String, val lastName: String) {
	override fun equals(o: Any?): Boolean {
		val otherPerson = o as? Person ?: return false

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

	override fun hashCode(): Int = firstName.hashCode() * 37 + lastName.hashCode()
}
  • 타입이 일치하지 않으면, false 반환된다.
  • 안전한 캐스트를 하고 나면 스마트 캐스트 처리된다.

널 아님 단언 : !!

널 아님 단언!! 처리를 통해 널이 될 수 있는 타입 의 변수이더라도 널이 될 수 없는 타입 으로 변환한다. 하지만 null 에 대하여 !!를 적용하면 NPE 가 발생한다.

!! 의 사용 유의점으로, 혹시라도 예외가 발생할 경우 스택 트레이스stack trace 에는 어떤 파일의 몇 번째 줄인지에 대한 정보가 들어있을 뿐이다. 그래서 한 줄에 !!를 중첩되기 사용하면, 디버깅이 힘들어질 수 있다. (e.g person.company!!.address!!.country)

!! 기호로 정의한 이유는 마치 컴파일러에게 소리 지르는 것 같은 느낌을 주기 위해 정해진 표기법이라고 한다.

let 함수

let 함수는 Kotlin에서 null이 될 수 있는 식을 더 쉽게 다룰 수 있는 방법이다.

let 함수

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

if (email != null) sendEmailTo(email)
email?.let { sendEmailTo(it) }

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

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

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

class MyTest {
    private var myService: MyService? = null

    @Before
    fun setUp() {
    	  myService = MyService
    }

    @Test
    fun test() {
        println(myService!!.performAction())
    }
}

위 코드처럼 구현하고, myService 프로퍼티를 여러 번 사용해야한다면 어떨까? 이를 해결하기 위해 lateinit 를 사용한다.

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

class MyTest {
    private lateinit var myService: MyService

    @Before
    fun setUp() {
    	  myService = MyService
    }

    @Test
    fun test() {
        println(myService.performAction())
    }
}
  • 나중에 초기화하는 프로퍼티 는 항상 var 여야 한다.
  • val 프로퍼티는 final 필드이기 때문에, 생성자 안에서 반드시 초기화해야하기 때문이다.

널이 될 수 있는 타입의 확장

널이 될 수 있는 타입 의 확장 함수를 정의하면 null 를 다루는 강력한 도구로 활용할 수 있다. 예제로, String? 의 타입의 isNullOrEmpty 이나 isNullOrBlank 함수들은 수신 객체에 대해 null 검사를 먼저하고, 다음 처리를 진행한다.

타입 파라미터의 널 가능성

Kotlin 에서는 기본적으로 함수의 파라미터의 타입은 널이 될 수 있는 타입 으로 정의된다. ? 가 없더라도 함수가 받는 파라미터의 타입은 널이 될 수 있는 타입 이기 때문에 함수 안에서는 해당 파라미터 정보를 활용할 때 안전한 함수 호출 ?. 를 활용하는 것을 추천한다.

널 가능성 NullabilityJava
플랫폼 타입
  • 플랫폼 타입Kotlin 에서 null 관련 정보를 알 수 없는 타입을 뜻한다. 그 타입은 널이 될 수 있는 타입 으로 처리해도 되고, 널이 될 수 없는 타입 으로 처리해도 무방한다.
  • KotlinJava 에서 전달받은 반환값에 대한 타입은 플랫폼 타입 으로 전달 된다.
  • 따라서, Java 와 함께 사용할 때는 항상 널이 될 수 있는 타입 으로 간주하여 개발을 해야할 것 같다.

코틀린의 원시 타입

Kotlin 에는 int, boolean 과 같은 Java 의 원시 타입 primitive type 과는 다른 형태의 원시 타입을 지원한다.

원시 타입 : Int, Boolean 등

Kotlin 에서는 원시 타입과 참조 타입을 따로 구분하지 않는다. 이 방식은 개발자들에게 편리함을 많이 가져다줄 수 있다.

Java 에서 Collection 에 원시 타입의 정수값을 저장하고자 한다면, Collection<int> 가 아니라 Collection<Integer> 로 선언했어야 했다. 하지만 Kotlin 에서는 구분하지 않기 때문에 Collection<Int> 로 선언이 가능하다.

그렇다고 해서, Kotlin 에서는 원시 타입도 참조 타입처럼 항상 객체로 저장되는 것은 아니다. 대부분의 경우에는 원시 타입은 Java 의 원시 타입으로 변환되어 컴파일된다. 하지만 참조 타입이 필요한 경우 (eg. Collection 의 타입 선언) Kotlin 의 원시 타입은 참조 타입으로 컴파일된다.

널이 될 수 있는 원시 타입

Koltin 의 원시 타입은 객체처럼 취급되기 때문에 null 이 될 수 도 있다.

숫자 변환

Kotlin 의 숫자 변환은 직접 변환은 어렵지만, 다양한 확장 함수를 통하여 변환이 가능하다.

val i = 1
val l: Long = i   // 불가

val l: Long = i.toLong()

Any, Any? : 최상위 타입

Any 타입은 JavaObject 클래스의 최상위 타입처럼 널이 될 수 없는 타입 의 최상위 조상 타입이다.

Any? 타입은 널이 될 수 있는 타입 의 최상위 타입이 된다.

Unit 타입 : 코틀린 void

Unit 타입은 Javavoid 같은 기능을 한다. 하지만 KotlinUnit 타입은 보다 강력한 역할을 할 수 있다.

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

class NoResultProcessor<Unit> {
    override fun process() {
        // 함수에서 별도 `return` 식을 명시할 필요가 없다.
    }
}

Nothing 타입 : 이 함수는 결코 정상적으로 끝나지 않았다.

Nothing 타입은 함수를 호출하는 코드를 분석하는 경우 함수가 정상적으로 끝나지 않는다는 사실을 알기 위해 사용하면 유용하다. 따라서 Nothing 타입은 함수의 반환 타입 또는 반환 타입으로 쓰일 타입 파라미터로만 사용 가능하다.

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

val address = person.address ?: fail("No address")

컬렉션과 배열

Kotlin 컬렉션은 Java 의 컬렉션을 활용하지만 보다 다양하고 강력한 확장 함수를 제공하고 있다. 그리고 null 처리에 용이한 컬렉션 타입과 읽기 전용과 변경 가능한 컬렉션 타입 구분에 대해 알아보고, Java 와 어떻게 상호 운용이 가능한지 살펴보고자 한다.

널 가능성과 컬렉션

널을 포함한 컬렉션

컬렉션의 널 처리 확장 함수
  • filterNotNull 함수 : filter 함수의 확장 함수로 수신 객체의 null 여부를 감사하고 필터 처리하여 반환한다.

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

읽기 전용 컬렉션 Collection

Kotlin 의 제일 기조적인 Collection 인터페이스 (eg. List, MAP) 를 사용하면 읽기만 가능한 읽기 전용 컬렉션 이다.

변경 가능한 컬렉션 MutableCollection

기본적인 Collection 인터페이스에 Mutable (변할 수 있는)이란 키워드를 붙여주면, 변경 가능한 컬렉션 이 된다.

변경 가능한 컬렉션

Collection 을 선언할 때는 먼저 읽기 전용 컬렉션 으로 선언하고, 필요시에만 변경 가능한 컬렉션 으로 변환하는 것을 추천한다.

코틀린 컬렉션과 자바

KotlinCollection 은 모두 JavaCollection 이다. 이는 Java 에게 Kotlin변경 가능한 컬렉션 을 전달하더라도 변환이 필요없이 동작 가능하다. 단지 Kotlin 에서는 JavaCollection 을 2가지 표현으로 제공할 뿐이다.

코틀린 Collection 의 계층 구조

코틀린 Collection 상속 관계

위 그림 처럼 KotlinJava 와의 호환성을 제공하면서, 읽기 전용 인터페이스와 변경 가능 인터페이스를 분리하여 제공한다.

읽기 전용 컬렉션 은 곧 불변 컬렉션 으로 변경될 예정이라고 한다.

컬렉션을 플랫폼 타입으로 다루기

Java 에서 CollectionKotlin 으로 넘길 때는 다른 타입들처럼 플랫폼 타입 으로 전달된다. 그래서 해당 객체는 읽기 전용 상태와 변경 가능한 상태 모두 해당되기 때문에 유의해서 다룰 필요가 있다.

  • 컬렉션이 널이 될 수 있는가?
  • 컬렉션의 원소가 널이 될 수 있는가?
  • 오버라이드하는 메소드가 컬렉션을 변경할 수 있는가?

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

Java 원시 타입 배열의 형태는 int[], char[] 와 같은 형태이지만, KotlinCollection 과 비슷한 Array<T> 형태를 가지고 있다.

코틀린 원시 타입 배열 형태
  • 기본 : Array<T>
  • 별도 클래스 : IntArray, BooleanArray, CharArray
    • 위와 같은 클래스 선언하여 배열 생성할 경우, 람다를 통해서 초기화 가능

하지만 위와 같은 배열 형태일지라도, Java 로 변환되면 int[], char[] 로 컴파일된다.

배열 선언 방식
val arr = arrayOf(1, 2, 3)

val arrOfyNulls = arrayOfNulls<Int>(null)
println(arrOfyNulls.joinToString())	// null, null, null

val intArr = intArrayOf(1, 2, 3)

val intArr = IntArray(5) { i -> (i + 1) * (i + 1)}
println(intArr.joinToString())		// 1, 4, 9, 16, 25

출처