Ch 05. 콜렉션 사용하기
About
코틀린은 Java 콜렉션으로 제공되는 mutable 콜렉션 인터페이스 뷰와 immutable 콜렉션 인터페이스 뷰 모두를 제공한다. 프로그램이 간단한 싱글 스레드라면 mutable, 더 복잡한 함수형 비동기 프로그램이라면 immutable 콜렉션 인터페이스를 사용하는 게 더 안전하다.
5-1. 콜렉션의 특징
코틀린에서도 Java에서 사용하는 기본 콜렉션 리스트, 셋, 맵을 모두 사용할 수 있다.
Java의 mutable 콜렉션 인터페이스는 코틀린에서 immutable 읽기전용 인터페이스와 mutable 읽기-쓰기 인터페이스 2개의 인터페이스로 나뉘어졌다.
코틀린 콜렉션은 JDK에서 제공해주는 함수 이외에 몇 가지 편리한 메소드를 제공한다.
다음과 같은 종류를 많이 사용할 것이다.
Pair - 값이 두 개인 튜플
Triple - 값이 세 개인 튜플
Array - 객체나 프리미티브 타입으로 구성되어 순번이 있고, 크기가 고정된 콜렉션
List - 객체들이 정렬된 콜렉션
Set - 객체들이 정렬되지 않은 콜렉션
Map - 연관 사전 혹은 키와 값의 맵
코틀린이 제공해 주는 콜렉션의 진화는 두 가지다. 함수 확장과 뷰, 이 두 가지에 대해 알아보자.
코틀린이 제공하는 편리한 메소드들
코틀린 스탠다드 라이브러리에 포함된 kotlin.collections
패키지에 Java 콜렉션에 유용한 함수들이 많이 추가되었다. 코틀린에서는 Java의 콜렉션을 각자 편한 방법대로 사용할 수 있다. 동일한 콜렉션이라면 코틀린이 추가한 메소드를 사용할 수 있다.
예를 들어, Java에서 우리는 names
란 이름을 가진 스트링 리스트를 반복할 때 전통적인 for 루프를 사용했다.
그리고 for-each를 이용해서 더 직관적으로 사용하기도 했다.
두 번째 코드가 첫 번째 코드보다 깔끔하다. 하지만 두 번째 코드는 리스트의 값만 얻을 수 있고 인덱스를 활용할 수 없다. 실제로 Java에서 명령형 스타일의 for-each가 아닌 함수형 스타일의 forEach문을 사용한다면 인덱스는 사용할 수 없다.
코틀린은 withIndex()
메소드를 제공해 인덱스와 값 모두를 편하게 얻게 해준다.
코틀린의 listOf()
메소드를 이용해서 JDK에서 가지고 온 ArrayList
객체를 가져오고, 그 객체에 있는 withIndex()
메소드를 호출했다. withIndex()
메소드는 IndexedValue
라는 특별한 반복자(iterator)를 리턴한다. IndexedValue
는 data class이다.
코틀린에서는 data 클래스의 구조분해를 사용해 값을 아주 쉽게 추출할 수 있다. 이 기능으로 index
와 value
를 모두 얻을 수 있었다. 다음은 위 코드의 실행 결과이다.
출력 결과를 보면, names
는 JDK의 ArrayList
의 인스턴스라는 것을 알 수 있다. 하지만 우리는 코틀린을 이용해서 Java보다 편하게 반복할 수 있었다. withIndex()
는 코틀린이 JDK 클래스에 편의를 위해 추가해놓은 수많은 메소드 중 하나일 뿐이다. kotlin.collections
패키지에 속한 메소드들을 시간을 들여 살펴보기를 권한다.
뷰
immutable 콜렉션은 동시성을 사용하는 함수형 프로그래밍 또는 비동기 처리를 하는 애플리케이션을 사용할 때 훨씬 안정적이다. Java의 대부분 콜렉션은 mutable이고, 최근 몇 년간 immutable 콜렉션을 선보였다. 그러나 mutable, immutable 버전 모두 같은 인터페이스를 구현하고 있다는 문제를 갖고 있다. 그래서 immutable 콜렉션을 변경하려는 모든 시도(e.g., List
의 add()
메소드)를 하면 실행 시간에 UnsupportedOperationException
이 나온다.
이와 달리 코틀린은 연산이 불가능하다는 것을 실행 시간이 되어서야 알리지 않는데, 그래서 코틀린에 뷰가 있는 것이다.
리스트, 셋, 맵은 각기 뷰를 두 가지씩 가지고 있다.
읽기전용(read-only) 뷰로 통하는 immutable 뷰와
읽기-쓰기(read-write) 뷰로 불리는 mutable 뷰다.
두 뷰 모두 Java의 기본 콜렉션에 맵핑된다. 오리지널 컬렉션 대신 이 뷰들을 사용하면 런타임 시 오버헤드가 없고, 컴파일 시간이나 실행 시간에 변환이 발생하지 않는다. 읽기전용 뷰에는 읽기 연산자만 사용 가능하다. 즉, 쓰기를 시도한다면 컴파일 단계에서 실패한다. 예를 들어 List
와 MutableList
는 코틀린의 ArrayList
뷰다. 하지만 List
뷰를 사용할 때 요소를 추가하거나 인덱스를 사용해 값을 set하려고 하면 컴파일 시점에 실패한다.
주의사항은 읽기전용 뷰가 스레드 안정성을 제공해준다고 가정해선 안된다. 읽기전용 참조는 mutable 콜렉션이다. 비록 당신이 콜렉션을 변경할 수는 없지만, 다른 스레드가 참조하고 있는 콜렉션을 변경하지 않는다는 걸 보장해 주지 않는다.
비슷하게, 여러 개의 뷰가 인스턴스를 참조하고 있는 중이고, 그 중 일부는 읽기전용, 일부는 읽기-쓰기용으로 사용된다면 사용 시에 두 개의 스레드에서 읽기-쓰기 뷰를 이용해서 동시에 한 콜렉션을 변경하지 않도록 엄청난 주의를 기울여야 한다.
5-2. 페어와 트리플 사용하기
튜플은 작고 셀 수 있는 크기의 객체의 배열이다.
다양한 사이즈의 튜플을 만드는 것을 허용하는 언어들과 다르게, 코틀린은 두 개의 사이즈의 튜플만을 허용한다. 2개와 3개이다. 이 두 가지 튜플은 빠르게 2개 또는 3개의 객체를 콜렉션으로 만들고 싶을 때 사용한다.
먼저 우리는
Pair
의 생성자를 이용해 인스턴스를 만들었다.그리고
to()
확장함수를 이용해 Map의 엔트리가 될 페어를 만들었다.
to()
확장함수는 코틀린의 모든 객체에서 사용이 가능하다. to()
메소드는 Pair
의 인스턴스를 만든다. 앞에 나온 값이 Pair
의 첫 번째 값, 뒤에 나온 값이 두 번째 값이 된다.
간결한 문법으로 페어를 만드는 것은 아주 유용하다. 프로그래밍에서 페어는 아주 흔하게 사용되고, 요구된다.
예를 들어 공항 카드목록이 있고, 각 공항의 온도를 알고 싶다고 생각해보자. 이 경우 공항코드와 공항의 온도를 쌍(페어)로 나타내는 것은 자연스러운 일이다. Java를 이용해 공항코드와 온도를 배열로 갖고 있다면 작업을 하는 것이 성가신 일이 될 것이다. 왜냐하면 코드는 String
, 온도는 double
이어서 타입 안정성을 잃게 될 것이므로, 결국 배열은 Object
타입이 될 것이다. Java를 쓸 때는 일반적으로 저 두 값을 갖는 특별한 클래스를 만들어 타입 안정성을 가져가고, 코드의 혼란을 줄인다. 하지만 이 목적 하나만으로 클래스를 새로 만들어야 하기 때문에 짐이 늘어난다. Java는 이런 경우에 사용할 수 있는 좋은 방법을 제공해주지 않는다. 코틀린의 페어가 이런 이슈를 우아하게 해결해준다.
airportCodes
콜렉션을 함수형 스타일로map()
반복자를 이용해 반복했다.반복문은
airportCodes
가 가지고 있던 공항 코드를(코드, 온도)
꼴의Pair
로 도치시켰다.그 결과
airportCodes
는List<Pair<String, String>>
가 되었다.마지막으로
Pair
의 리스트의 값을 반복하면서 각 공항코드와 온도를 출력하도록 했다.Pair
의 값은first
와second
속성을 이용해 가져왔다.
코드를 실행시켜서 프로그램이 출력하는 가짜 온도를 확인해보면 다음과 유사하게 나온다.
페어는 객체 쌍이 필요하거나 튜플이 필요한 부분 어디에서든 적극적으로 사용하자. 간결한 코드를 만들 수 있을 뿐 아니라 컴파일 시간에서 타입 안정성 또한 제공한다.
페어는 2개의 값을 다룰 때 유용하다. 특별해보이지만, 코틀린 스탠다드 라이브러리에 들어 있는 하나의 클래스일 뿐이다. 필요하다면 직접 페어같은 클래스를 만들 수 있다.
3개의 객체가 필요하다면 페어 대신 트리플(Triple
)을 사용하면 된다. 예를 들어 원의 위치를 나타내고 싶을 때 Circle
같은 클래스를 만들 필요가 없이, 그냥 Triple<Int, Int, Double>
인 트리플 인스턴스를 만들면 된다. 첫 번째, 두 번째 값은 각각 중심점의 X, Y좌표, 세 번째 값은 반지름을 나타낸다. 이렇게 하면 코드를 더 적게 사용하면서도 타입 안정성을 가져갈 수 있다.
페어와 트리플 모두 immutable이다. 두 클래스는 값을 두 개씩 또는 세 개씩 반복적으로 그루핑할 때 유용하다. 만약에 3개보다 더 많은 mutable을 그루핑하고 싶다면 데이터 클래스를 만드는 것을 고려해보자.
하지만 mutable 콜렉션의 값을 취급하는 콜렉션이 필요하다면, 배열이 좋은 선택이다. 바로 살펴보도록 한다.
5-3. 객체 배열과 프리미티브 배열
Array<T>
클래스는 코틀린의 배열을 상징한다. 배열은 낮은 수준의 최적화가 사용할 때만 사용하도록 하고, 그 외에는 List
같은 다른 자료구조를 사용하라.
배열을 만드는 가장 쉬운 방법은 arrayOf()
라는 최상위 함수(top-level function)를 사용하는 것이다. 배열을 만들면 인덱스 연산자 []
를 이용해서 요소에 접근할 수 있다.
friends
변수는 새로 만들어진 배열 인스턴스의 참조를 갖고 있다. 배열의 타입은 Kotlin.Array
(Array<T>
이다)이지만 JVM에서 실행될 때 적용되는 진짜 타입은 Java의 String
배열이다. 이 요소들의 값을 갖고 오기 위해서 인덱스 연산자인 []
가 사용됐다. 인덱스 연산자는 Array<T>
의 get()
메소드를 호출한다. 인덱스 연산자가 왼쪽에 있다면, Array<T>
의 set()
메소드를 호출한다.
이전 코드에서 String
배열을 만들어서 정수 배열을 만들 때도 아래의 예제와 같은 메소드를 사용하려는 생각이 들 수 있다.
이 코드는 동작하지만 스마트하지 못하다. arrayOf()
에 숫자가 전달되면 우리가 예상하는 것처럼 Array<T>
의 인스턴스가 만들어지기는 한다. 하지만 내부적으로는 Integer
클래스의 배열이 만들어진다. Integer
클래스로 작업을 하면 프리미티브 int
를 사용할 때에 비해서 오버헤드가 크게 생긴다.
클래스로 박싱되면서 발생하는 오버헤드를 피하기 위해서 만들어진 intArrayOf()
함수 같은 특수한 함수들이 있다. Integer
배열이 아닌 int
배열을 만들기 위해서 이전 코드를 다음과 같이 수정한다.
Array<T>
에 사용된 연산은 IntArray
같이 비록 다른 타입을 사용하고 있지만 타입 특화 배열 클래스에서도 동일하게 사용 가능하다.
값을 가져오고(get) 설정하기(set) 위해 인덱스 연산자 []
를 사용할 수 있을 뿐 아니라 size
속성을 이용해 배열의 크기도 알 수 있다. Array
에 있는 많은 함수들을 이용해서 배열을 편리하게 사용할 수 있다. size
속성과 유용한 메소드인 average
를 방금 만든 배열에서 사용해본다.
객체와 프리미티브 타입의 배열의 메소드들에 대해 알아보기 위해 Kotlin.Array<T>
클래스를 탐구해보자.
배열을 만들 때 하드코딩으로 값을 적는 대신 값을 계산해서 넣을 수도 있다. 아래의 예제 코드는 1~5까지의 값을 제곱한 후 모든 값을 합친 후 배열에 넣는다.
Array
의 생성자는 파라미터로 1. <배열의 사이즈>와 2. <0으로 시작하는 인덱스를 받아 해당 위치에 있는 값을 리턴해주는 함수>를 받는다.
만약에 정렬된, 길이가 바뀔 수 있는 콜렉션을 원한다면 리스트 사용을 고려해야 한다. 배열은 mutable한 반면, 리스트는 mutable/immutable 모두 제공하므로 원하는 대로 사용 가능하다.
5-4. 리스트 사용하기
리스트를 만드는 첫 단계에서 코틀린은 개발자에게 immutable 또는 mutable인지를 선택하도록 한다. immutable 리스트를 만드려면 listOf()
메소드(immutable이 함축되어있다)를 사용하면 된다. immutable과 mutable을 선택할 수 있다면 immutable을 선호해야 한다. 하지만 꼭 필요해서 mutable 리스트를 만들어야 한다면 mutableListOf()
를 사용한다.
listOf()
함수는 kotlin.collections.List<T>
인터페이스의 참조를 리턴한다.
아래의 코드에서 fruits
는 String
을 파라미터로 하는 kotlin.collections.List<T>
인터페이스를 참조한다.
리스트의 요소에 접근하기 위해 다음과 같은 방법이 있다.
전통적인
get()
메소드를 사용할 수 있다.인덱스 연산자
[]
역시 사용 가능하다. 인덱스 연산자를 사용하면 내부적으로get()
메소드를 사용하게 된다.
인덱스를 사용하는 편이 get()
보다 노이즈가 적고 편리하다. get()
대신 []
를 사용하도록 하자.
콜렉션에 값이 있는지 없는지 확인하기 위해 contains()
메소드를 사용하거나 in
연산자를 사용할 수 있다.
in
을 사용하는 것이 더 표현력이 좋고, 직관성이 있다.
listOf()
가 리턴하는 참조를 사용할 때 리스트를 변경할 수는 없다. 안 될 것이 뻔하지만 아래 코드를 사용해 검증해보자.
kotlin.collections.List<T>
의 인터페이스는 컴파일 시간에 Java에서 많이 사용했을 Arrays.asList()
로 만든 JDK 객체의 뷰로 동작한다. 하지만 이 인터페이스는 변화를 허용하거나 리스트를 바꿀 수 있는 권한을 가진 메소드가 없다. 그래서 add()
메소드를 제공해주기 때문에 코틀린은 뷰의 변경 불가능한 부분을 이용해 코드를 더 안전하게 만들고 실행 시간에 오버헤드나 변경이 없도록 한다.
이런 보호조치는 아주 좋지만, 이 조치로 다른 과일을 추가하는 것을 막을 수는 없다. 이럴 때 아주 편리한 +
연산자를 사용하게 된다.
이 연산은 기존 fruits
를 변경시키지 않고, 기존 리스트를 카피해 새로운 리스트를 만들고 새로운 요소를 추가한다.
반대로 -
연산자는 기존 리스트에서 특정 요소를 제외한 새로운 리스트를 만들 때 사용한다.
만약 제거하려는 요소가 현재 리스트에 없다면 아무것도 제거하지 않은 동일한 리스트가 만들어진다.
List<T>
인터페이스는 이전 예제에서 빛이 났다. 그리고 코틀린은 많은 메소드를 제공해준다. fruits
는 List<T>
인터페이스이다. 그러면 fruits
의 클래스는 무엇일까?
결과를 보면 우리는 코틀린의 뷰 인터페이스로 fruits
에 접근했지만 fruits
인스턴스는 JDK가 제공해 주는 인터페이스이다.
listOf()
메소드는 읽기전용 참조를 리턴해 준다. 하지만 mutable 리스트를 만들어야 겠다는 생각이 든다면 당장 주변에 도움의 손길을 요청하라. 그러면 분명 mutable 리스트를 만들지 말라고 할 것이다. 충분한 생각과 의논이 끝난 후에도 mutable 리스트를 만드는 것이 올바른 생각이라는 판단이 든다면 mutableListOf()
함수를 이용해 리스트를 만들 수 있다. List<T>
에서 사용하던 모든 기능들이 MutableList<T>
에서도 사용 가능하다. mutableListOf()
메소드를 이용해 생성된 인스턴스는 java.util.Arrays$ArrayList
가 아니고 java.util.ArrayList
이다.
다음 코드를 입력하면 읽기전용이 아닌 읽기-쓰기가 모두 가능한 인터페이스를 얻을 수 있다.
이 인터페이스를 사용한다면 리스트를 변경할 수 있다.
mutableListOf()
함수로 가지고 온 MutabeList<T>
인터페이스로 ArrayList<T>
를 다루는 대신 arrayListOf()
함수를 이용해서 ArrayList<T>
의 참조를 직접 획득할 수도 있다.
가능한 mutableListOf()
, arrayListOf()
보다는 listOf()
를 사용하자. 변경 가능 객체는 좋지 않다.
리스트를 만들었기 때문에 명령형 스타일로도 반복을 할 수 있다. 그리고 함수형 스타일로도 반복을 할 수 있다.
5-5. 셋 사용하기
셋은 정렬되지 않은 요소의 모음이다.
Set<T>
의 인스턴스를 만들기 위해서는setOf()
MutableSet<T>
를 만들기 위해서는mutableSetOf()
hashSetOf()
를 이용해java.util.HashSet<T>
의 참조를 만들 수도 있다.LinkedHashSet
을 만들려면linkedSetOf()
TreeSet<T>
를 만들려면sortedSetOf()
를 이용한다.
여기에 중복된 값을 가진 fruits
셋이 있다.
셋은 중복 요소를 허용하지 않기 때문에 셋이 만들어질 때 중복된 값은 누락된다.
인스턴스가 setOf()
로 만들어졌기 때문에 Set<T>
의 인터페이스로 되어있다. 하지만 JDK에서는 set이 무엇으로 취급되는지 확인해보자.
List<T>
처럼 Set<T>
와 MutableSet<T>
에는 +
, -
, contains
, in
등 많은 함수들이 있다. 아마 셋에 있으면 좋겠다고 생각하는 메소드는 이미 라이브러리에 포함되어있을 가능성이 크다.
5-6. 맵 사용하기
맵은 키-값 페어를 보관하는 콜렉션이다. JDK의 맵에서 사용 가능한 모든 메소드는 mutable 인터페이스에서 사용 가능하다. immutable 인터페이스에서는 읽기전용 메소드만 사용 가능하다.
mapOf()
를 사용해Map<K, V>
mutableMapOf()
를 사용해MutableMap<K, V>
JDK의
HashMap
의 참조를 얻기 위해서hashMapOf()
LinkedHashMap
을 얻기 위해서linkedMapOf()
SortedMap
을 얻기 위해서sortedMapOf()
를 사용한다.
키-값 페어가
to()
확장함수를 통해 만들어진다.to()
확장함수는 코틀린의 모든 객체에서 사용 가능하고,mapOf()
는Pair<K, V>
를 인자로 취급한다.size
속성은 맵에 속한 요소들의 숫자를 알려준다.
맵의 keys
속성을 이용해 맵에 존재하는 모든 키를 반복할 수 있고, values
를 이용해 맵에 존재하는 모든 값을 반복할 수 있다. containsKey()
, containsValue()
메소드로 맵 안에 키 또는 값이 존재하는지 체크해보고, contains()
메소드 또는 in
연산자를 이용해 맵에 키가 존재하는지 확인해볼 수 있다.
키에 해당하는 값을 찾기 위해서 get()
메소드를 사용할 수도 있다.
하지만 함정이 존재한다. 아래 코드는 동작하지 않는다.
해당 키가 맵에 존재하지 않으면 값이 나오지 않는다. get()
메소드는 키가 맵에 없을 경우 nullable
타입을 리턴한다. 코틀린은 컴파일 시간에 문제를 알려주고, nullable
참조 타입 사용을 권장한다.
get()
메소드는 인덱스 연산자 []
로도 사용할 수 있다.
[]
를 사용하면 매우 편리하다. 하지만 nullable
참조를 피하기 위해 키가 없으면 기본값을 리턴하도록 할 수 있다.
맵에 "agiledeveloper"라는 키가 없다면 두 번째 인자를 리턴한다. 키가 존재하면 그 키에 해당하는 값을 리턴한다.
Note:
getOrDefault()
는 elvis 연산자 (?:
)를 사용하여 대체가 가능하다.
mapOf()
함수는 읽기전용 참조만 전달해준다. 그래서 우리는 맵을 변경할 수 없다. 하지만 키-값 Pair
를 추가해 새로운 맵을 만들 수 있다.
반대로 -
연산자로 특정 키/값을 제거한 새로운 맵을 만들 수도 있다.
맵을 반복하기 위해 for
루프를 사용할 수 있다.
변수 entry
는 맵의 요소들을 참조해서 키와 값을 가져올 수 있다. entry
로부터 값을 가져오는 대신 구조분해를 이용해 다음과 같이 키와 값을 가져올 수도 있다.
반복이 진행되며 immutable 변수인 key
, value
에 자동으로 키와 값이 들어간다.
맵 인터페이스는 2개의 특별한 메소드인 getValue()
와 setValue()
를 갖고 있다. 두 메소드는 맵을 대리자(delegate)로 사용 가능하게 해주는 메소드이다.
정리
코틀린은 Java의 콜렉션을 확장하는 동시에 읽기전용 뷰를 통해 컴파일 시간의 안정성을 향상시켰다. 함수형 코드를 쓰거나, 동시성 코드를 작성하거나, 비동기 프로그램을 만들 때는 읽기전용 뷰를 사용해야 한다.
코틀린의 페어와 트리플은 한정된 작은 크기의 콜렉션을 만들기에 유용하다.
크기가 크고, 고정된 크기의 콜렉션을 만들 때는
Array
클래스를 사용하는 것이 좋다.반면에 크기가 변경되는 콜렉션이라면 리스트와 셋 중 골라서 사용하도록 하자.
콜렉션을 사용할 때는 콜렉션 생성 메소드(
listOf()
,mutableListOf()
등)를 선택해야만 한다.
Last updated