컬렉션 개요

컬렉션 개요

Kotlin 표준 라이브러리는 컬렉션을 다루는 폭넓은 도구 세트를 제공해요. 컬렉션이란 풀고자 하는 문제에 의미가 있고 흔히 조작되는, 개수가 가변적인(어쩌면 0개인) 항목들의 묶음을 말해요.

출처: Kotlin 공식 문서

본문

컬렉션은 대부분의 프로그래밍 언어가 공통으로 쓰는 개념이라, 예를 들어 Java나 Python 컬렉션에 이미 익숙하다면 이 소개는 건너뛰고 상세 섹션으로 가도 돼요.

컬렉션은 보통 같은 타입(그리고 그 서브타입)의 객체 여러 개를 담아요. 컬렉션 안의 객체를 요소(element) 또는 *항목(item)*이라고 부르죠. 예를 들어 어떤 학과의 모든 학생은, 그들의 평균 나이를 계산하는 데 쓸 수 있는 하나의 컬렉션을 이뤄요.

Kotlin과 관련 있는 컬렉션 타입은 이래요.

  • List는 인덱스(요소의 위치를 나타내는 정수)로 요소에 접근하는 순서 있는 컬렉션이에요. 요소는 리스트에 두 번 이상 나타날 수 있어요. 리스트의 예로는 전화번호가 있어요. 숫자들의 묶음이고, 순서가 중요하며, 반복될 수 있죠.
  • Set은 유일한 요소들의 컬렉션이에요. 반복이 없는 객체 묶음이라는 수학적 집합의 추상을 반영하죠. 일반적으로 집합 요소의 순서는 의미가 없어요. 예를 들어 복권의 숫자들은 집합을 이뤄요. 유일하고, 순서가 중요하지 않으니까요.
  • Map(또는 dictionary)은 키-값 쌍의 집합이에요. 키는 유일하고, 각 키는 정확히 하나의 값에 대응해요. 값은 중복될 수 있어요. Map은 객체 사이의 논리적 연결을 저장하는 데 유용해요. 예를 들어 직원의 ID와 그 직위 같은 거죠.

Kotlin은 컬렉션에 저장된 객체의 정확한 타입과 무관하게 컬렉션을 조작하게 해줘요. 다시 말해 String 리스트에 String을 추가하는 방식이 Int나 사용자 정의 클래스에서도 똑같다는 뜻이에요. 그래서 Kotlin 표준 라이브러리는 어떤 타입의 컬렉션이든 만들고 채우고 관리하기 위한 제네릭 인터페이스, 클래스, 함수를 제공해요.

컬렉션 인터페이스와 관련 함수는 kotlin.collections 패키지에 있어요. 그 내용을 개요로 살펴볼게요.

배열은 컬렉션의 한 타입이 아니에요. 자세한 내용은 배열을 참고하세요.

컬렉션 타입

Kotlin 표준 라이브러리는 기본 컬렉션 타입(집합, 리스트, 맵)의 구현을 제공해요. 각 컬렉션 타입은 인터페이스 한 쌍으로 표현돼요.

  • 컬렉션 요소에 접근하는 연산을 제공하는 읽기 전용(read-only) 인터페이스.
  • 요소 추가, 제거, 업데이트 같은 쓰기 연산으로 해당 읽기 전용 인터페이스를 확장하는 가변(mutable) 인터페이스.

가변 컬렉션을 반드시 var에 할당할 필요는 없다는 점을 기억하세요. 가변 컬렉션의 쓰기 연산은 val에 할당돼 있어도 여전히 가능해요. 가변 컬렉션을 val에 할당하는 이점은, 가변 컬렉션에 대한 참조를 수정으로부터 보호한다는 거예요. 코드가 커지고 복잡해질수록 참조의 의도치 않은 수정을 막는 게 더 중요해져요. 더 안전하고 견고한 코드를 위해 가능한 한 val을 사용하세요. val 컬렉션을 다시 할당하려 하면 컴파일 오류가 나요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three", "four")
    numbers.add("five")   // this is OK
    println(numbers)
    //numbers = mutableListOf("six", "seven")      // compilation error
//sampleEnd

}

읽기 전용 컬렉션 타입은 공변(covariant)이에요. 즉 Rectangle 클래스가 Shape를 상속한다면, List<Shape>가 필요한 어디든 List<Rectangle>을 사용할 수 있단 뜻이에요. 다시 말해 컬렉션 타입은 요소 타입과 같은 서브타입 관계를 가져요. Map은 값 타입에 대해서는 공변적이지만, 키 타입에는 그렇지 않아요.

