[Kotlin] 코틀린,자바 컬렉션과 nullability, 변경 가능성
Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
Nullability 와 Collection
컬렉션에 대해 nullable 을 적용할 때는 조심해야 한다.
리스트 자체가 nullable 인지, 원소가 nullable 인지, 혹은 둘 다 nullable 인지를 잘 고려하자.
nullable 값으로 이뤄진 컬렉션 예제
fun readNumbers(reader: BufferedReader): List<Int?> {
val result = ArrayList<Int?>() // Int? 값으로 이뤄진 리스트 생성
for (line in reader.lineSequence()) {
try {
val number = line.toInt()
result.add(number)
} catch (e: NumberFormatException) {
result.add(null) // 현재 줄을 파싱할 수 없으므로 리스트에 null 을 추가
}
}
return result
}
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")
}
val reader = BufferedReader(StringReader("1\nabc1\n42"))
val numbers = readNumbers(reader)
addValidNumbers(numbers)
// print
/*
Sum of valid numbers: 43
Invalid numbers: 1
*/
- `readNumbers`
- 리턴 타입: nullable 원소로 이뤄진 `LIst`
- 동작: 파일의 각 줄을 읽어서 숫자로 변환하기 위해 파싱
- `addValidNumbers`
- 파라미터: nullable 값으로 이뤄진 `List`
- 동작: nullable 값으로 이뤄진 리스트에서 null 값을 걸러준다.
참고로 `addValidNumbers` 함수는 kotlin stlib 함수 `filterNotNull` 로 대체가 가능하다.
fun addValidNumbers(numbers: List<Int?>) { val validNumbers = numbers.filterNotNull() // 타입이 List<Int> 가 됨. println("Sum of valid numbers: ${validNumbers.sum()} ") println("Invalid numbers: ${numbers.size - validNumbers.size}") }
read-only Collection & Mutable Collection (읽기 전용 & 변경 가능한 컬렉션)
코틀린은 `Collection` 안의 data 에 접근하는 인터페이스 와 data 를 변경하는 인터페이스가 분리되어 있다.
코틀린 컬렉션의 가장 기초적인 인터페이스는 `kotlin.collections.Collection` 이다.
- `Collection` 인터페이스
- 원소에 대해 iteration 기능
- 컬렉션의 크기를 얻는 기능
- 컬렉션의 데이터를 읽는 기능
- 어떤 값이 들어있는지 검사 기능
하지만, 이 인터페이스에는 컬렉션에 원소를 추가하거나 제거하는 메서드는 없다.
data 를 수정하려면 위 인터페이스를 구현하는 `kotlin.collections.MutableCollection` 인터페이스를 사용해야 한다.
- `MutableCollection` 인터페이스
- 원소 추가/삭제 기능이 추가되어 있음!
가능하면 read-only 컬렉션을 사용하는 것을 일반적인 컨벤션으로 하고,
변경이 필요할 때 mutable 버전을 사용하는 것이 좋다.
어떤 component 의 내부 상태에 collection 이 있다면,
그 `Collection` 을 `MutableCollection` 을 인자로 받는 함수에 전달할 때,
만약 원본의 변경을 막아야 한다면 Collection 을 복사해야 한다. 이 패턴을 defensive copy 라고 한다.
방어적 복사 예제
fun <T> copyElements(source: Collection<T>, target: MutableCollection<T>) {
for (item in source) {
target.add(item) // mutable 인 target 컬렉션에 원소를 추가
}
}
val source: Collection<Int> = arrayListOf(3, 5, 7)
val target: MutableCollection<Int> = arrayListOf(1)
copyElements(source, target)
assert(target == arrayListOf(1, 3, 5, 7))
함수의 `source` 컬렉션은 변경되지 않고 `target` 컬렉션은 변경되었다는 것을 알 수 있다.
당연히 함수의 target 파라미터로 read-only 컬렉션을 넘기는 것은 불가능하다.
만약 그렇다면 `Type mismatch: inferred type is Collection<Int> but MutableCollection<Int> was expected`. 에러가 발생한다.
읽기 전용 컬렉션은 불변이다?
읽기 전용 컬렉션이라고 꼭 immutable 컬렉션일 필요는 없다.
read-only 인터페이스 타입인 변수를 사용할 때 실제로는 어떤 컬렉션 인스턴스를 가리키는 여러 참조 중 하나일 수 있다.
아래 그림을 보면 이해하기 쉬울 것이다.
왼쪽 list 는 read-only 인 `List<String>` 이고, 오른쪽은 `MutableList<String>` 이다.
실제 컬렉션은 변경될 수 있다.
위와 같은 상황에서처럼, 컬렉션을 참조하는 다른 컬렉션이 그 컬렉션의 내용을 변경하는 상황이 생길 수도 있다.
이 때는 `ConcurrentModificationException` 등의 예외가 발생할 수 있다.
따라서 read-only 컬렉션이 항상 thread-safe 하지는 않다!
다중 스레드 환경에서 데이터를 다룰 때는 데이터를 적절히 동기화하거나, 동시 접근을 허용하는 데이터 구조를 사용하자.
코틀린 컬렉션 & 자바
모든 코틀린 컬렉션은 그에 상응하는 자바 컬렉션 인터페이스의 인스턴스이다.
코틀린 컬렉션과 자바 컬렉션은 서로 변환할 필요도 없고, 별도 래퍼 클래스나, 데이터 복사 또한 필요없다.
하지만 코틀린은 모든 자바 컬렉션 인터페이스마다 read-only 와 mutable 인터페이스라는 두가지 표현을 제공한다.
자바 `ArrayList` 와 `HashSet` 코드
public class JavaCollectionAndKotlin {
ArrayList<Integer> list = new ArrayList<>();
HashSet<Integer> set = new HashSet<>();
}
코틀린 코드에서 자바 `ArrayList` 와 `HashSet` 의 타입 확인
val list = JavaCollectionAndKotlin().list
assert(list is MutableList<Int>)
assert(list is List<Int>)
assert(list is MutableCollection<Int>)
assert(list is Collection<Int>)
assert(list is MutableIterable<Int>)
assert(list is Iterable<Int>)
val set = JavaCollectionAndKotlin().set
assert(set is MutableSet<Int>)
assert(set is MutableCollection<Int>)
assert(set is Collection<Int>)
assert(set is MutableIterable<Int>)
assert(set is Iterable<Int>)
코틀린에서는 `java.util.ArrayList` 와 `java.util.HashSet` 의 두 자바 표준 클래스를
마치 각각 `MutableList` 와 `MutableSet` 인터페이스를 상속한 것처럼 취급한다.
자바의 `LinkedList` 와 `SortedList` 등 모두 코틀린의 상위 타입을 갖는 것처럼 취급한다.
코틀린은 이렇게 자바와의 호환성을 제공하면서 read-only 와 mutable 인터페이스를 분리한다.
컬렉션을 만들 때 사용하는 함수
컬렉션 타입 | read-only 타입 | mutable 타입 |
`List` | `listOf` | `mutableListOf`, `arrayListOf` |
`Set` | `setOf` | `mutableSetOf`, `hashSetOf`, `linkedSetOf`, `sortedSetOf` |
`Map` | `mapOf` | `mutableMapOf`, `hashMapOf`, `linkedMapOf`, `sortedMapOf` |
assert(setOf<Int>(1,2) is java.util.HashSet)
참고로 `setOf<Int>()` 나 `setOf<Int>(1)` 처럼 원소가 1개 이하인 경우에는 `false` 가 나온다.
`setOf` 함수는 내부적으로 최적화된 불변 `Set` 구현을 선택하며,
이 구현은 `HashSet` 일 수도 있고, 다른 특정한 종류일 수도 있다.
따라서 `setOf` 함수를 사용할 때 어떤 구현체가 선택되는지에 대한 명확한 보장이 없다.
- 자바 메서드를 호출하되 컬렉션을 인자로 넘겨야 한다면, 추가 작업 없이 직접 컬렉션을 넘기면 된다.
- 코틀린에서 read-only 컬렉션으로 선언된 객체라도, 자바 코드에서는 그 컬렉션 객체의 내용을 변경할 수 있다.
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;
}
}
fun printInUppercase(list: List<String>) { // read-only 파라미터임.
println(CollectionUtils.uppercaseAll(list)) // 컬렉션을 변경하는 자바 함수 호출
println(list.first()) // 컬렉션이 변경되었는지 확인하기
}
val list = listOf("a", "b", "c")
assert(printInUppercase(list) == "A")
// print [A, B, C]
이렇게 컬렉션을 자바로 넘기는 코틀린 코드를 작성하면,
호출하려는 자바 코드가 컬렉션을 변경할지 여부에 따라 우리가 올바른 파라미터 타입을 잘 선택하여 사용해야 한다.
코틀린의 nullable 원소로 이뤄진 컬렉션 타입에도 자바 코드에서 null 을 넣는 등의 문제가 있다.
즉, 코틀린 컬렉션을 자바 코드에 넘길 때 특별히 주의하자.
컬렉션을 플랫폼 타입으로 다루기
코틀린에서는 자바에서 선언한 컬렉션 타입 변수를 platform 타입으로 본다.
코틀린에서는 컬렉션의 nullability, 컬렉션 원소의 nullability 뿐만 아니라, 컬렉션의 변경 가능성도 알 수 없다.
변경 가능성을 모른다는 것은 Collection 타입이 메서드 시그니처에 있는 자바 메서드를 오버라이드 할 때,
read-only 와 mutable Collection 의 차이가 문제가 된다.
아래 세 가지를 잘 고려하여 선택해야 한다.
- 컬렉션이 nullable 한가?
- 컬렉션 원소가 nullable 한가?
- override 하는 메서드가 컬렉션을 변경할 수 있는가?
자바 인터페이스를 구현하는 예시 코드를 보자.
컬렉션 파라미터가 있는 자바 인터페이스
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>?) {
TODO("Not yet implemented")
}
}
- 일부 파일은 binary 파일이다. 그 파일의 내용은 텍스트로 표현할 수 없는 경우가 있으므로 `textContents` 는 nullable 이다.
- 파일의 각 줄은 null 일 수 없다. `textContents` 의 원소는 not-null 이다.
- `textContents` 는 파일의 내용을 표현하며, 내용을 바꾸면 안된다면, read-only 이다.
컬렉션 파라미터가 있는 또 다른 자바 인터페이스
public interface DataParser<T> {
void parseData(String input, List<T> output, List<String> errors);
}
class PersonParser : DataParser<Person> {
override fun parseData(input: String, output: MutableList<Person>, errors: MutableList<String?>) {
TODO("Not yet implemented")
}
}
- 호출부에서 항상 에러 메시지를 받아야 한다. `List<String>` 은 not-null 이다.
- `errors` 의 원소는 nullable 이다. (파싱 과정에서 에러가 발생하지 않는다면 null)
- 구현 코드에서 원소를 추가할 수 있어야 하므로, `List<String>` 은 mutable 이다.
이러한 선택을 제대로 하려면 자바 인터페이스나 클래스가 어떠한 맥락에서 사용되는지 정확히 알아야 한다.
자바에서 가져온 컬렉션에 대해 코틀린 구현에서 어떤 작업을 수행해야 할지 잘 검토하자!
mutable 컬렉션 사용을 고려하라
mutable 컬렉션을 사용하는 것이 read-only 컬렉션을 사용하는 것보다 성능적인 측면에서 더 낫다.
read-only 컬렉션에 원소를 추가한다면, 새 collection 을 만들면서 여기에 원소를 추가한다.
그런데 컬렉션을 복제하는 처리는 비용이 크다.
그래서 복제 처리를 하지 않는 mutable 컬렉션이 성능 관점에서 더 낫다.
read-only 컬렉션을 사용하는 이유는 더 나은 안정성 때문이다.
하지만, 일반적인 지역변수로 사용할 때는 문제가 되는 경우(동기화 & 캡슐화)에 해당하지 않는다.
즉, 지역 변수로 사용할 때는 mutable 컬렉션을 사용하는 것이 더 합리적이다.
코틀린 stlib 에서도 내부적으로 어떤 처리를 할 때에는 mutable 컬렉션을 사용하도록 구현되어 있다.
inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
val size = if (this is Collection<*>) this.size else 10
val destination = ArrayList<R>(size)
for (item in this)
destination.add(transform(item))
return destination
}
참고로 read-only 클래스를 사용한다면 아래처럼 해야 한다. (실제로는 이렇게 구현되어 있지 않다. read-only 이므로!)
inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
var destination = listOf<R>()
for (item in this)
destination += transform(item)
return destination
}
가변 컬렉션은 일반적으로 추가 처리가 더 빠르다.
read-only 컬렉션은 방어적 복사를 하면서 컬렉션 변경과 관련된 처리를 더 세부적으로 조정할 수 있다.
보통 지역 처리에서는 이러한 세부적인 조정이 필요하지 않으므로, mutable 컬렉션을 사용하는 것이 더 좋다.