Kotlin

[Kotlin] 코틀린,자바 컬렉션과 nullability, 변경 가능성

sh1mj1 2024. 1. 22. 15:13

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 컬렉션을 사용하는 것이 더 좋다.