6.3.1 null 가능성과 컬렉션
컬렉션 안에 null 값을 넣을 수 있는지 여부는 어떤 변수의 값이 null이 될 수 있는지 여부와 마찬가지로 중요하다. 변수 타입 뒤에 ?를 붙이면 그 변수에 null을 저장할 수 있다는 뜻인 것처럼 타입 인자로 쓰인 타입에도 같은 표시를 사용할 수 있다. 타입 인자 안에서 ?가 하는 일을 이해하기 위해 파일의 각 줄을 읽어서 숫자로 변환하기 위해 파싱하는 다음 예제를 보자.
// null이 될 수 있는 값으로 이뤄진 컬렉션 만들기
fun readNumbers(reader: BufferedReader): List<Int?> {
val result = ArrayList<Int?>() // null이 될 수 있는 Int 값으로 이뤄진 리스트를 만든다.
for (line in reader.lineSequence()) {
try {
val number = line.toInt()
result.add(number) // 정수(null이 아닌 값)를 리스트에 추가한다.
}
catch(e: NumberFormatException) {
result.add(null) // 현재 줄을 파싱할 수 없으므로 리스트에 null을 추가한다.
}
return result
}
}
List<Int?>는 Int? 타입의 값을 저장할 수 있다. 다른 말로 하면 그 리스트에는 Int나 null을 저장할 수 있다. 현재 줄을 파싱할 수 있으면 result에 정수를 넣고 그렇지 않으면 null을 넣는다.
경우에 따라, List<Int?>?의 형태로 null이 될 수 있는 값으로 이뤄진 null이 될 수 있는 리스트를 정의해야할 수도 있다. 이런 리스트를 처리할 때는 변수에 대해 null 검사를 수행한 다음에 그 리스트에 속한 모든 원소에 대해 다시 null 검사를 수행해야 한다.
6.3.2 읽기 전용과 변경 가능한 컬렉션
코틀린 컬렉션과 자바 컬렉션을 나누는 가장 중요한 특성 하나는 코틀린에서는 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했다는 점이다. 이런 구분은 코틀린 컬렉션을 다룰 때 사용하는 가장 기초적인 인터페이스인 kotlin.collections.Collection부터 시작한다. 이 Collection 인터페이스를 사용하면 컬렉션 안의 원소에 대해 이터레이션하고, 컬렉션의 크기를 얻고, 어떤 값이 컬렉션 안에 들어있는지 검사하고, 컬렉션에서 데이터를 읽는 여러 다른 연산을 수행할 수 있다. 하지만 Collection에는 원소를 추가하거나 제거하는 메소드가 없다.
컬렉션의 데이터를 수정하려면 kotlin.collections.MutableCollection 인터페이스를 사용하라. MutableCollection은 일반 인터페이스인 kotlin.collections.Collection을 확장하면서 원소를 추가하거나, 삭제하거나, 컬렉션 안의 원소를 모두 지우는 등의 메소드를 더 제공한다.
코드에서 가능하면 항상 읽기 전용 인터페이스를 사용하는 것을 일반적인 규칙으로 삼자. 코드가 컬렉션을 변경할 필요가 있을 때만 변경 가능한 버전을 사용하자.
어떤 컴포넌트의 내부 상태에 컬렉션이 포함된다면 그 컬렉션을 MutableCollection을 인자로 받는 함수에 전달할 때는 어쩌면 원본의 변경을 막기 위해 컬렉션을 복사해야할 수도 있다(이런 패턴을 방어적 복사라고 부른다).
6.3.3 코틀린 컬렉션과 자바
모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스라는 점은 사실이다. 따라서 코틀린과 자바 사이를 오갈 때 아무 변환도 필요 없다. 또한 래퍼 클래스를 만들거나 데이터를 복사할 필요도 없다. 하지만 아래 그림에서 보듯 코틀린은 모든 자바 컬렉션 인터페이스마다 읽기 전용 인터페이스와 변경 가능한 인터페이스라는 두 가지 표현(representation)을 제공한다.
위 그림에 표시된 모든 컬렉션 인터페이스는 코틀린에서 정의한 것이다. 코틀린의 읽기 전용과 변경 가능 인터페이스의 기본 구조는 java.util 패키지에 있는 자바 컬렉션의 인터페이스의 구조를 그대로 옮겨 놓았다. 추가로 변경 가능한 각 인터페이스는 자신과 대응하는 읽기 전용 인터페이스를 확장(=상속)한다. 변경 가능한 인터페이스는 java.util 패키지에 있는 인터페이스와 직접적으로 연관되지만, 읽기 전용 인터페이스에는 컬렉션을 변경할 수 있는 모든 요소가 빠져있다.
컬렉션과 마찬가지로 Map 클래스(맵은 Collection이나 Iterable을 확장하지 않는다)도 코틀린에서 Map과 MutableMap이라는 두 가지 버전으로 나타난다.
컬렉션 타입 | 읽기 전용 타입 | 변경 가능 타입 |
Lsit | listOf | mutableListOf, arrayListOf |
Set | setOf | mutableSetOf, hashSetOf, linkedSetOf, sortedSetOf |
Map | mapOf | mutableMapOf, hashMapOf, linkedMapOf, sortedMapOf |
setOf()와 mapOf()는 자바 표준 라이브러리에 속한 클래스의 인스턴스를 반환한다. 그들은 내부에서는 변경 가능한 클래스다(Collection.unmodifiable로 감싸면 간접 접근에 따른 부가 비용이 생기므로 unmodifiable로 감싸지는 않는다). 하지만 그 둘이 변경 가능한 클래스라는 사실에 의존하면 안 된다. 미래에는 setOf나 mapOf가 진정한 불변 컬렉션 인스턴스를 반환하게 바뀔 수도 있다.
자바 메소드를 호출하되 컬렉션을 인자로 넘겨야 한다면 따로 변환하거나 복사하는 등의 추가 작업 없이 직접 컬렉션을 넘기면 된다. 예를 들어, java.util.Collection을 파라미터로 받는 자바 메소드가 있다면 아무 Collection이나 MutableCollection 값을 인자로 넘길 수 있다.
이런 성질로 인해 컬렉션의 변경 가능성과 관련해 중요한 문제가 생긴다. 자바는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않으므로, 코틀린에서 읽기 전용 Collection으로 선언된 객체라도 자바 코드에서는 그 컬렉션 객체의 내용을 변경할 수 있다. 코틀린 컴파일러는 자바 코드가 컬렉션에 대해 어떤 일을 하는지 완전히 분석할 수 없다. 따라서 컬렉션을 변경하는 자바 메소드에 읽기 전용 Collection을 넘겨도 코틀린 컴파일러가 이를 막을 수 없다. 예를 들어 다음 두 코드를 함께 사용하면 호환 가능한 코틀린/자바 혼용 프로그램이 된다.
/* 자바 */
// CollectionUtils.java
public class CollectionUtils {
public static List<String> uppercaseAll(List<String items) {
for (int i = 0; i < items.size(); i++) {
items.set(i, items.get(i).toUpperCase());
}
return items;
}
}
/* 코틀린 */
// collections.kt
fun printInUppercase(list: List<String>) { // 읽기 전용 파라미터를 선언한다.
println(CollectionUtils.uppercaseAll(list)) // 컬렉션을 변경하는 자바 함수를 호출한다.
println(list.first()) // 컬렉션이 변경됐는지 살펴본다.
}
>>> val list = listOf("a", "b", "c")
>>> printInUppercase(list)
[A, B, C]
A
따라서 컬렉션을 자바로 넘기는 코틀린 프로그램을 작성한다면 호출하려는 자바 코드가 컬렉션을 변경할지 여부에 따라 올바른 파라미터 타입을 사용할 책임은 개발자 본인에게 있다.
이런 함정은 null이 아닌 원소로 이뤄진 컬렉션 타입에서도 발생한다. null이 아닌 원소로 이뤄진 컬렉션을 자바 메소드로 넘겼는데 자바 메소드가 null을 컬렉션에 넣을 수도 있다. 코틀린에서 이를 금지할 방법이 없고, 성능을 포기하지 않고는 컬렉션에 null 값이 들어왔는지 감지할 방법도 없다. 따라서 컬렉션을 자바 코드에게 넘길 때는 특별히 주의를 기울여야 하며, 코틀린 쪽 타입이 적절히 자바 쪽에서 컬렉션에게 가할 수 있는 변경의 내용을 반영하게 해야 한다.
6.3.4 컬렉션을 플랫폼 타입으로 다루기
플랫폼 타입의 경우 코틀린 쪽에는 null 관련 정보가 없다. 따라서 컴파일러는 코틀린 코드가 그 타입을 null이 될 수 있는 타입이나 null이 될 수 없는 타입 어느 쪽으로든 사용할 수 있게 허용한다. 마찬가지로 자바 쪽에서 선언한 컬렉션 타입의 변수를 코틀린에서는 플랫폼 타입으로 본다. 플랫폼 타입인 컬렉션은 기본적으로 변경 가능성에 대해 알 수 없다. 따라서 코틀린 코드는 그 타입을 읽기 전용 컬렉션이나 변경 가능한 컬렉션 어느 쪽으로든 다룰 수 있다. 보통은 원하는 동작이 그냥 잘 수행될 가능성이 높으므로 이는 실제 문제가 되지는 않는다.
하지만 컬렉션 타입이 시그니처에 들어간 자바 메소드 구현을 오버라이드하려는 경우 읽기 전용 컬렉션과 변경 가능 컬렉션의 차이가 문제가 된다. 플랫폼 타입에서 null 가능성을 다룰 때처럼 이런 경우에도 오버라이드하려는 메소드의 자바 컬렉션 타입을 어떤 코틀린 컬렉션 타입으로 표현할지 결정해야 한다.
6.3.5 객체의 배열과 원시 타입의 배열
자바 main 함수의 표준 시그니처에는 배열 파라미터가 들어있어서 지금까지 살펴본 여러 예제에서 코틀린 배열 타입을 이미 봤다. 다음 예제는 코틀린 배열이 어떻게 생겼는지 다시 한 번 보여준다.
// 코틀린에서 배열 사용하기
fun main(args: Array<String> {
for (i in args.indices) {
println("Argument $i is: ${args[i]}") // array[index]로 인덱스를 사용해 배열 원소에 접근한다.
}
}
코틀린 배열은 타입 파라미터를 받는 클래스다. 배열의 원소 타입은 바로 그 타입 파라미터에 의해 정해진다.
코틀린에서 배열을 만드는 방법은 다양하다.
- arrayOf 함수에 원소를 넘기면 배열을 만들 수 있다.
- arrayOfNulls 함수에 정수 값을 인자로 넘기면 모든 원소가 null이고, 인자로 넘긴 값과 크기가 같은 배열을 만들 수 있다. 물론 원소 타입이 null이 될 수 있는 타입인 경우에만 이 함수를 쓸 수 있다.
- Array 생성자는 배열 크기와 람다를 인자로 받아서 람다를 호출해서 각 배열 원소를 초기화해준다. arrayOf를 쓰지 않고 각 원소가 null이 아닌 배열을 만들어야 하는 경우 이 생성자를 사용한다.
간단한 예제로, 다음은 Array 생성자를 사용해 a부터 z까지 26개의 알파벳 소문자에 해당하는 문자열이 원소인 배열을 만든다.
// 알파벳으로 이뤄진 배열 만들기
>>> val letters = Array<String>(26) { i -> ('a' + i).toString() }
>>> println(letters.joinToString(""))
abcdefghijklmnopqrstuvwxyz
코틀린에서는 배열을 인자로 받는 자바 함수를 호출하거나 vararg 파라미터를 받는 코틀린 함수를 호출하기 위해 자주 배열을 만든다. 하지만 이 때 데이터가 이미 컬렉션에 들어있다면 컬렉션을 배열로 변환해야 한다. toTypedArray 메소드를 사용하면 쉽게 컬렉션을 배열로 바꿀 수 있다.
// 컬렉션을 vararg 메소드에게 넘기기
>>> val strings = listOf("a", "b", "c")
>>> println("%s/%s/%s".format(*strings.toTypedArray())) // vararg 인자를 넘기기 위해 스프레드 연산자(*)를 써야한다.
a/b/c
다른 제네릭 타입에서처럼 배열 타입의 타입 인자도 항상 객체 타입이 된다. 따라서 Array<Int> 같은 타입을 선언하면 그 배열은 박싱된 정수의 배열(자바 타입은 java.lang.Integer[])이다. 박싱하지 않은 원시 타입의 배열이 필요하다면 그런 타입을 위한 특별한 배열 클래스를 사용해야 한다.
코틀린은 원시 타입의 배열을 표현하는 별도 클래스를 각 원시 타입마다 하나씩 제공한다. 예를 들어, Int 타입의 배열은 IntArray다. 코틀린은 ByteArray, CharArray, BooleanArray 등의 원시 타입 배열을 제공한다. 이 모든 타입은 자바 원시 타입 배열인 int[], byte[], char[] 등으로 컴파일된다. 따라서 그런 배열의 값은 박싱하지 않고 가장 효율적인 방식으로 저장된다.
'Kotlin' 카테고리의 다른 글
[Kotlin in Action] 7.2 비교 연산자 오버로딩 (0) | 2025.03.26 |
---|---|
[Kotlin in Action] 7.1 산술 연산자 오버로딩 (0) | 2025.03.26 |
[Kotlin in Action] 6.2 코틀린의 원시 타입 (0) | 2025.03.25 |
[Kotlin in Action] 6.1 널 가능성 (0) | 2025.03.22 |
[Kotlin in Action] 5.5 수신 객체 지정 람다: with와 apply (0) | 2025.03.20 |
댓글