[Chapter06] 원시 타입, 컬렉션 및 배열

Kotlin In Action을 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀

코틀린의 원시 타입

코틀린은 원시 타입과 래퍼 타입을 따로 구분하지 않는다. 이들이 어떻게 원시 타입에 대한 래핑이 작동하는지 살펴보자.

1. 원시 타입 : Int, Boolean 등

자바는 int와 같은 원시 타입(primitive type)과 String과 같은 참조 타입(reference type)을 구분한다. 원시 타입에는 변수에 그 값이 직접 들어가지만, 참조 타입의 경우 메모리상의 객체 위치가 들어간다.

원시 타입은 효율적으로 저장하고, 전달할 수 있지만, 값에 대한 메소드를 호출하거나 컬렉션에 담을 수 없다. 때문에 이런 원시 타입을 래퍼 타입으로 감싸서 Collection<Integer> 처럼 사용한다.

코틀린은 원시 타입과 래퍼 타입을 구분하지 않으므로 항상 같은 타입을 사용한다. 이런 방식을 사용하면 원시 타입의 값에 대해 메소드를 호출할 수 있게 된다.

fun main() {  
    val list: List<Int> = listOf(10, 50, 100, 150)  
    for (progress in list) {
        // 10% done -> 50% done -> 100% done -> 100% done
        showProgress(progress)  
    }  
}  

private fun showProgress(progress: Int) {  
    // 값ㅇ르 특정 범위로 제한
    val percent = progress.coerceIn(0, 100)  
    println("$percent done")  
}

하지만 위 처럼 원시 타입과 참조 타입을 같이 사용하게 되면, 비효율적일 것 같다는 생각이 들지만, 그렇지 않다. 실행 시점에 코틀린의 Int 타입은 자바의 int으로 컴파일 된다. 실제로 위 코드를 디컴파일 할 경우 int percent로 변환되는 것을 볼 수 있다.

하지만 Int를 제네릭 타입에 넣을 경우 자바 원시 타입은 int가 null 참조를 받아들일 수 없어, 래퍼 타입인 Integer로 변환되어 들어가게 된다.

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

자바에서의 원시 타입에는 null을 대입할 수 없지만, 코틀린에서는 원시 타입을 nullable한 타입으로 지정이 가능하다. Int?로 선언해 디컴파일해보면 Integer로 변환되어 들어가는 것을 볼 수 있을 것이다. 다음 코드를 통해 원시 타입의 널 가능성을 확인해보자.

data class Person(val name: String, val age: Int? = null) {  
    fun isOlderThan(other: Person): Boolean? {  
        if(age == null || other.age == null) return null  
        return age > other.age  
    }  
}

age 프로퍼티는 nullable하기 때문에 먼저 널에 대한 여부를 확인해야 한다. 이후 컴파일러는 두 값이 널이 아님을 판단해 널을 허용하지 않는 일반 타입으로써 사용할 수 있게 허용한다. 위 클래스를 디컴파일 해보면 다음과 같이 변환된다.

public final class Person {  
    @NotNull  
    private final String name;  
    @Nullable  
    private final Integer age;  

    @Nullable  
    public final Boolean isOlderThan(@NotNull Person other) {  
        Intrinsics.checkNotNullParameter(other, "other");  
        return this.age != null && other.age != null ? this.age > other.age : null;  
    }

    // equals, copy 등등 생략
}

또한, 제네릭 클래스의 경우 래퍼 타입을 사용하는데, 코틀린의 경우 원시 타입을 넘기면 래퍼인 Integer 타입으로 넘어가게 된다. 이렇게 컴파일 되는 이유는 JVM에서 제네릭 타입 인자로 원시 타입을 허용하지 않기 때문이다. 따라서 자바, 코틀린 모두 박스 타입을 사용해야 한다.

3. 숫자 변환

코틀린은 한 타입의 숫자를 다른 타입의 숫자로 자동 변환하지 않는다.

val i = 1  
// 컴파일 에러 : Type Mismatch
val l: Long = i

