안드로이드 개발자 노트
[코틀린 완벽 가이드] 7-1장 : 컬렉션 본문
컬렉션 (컬렉션과 I/O 자세히 알아보기)
컬렉션을 조작하는 모든 연산이 인라인 함수이다. 따라서 이런 연산을 사용해도 함수 호출이나 람다 호출에 따른 부가 비용이 들지 않는다.
1. 컬렉션 타입
코틀린 컬렉션 타입은 기본적으로 네 가지로 분류된다.
배열, 이터러블, 시퀀스, 맵
컬렉션은 기본적으로 원소의 타입을 지정하는 제네릭 타입이다.
이터러블
이터러블은 Iterable<T> 타입으로 표현된다.
일반적으로 즉시 계산(eager)되는 상태가 있는(stateful) 컬렉션을 표현한다.
- 즉시 계산: 필요한 시점에 원소가 초기화되지 않고 컬렉션을 최초로 생성할 때 초기화된다는 뜻
- 상태가 있다: 컬렉션이 원소를 필요할 때 생성하는 제너레이터 함수를 유지하지 않고 원소를 저장한다는 뜻
이터러블 타입은 원소를 순회할 수 있는 iterator()라는 메서드를 제공한다. for 루프에서 이 메서드를 통해 모든 이터러블 객체를 활용할 수 있다.
val list = listOf("red", "green", "blue")
for (item in list) {
print(item + " ")
} // red green blue
자바와 비교할 때 코틀린 이터러블의 특징은 불변 컬렉션과 가변 컬렉션을 구분한다는 점이다.
불변 컬렉션은 생성한 다음 내용을 바꿀 수 없고, 가변 컬렉션은 언제든 원소를 추가하거나 삭제할 수 있다.
컬렉션의 변경 가능성과 변수의 변경 가능성과 아무런 관계가 없다. 불변 변수의 가변 컬렉션은 다른 컬렉션을 가리키게 변경할 수는 없지만, 변수가 가리키는 컬렉션에 원소를 추가하거나 삭제할 수 있다.
val list = ArrayList<String>()
list.add("abc") // Ok: 컬렉션 데이터를 변경
list = ArrayList<String>() // error
가변 이터러블의 기본 타입은 MutableIterable로 표현되며, 이 인터페이스의 객체는 MutableIterator 인스턴스를 생성할 수 있다.
불변 컬렉션 타입에서 유용한 특징으로는 공변성이 있다.
공변성은 T가 U의 하위 타입인 경우 Iterable<T>도 Iterable<U>의 하위 타입이라는 뜻이다.
Iterator, Collection, List, Set, Map 등과 같은 컬렉션 관련 타입도 이런 공변성이 성립한다.
fun processCollection(c: Iterable<Any>) {...}
fun main() {
val list = listOf("a", "b", "c") // List<String>
processCollection(list) // Ok: List<String>을 List<Any>로 전달
}
가변 컬렉션의 경우 이런 코드가 작동하지 않는다.
fun processCollection(c: MutableCollection<Any>) {
c.add(123) // 정수를 문자열 리스트에 추가하는 것과 같은 일이 생긴다.
}
fun main() {
val list = listOf("a", "b", "c") // List<String>
processCollection(list) // 컴파일이 되면 문제가 생긴다.
}
컬렉션, 리스트, 집합
Collection을 상속한 클래스는 크게 다음과 같이 분류할 수 있다.
- List와 MutableList로 표현되는 리스트는 인덱스를 통하여 접근이 가능하며, 순서가 있는 컬렉션이다. 리스트는 인덱스를 통한 임의 접근이 가능한 ArrayList와 원소를 빠르게 추가하거나 삭제할 수 있지만, 인덱스로 원소에 접근할 때는 O(n)의 시간이 걸리는 LinkedList가 있다.
- 집합은 유일한 원소들로 이뤄진 컬렉션이다. 원소의 순서는 구현에 따라 다르다.
- HashSet은 해시 테이블을 기반으로 하며, 원소의 해시 코드에 따라 원소 순서가 정해진다.
- LinkedHashSet은 해시 테이블이 기반이지만, 삽입 순서를 유지하기 때문에 이터레이션하면 삽입된 순서대로 원소를 순회할 수 있다.
- TreeSet은 이진 검색 트리가 기반이머, 어떤 비교 규칙에 따라 일관성 있는 원소 순서를 제공한다.
시퀀스
자바의 스트림과 비슷한 코틀린의 시퀀스도 iterator() 메소드를 제공한다. 이 메서드를 통해 시퀀스의 내용을 순회할 수 있지만 이터러블과는 의도가 조금 다르다. Collection 에서 연산은 즉시(eager) 발생하지만, Sequence 에서 연산은 지연(lazy) 처리된다. 즉 map, filter 등이 호출될 때, Collection 에서는 전체 원소를 순회하지만, Sequence 는 순회하지 않을 수 있다. 이터러블과 달리 시퀀스는 내부적이므로 외부에서 직접 사용할 수 없다.
맵
맵은 키(key) 와 값(value) 쌍으로 이뤄진 집합이다. 맵 자체는 Collection의 하위 타입이 아니지만, 맵의 원소들은 컬렉션처럼 사용할 수 있다. 키-값 쌍은 Map.Entry 와 MutableMap.MutableEntry 인터페이스로 표현된다. 맵은 두 가지 종류의 원소가 들어있기 때문에 맵의 타입은 두 가지 타입을 파라미터로 받는 제너릭 타입( Map<String, Int> )이다. 맵은 HashMap, LinkedHashMap, TreeMap 등이 있으며, 이들의 성질은 각각에 대응하는 Set 클래스와 성질이 비슷하다.
2. Comparable과 Comparator
Comparable(비교 가능) 인스턴스는 자연적인 순서를 지원하며, 동일한 타입의 다른 인스턴스와 순서를 비교할 때 쓸 수 있는 compareTo() 메서드를 포함한다.
class Person(
val firstName: String,
val familyName : String,
val age: Int
): Comparable<Person> {
val fullName get() = "$firstName $familyName"
override fun compareTo(other: Person): Int = fullName.compareTo(other.fullName)
}
compareTo() 함수는 수신 객체 인스턴스가 인자로 받은 상대 인스턴스보다 더 크면 양수, 더 작으면 음수, 같으면 0을 반환한다. compareTo() 구현은 equals() 함수 구현과 서로 의미가 일치해야 한다. 비교기(comparator)를 사용하여 클래스의 여러 프로퍼티 조합을 기준으로 정렬할 수도 있다. Comparator<T> 클래스는 타입 T의 인스턴스 객체를 두 개 인자로 받아 비교한 결과를 반환하는 compare() 함수를 제공한다.
val AGE_COMPARATOR = Comparator<Person>{ p1, p2 ->
p1.age.compareTo(p2.age)
}
람다 비교 함수를 통해 비교기를 간결하게 작성할 수 있으며, compareBy()나 compareByDescending() 함수를 통해 대상 객체 대신 사용할 수 있는 비교 가능 객체를 제공하게 함으로써 비교기를 만들 수도 있다.
val AGE_COMPARATOR = compareBy<Person>{ it.age }
val REVERSE_AGE_COMPARATOR = compareByDescending<Person> { it.age }
또한, 비교기 인스턴스를 sorted()나 max() 처럼 순서를 인지하는 함수에 넘길 수도 있다.
3. 컬렉션 생성하기
ArrayList나 LinkedHashSet 같은 클래스의 인스턴스를 생성자를 호출해 생성할 수 있다.
val list = ArrayList<String>()
list.add("red")
list.add("green")
println(list) // [red, green]
val set = HashSet<Int>()
set.add(12)
set.add(21)
set.add(12)
println(set) // [12, 21]
val map = TreeMap<Int, String>()
map[20] = "Twenty"
map[10] = "Ten"
println(map) // {10=Ten, 20=Twenty}
arrayOf() 와 비슷하게 가변 길이 인자를 받는 함수를 사용해 몇몇 컬렉션 클래스의 인스턴스를 생성할 수 있다.
- emptyList() / emptySet(): 불변인 리스트/집합 인스턴스를 생성한다.
- listOf() / setOf(): 인자로 제공한 가변 길이 인자에 기반한 불변 리스트/집합 인스턴스를 만든다.
- listOfNotNull(): 널인 값을 걸러내고 남은 원소들로 이뤄진 새 불변 리스트를 만든다.
- mutableListOf() / mutableSetOf(): 가변 리스트/집합의 디폴트 구현 인스턴스를 만든다.
- arrayListOf(): 새로운 ArrayList를 만든다.
- hashSetOf() / linkedSetOf() / sortedSetOf(): HashSet / LinkedHashSet / TreeSet의 새 인스턴스를 만든다.
val emptyList = emptyList<String>()
println(emptyList) // []
emptyList.add("abc") // Error
val singletonSet = setOf("abc")
println(singletonSet) // [abc]
singletonSet.remove("abc") // Error
val mutableList = mutableListOf("abc")
println(mutableList) // [abc]
mutableList.add("def")
mutableList[0] = "xyz"
println(mutableList) // [xyz, def]
val sortedSet = sortedSetOf(8, 5, 7, 1, 4)
println(sortedSet) // [1, 4, 5, 7, 8]
sortedSet.add(2)
println(sortedSet) // [1, 2, 4, 5, 7, 8]
맵을 만들 때도 비슷한 함수를 쓸 수 있다.
- emptyMap(): 빈 불변 맵을 만든다.
- mapOf(): 새 불변 맵을 만든다(내부적으로 LinkedHashMap을 만든다).
- mutableMapOf(): 가변 맵의 디폴트 구현 인스턴스를 만든다(내부적으로 LinkedHashMap을 만든다).
- hashMapOf() / linkedMapOf() / sortedMapOf(): HashMap / LinkedHashMap / TreeMap의 새 인스턴스를 만든다.
맵 함수들은 Pair 객체들로 이뤄진 가변 인자를 받는다. to 중위 연산자를 사용하면 Pair 객체를 쉽게 만들 수 있다.
val emptyMap = emptyMap<Int, String>()
println(emptyMap) // {}
emptyMap[10] = "Ten" // Error
val singletonMap = mapOf(10 to "Ten")
println(singletonMap) // {10=Ten}
singletonMap.remove("abc") // error
val mutableMap = mutableMapOf(10 to "Ten")
println(mutableMap) // {10=Ten}
mutableMap[20] = "Twenty"
mutableMap[100] = "Hundred"
mutableMap.remove(10)
println(mutableMap) // {20=Twenty, 100=Hundred}
val sortedMap = sortedMapOf(3 to "three", 1 to "one", 2 to "two")
println(sortedMap)
sortedMap[0] = "zero"
println(sortedMap)
Pair를 사용하지 않고, 가변 맵을 만들고 set() 메서드나 인덱스 연산자([ ])를 사용해 원소를 추가할 수도 있다.
배열과 비슷하게 크기를 지정하고 인덱스로부터 값을 만들어주는 함수를 제공함으로써 새 리스트를 만들 수도 있다.
println(List(5) { it * it}) // [0, 1, 4, 9, 16]
val numbers = MutableList(5) { it * 2 }
println(numbers) // [0, 2, 4, 6, 8]
numbers.add(100)
println(numbers) // [0, 2, 4, 6, 8, 100]
시퀀스는 sequenceOf() 함수를 사용해 구현할 수 있으며, 가변 인자또는 배열, 이터러블, 맵 등의 기존 컬렉션에 대해 asSequence() 함수를 호출해서 시퀀스를 얻을 수도 있다.
println(sequenceOf(1, 2, 3).iterator().next()) // 1
println(listOf(10, 20, 30).asSequence().iterator().next()) // 10
println(
mapOf(1 to "One", 2 to "Two").asSequence().iterator().next()
) // 1=One
맵에 대해 asSequence()를 호출하면 맵 엔트리(키, 값 쌍 타입)의 시퀀스를 얻는다. 또한, 제너레이터 함수를 이용해 시퀀스를 만들 수도 있다. generateSequence() 함수는 두 가지 방법으로 구현이 가능하다.
1. 시퀀스의 다음 원소를 생성해주는 파라미터가 없는 함수를 인자로 받는다. 이 함수가 널을 반환할 때까지 시퀀스의 원소 생성이 계속된다.
// 1
val numbers = generateSequence{ readLine()?.toIntOrNull() }
2. 초깃값과 파라미터가 하나인 함수를 인자로 받는다. 이 함수는 이전 값으로부터 다음 값을 만들어낸다. 마찬가지로 널을 반환하면 시퀀스가 끝난다.
// 2
// 무한 시퀀스(단, 값 오버플로가 발생해서 음스와 양수를 왔다갔다 함): 1, 2, 4, 8, ...
val powers = generateSequence(1) { it*2 }
// 유한 시퀀스: 10, 8, 6, 4, 2, 0
val evens = generateSequence(10) { if (it >= 2) it - 2 else null}
3. 시퀀스 원소를 부분 부분 지정한다. SequenceScope가 수신 객체 타입인 확장 람다를 받는 sequence() 함수를 통해 빌더를 구현할 수 있다. SequenceScope 타입은 확장 람다 본문 안에서 시퀀스 뒤에 값을 추가할 수 있는 다음 함수를 제공한다.
- yield(): 원소를 하나 시퀀스에 추가한다.
- yieldAll(): 지정한 이터레이터, 이터러블, 시퀀스에 들어있는 모든 원소를 시퀀스에 추가한다.
시퀀스에서 각 부분에 속한 원소에 접근하는 경우에만 호출되며, 원소가 지연 연산된다.
val numbers = sequence {
yield(0)
yieldAll(listOf(1, 2, 3))
yieldAll(intArrayOf(4, 5, 6).iterator())
yieldAll(generateSequence(10) { if (it < 50) it*3 else null})
}
println(numbers.toList()) // [0, 1, 2, 3, 4, 5, 6, 10, 30, 90]
마지막으로 배열 원소를 바탕으로 리스트를 만들거나 시퀀스 집합으로 만드는 toSet(), toList(), toMap()과 같은 컬렉션 사이의 변환을 처리하는 함수이다.
println(listOf(1, 2, 3, 2, 3).toSet()) // [1, 2, 3]
println(arrayOf("red", "green", "blue").toSortedSet()) // [blue, green, red]
println(mapOf(1 to "one", 2 to "two", 3 to "three").toList()) // [(1, one), (2, two), (3, three)]
println(sequenceOf(1 to "one", 2 to "two", 3 to "three").toMap()) // {1=one, 2=two, 3=three}
4. 기본 컬렉션 연산
컬렉션 타입이 지원하는 기본 연산들이 있다. 모든 컬렉션이 기본으로 지원하는 공통 연산으로는 이터레이션이 있다.
val map = mapOf(1 to "one", 2 to "two", 3 to "three")
for ((key, value) in map) {
println("$key -> $value")
}
for 루프 대신 forEach() 확장 함수를 쓸 수도 있다. 이 함수는 컬렉션의 각 원소를 제공하면서 인자로 받은 람다를 실행해준다.
intArrayOf(1, 2, 3).forEach { println(it*it) }
listOf("a", "b", "c").forEach { println("'$it'") }
sequenceOf("a", "b", "c").forEach { println("'$it'") }
mapOf(1 to "one", 2 to "two", 3 to "three").forEach { (key, value) ->
println("$key -> $value")
}
원소의 인덱스를 참조해야 한다면 forEachIndexed() 함수를 쓰면 된다.
listOf(10, 20, 30).forEachIndexed { i, n -> println("$i: ${n*n}") }
컬렉션 타입이 제공하는 기본 기능은 다음과 같다.
- size 프로퍼티는 원소의 개수를 리턴
- isEmpty() 함수는 컬렉션에 원소가 있는지 없는지 검사
- contains() / containsAll() 함수는 인자값 혹은 인자로 지정한 컬렉션이 수신 객체 컬렉션에 들어있는지 검사
val list = listOf(1, 2, 3)
println(list.isEmpty()) // false
println(list.size) // 3
println(list.contains(4)) // false
println(list.containsAll(listOf(1, 2))) // true
println(2 in list) // true
// contains() 함수 대신 in 연산자를 사용할 수 있다.
MutableCollection 타입은 원소를 추가하거나 제거할 수 있는 메서드를 제공한다.
val list = arrayListOf(1, 2, 3)
list.add(4) // [1, 2, 3, 4]
list.remove(3) // [1, 2, 4]
list.addAll(setOf(5, 6)) // [1, 2, 4, 5 ,6]
list.removeAll(listOf(1, 2)) // [4, 5, 6]
list.retainAll(listOf(5, 6, 7)) // [5, 6] 교집합
list.clear() // []
+= 나 -= 복합 연산을 사용할 수도 있다.
list += 4
list -= 3
list += setOf(5, 6)
list -= listOf(1, 2)
하지만 변수가 불변 변수인 경우 불가능하다.
val readOnly = listOf(1, 2, 3)
readOnly += 4 // Error
var mutable = listOf(1, 2, 3)
mutable += 4 // Correct
불변 / 가변 컬렉션 모두 + 와 - 연산자를 지원하며 원본을 그대로 두고 새로운 컬렉션을 생성한다.
println(listOf(1, 2, 3) + 4) // [1, 2, 3, 4]
println(listOf(1, 2, 3) - setOf(2, 5)) // [1, 3]
이러한 컬렉션 연산들은 새로운 컬렉션을 만들기 때문에 프로그램 성능에 영향을 미칠 수 있다.
리스트도 원소를 인덱스로 접근하거나 변경할 수 있는 메서드를 제공한다.
val list = listOf(1, 4, 6, 2, 4, 1, 7)
println(list.get(3)) // 2
println(list[2]) // 6
println(list[10]) // ArrayIndexOutOfBoundsException
println(list.indexOf(4)) // 1
println(list.lastIndexOf(4)) // 4
println(list.indexOf(8)) // -1
val arrayList = arrayListOf(1, 4, 6, 2, 4, 1, 7)
arrayList.set(3, 0) // [1, 4, 6, 0, 4, 1, 7]
arrayList[2] = 1 // [1, 4, 1, 0, 4, 1, 7]
arrayList.removeAt(4) // [1, 4, 1, 0, 4, 7]
arrayList.add(3, 8) // [1, 4, 1, 8, 0, 4, 7]
subList() 함수는 시작 인덱스(포함)와 끝 인덱스(불포함)로 지정한 리스트의 일부분에 대한 레퍼를 만든다. 이 레퍼는 원본 컬렉션의 데이터를 공유하기 때문에 원본의 변화가 반영된다.
val list = arrayListOf(1, 4, 6, 2, 4, 1, 7)
val segment = list.subList(2, 5) // 6, 2, 4, 1
list[3] = 0
println(segment[1]) // 0
segment[1] = 8
println(list[3]) // 8
집합(Set)은 컬렉션 공통 메서드만 지원하며 중복을 허용하지 않는다.
Map 인스턴스는 키를 사용해 값을 얻는 메서드와 모든 키나 값의 컬렉션을 돌려주는 메서드 등을 지원한다.
val map = mapOf(1 to "I", 5 to "V", 10 to "X", 50 to "L")
println(map.isEmpty()) // false
println(map.size) // 4
println(map.get(5)) // V
println(map[10]) // X
println(map[100]) // null
println(map.getOrDefault(100, "?")) // ?
println(map.getOrElse(100) { "?" }) // ?
println(map.containsKey(10)) // true
println(map.containsValue("C")) // false
println(map.keys) // [1, 5, 10, 50]
println(map.values) // [I, V, X, L]
println(map.entries) // [1=I, 5=V, 10=X, 50=L]
MutableMap 인스턴스는 기본적인 변경 연산과 +, - 연산자를 지원한다.
val map = sortedMapOf(1 to "I", 5 to "V")
map.put(100, "C") // {1=I, 5=V, 100=C}
map[500] = "D" // {1=I, 5=V, 100=C, 500=D}
map.remove(1) // {5=V, 100=C, 500=D}
map.putAll(mapOf(10 to "X")) // {5=V, 10=X, 100=C, 500=D}
map += 50 to "L" // {5=V, 10=X, 50=L, 100=C, 500=D}
map += mapOf(2 to "II", 3 to "III") // {2=II, 3=III, 5=V, 10=X, 50=L, 100=C, 500=D}
map -= 100 // {2=II, 3=III, 5=V, 10=X, 50=L, 500=D}
map -= listOf(2, 3) // {5=V, 10=X, 50=L, 500=D}
맵에서도 불변 / 가변 컬렉션 상관 없이 불변 변수만 +=, -= 연산이 불가능하다. 또한 + 연산자는 키-값 쌍을 인자로 받지만 - 연산자는 키만 받는다.
5. 컬렉션 원소에 접근하기
코틀린에서는 개별 컬렉션 원소에 대한 접근을 편하게 해주는 확장 함수들이 있다.
first() / last() 함수는 각각 컬렉션의 첫 번째와 마지막 원소를 반환하며, 컬렉션이 비어있으면 NoSuchElementException을 발생시킨다. 원소가 없으면 널을 반환하는 안전한 fistOrNull() / lastOrNull() 이 있다.
println(listOf(1, 2, 3).first()) // 1
println(listOf(1, 2, 3).last()) // 3
println(emptyArray<String>().first()) // Exception
println(emptyArray<String>().firstOrNull()) // null
val seq = generateSequence(1) { if (it > 50) null else it * 3 }
println(seq.first()) // 1
println(seq.last()) // 81
조건에 맞는 첫 번째나 마지막 원소를 찾을 수도 있다.
println(listOf(1, 2, 3).first { it > 2 }) // 3
println(listOf(1, 2, 3).lastOrNull { it < 0 }) // null
println(intArrayOf(1, 2, 3).first { it > 3 }) // Exception
single() 함수는 싱글턴 컬렉션의 원소(단일 요소)를 반환한다. 컬렉션이 비어있거나 원소가 두 개 이상이면 예외를 던진다. singleOrNull()은 동일한 경우 null을 반환한다.
println(listOf(1).single()) // 1
println(emptyArray<String>().singleOrNull()) // null
println(setOf(1, 2, 3).singleOrNull()) // null
println(sequenceOf(1, 2, 3).single()) // Exception
elementAt() 함수를 사용하면 인덱스를 사용해 컬렉션의 원소를 읽을 수 있다.
println(listOf(1, 2, 3).elementAt(2)) // 3
println(sortedSetOf(1, 2, 3).elementAtOrNull(-1)) // null
println(arrayOf("a", "b", "c").elementAtOrElse(1) { "??? "}) // b
val seq = generateSequence(1) { if (it > 50) null else it * 3 }
println(seq.elementAtOrNull(2)) // 9
println(seq.elementAtOrElse(100) { Int.MAX_VALUE }) // 2147483647
println(seq.elementAt(10)) // Exception
배열과 리스트에 대한 구조 분해를 통해 앞에서부터 최대 다섯 개의 원소를 추출할 수 있다.
컬렉션에 있는 원소 수보다 많이 가져오려고 하면 예외가 발생한다.
val list = listOf(1, 2, 3)
val (x, y) = list // 1, 2
val (a, b, c, d) = list // Exception
6. 컬렉션에 대한 조건 검사
코틀린은 컬렉션 원소에 대해 기본적인 검사를 구현하는 함수를 제공한다.
- all(): 컬렉션의 모든 원소가 주어진 술어를 만족하면 true를 반환한다.
- none(): all() 과 반대다. 조건을 만족하는 원소가 하나도 없으면 true를 반환한다.
- any(): 컬렉션 원소 중 하나라도 술어를 만족할 때 true를 반환한다.
빈 컬렉션의 경우 all()과 none() 함수는 true를, any() 함수는 false를 반환하며, 무한 시퀀스에 all(), none(), any()를 적용하면 실행이 끝나지 않을 수도 있다.
// 0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 0, ...으로 이뤄진 시퀀스
val seq = generateSequence(0) { (it + 1) % 5 }
println(seq.all { it < 5 }) // 끝나지 않음
any()와 none() 함수에는 술어를 지정하지 않고 파라미터가 없는 오버로딩된 버전이 있다. 이런 함수는 단순히 컬렉션 객체가 비어있는지만 검사하는 isEmpty() / isNotEmpty() 함수와 같다.
7. 집계
코틀린은 컬렉션 원소의 합계를 계산하거나 최댓값 최솟값을 찾는 것처럼 컬렉션 내용으로부터 값을 계산해내는 집계 함수을 제공한다.
count() 함수는 컬렉션의 원소 개수를 반환하며 원소 개수가 Int.MAX_VALUE보다 크면 예외를 던진다. 술어를 적용해 주어진 조건을 만족하는 원소의 개수를 반환할 수도 있다.
println(listOf(1, 2, 3, 4).count { it < 0 }) // 0
println(listOf(1, 2, 3, 4).count { it % 2 == 0 }) // 2
println(mapOf(1 to "I", 5 to "V", 10 to "X").count { it.key == 1 }) // 1
val seq = generateSequence(1) { if (it < 50) it*3 else null } // 1, 3, 9, 27, 81
println(seq.count { it % 3 == 0 }) // 4
println(seq.count { it > 100 }) // 0
sum() 함수는 수로 이뤄진 배열, 이터러블, 시퀀스의 산술 합계를 구한다. 기본적으로 반환값 타입은 컬렉션의 원소 타입에 따라 달라진다. sumOf() 함수는 술어를 사용한 산술 합계이며, 반환되는 타입을 변환할 수 있다.
println(listOf(1, 2, 3, 4).sumOf { it/4.0 }) // 2.5
println(arrayOf("1", "2", "3").sumOf { it.toInt() }) // 6
val seq = generateSequence("X") {
if (it.length >= 5) null else it + "X"
}
println(seq.sumOf { it.length }) //15
average() 함수는 수로 이뤄진 배열, 이터러블, 시퀀스의 산술 평균을 구한다. 결과는 항상 Double이며, 컬렉션이 비어있으면 Double.NaN을 반환한다. count()와 같이 원소 개수가 Int.MAX_VALUE보다 크면 예외를 던진다.
minOrNull()과 maxOrNull() 함수는 배열, 이터러블, 시퀀스의 최솟값과 최댓값을 계산한다. 술어에 원소를 변환하여 비교할 수 없는 원소들로 이뤄진 컬렉션의 최댓값과 최솟값을 구할 수있는 minByOrNull()과 maxByOrNull() 도 있다.
class Person(val firstName: String,
val familyName: String,
val age: Int) {
override fun toString(): String = "$firstName $familyName: $age"
}
fun main() {
val persons = sequenceOf(
Person("Brook", "Watts", 25),
Person("Silver", "Hudson", 30),
Person("Dane", "Ortiz", 19),
Person("Val", "Hall", 28)
)
println(persons.minByOrNull { it.firstName }) // Brook Watts: 25
println(persons.maxByOrNull { it.firstName }) // Val Hall: 28
println(persons.minByOrNull { it.familyName }) // Val Hall: 28
println(persons.maxByOrNull { it.familyName }) // Brook Watts: 25
println(persons.minByOrNull { it.age }) // Dane Ortiz: 19
println(persons.maxByOrNull { it.age }) // Silver Hudson: 30
}
또는 minWithOrNull()과 maxWithOrNull() 함수를 사용할 수도 있다. 이 함수들은 비교기를 받아 서로 다른 순서를 지정한다.
class Person(val firstName: String,
val familyName: String,
val age: Int) {
override fun toString(): String = "$firstName $familyName: $age"
}
val Person.fullName get() = "$firstName $familyName"
val Person.reverseFullName get() = "$familyName $firstName"
val FULL_NAME_COMPARATOR = Comparator<Person>{ p1, p2 ->
p1.fullName.compareTo(p2.fullName)
}
val REVERSE_FULL_NAME_COMPARATOR = Comparator<Person>{ p1, p2 ->
p1.reverseFullName.compareTo(p2.reverseFullName)
}
fun main() {
val persons = sequenceOf(
Person("Brook", "Watts", 25),
Person("Silver", "Hudson", 30),
Person("Dane", "Ortiz", 19),
Person("Val", "Hall", 28)
)
println(persons.minWithOrNull(FULL_NAME_COMPARATOR)) // Brook Watts: 25
println(persons.maxWithOrNull(FULL_NAME_COMPARATOR)) // Val Hall: 28
println(persons.minWithOrNull(REVERSE_FULL_NAME_COMPARATOR)) // Dane Hall: 19
println(persons.maxWithOrNull(REVERSE_FULL_NAME_COMPARATOR)) // Silver Watts: 30
}
빈 컬렉션에 대해서 min / max 함수는 널을 반환한다.
joinToString() 함수는 컬렉션 원소를 문자열로 엮는 역할을 한다. 기본적으로 각 원소를 toString()로 변환한 후에 콤마와 공백으로 구분된다.
println(listOf(1, 2, 3).joinToString()) // 1, 2, 3
람다 파라미터를 통해 원소 변환을 제공해주며, 아래 함수들로 파라미터를 선택적으로 지정해 줄 수도 있다.
- separator: 인접한 두 원소 사이에 들어갈 구분 문자열(디폴트: 콤마와 공백)
- prefix / postfix: 결과 문자열의 멘 앞과 맨 뒤에 들어갈 문자열
- limit: 최대로 보여줄 수 있는 원소의 개수
- truncated: 컬렉션의 원소를 모두 표현하지 못할 때 이 파라미터를 뒤에 추가함(디폴트: ...)
val list = listOf(1, 2, 3)
println(list.joinToString(prefix = "[", postfix = "]")) // [1, 2, 3]
println(list.joinToString(separator = "|")) // 1|2|3
println(list.joinToString(limit = 2)) // 1, 2, ...
println(list.joinToString(
limit = 1,
separator = " ",
truncated = "???"
)) // 1 ???
더 일반적인 joinTo() 함수도 있다. 이 함수는 문자열을 새로 생성하는 대신 파라미터로 받은 Appendable 객체 뒤에 덧붙여준다.
import java.lang.StringBuilder
fun main() {
val builder = StringBuilder("joinTo: ")
val list = listOf(1, 2, 3)
println(list.joinTo(builder, separator = "|")) // joinTo: 1|2|3
}
두 값을 조합하는 fold() / reduce() 함수는 임의의 집계 방식을 구현하게 해준다.
reduce() 함수는 파라미터가 두 개인 함수를 받는다. 첫 번째 인자는 누적된 값, 두 번째 인자는 컬렉션의 현재 값이며 집계 과정은 다음과 같다.
- 누적 값은 최초에 컬렉션의 첫 번째 원소로 초기화된다.
- 컬렉션의 매 원소에 대해 현재 누적값과 현재 원소를 파라미터로 받은 함수에 적용하고 이 함수 적용의 결과를 누적값에 대입한다.
- 누적의 결과를 변환한다.
컬렉션이 비어있으면 reduce() 함수는 예외를 던진다.
집계 규칙이 원소의 인덱스에 따라 다르게 할 수 있는 reduceIndexed() 함수도 있다.
println(intArrayOf(1, 2, 3, 4, 5).reduce { acc, n -> acc * n }) // 120
println(listOf("a", "b", "c", "d").reduce { acc, s -> acc + s }) // abcd
println(intArrayOf(1, 2, 3, 4, 5).reduceIndexed { i, acc, n -> if (i % 2 == 1) acc * n else acc }) // 8
println(listOf("a", "b", "c", "d").reduceIndexed { i, acc, s -> if (i % 2 == 1) acc + s else acc }) // abd
누적값의 초깃값 지정하는 fold() / foldIndexed()를 사용해 누적 값을 원하는 타입으로 만들 수도 있다. 리스트가 비어있어도 reduce()와 달리 fold()는 예외를 발생시키지 않는다. 마지막 원소부터 반대 방향으로 계산할 수도 있는데, 함수의 이름 뒤에 Right를 붙이면 가능하다.
println(intArrayOf(1, 2, 3, 4, 5).fold("") { acc, n -> acc + ('a' + n - 1) }) // abcd
println(listOf(1, 2, 3, 4).foldIndexed("") { i, acc, n -> if (i % 2 == 1) acc + ('a' + n - 1) else acc }) // bd
println(arrayOf("a", "b", "c", "d").reduceRight { s, acc -> acc + s }) // dcba
println(listOf("a", "b", "c", "d").reduceRightIndexed { i, s, acc -> if (i % 2 == 0) acc + s else acc }) // dca
println(intArrayOf(1, 2, 3, 4).foldRight("") { n, acc -> acc + ('a' + n - 1) }) // dcba
println(listOf(1, 2, 3, 4).foldRightIndexed("") { i, n, acc -> if (i % 2 == 0) acc + ('a' + n - 1) else acc }) // ca
fold / reduce 함수의 오른쪽 버전은 전달하는 파라미터 값이 반대가 된다.
8. 걸러내기
코틀린은 컬렉션에서 조건을 만족하지 못하는 원소는 걸러내는 확장 함수를 재공한다. 이 함수들은 원본 컬렉션을 변경하지 않고 새로운 컬렉션을 만들어내며 기본적으로 filter() 함수에 의해 제공된다. 이 함수에 각각의 원소를 it으로 받는 조건문을 술어로 넣어 사용하며 원소마다 조건문에 true면 유지, false면 버린다.
- Array<T>나 Iterable<T>를 거르면 List<T>를 얻는다.
- Map<K, V>를 거르면 Map<K, V>를 얻는다.
- Sequence<T>를 거르면 Sequence<T>를 얻는다.
// List: [green, blue, green]
println(listOf("red", "green", "blue", "green").filter { it.length > 3 })
// List: [green, blue]
println(setOf("red", "green", "blue", "green").filter { it.length > 3 })
// List: [green, blue, green]
println(arrayOf("red", "green", "blue", "green").filter { it.length > 3 })
// List: [2, 4]
println(byteArrayOf(1, 2, 3, 4, 5).filter { it % 2 == 0 })
// Map: {X=10, L=50}
println(mapOf("I" to 1, "V" to 5, "X" to 10, "L" to 50).filter { it.value > 5 })
// Sequence
val seq = generateSequence(100) { if (it != 0) it/3 else null }.filter { it > 10 }
// 리스트로 변환: [100, 33, 11]
println(seq.toList())
맵의 경우 파라미터가 맵 엔트리 타입을 인자로 받으며 키나 값만 걸러낼 수 있는 filterKeys() / filterValues() 함수도 있다.
또한 filterNot() 함수로 조건에 부정인 원소만 걸러낼 수도 있다.
val map = mapOf("I" to 1, "V" to 5, "X" to 10, "L" to 50)
println(map.filterKeys { it != "L" }) // {I=1, V=5, X=10}
println(map.filterValues { it >= 10 }) // {X=10, L=50}
println(map.filterNot { it.value > 5 }) // {X=10, L=50}
filterIndexed() 함수를 사용하면 인덱스 값을 받아 걸러낼 수 있다. 인덱스를 받기 때문에 맵에서는 사용할 수 없다.
filterNotNull() 함수로 널인 원소를 걸러내어 항상 널이 될 수 없는 타입이 원소인 컬렉션을 만들 수도 있다.
val seq = generateSequence(100) { if (it != 0) it/3 else null }
println(seq.filterIndexed { i, v -> v > 0 && i > 0 }.toList())
val list = listOf("red", null, "green", null, "blue")
list.forEach { println(it.length) } // 원소로 널이 올 수 있기 때문에 non-null선언이 필요함
list.filterNotNull().forEach { println(it.length) } // Ok
filterIsInstance() 함수를 사용해 원소 중 특정 타입만 남길 수 있다.
val hotchpotch = listOf(1, "two", 3, "four", 5, "six")
val numbers = hotchpotch.filterIsInstance<Int>()
val strings = hotchpotch.filterIsInstance<String>()
println(numbers) // [1, 3, 5]
println(strings) // [two, four, six]
위 함수들은 모두 새로운 불변 컬렉션을 만들어내지만, 함수 이름 끝에 To를 붙인 버전을 사용하면 걸러낸 결과를 미리 선언한 가변 컬렉션에 집어넣을 수 있다. 컬렉션을 순회하는 중에 이 함수를 대상을 순회하고있는 컬렉션으로 지정하면 순회하는 중에 컬렉션 내용이 바뀌므로 ConcurrentModificationException 예외가 발생한다.
val list = arrayListOf("red", "green", "blue")
list.filterTo(list) { it.length > 3 } // ConcurrentModificationException
partition() 함수는 원래 컬렉션을 주어진 술어를 만족하는 부분 컬렉션과 만족하지 않는 부분 컬렉션의 쌍으로 만들어준다.
val (evens1, odds1) = listOf(1, 2, 3, 4, 5).partition { it % 2 == 0 }
println(evens1) // [2, 4]
println(odds1) // [1, 3, 5]
val seq = generateSequence(100) { if (it == 0) null else it/3 }
val (evens2, odds2) = seq.partition { it % 2 == 0 }
println(evens2) // [100, 0]
println(odds2) // [33, 11, 3, 1]
맵은 partition() 함수를 제공하지 않는다.
9. 변환
코틀린은 컬렉션의 모든 원소를 주어진 규칙에 따라 변경한 다음, 정해진 방식으로 조합해서 새로운 컬렉션을 만들어내는 변환 함수를 제공한다. 변환 함수는 매핑(mapping), 평평하게 하기(flattening), 연관 짓기(association)로 나눌 수 있다.
map() 함수는 매핑 변환에 사용하는 기본적인 함수이며, 모든 원소를 변경하여 새로운 컬렉션을 만들어낸다.
인덱스를 고려할 수 있는 mapIndexed() 함수와 filterNotNull()과 비슷한 mapNotNull()과 mapIndexedNotNull()이 있다.
// [(0, 0), (1, 2), (2, 4), (3, 9), (4, 16), (5, 25)]
println(List(6) { it*it }.mapIndexed { i, n -> i to n })
println(arrayOf("1", "red", "2", "green", "3").mapNotNull { it.toIntOrNull() }) // [1, 2, 3]
println(listOf("1", "red", "2", "green", "3").mapIndexedNotNull { i, s ->
s.toIntOrNull()?.let { i to it }
}) // [(0, 1), (2, 2), (4, 3)]
map() 함수를 컬렉션 맵에 적용할 수도 있으며, 키와 값만 변환한 새 맵을 돌려주는 mapKeys()와 mapValues() 함수도 있다.
val map = mapOf("I" to 1, "V" to 5, "X" to 10, "L" to 50)
// [I 1, V 5, X 10, L 50]
println(map.map { "${it.key} ${it.value}" })
// [i=1, v=5, x=10, l=50]
println(map.mapKeys { it.key.lowercase() })
// [I=1, V=5, X=a, L=32]
println(map.mapValues { it.value.toString(16) })
flatMap() 함수를 사용하면 각 원소를 컬렉션으로 변환한 다음, 차례로 이어 붙여서 한 컬렉션으로 합친다. 이 함수를 시퀀스에 사용하면 시퀀스를, 다른 컬렉션에 사용하면 리스트를 반환하며, flatten() 함수로 변환없이 이어 붙인 컬렉션을 반환시킬 수도 있다.
// [a, b, c, d, e, f, g, h, i]
println(setOf("abc", "def", "ghi").flatMap { it.asIterable() })
// [1, 1, 2, 1, 2, 3]
println(Array(3) { it + 1 }.flatMap { 1..it })
// [1, 2, 3, 4, 5]
println(listOf(listOf(1, 2), setOf(3, 4), listOf(5)).flatten()) // .flatMap { it }과 같다
// [1, 2, 3, 4]
println(sequence {
yield(sequenceOf(1, 2))
yield(sequenceOf(3, 4))
}.flatten().toList())
필터와 마찬가지로 함수 뒤에 To를 붙이면 새 컬렉션을 만들지 않고 기존 컬렉션에 원소를 추가해준다.
val result = ArrayList<String>()
listOf(1, 2, 3).mapTo(result) { it.toString() }
arrayOf("one", "two", "three").mapIndexedTo(result) { i, s ->
"${i + 1}: $s"
}
sequenceOf("100", "?", "101", "?", "110").mapNotNullTo(result) {
it.toIntOrNull(2)?.toString()
}
println(result) // [1, 2, 3, 1: one, 2: two, 3: three, 4, 5, 6]
listOf(
listOf("abc", "def"), setOf("ghi")
).flatMapTo(result) { it }
sequenceOf(
sequenceOf(1, 2), sequenceOf(3, 4)
).flatMapTo(result) { it.map { "$it" }}
println(result) // [1, 2, 3, 1: one, 2: two, 3: three, 4, 5, 6, abc, def, ghi, 1, 2, 3, 4]
주어진 변환 함수를 바탕으로 컬렉션 원소를 맵의 key 혹은 value로 만들 수 있는 함수들이 있다.
associateWith() 함수는 원소를 맵의 value 값으로 변환해주고, associateBy() 함수는 원소를 맵의 key 값으로 변환시켜준다.
기본적으로 키값이 겹치면 한 값만 남게된다.
// associateWith
// {red=3, green=5, blue=4}
println(listOf("red", "green", "blue").associateWith { it.length })
// {10=1, 15=1, 20=2, 25=2, 30=3, 35=3}
println(intArrayOf(10, 15, 20, 25, 30, 35).associateWith { it/10 })
// {1=1, 3=10, 9=100, 27=1000, 81=10000}
println(generateSequence(1) { if (it > 50) null else it*3 }
.associateWith { it.toString(3) })
// associateBy
// {3=red, 5=green, 4=blue}
println(listOf("red", "green", "blue").associateBy { it.length })
// {1=15, 2=25, 3=35}: 키 값이 겹치면 마지막에 들어온 원소를 남긴다.
println(intArrayOf(10, 15, 20, 25, 30, 35).associateBy { it/10 })
// {1=1, 10=3, 100=9, 1000=27, 10000=81}
println(generateSequence(1) { if (it > 50) null else it*3 }
.associateBy { it.toString(3) })
associate() 함수는 원소를 사용해 키와 값을 모두 만들어낸다. 이는 associateBy() 함수로 오버로딩한 함수를 사용해 구현할 수도 있다.
// {RED=3, GREEN=5, BLUE=4}
println(listOf("red", "green", "blue").associate { it.uppercase() to it.length })
// {1=15, 2=25, 3=35}
println(intArrayOf(10, 15, 20, 25, 30, 35).associate { it/10 to it })
// {3^0=1, 3^1=10, 3^2=100, 3^3=1000, 3^4=10000}
println(generateSequence(1) { if (it > 50) null else it*3 }
.associate {
val s = it.toString(3)
"3^${s.length - 1}" to s
}
)
// {RED=3, GREEN=5, BLUE=4}
println(
listOf("red", "green", "blue").associateBy(
keySelector = { it.uppercase()}, // 오버로딩된 함수들
valueTransform = { it.length }
)
)
마찬가지로 To를 붙여 기존의 가변 맵에 결과 원소를 추가하는 함수들도 있다.
10. 하위 컬렉션 추출
코틀린은 걸러내기와 비슷한 기준에 따라 컬렉션의 일부를 추출해내는 하위 컬렉션 추출 함수를 제공한다.
subList() 함수와 비슷한 slice() 함수는 정수 범위를 사용해 새로운 리스트를 만들어 내거나 레퍼 객체를 만들어내며, 다른 배열로 추출하고 싶다면 sliceArray() 함수를 사용하면 된다.
// 0, 1, 4, 9, 16, 25
println(List(6) { it*it }.slice(2..4)) // [4, 9, 16]
// 0, 1, 8, 27, 64, 125
val slice = Array(6) { it*it*it }.sliceArray(2..4).contentToString()
println(slice) // [8, 27, 64]
take()와 takeLast() 함수는 원소를 주어진 개수만큼 추출한다. take()는 맨 앞, takeLast()는 맨 뒤에서부터 개수를 센다.
그와 반대되는 drop()과 dropLast() 함수도 있다. 주어진 개수만큼 원소를 제거한 나머지 원소를 돌려준다.
// take
println(List(6) {it*it}.take(2)) // [0, 1]
println(List(6) {it*it}.takeLast(2)) // [16, 25]
val seq = generateSequence(1) { if (it > 100) null else it*3 }
println(seq.take(3).toList()) // [1, 3, 9]
// drop
println(List(6) {it*it}.drop(2)) // [4, 9, 16, 25]
println(List(6) {it*it}.dropLast(2)) // [0, 1, 4, 9]
println(seq.drop(3).toList()) // [27, 81, 243]
take / drop 에 술어를 받아 주어진 조건을 만족하지 못하는 첫 번째 원소를 발견할 때까지 원소를 남기거나 제거하는 버전도 있다.
val list = List(6) {it*it}
println(list.takeWhile { it < 10 }) // [0, 1, 4, 9]
println(list.takeLastWhile { it > 10 }) // [16, 25]
println(list.dropWhile { it < 10 }) // [16, 25]
println(list.dropLastWhile { it > 10 }) // [0, 1, 4, 9]
chunked() 함수는 주어진 개수를 넘지 않는 작은 리스트들(청크)로 나눠준다. 함수를 지정하여 청크를 임의의 값으로 변경할 수도 있다.
val list = List(10) {it*it}
// [[0, 1, 4], [9, 16, 25], [36, 49, 64], [81]]
println(list.chunked(3))
// [5, 50, 149, 81]
println(list.chunked(3) { it.sum() })
windowed() 함수를 사용하면 일정한 간격으로 청크를 연속적으로 얻어낸 슬라이딩 윈도우를 얻을 수 있다. chunked()와 마찬가지로 함수를 지정하여 윈도우에 속한 원소를 집계하거나 할 수 있다. 아래 예제에서 일정한 간격은 1이다.
val list = List(6) {it*it}
// [[0, 1, 4], [1, 4, 9], [4, 9, 16], [9, 16, 25]]
println(list.windowed(3))
// [5, 14, 29, 50]
println(list.windowed(3) { it.sum() })
슬라이딩 윈도우를 생성하는 규칙을 정하는 파라미터들이 있다.
- step: 윈도우의 첫 번째 원소 사이의 거리(디폴트: 1)
- patialWindows: 지정한 윈도우 크기보다 작은 크기의 윈도우를 포함시킬지 여부(디폴트: false)
val list = List(6) {it*it}
// [[0, 1, 4], [4, 9, 16]]
println(list.windowed(3, step = 2))
// [[0, 1, 4], [1, 4, 9], [4, 9, 16], [9, 16, 25], [16, 25], [25]]
println(list.windowed(3, partialWindows = true))
zipWithNext() 함수는 원소가 두 개인 Pair 윈도우를 만든다. 마찬가지로 람다 함수를 지정하여 집계할 수도 있다.
val list = List(6) {it*it}
// [(0, 1), (1, 4), (4, 9), (9, 16), (16, 25)]
println(list.zipWithNext())
// [0, 4, 36, 144, 400]
println(List(6) {it*it}.zipWithNext { a, b -> a * b })
11. 순서
코틀린은 컬렉션 원소를 정해진 순서에 따라 정렬하는 함수를 제공한다.
오름차순을 정렬하는 sorted() 함수와 내림차순으로 정렬하는 sortedDescending() 함수가 있으며, 원소가 비교 가능하지 않아도 순서를 제공할 수 있는 sortedBy()나 sortedWith() 함수도 있다. sortedBy()는 원소를 비교 가능한 타입의 값으로 변환하는 함수를 인자로 받고, sortedWith() 함수는 비교기 인스턴스를 인자로 받는다.
class Person(val firstName: String,
val familyName: String,
val age: Int) {
override fun toString(): String = "$firstName $familyName: $age"
}
val Person.fullName get() = "$firstName $familyName"
val Person.reverseFullName get() = "$familyName $firstName"
val FULL_NAME_COMPARATOR = Comparator<Person>{ p1, p2 ->
p1.fullName.compareTo(p2.fullName)
}
val REVERSE_FULL_NAME_COMPARATOR = Comparator<Person>{ p1, p2 ->
p1.reverseFullName.compareTo(p2.reverseFullName)
}
fun main() {
val persons = listOf(
Person("Brook", "Hudson", 25),
Person("Silver", "Watts", 30),
Person("Dane", "Hall", 19),
Person("Val", "Ortiz", 28)
)
// [Brook Hudson: 25, Dane Hall: 19, Silver Watts: 30, Val Ortiz: 28]
println(persons.sortedWith(FULL_NAME_COMPARATOR))
// [Dane Hall: 19, Brook Hudson: 25, Val Ortiz: 28, Silver Watts: 30]
println(persons.sortedWith(REVERSE_FULL_NAME_COMPARATOR))
// [Dane Hall: 19, Brook Hudson: 25, Val Ortiz: 28, Silver Watts: 30]
println(persons.sortedBy { it.age })
// [Silver Watts: 30, Val Ortiz: 28, Brook Hudson: 25, Dane Hall: 19]
println(persons.sortedByDescending { it.age })
}
reversed() 함수는 원소를 역순으로 나열해주머, asReversed() 함수로 원본에 대한 레퍼를 가져올 수도 있다. asReversed() 함수는 원본과 반환된 리스트가 같은 데이터를 공유하기 때문에 메모리 효율이 더 좋다.
val list = arrayListOf("red", "green", "blue")
val reversedCopy = list.reversed()
val reversedMirror = list.asReversed()
list[0] = "violet"
println(list) // [violet, green, blue]
println(reversedCopy) // [blue, green, red]
println(reversedMirror) // [blue, green, violet]
마지막으로 원본의 원소를 임의로 재배치한 새 리스트를 돌려주는 shuffled() 함수가 있다.
println(listOf(1, 2, 3, 4, 5).shuffled()) // [3, 5, 4, 1, 2]
'Kotlin > 코틀린 완벽 가이드' 카테고리의 다른 글
[코틀린 완벽 가이드] 7-2장 : 파일과 I/O 스트림 (0) | 2022.10.29 |
---|---|
[코틀린 완벽 가이드] 6장 : 특별한 클래스 사용하기 (0) | 2022.10.10 |
[코틀린 완벽 가이드] 5장 : 고급 함수와 함수형 프로그래밍 활용하기 (0) | 2022.10.04 |