반대로 가변 컬렉션은 공변적이지 않아요. 그렇지 않으면 런타임 실패로 이어지니까요. MutableList<Rectangle>MutableList<Shape>의 서브타입이라면, 다른 Shape 상속자(예: Circle)를 그 안에 넣어서 Rectangle 타입 인자를 위반할 수 있게 돼요.

아래는 Kotlin 컬렉션 인터페이스의 다이어그램이에요.

(Kotlin 공식 문서의 다이어그램을 참고하세요)

인터페이스와 그 구현을 하나씩 살펴볼게요. Collection에 대해 배우려면 아래 섹션을 읽어 보세요. List, Set, Map에 대해 배우려면 해당 섹션을 읽거나 Kotlin Developer Advocate인 Sebastian Aigner의 동영상을 보면 돼요.

Collection

Collection은 컬렉션 계층의 뿌리예요. 이 인터페이스는 읽기 전용 컬렉션의 공통 동작(크기 가져오기, 항목 포함 여부 확인 등)을 나타내요. Collection은 요소를 순회하는 연산을 정의하는 Iterable 인터페이스를 상속해요. Collection을 여러 컬렉션 타입에 적용되는 함수의 매개변수로 쓸 수 있어요. 더 구체적인 경우에는 Collection의 상속자(ListSet)를 쓰세요.

fun printAll(strings: Collection<String>) {
    for(s in strings) print("$s ")
    println()
}
    
fun main() {
    val stringList = listOf("one", "two", "one")
    printAll(stringList)
    
    val stringSet = setOf("one", "two", "three")
    printAll(stringSet)
}

MutableCollectionaddremove 같은 쓰기 연산을 가진 Collection이에요.

fun List<String>.getShortWordsTo(shortWords: MutableList<String>, maxLength: Int) {
    this.filterTo(shortWords) { it.length <= maxLength }
    // throwing away the articles
    val articles = setOf("a", "A", "an", "An", "the", "The")
    shortWords -= articles
}

fun main() {
    val words = "A long time ago in a galaxy far far away".split(" ")
    val shortWords = mutableListOf<String>()
    words.getShortWordsTo(shortWords, 3)
    println(shortWords)
}

List

List는 요소를 지정된 순서로 저장하고 인덱스 접근을 제공해요. 인덱스는 첫 번째 요소의 0에서부터 (list.size - 1)lastIndex까지 가요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")
    println("Number of elements: ${numbers.size}")
    println("Third element: ${numbers.get(2)}")
    println("Fourth element: ${numbers[3]}")
    println("Index of element \"two\" ${numbers.indexOf("two")}")
//sampleEnd
}

List 요소(널 포함)는 중복될 수 있어요. 리스트는 같은 객체를 얼마든지 많이, 또는 여러 번 담을 수 있죠. 두 리스트는 크기가 같고 같은 위치에 구조적으로 동등한 요소를 가질 때 같다고 간주돼요.

data class Person(var name: String, var age: Int)

fun main() {
//sampleStart
    val bob = Person("Bob", 31)
    val people = listOf(Person("Adam", 20), bob, bob)
    val people2 = listOf(Person("Adam", 20), Person("Bob", 31), bob)
    println(people == people2)
    bob.age = 32
    println(people == people2)
//sampleEnd
}

MutableList는 특정 위치에 요소를 추가하거나 제거하는 것 같은, 리스트 전용 쓰기 연산을 가진 List예요.

fun main() {
//sampleStart
    val numbers = mutableListOf(1, 2, 3, 4)
    numbers.add(5)
    numbers.removeAt(1)
    numbers[0] = 0
    numbers.shuffle()
    println(numbers)
//sampleEnd
}

보시다시피 어떤 면에서 리스트는 배열과 아주 비슷해요. 하지만 중요한 차이가 하나 있어요. 배열의 크기는 초기화 시 결정되어 절대 바뀌지 않지만, 리스트는 미리 정의된 크기가 없어요. 리스트의 크기는 추가, 업데이트, 제거 같은 쓰기 연산의 결과로 바뀔 수 있죠.

Kotlin에서 MutableList의 기본 구현은 ArrayList이며, 크기 조절 가능한 배열로 생각하면 돼요.

