KB 26 컬렉션 — List 와 MutableList 는 다른 타입이다
고친 사람 github-actions[bot]
0. add 가 없는 리스트
자바에서 List.of 로 만든 리스트에 원소를 더하면 어떻게 되는지 기억하시나요. 컴파일은 됩니다. 문제는 실행입니다.
import java.util.List;
public class First {
public static void main(String[] args) {
List<String> names = List.of("kim");
names.add("lee");
System.out.println(names);
}
}
$ javac -d jout First.java
$ java -cp jout First
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
at First.main(First.java:6)
javac 는 아무 말이 없었고, 6번째 줄 add 에서 UnsupportedOperationException 이 났습니다. 「이 리스트는 그 연산을 지원하지 않는다」는 예외입니다. List 인터페이스에는 add 가 있는데, List.of 가 돌려준 객체가 그 호출을 거절한 것입니다.
같은 일을 코틀린으로 썼습니다.
fun main() {
val names = listOf("kim")
names.add("lee") // 컴파일 오류
println(names)
}
fun main()— 프로그램이 시작하는 함수입니다. 클래스 없이 파일 바로 아래에 둡니다val— 다시 대입할 수 없는 변수입니다. 타입을 안 적으면 오른쪽 값에서 알아냅니다listOf("kim")— 리스트를 만드는 코틀린 표준 함수입니다. 자바의List.of에 해당합니다
코틀린 컴파일러 kotlinc 에 넣었습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다.
$ kotlinc First.kt -d out
First.kt:3:11: error: unresolved reference 'add'.
names.add("lee") // 컴파일 오류
^^^
실행까지 가지 못했습니다. unresolved reference 'add' 는 「add 라는 이름을 찾을 수 없다」는 뜻입니다. 거절당한 게 아니라, listOf 가 돌려준 타입에는 add 라는 함수가 아예 없습니다.
KB 05 val 과 var — final 과 무엇이 같고 다른가 에서 mutableListOf 를 listOf 로 바꾸면 add 가 에러가 된다고 짧게 짚었습니다. 이 편은 그 이야기의 본편입니다. 1절이 리스트 타입이 둘로 갈리는 까닭과 그 함정, 2절이 만드는 법, 3절이 자주 쓰는 연산, 4절이 자바와 오갈 때입니다. 5절에서 연산을 잇는 체인의 비용을 셉니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.
1. 읽기 전용과 변경 가능 — List 와 MutableList
코틀린의 컬렉션(여러 값을 담는 리스트·셋·맵을 묶어 부르는 말)은 인터페이스가 두 벌입니다. List 는 읽는 함수(get·size·contains)만 가진 읽기 전용(read-only) 타입입니다. MutableList 는 List 를 물려받고 add·remove·set 을 더한 변경 가능(mutable) 타입입니다(공식 문서).
listOf 는 List 를 돌려주고, mutableListOf 는 MutableList 를 돌려줍니다. 0절의 names 가 List 라서 add 가 안 보였던 것입니다.
flowchart TD
C["Collection · 읽기: size · contains"] --> L["List · 읽기: get · indexOf"]
C --> MC["MutableCollection · add · remove"]
L --> ML["MutableList · add · remove · set"]
MC --> ML
셋·맵도 같은 모양입니다. Set 과 MutableSet, Map 과 MutableMap 이 짝을 이룹니다. 자바는 java.util.List 하나에 읽기와 쓰기를 다 넣어 두고, 쓰기를 막고 싶으면 실행 중에 예외를 던지는 객체를 돌려줍니다. 코틀린은 그 구분을 타입으로 옮겨서 컴파일 때 막습니다.
대입은 한 방향만 된다
MutableList 가 List 를 물려받았으니, MutableList 를 List 변수에 담는 것은 됩니다. 반대는 어떨까요.
fun main() {
val m: MutableList<Int> = mutableListOf(1)
val r: List<Int> = m
// ↓ 컴파일 오류
val back: MutableList<Int> = r
}
val m: MutableList<Int>— 이름 뒤 콜론 다음이 타입입니다.<Int>는 원소 타입으로, 자바의List<Integer>의<Integer>와 같은 쓰임입니다
$ kotlinc Types.kt -d out
Types.kt:5:32: error: initializer type mismatch: expected 'MutableList<Int>', actual 'List<Int>'.
val back: MutableList<Int> = r
^
3번째 줄은 통과하고 5번째 줄만 막혔습니다. r 이 가리키는 객체는 방금 mutableListOf 로 만든 것이지만, 컴파일러는 변수의 타입 List<Int> 만 봅니다. 읽기 전용 타입으로 한 번 넘긴 값을 변경 가능 타입으로 되돌려 받을 수는 없습니다.
읽기 전용은 불변이 아니다
이름 때문에 가장 많이 헷갈리는 대목입니다. List 는 「이 변수로는 바꿀 수 없다」는 뜻이지, 「이 리스트는 절대 안 바뀐다」는 뜻이 아닙니다. 한 번 만들면 누구도 내용을 못 바꾸는 성질은 불변(immutable)이라고 따로 부릅니다.
실무에서 흔한 모양으로 확인했습니다. 장바구니 클래스가 안에서는 MutableList 로 원소를 더하고, 밖에는 List 로만 내보냅니다.
class Cart {
private val _items = mutableListOf<String>()
val items: List<String> get() = _items
fun add(item: String) { _items.add(item) }
}
fun main() {
val cart = Cart()
cart.add("apple")
val seen = cart.items
val snapshot = cart.items.toList()
cart.add("pear")
println(seen) // [apple, pear]
println(snapshot) // [apple]
(seen as MutableList<String>).add("hack")
// ↓ [apple, pear, hack]
println(cart.items)
}
mutableListOf<String>()— 원소가 없으니 원소 타입을<String>으로 직접 적었습니다val items: List<String> get() = _items— 읽을 때마다_items를 돌려주는 프로퍼티입니다. 프로퍼티는 필드와 게터를 한 이름으로 묶은 선언이고,get()은 그 게터를 직접 정의한 것입니다(KB 05 6절)toList()— 지금 원소를 담은 새 리스트를 만들어 돌려줍니다seen as MutableList<String>—as는 자바의 캐스트(MutableList<String>) seen에 해당합니다
$ kotlinc Shared.kt -d out
$ kotlin -cp out SharedKt
seen 은 apple 만 있을 때 받아 둔 변수인데, 나중에 더한 pear 까지 보였습니다. seen 과 _items 가 같은 리스트 객체를 가리키기 때문입니다. List 타입은 「이 변수로는 읽기만 한다」는 표시일 뿐이고, 객체 자체는 _items 쪽에서 계속 바뀝니다.
toList() 로 받은 snapshot 은 [apple] 에 머물렀습니다. 새 리스트로 복사해 둔 것이라 원본이 바뀌어도 따라가지 않습니다.
마지막 두 줄이 더 불편한 사실을 보여 줍니다. as 로 MutableList 라고 우기자 add 가 컴파일되고, 돌아가고, cart 안의 리스트까지 바뀌었습니다. 속의 객체가 원래 변경 가능한 리스트였기 때문입니다. 읽기 전용은 컴파일러가 변수 타입으로 거는 약속이지, 객체를 잠그는 장치가 아닙니다.
그래서 쓰는 법이 갈립니다. 자기 코드끼리 「이 함수는 리스트를 안 바꾼다」를 알리는 데는 List 로 충분합니다. 받아 간 쪽이 나중에 바뀐 내용을 보면 안 되는 경우, 곧 그때의 값을 떠 두어야 하는 경우라면 toList() 로 복사해 넘깁니다.
정리하면, List 와 MutableList 는 다른 타입이고 MutableList → List 로만 대입됩니다. 다만 List 는 읽기 전용일 뿐 불변이 아니어서, 같은 객체를 MutableList 로 쥔 쪽이 바꾸면 그대로 보입니다.
2. 만들기 — listOf 부터 buildList·mapOf 까지
리스트·맵·셋을 만드는 함수는 이름만 봐도 무엇이 나오는지 알 수 있게 지어져 있습니다. 이 절에서는 자주 쓰는 여섯 가지를 한 파일에서 만들고 찍습니다.
fun main() {
val a = listOf(1, 2, 3)
val b = mutableListOf(1, 2, 3)
b.add(4)
println(b) // [1, 2, 3, 4]
val c = emptyList<String>()
println(c.size) // 0
val d = buildList {
add(0)
if (a.size > 2) add(9)
addAll(a)
}
println(d) // [0, 9, 1, 2, 3]
val p = "a" to 1
println(p) // (a, 1)
println(p.first) // a
println(p.second) // 1
val m = mapOf("a" to 1, "b" to 2)
println(m["a"]) // 1
println(m["z"]) // null
val mm = mutableMapOf("a" to 1)
mm["b"] = 2
println(mm) // {a=1, b=2}
println(setOf(1, 2, 2)) // [1, 2]
}
emptyList<String>()— 빈 읽기 전용 리스트입니다. 원소가 없어 타입을 알아낼 수 없으니<String>을 적습니다buildList { ... }— 중괄호 안에서add로 채우고, 다 채운 결과를 읽기 전용List로 돌려줍니다. 중괄호는 코틀린의 람다(이름 없이 그 대목에 바로 적는 함수)입니다m["a"]— 맵에서 키"a"의 값을 꺼냅니다. 자바의m.get("a")입니다. 키가 없으면null입니다mm["b"] = 2— 자바의mm.put("b", 2)입니다
$ kotlinc Make.kt -d out
$ kotlin -cp out MakeKt
값은 전부 줄 옆에 적었습니다. 셋만 짚겠습니다.
buildList 는 「조건에 따라 원소를 넣었다 뺐다 하며 만든 다음, 다 만든 뒤에는 읽기 전용으로 쓰고 싶을 때」를 위한 함수입니다. 자바에서 ArrayList 를 만들어 채운 뒤 List 타입으로 내보내던 두 단계를 한 식으로 줄입니다. 돌려준 타입이 정말 List 인지는 d.add(2) 를 한 줄 더해 확인했습니다. 0절과 같은 unresolved reference 'add'. 에러가 났습니다.
"a" to 1 이 맵을 만드는 핵심입니다. to 는 두 값을 묶어 Pair 를 만드는 함수이고, 점과 괄호 없이 a to b 로 쓰게 선언된 중위 함수(infix function)입니다(공식 문서). Pair 는 값 둘을 first·second 로 담는 작은 클래스입니다. mapOf 는 이 Pair 들을 받아 키와 값으로 씁니다.
setOf(1, 2, 2) 는 겹친 2 를 하나로 줄여 [1, 2] 를 찍었습니다. mapOf 로 만든 맵은 넣은 순서대로 찍힙니다(공식 문서).
만드는 함수를 모으면 이렇습니다.
| 만들 것 | 읽기 전용 | 변경 가능 | 자바에서 흔히 쓰던 것 |
|---|---|---|---|
| 리스트 | listOf(...) · emptyList() · buildList { } |
mutableListOf(...) |
List.of · new ArrayList<>() |
| 셋 | setOf(...) |
mutableSetOf(...) |
Set.of · new HashSet<>() |
| 맵 | mapOf("a" to 1) |
mutableMapOf("a" to 1) |
Map.of("a", 1) · new HashMap<>() |
정리하면, 이름에 mutable 이 붙으면 변경 가능 타입, 안 붙으면 읽기 전용 타입이 나옵니다. 맵은 키 to 값 으로 만든 Pair 를 넘깁니다.
3. 자주 쓰는 연산 — stream() 도 collect 도 없다
자바에서 리스트를 거르고 바꾸려면 stream() 을 열고 collect 나 toList() 로 닫습니다. 코틀린에서는 리스트에 연산이 바로 붙어 있습니다. 이 절에서는 주문 목록 하나에 일곱 가지 연산을 걸어 봅니다.
data class Order(val user: String, val amount: Int)
fun main() {
val orders = listOf(
Order("kim", 300),
Order("lee", 100),
Order("kim", 200)
)
// ↓ [kim, kim]
println(orders.filter { it.amount >= 200 }.map { it.user })
// ↓ null
println(orders.firstOrNull { it.user == "park" })
// ↓ true
println(orders.any { it.amount > 250 })
// ↓ 600
println(orders.sumOf { it.amount })
println(orders.groupBy { it.user })
println(orders.associateBy { it.user })
}
data class Order(...)— 괄호 안 값으로equals·toString을 만들어 주는 클래스입니다. 찍으면Order(user=kim, amount=300)처럼 나옵니다(KB 23 data class — 한 줄이 만드는 다섯 가지){ it.amount >= 200 }— 람다입니다. 매개변수가 하나면 이름을 안 적고it으로 부릅니다firstOrNull { }— 조건에 맞는 첫 원소, 없으면null입니다sumOf { }— 원소마다 뽑은 값을 더합니다
뒤의 두 줄은 한 줄이 길어서 옆에 붙이지 않았습니다. 앞 네 줄은 줄 옆의 값과 같으니 ... 로 줄였습니다.
$ kotlinc Ops.kt -d out
$ kotlin -cp out OpsKt
...
{kim=[Order(user=kim, amount=300), Order(user=kim, amount=200)], lee=[Order(user=lee, amount=100)]}
{kim=Order(user=kim, amount=200), lee=Order(user=lee, amount=100)}
groupBy 는 키마다 원소 리스트를 모읍니다. kim 의 주문 둘이 한 리스트에 들어갔습니다. associateBy 는 키마다 원소 하나를 둡니다. 그런데 kim 이 둘이라 어느 쪽이 남았는지 보면, 뒤에 온 amount=200 입니다.
자바 스트림으로 옮기면 이렇게 짝이 맞습니다. 표의 앞 네 줄은 OpsFull.java 로 옮겨 돌렸고 같은 네 값을 찍었습니다.
| 하려는 일 | 자바 스트림 | 코틀린 |
|---|---|---|
| 거르고 바꾸기 | stream().filter(...).map(...).toList() |
filter { }.map { } |
| 조건에 맞는 첫 원소 | filter(...).findFirst().orElse(null) |
firstOrNull { } |
| 하나라도 맞나 | anyMatch(...) |
any { } |
| 합계 | mapToInt(...).sum() |
sumOf { } |
| 키마다 리스트로 묶기 | collect(Collectors.groupingBy(...)) |
groupBy { } |
| 키마다 하나로 | collect(Collectors.toMap(..., o -> o)) |
associateBy { } |
코틀린 쪽에는 stream() 도 끝을 닫는 collect 도 없습니다. filter 가 곧바로 새 List 를 돌려주니, 다음 연산을 그 리스트에 바로 잇습니다. 원래 orders 는 손대지 않고 매번 새 리스트나 맵을 만듭니다.
키가 겹칠 때 자바는 멈추고 코틀린은 덮어쓴다
표의 마지막 줄은 짝이 맞아 보여도 동작이 다릅니다. 같은 주문 목록을 자바로 묶었습니다.
import java.util.List;
import java.util.stream.Collectors;
public class Ops {
record Order(String user, int amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("kim", 300),
new Order("lee", 100),
new Order("kim", 200));
System.out.println(orders.stream()
.collect(Collectors.groupingBy(Order::user)));
System.out.println(orders.stream()
.collect(Collectors.toMap(Order::user, o -> o)));
}
}
record 는 자바 16 부터 있는 값 클래스로, 코틀린의 data class 에 해당합니다. 스택 트레이스(예외가 지나온 메서드 호출 목록)의 가운데 여덟 줄은 ... 로 줄였습니다.
$ javac -d jout Ops.java
$ java -cp jout Ops
{lee=[Order[user=lee, amount=100]], kim=[Order[user=kim, amount=300], Order[user=kim, amount=200]]}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key kim (attempted merging values Order[user=kim, amount=300] and Order[user=kim, amount=200])
at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)
...
at Ops.main(Ops.java:15)
자바의 toMap 은 키 kim 이 두 번 나오자 Duplicate key kim 으로 멈췄습니다. 코틀린의 associateBy 는 멈추지 않고 뒤의 값으로 덮어썼습니다. 공식 문서도 키가 같으면 마지막 원소가 남는다고 적습니다(공식 문서).
자바에서 toMap 이 예외로 알려 주던 중복을 코틀린은 조용히 넘깁니다. 키가 겹칠 수 있는 데이터라면 associateBy 대신 groupBy 로 묶어 개수를 확인하는 편이 안전합니다.
한 가지 더, 첫 줄의 순서도 갈렸습니다. 자바 groupingBy 는 lee 가 먼저, 코틀린 groupBy 는 원래 목록에 나온 순서대로 kim 이 먼저입니다. 자바 groupingBy 는 돌려주는 맵의 종류도 순서도 약속하지 않습니다(공식 문서). 코틀린 groupBy 는 키가 처음 나온 순서를 지킵니다(공식 문서).
정리하면, 코틀린 컬렉션 연산은 리스트에 바로 붙어 새 리스트·맵을 돌려줍니다. 이름은 자바 스트림과 거의 짝이 맞지만, associateBy 는 중복 키를 덮어쓴다는 점이 toMap 과 다릅니다.
4. 자바와 오갈 때 — 읽기 전용은 코틀린 안에서만의 약속
1절에서 읽기 전용은 컴파일러의 약속이라고 했습니다. 그러면 그 약속을 모르는 자바 코드에 넘기면 어떻게 될까요. 이 절에서는 원소를 더하는 메서드와 첫 원소를 바꾸는 메서드를 자바로 만들고, 코틀린 List 를 넘깁니다.
import java.util.List;
public class JavaSide {
public static void addOne(List<Integer> xs) {
xs.add(99);
}
public static void setFirst(List<Integer> xs) {
xs.set(0, 99);
}
}
fun main() {
val ro = listOf(1, 2, 3)
// ↓ java.util.Arrays$ArrayList
println(ro.javaClass.name)
JavaSide.setFirst(ro)
println(ro) // [99, 2, 3]
val back = mutableListOf(1, 2, 3)
val view: List<Int> = back
JavaSide.addOne(view)
println(view) // [1, 2, 3, 99]
JavaSide.addOne(ro)
}
ro.javaClass.name— 실행 중인 객체의 JVM 클래스 이름입니다. 자바의ro.getClass().getName()에 해당합니다
자바 클래스를 먼저 컴파일하고, 그 결과를 -cp(클래스를 찾을 경로)로 넘겨 코틀린을 컴파일했습니다.
$ javac -d jout JavaSide.java
$ kotlinc Pass.kt -cp jout -d out
$ kotlin -cp out:jout PassKt
java.util.Arrays$ArrayList
[99, 2, 3]
[1, 2, 3, 99]
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.AbstractList.add(AbstractList.java:155)
at java.base/java.util.AbstractList.add(AbstractList.java:113)
at JavaSide.addOne(JavaSide.java:5)
at PassKt.main(Pass.kt:11)
at PassKt.main(Pass.kt)
컴파일에서는 아무것도 막히지 않았습니다. 코틀린의 List 와 MutableList 는 JVM 에서 둘 다 java.util.List 로 보입니다(공식 문서). 자바 쪽 매개변수가 java.util.List 이니 add 든 set 이든 컴파일됩니다.
결과는 세 갈래로 갈렸습니다.
| 넘긴 것 | 자바가 부른 것 | 결과 |
|---|---|---|
listOf(1, 2, 3) |
set(0, 99) |
바뀌었다 → [99, 2, 3] |
mutableListOf 를 List 로 쥔 view |
add(99) |
더해졌다 → [1, 2, 3, 99] |
listOf(1, 2, 3) |
add(99) |
UnsupportedOperationException |
첫 줄이 가장 놀랍습니다. listOf 로 만든 읽기 전용 리스트가 자바 쪽 set 으로 바뀌었습니다. 까닭은 첫 출력의 클래스 이름에 있습니다. 원소가 여럿인 listOf 는 자바의 Arrays.asList 와 같은 Arrays$ArrayList 를 돌려줍니다. 이 리스트는 크기만 고정이라 add 는 거절하고 set 은 받아 줍니다(Arrays.asList 문서).
0절의 자바 List.of 는 스택 트레이스에 보이듯 ImmutableCollections 쪽의 불변 리스트라 add 부터 거절했습니다. 코틀린 listOf 는 그만큼 단단하지 않습니다. 코틀린 안에서 set 을 못 부르게 막을 뿐입니다.
자바가 돌려준 리스트는 둘 다로 받는다
반대 방향도 한 번 봅니다. 자바 메서드 Maker.make() 가 new ArrayList<>(List.of(1)) 을 List<Integer> 로 돌려줄 때, 코틀린은 그것을 무슨 타입으로 볼까요. 일부러 Int 변수에 넣어 에러 문구로 물었습니다.
fun main() {
val r: List<Int> = Maker.make()
val m: MutableList<Int> = Maker.make()
m.add(2)
println(m)
val s: Int = Maker.make() // 컴파일 오류
}
$ javac -d jout Maker.java
$ kotlinc Recv.kt -cp jout -d out
Recv.kt:6:16: error: initializer type mismatch: expected 'Int', actual '(Mutable)List<Int!>!'.
val s: Int = Maker.make() // 컴파일 오류
^
에러는 6번째 줄 하나뿐입니다. 2·3번째 줄에서 같은 값을 List 로도 MutableList 로도 받았는데 둘 다 통과했습니다. 마지막 줄을 뺀 파일은 컴파일되고 [1, 2] 를 찍었습니다.
(Mutable)List<Int!>! 는 「읽기 전용인지 변경 가능인지 모르는 리스트」라는 표시입니다. 자바에는 그 구분이 없으니 코틀린이 어느 쪽으로 받든 허락합니다. 끝의 ! 는 널 검사를 건너뛴 플랫폼 타입 표시로, KB 17 플랫폼 타입 — 자바에서 온 값은 검사를 건너뛴다 에서 다뤘습니다.
실무 규칙
자바 라이브러리에 코틀린 리스트를 넘길 때는 「읽기 전용이니 안전하다」고 기대하지 않습니다. 넘긴 뒤에도 원래 리스트가 바뀌면 안 된다면 toList() 로 복사한 것을 넘깁니다. 자바 쪽의 변경 시도를 예외로 막아야 한다면 자바 표준의 Collections.unmodifiableList 로 감싸 넘기는 방법이 있습니다(공식 문서).
자바에게서 받는 쪽은 쓰려는 대로 고릅니다. 읽기만 할 거면 List 로 받아 코틀린 안에서 실수로 바꾸는 일을 막습니다. MutableList 로 받을 때는 그 객체가 정말 add 를 받아 주는지 자바 쪽 구현을 확인해야 합니다. 타입이 허락해도 0절처럼 실행에서 거절할 수 있습니다.
정리하면, 코틀린의 읽기 전용은 자바 코드에 닿는 순간 사라집니다. listOf 로 만든 리스트도 자바에서 set 으로 바뀝니다. 자바가 돌려준 리스트는 List 로도 MutableList 로도 받을 수 있습니다.
5. 성능 — 체인은 단계마다 중간 리스트를 만든다
3절에서 filter { }.map { } 처럼 연산을 이어 붙였습니다. 이렇게 연산을 이어 붙인 것을 체인이라고 부릅니다. 체인의 각 단계는 새 리스트를 돌려주므로, 단계가 셋이면 리스트도 셋이 생깁니다. 대부분은 문제가 안 되지만, 원소가 많은데 앞에서 몇 개만 쓰고 끊는 경우에는 헛일이 커집니다.
이 절에서는 1부터 1000 까지를 두 배로 만들고(map), 3의 배수만 남기고(filter), 앞의 셋만 가져갑니다(take(3)). map 안에 카운터를 넣어 원소가 몇 번 map 을 지나가나를 셉니다. 같은 체인을 리스트로 한 번, asSequence() 를 붙여 한 번 돌립니다.
fun main() {
val nums = (1..1_000).toList()
var listCount = 0
val a = nums
.map { listCount++; it * 2 }
.filter { it % 3 == 0 }
.take(3)
println(a) // [6, 12, 18]
println(listCount) // 1000
var seqCount = 0
val b = nums.asSequence()
.map { seqCount++; it * 2 }
.filter { it % 3 == 0 }
.take(3)
.toList()
println(b) // [6, 12, 18]
println(seqCount) // 9
}
(1..1_000).toList()—1..1_000은 1부터 1000 까지의 범위이고(KB 10 범위와 반복 — for 문에 세미콜론이 없다),toList()로 리스트로 만듭니다. 밑줄은 자릿수를 끊어 읽는 표시입니다{ listCount++; it * 2 }— 세미콜론으로 두 식을 한 줄에 적었습니다. 람다는 마지막 식it * 2를 돌려줍니다. 바깥var를 람다 안에서 바꿀 수 있습니다asSequence()— 리스트를Sequence로 바꿉니다. 아래에서 풉니다take(3)— 앞에서 세 개만 가져갑니다
$ kotlinc Chain.kt -d out
$ kotlin -cp out ChainKt
결과 리스트는 둘 다 [6, 12, 18] 인데, map 을 지나간 원소 수는 1000 대 9 입니다. 리스트 쪽은 map 이 1000 개를 다 바꿔 1000 칸짜리 리스트를 만들고, filter 가 그것을 다 훑어 또 리스트를 만든 뒤에야 take(3) 이 앞의 셋을 가져갑니다. 시퀀스 쪽은 3, 6, 9 번째 원소에서 답 셋이 채워지자 멈췄습니다.
시퀀스는 원소를 하나씩 끝까지 보낸다
Sequence 는 연산을 걸어 둘 뿐 바로 계산하지 않고, 결과가 필요해질 때 원소를 하나씩 단계 끝까지 흘려보내는 컬렉션 타입입니다(공식 문서). 필요할 때까지 계산을 미루는 방식을 지연 평가(lazy evaluation)라고 부릅니다. 자바 스트림이 같은 방식으로 돕니다.
순서로 확인했습니다. 원소 두 개짜리 리스트에 map 과 filter 를 걸고, 람다마다 찍게 했습니다. 시퀀스 쪽은 체인을 만든 뒤 클래스 이름을 찍고, 그다음에 toList() 를 불렀습니다. 무엇이 어떤 순서로 찍힐까요.
fun main() {
listOf(1, 2)
.map { println("map $it"); it }
.filter { println("filter $it"); true }
println("--")
val lazy = listOf(1, 2).asSequence()
.map { println("map $it"); it }
.filter { println("filter $it"); true }
println(lazy.javaClass.name)
println("--")
lazy.toList()
}
$ kotlinc Lazy.kt -d out
$ kotlin -cp out LazyKt
map 1
map 2
filter 1
filter 2
--
kotlin.sequences.FilteringSequence
--
map 1
filter 1
map 2
filter 2
리스트는 단계별로 돕니다. map 이 원소 둘을 다 처리하고 나서 filter 가 돕니다. 시퀀스는 원소별로 돕니다. 1 이 map 과 filter 를 다 지난 뒤에 2 가 들어갑니다. take(3) 이 셋을 채우자 멈출 수 있었던 까닭이 이 순서입니다.
가운데 부분도 봐야 합니다. 시퀀스에 map 과 filter 를 걸어 둔 시점에는 아무것도 안 찍혔습니다. 대신 FilteringSequence 라는 객체가 생겼습니다. 계산은 toList() 를 부를 때 시작됐습니다. toList() 처럼 결과를 실제로 만들어 내는 연산을 최종 연산(terminal operation)이라고 부릅니다. 최종 연산을 안 부르면 시퀀스 체인은 아무 일도 하지 않습니다.
작은 리스트는 리스트가 낫다
그러면 늘 시퀀스를 쓰면 될까요. 그렇지 않습니다. 시퀀스도 공짜가 아닙니다.
위 출력의 FilteringSequence 처럼, 시퀀스는 단계마다 앞 단계를 감싸는 객체를 하나씩 만듭니다. 그리고 리스트의 map·filter 는 inline 함수라 넘긴 람다가 호출부에 펼쳐지지만, 시퀀스의 map·filter 는 inline 이 아니라 람다가 객체로 넘어갑니다(공식 문서). inline 과 람다 객체의 관계는 KB 25 람다와 함수 타입 — 함수를 값으로 넘긴다 에서 봤습니다. 원소마다 이 객체들을 거쳐 한 칸씩 넘기는 비용도 붙습니다.
공식 문서도 시퀀스의 지연 방식에 드는 비용이 작은 컬렉션이나 단순한 계산에서는 클 수 있다고 적습니다(공식 문서). 이 편에서 시간을 재지는 않았습니다. 판단 기준을 조건으로 적으면 이렇습니다.
| 조건 | 고를 것 | 까닭 |
|---|---|---|
| 원소가 적다 | 리스트 | 중간 리스트가 작고, 시퀀스가 만드는 객체가 오히려 더 붙는다 |
| 단계가 하나 | 리스트 | 중간 리스트가 생기지 않는다 |
원소가 많고, 단계가 여럿이고, take·first 처럼 앞에서 끊는다 |
시퀀스 | 끊은 뒤의 원소는 아예 계산하지 않는다 |
| 원소가 많고 단계가 여럿이지만 끝까지 다 쓴다 | 상황에 따라 갈린다(미측정) | 계산 횟수는 같다. 중간 리스트가 줄어드는 대신 시퀀스 객체가 붙는다 |
셋째 줄이 시퀀스가 확실히 이기는 경우입니다. 위 실측이 바로 그 모양이었습니다.
크기와 관련해 하나 더 있습니다. 원소 개수를 미리 알고 ArrayList 를 채운다면 ArrayList<Int>(1_000) 처럼 처음 크기를 넘겨 둘 수 있습니다. 넘기지 않으면 ArrayList 는 원소가 찰 때마다 스스로 속의 배열을 키웁니다(공식 문서). 자바에서 new ArrayList<>(n) 을 쓰던 것과 같은 판단입니다.
정리하면, 리스트 체인은 단계마다 중간 리스트를 만들고, 시퀀스는 원소를 하나씩 끝까지 보내다 필요한 만큼에서 멈춥니다. 앞에서 끊는 긴 체인이면 시퀀스, 작은 리스트면 리스트가 낫습니다.
6. 한 장 요약
코틀린은 리스트를 읽기 전용
List와 변경 가능MutableList두 타입으로 가른다.listOf는List를 돌려주니add가 컴파일 에러다.MutableList는List에 담을 수 있지만 거꾸로는 안 된다. 읽기 전용은 불변이 아니다 — 같은 객체를MutableList로 쥔 쪽이 바꾸면 보이고, 자바 코드에 넘기면 약속이 사라져listOf리스트도set으로 바뀐다. 연산은 리스트에 바로 붙어 새 리스트를 돌려주고, 체인은 단계마다 중간 리스트를 만든다. 원소가 많고 앞에서 끊는 긴 체인이면asSequence()가 확실히 이기고, 작은 리스트는 리스트가 낫다.
| 주제 | 자바 | 코틀린 | 확인한 방법 |
|---|---|---|---|
불변 리스트에 add |
List.of 는 컴파일되고 실행에서 UnsupportedOperationException |
listOf 는 add 가 없어 컴파일 에러 |
javac 실행 · kotlinc 에러 |
| 읽기·쓰기 구분 | java.util.List 하나 |
List · MutableList 두 타입 |
kotlinc 에러 |
List → MutableList 대입 |
— | 컴파일 에러 | kotlinc 에러 |
| 읽기 전용의 뜻 | — | 불변 아님. 같은 객체를 다른 쪽이 바꾸면 보인다 | 실행 출력 |
| 그때 값을 복사해 두기 | new ArrayList<>(list) |
toList() |
실행 출력 |
| 조건부로 채운 뒤 읽기 전용 | ArrayList 를 채워 List 로 |
buildList { } |
실행 출력 · kotlinc 에러 |
| 맵 만들기 | Map.of("a", 1) |
mapOf("a" to 1), to 가 Pair 를 만든다 |
실행 출력 |
| 거르고 바꾸기 | stream().filter().map().toList() |
filter { }.map { } |
양쪽 실행 출력 |
| 키가 겹칠 때 | toMap 이 IllegalStateException |
associateBy 가 뒤의 값으로 덮어쓴다 |
양쪽 실행 출력 |
자바에 넘긴 listOf |
set 은 되고 add 는 예외 |
컴파일은 막지 않는다 | 실행 출력 |
자바가 돌려준 List |
— | (Mutable)List<Int!>!, List·MutableList 둘 다로 받는다 |
kotlinc 에러 |
| 체인의 중간 계산 | 스트림은 원소별 | 리스트는 단계별 1000 번, 시퀀스는 원소별 9 번 | 카운터 · 출력 순서 |
관련 항목
코틀린 컬렉션을 이루는 인터페이스
컬렉션 · List · MutableList · Set · MutableSet · Map · MutableMap · Collection · MutableCollection
읽기 전용과 맞세워지는 성질
읽기 전용 컬렉션 · 불변 컬렉션 · 불변 객체 · 가변 객체 · 방어적 복사 · 얕은 복사
컬렉션을 만드는 표준 함수
listOf · mutableListOf · buildList · emptyList · mapOf · setOf · Pair · 중위 함수
컬렉션에 거는 연산
filter · map · firstOrNull · any · sumOf · groupBy · associateBy · 람다 · 고차 함수
같은 일을 하는 자바 쪽 도구
자바 스트림 · Collectors · List.of · Arrays.asList · Collections.unmodifiableList · UnsupportedOperationException · record
자바와 오가며 바뀌는 타입
매핑 타입 · 플랫폼 타입 · java.util.List · ArrayList · LinkedHashMap · HashMap
체인 비용을 줄이는 계산 방식
Sequence · 지연 평가 · 중간 연산 · 최종 연산 · asSequence · inline 함수 · 객체 할당