[공식 문서](Why Cotlin Doesn't Automatically Transform Number Types)에 따르면, 작은 유형이 큰 유형의 하위 타입이 아니기 때문에 허용하지 않는다고 한다.

즉, 이를 허용할 경우 다음과 같은 혼란이 발생할 수 있다.

val x = 1  
val longList = listOf(1L, 2L, 3L)
x in longList

자바에서 실행할 경우 위 코드는 false를 반환한다. 하지만, 코틀린의 경우 묵시적 형변환을 허용하지 않기 때문에 컴파일 에러가 발생하게 된다. 이럴 경우 x.toLong()을 사용해 타입을 변환한 후 비교해야 한다.

이러한 혼란을 피하기 위해 타입 변환을 명시하기로 결정한 것이다.

4. Any, Any? : 최상위 타입

Any 타입은 java.lang.Object에 대응한다. 또한, Any 자체는 널이 될 수 없는 타입이기 때문에 널을 허용하려면 Any?를 사용해야 한다. 기본적으로 모든 클래스는 toString, equals, hashCode 메소드를 갖고 있는데, 이는 Any에 정의된 메소드를 상속한 것이다. 하지만 wait이나 notify 등의 메소드는 Any에서 사용할 수 없다. 이를 사용하려면 Object 타입으로 캐스트해야 한다.

5. Unit 타입 : 코틀린의 void

Unit 타입은 자바의 void와 같은 기능을 한다.

fun f(): Unit {}
fun f() {}

위 코드와 같이 반환 타입이 없을 경우 생략을 할 수도 있는데, 사실 Unit 값을 묵시적으로 반환하고 있는 것이다.

코틀린의 Unit과 자바의 void는 어떤 차이점이 있을까? 자바에서 void는 함수의 반환 값이 없을 때만 보통 사용된다. 하지만 코틀린의 Unit은 타입 인자로도 쓸 수 있다. 이는 제네릭 파라미터를 반환하는 함수를 오버라이드하면서 반환 타입으로 Unit을 쓸 때 유용하다.

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

class NoResultProcessor : Processor<Unit> {
    override fun process() {
        // return을 명시할 필요가 없다.  
    }
}

위 인터페이스에 작성된 process 함수는 T 타입을 반환하도록 되어있지만, 해당 인터페이스를 구현한 구현체에서 타입을 Unit으로 선언해 process 함수에서 아무 것도 반환하지 않아도 된다.

코틀린에서 Void가 아닌 Unit이라는 이름을 선택한 이유는, 함수형 프로그래밍에서 전통적으로 Unit은 '단 하나의 인스턴스만 갖는 타입'을 의미해왔고, 이러한 인스턴스의 유무가 자바의 void와 코틀린의 Unit을 구분하는 큰 차이가 된다.

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

결코 성공적으로 값을 돌려주는 일이 없어, '반환 값'이라는 개념 자체가 의미 없는 함수가 일부 존재한다. 이렇게 함수가 정상적으로 끝나지 않는다는 사실을 알릴 때, Nothing이라는 반환 타입을 사용한다.

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

Nothing 타입은 아무 값도 포함하지 않아, 함수의 반환 타입 혹은 반환 타입으로 쓰일 타입 파라미터로만 사용할 수 있다.

// fail의 반환 타입이 Nothing이므로, address가 받아들일 수 있다.
val address = company.address ?: fail("No address")
println(address.city)

컴파일러는 Nothing이 반환 타입인 함수가 결코 정상 종료되지 않음을 알기 때문에 엘비스 연산자의 우항에서 예외가 발생한다는 사실을 파악하고 address의 값이 널이 아님을 추론할 수 있다.

컬렉션과 배열

코틀린의 컬렉션 지원, 자바와 코틀린 컬렉션 간의 관계에 대해 살펴보자.

1. 널 가능성과 컬렉션

컬렉션 안에 널 값을 넣을 수 있는지 여부는, 어떤 변수의 값이 널이 될 수 있는지 여부와 마찬가지로 중요하다.

fun readNumbers(reader: BufferedReader): List<Int?> {  
    val result = ArrayList<Int?>()  

    for (line in reader.lineSequence()) {  
        try {  
            val number = line.toInt()  
            result += (number)  
        } catch (e: NumberFormatException) {  
            result += (null)  
        }  
    }  
    return result  
}

위 코드를 살펴보면, 현재 줄을 파싱할 수 있으면 result에 정수를 넣고, 그렇지 않으면 null을 넣는다. 코틀린 1.1부터 제공하는 toIntOrNull을 사용하면 코드를 더 줄일 수 있다.

List<Int?>List<Int>?의 차이를 보자. 첫 번째 경우 리스트 자체는 항상 널이 아니다. 하지만 리스트에 들어있는 각 원소는 널이 될 수 있다. 두 번째 경우는 리스트를 가리키는 변수에는 널이 들어갈 수 있지만, 리스트 안에는 널이 아닌 값만 들어갈 수 있다.

널이 될 수 있는 값으로 이뤄진 리스트를 다루는 예제를 확인해보자.

fun addValidNumbers(numbers: List<Int?>) {  
    var sumOfValidNumbers = 0  
    var invalidNumbers = 0  

    for (number in numbers) {  
        if (number != null) {  
            sumOfValidNumbers += number  
        } else {  
            invalidNumbers++  
        }  
    }  
    println("Sum of valid numbers : $sumOfValidNumbers")  
    println("Invalid numbers: $invalidNumbers")  
}
fun main() {  
    val reader = BufferedReader(StringReader("1\nabc\n42"))  
    val numbers = readNumbers(reader)  
    addValidNumbers(numbers)  
}

리스트의 원소에 접근하면, Int? 타입의 값을 얻으며, 그 값을 산술식에 사용하기 전에 널 여부를 검사해야 한다. 이렇게 널이 될 수 있는 값으로 이뤄진 컬렉션을 사용할 때, 널을 걸러내는 filterNotNull이라는 함수가 존재한다.

fun addValidNumbers(numbers: List<Int?>) {  
    val validNumbers = numbers.filterNotNull()  
    println("Sum of valid numbers : ${validNumbers.sum()}")  
    println("Invalid numbers: ${numbers.size - validNumbers.size}")  
}

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

코틀린 컬렉션을 다룰 때 사용하는 가장 기초적인 인터페이스인 kotlin.collections.Collection부터 시작한다. 해당 인터페이스를 사용하면, 컬렉션 안의 원소에 대한 이터레이션, 크기, 값이 들어있는지에 대한 검사 등에 대한 기능을 사용할 수 있다. 하지만 이 Collection에는 원소를 추가하거나 제거하는 메소드가 없다.

만약 데이터를 수정하려면, kotlin.collections.MutableCollection 인터페이스를 사용해야 한다. 해당 인터페이스는 Collection을 확장해 원소를 추가, 삭제에 대한 기능을 추가적으로 제공한다. var, val과 마찬가지로 읽기 전용 인터페이스와 변경 가능 인터페이스를 구별하는 이유는 프로그램에서 데이터에 어떤 일이 벌어지는지를 더 쉽게 이해하기 위함이다.

fun <T> copyElements(source: Collection<T>,  
                     target: MutableCollection<T>) {
    // 방어적 복사(defensive copy)
    for (item in source) {  
        target.add(item)  
    }  
}

3-3. 코틀린 컬렉션과 자바

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

image

Map 또한 Map, MutableMap으로 나뉜다.

컬렉션 타입 읽기 전용 타입 변경 가능 타입
List listOf mutableListOf, arrayListOf
Set setOf mutableSetOf, hashSetOf, linkedSetOf, sortedSetOf
Map mapOf mutableMapOf, hashMapOf, linkedMapOf, sortedMapOf
setOf, mapOf는 자바 라이브러리에 속한 클래스의 인스턴스를 반환하지만, 실제로는 불변 컬렉션 인스턴스를 반환한다. 때문에 자바 메소드를 호출할 때, 컬렉션을 인자로 넘겨야 한다면 Collection을 MutableCollection으로 변환하는 과정 없이 직접 컬렉션 자체를 넘기면 된다.
public class CollectionUtils {  
    public static List<String> uppercaseAll(List<String> items) {  
        items.replaceAll(String::toUpperCase);  
        return items;  
    }  
}
val strList = listOf("hello", "water")  
CollectionUtils.uppercaseAll(strList) 
// 출력 : [HELLO, WATER]
println(strList)

이렇게 불변 컬렉션을 넘겨도 자바는 따로 구분하지 않기 때문에 컬력센 객체의 내부 내용을 변경할 수 있다.

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

컬렉션을 플랫폼 타입으로 다루기 위해서는 다음을 잘 생각해야 한다.

  • 컬렉션이 널이 될 수 있는가?
  • 컬렉션의 원소가 널이 될 수 있는가?
  • 오버라이드하는 메소드가 컬렉션을 변경할 수 있는가?
public interface FileContentProcessor {  
    void processContents(File path, byte[] binaryContents, List<String> textContents);  
}

만약 위 인터페이스를 코틀린으로 구현한다면 다음을 선택해야 한다.

  • 일부 파일은 이진 파일이다.
  • 이진 파일 안의 내용은 텍스트로 표현할 수 없는 경우가 있으니, 리스트는 널이 될 수 있다.
  • 파일의 각 줄은 널일 수 없으므로 이 리스트의 원소는 널이 될 수 없다.
  • 이 리스트는 파일의 내용을 표현하며, 그 내용을 바꿀 필요가 없으므로 읽기 전용이다.

이를 고려해서 코틀린에서는 다음과 같이 구현할 수 있다.

class FileIndexer : FileContentProcessor {  
    override fun processContents(  
        path: File,  
        binaryContents: ByteArray?,  
        textContents: List<String>?) {  
        // ..  
    }  
}

그렇다면 컬렉션 파라미터가 있는 인터페이스는 어떨까?

public interface DataParser<T> {  
    void parseData(String input, List<T> output, List<String> errors);  
}

위 인터페이스를 구현한 클래스가 텍스트를 폼에서 읽은 데이터를 파싱해서 객체 리스트를 만들고, 그 리스트의 객체들을 출력 리스트(output) 뒤에 추가하고, 데이터를 파싱하는 과정에서 발생한 오류 메시지를 별도의 리스트(errors)에 담는다. 여기서 고려해야할 사항은 다음과 같다.

  • 호출하는 쪽에서 항상 오류 메시지(errors)를 받아야 한다.
    • List<String>은 널이 되면 안 된다.
    • errors의 원소는 널이 될 수도 있다.
    • 파싱할 때 오류가 발생하지 않으면 그 정보와 관련된 오류 메시지는 널이다.
  • 구현 코드에서 원소를 추가할 수 있어야 하므로, List<String>은 변경 가능해야 한다.
class PersonParser : DataParser<Person> {  
    override fun parseData(  
        input: String,  
        output: MutableList<Person>,  
        errors: MutableList<String?>) {  
        // ..  
    }  
}

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

코틀린에서 배열을 만드는 방법은 여러가지가 있다.

  • arrayOf -> 원소 지정 가능
  • arrayOfNulls -> 모든 원소가 null인 배열 / 크기 지정 가능
  • Array -> 크기 지정 가능 / 원소 지정 가능
fun main() {  
    val letters = Array<String>(26) { i -> ('a' + i).toString() }  
    println(letters.joinToString(" "))  
}

만약 컬렉션을 사용 중일 때, vararg 파라미터를 받는 함수에 값을 넘겨줘야 한다면 toTypedArray를 사용하면 배열로 쉽게 변경할 수 있다.

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

format의 경우 vararg를 통해 값을 전달 받아 각각의 %s에 넣어주므로, 배열 자체를 넘겨줘도 값을 출력할 수 있다.