Set

Set은 유일한 요소를 저장하며, 그 순서는 일반적으로 정의되지 않아요. null 요소도 유일해요. Setnull을 하나만 담을 수 있죠. 두 집합은 크기가 같고, 한 집합의 각 요소에 대해 다른 집합에 같은 요소가 있을 때 같아요.

fun main() {
//sampleStart
    val numbers = setOf(1, 2, 3, 4)
    println("Number of elements: ${numbers.size}")
    if (numbers.contains(1)) println("1 is in the set")

    val numbersBackwards = setOf(4, 3, 2, 1)
    println("The sets are equal: ${numbers == numbersBackwards}")
//sampleEnd
}

MutableSetMutableCollection의 쓰기 연산을 가진 Set이에요.

MutableSet의 기본 구현인 LinkedHashSet은 요소 삽입 순서를 보존해요. 그래서 first()last()처럼 순서에 의존하는 함수가 그런 집합에서 예측 가능한 결과를 돌려줘요.

fun main() {
//sampleStart
    val numbers = setOf(1, 2, 3, 4)  // LinkedHashSet is the default implementation
    val numbersBackwards = setOf(4, 3, 2, 1)
    
    println(numbers.first() == numbersBackwards.first())
    println(numbers.first() == numbersBackwards.last())
//sampleEnd
}

대안 구현인 HashSet은 요소 순서에 대해 아무것도 말하지 않으므로, 그런 함수를 호출하면 예측 불가능한 결과가 나와요. 다만 HashSet은 같은 수의 요소를 저장할 때 메모리를 덜 사용해요.

Map

MapCollection 인터페이스의 상속자는 아니지만, 여전히 Kotlin 컬렉션 타입이에요. Map키-값 쌍(또는 엔트리)을 저장해요. 키는 유일하지만, 서로 다른 키가 같은 값과 쌍을 이룰 수 있어요. Map 인터페이스는 키로 값에 접근하기, 키와 값 검색하기 같은 특정 함수를 제공해요.

fun main() {
//sampleStart
    val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key4" to 1)
    
    println("All keys: ${numbersMap.keys}")
    println("All values: ${numbersMap.values}")
    if ("key2" in numbersMap) println("Value by key \"key2\": ${numbersMap["key2"]}")    
    if (1 in numbersMap.values) println("The value 1 is in the map")
    if (numbersMap.containsValue(1)) println("The value 1 is in the map") // same as previous
//sampleEnd
}

같은 쌍을 담은 두 맵은 쌍의 순서와 무관하게 같아요.

fun main() {
//sampleStart
    val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key4" to 1)    
    val anotherMap = mapOf("key2" to 2, "key1" to 1, "key4" to 1, "key3" to 3)
    
    println("The maps are equal: ${numbersMap == anotherMap}")
//sampleEnd
}

MutableMap은 맵 쓰기 연산을 가진 Map이에요. 예를 들어 새 키-값 쌍을 추가하거나 주어진 키와 연관된 값을 업데이트할 수 있어요.

fun main() {
//sampleStart
    val numbersMap = mutableMapOf("one" to 1, "two" to 2)
    numbersMap.put("three", 3)
    numbersMap["one"] = 11

    println(numbersMap)
//sampleEnd
}

MutableMap의 기본 구현인 LinkedHashMap은 맵을 순회할 때 요소 삽입 순서를 보존해요. 반면 대안 구현인 HashMap은 요소 순서에 대해 아무것도 말하지 않아요.

ArrayDeque

ArrayDeque는 양쪽 끝(덱) 큐의 구현으로, 큐의 앞이나 뒤에 요소를 추가하거나 제거할 수 있게 해줘요. 그래서 ArrayDeque는 Kotlin에서 스택(Stack)과 큐(Queue) 자료구조의 역할을 모두 담당해요. 내부적으로 ArrayDeque는 필요할 때 크기가 자동으로 조절되는 가변 배열로 구현돼요.

fun main() {
    val deque = ArrayDeque(listOf(1, 2, 3))

    deque.addFirst(0)
    deque.addLast(4)
    println(deque) // [0, 1, 2, 3, 4]

    println(deque.first()) // 0
    println(deque.last()) // 4

    deque.removeFirst()
    deque.removeLast()
    println(deque) // [1, 2, 3]
}

더 알아보기 (Learn more)