[코틀린] 람다 - 컬렉션 함수 API

25938 단어 코틀린코틀린

컬렉션 함수형 API

함수형 프로그래밍 스타일을 사용하면 컬렉션을 다룰 때 편리합니다.
대부분의 작업에 라이브러리 함수를 활용할 수 있고 그로 인해 코드를 간결하게 만들 수 있습니다.

이 글에서 컬렉션을 다루는 코틀린 표준 라이브러리 함수를 확인하겠습니다.

예제에서 사용되는 클래스

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

filter와 map

filter 함수

컬렉션을 이터레이션하면서 주어진 람다에 각 원소를 넘겨서 람다가 true를 반환하는 원소만 모읍니다. 결과는 입력 컬렉션의 원소 중에서 주어진 술어(참/거짓을 반환하는 함수를 술어라고 합니다)를 만족하는 원소만으로 이루어진 새로운 컬렉션입니다.

filter 함수를 사용해 나이가 30살 이상인 Person 리스트

val people = listOf(Person("Alice", 29), Person("Bob", 31))
// 술어를 만족하는 새로운 컬렉션을 반환
people.filter { it.age > 30 }

filter 함수는 컬렉션에서 원치 않는 원소를 제거합니다. 하지만 filter는 원소를 변환할 수는 없습니다.

map 함수

map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션으로 만듭니다. 결과는 원본 리스트와 원소의 개수가 같지만, 각 원소는 주어진 함수(람다)에 따라서 변환된 새로운 컬렉션입니다.

map 함수를 사용해 name 프로퍼티만 가져온 Person 리스트

val people = listOf(Person("Alice", 29), Person("Bob", 31))
// 주어진 람다를 적용한 새로운 컬렉션 반환
people.map { it.name }

이러한 예제는 멤버 참조를 사용해서 작성할 수도 있습니다.

map 함수에 멤버 참조 적용

people.map(Person::name)

주의할 점은 람다를 인자로 받는 함수에 람다를 넘기면 겉으로 볼 때는 단순해 보이는 식이 내부 로직의 복잡도로 인해 실제로는 엄청나게 복잡한 계산식이 될 수 있습니다.

filter와 map 함수를 맵에 적용할 수도 있지만 맵의 경우 키와 값을 처리하는 함수가 따로 존재합니다. filterKeys와 mapKeys는 키를 걸러 내거나 변환하고, filterValues와 mapValues는 값을 걸러 내거나 변환합니다.


all, any, none, count, find

all

모든 원소가 이 술어를 만족하는지 판단하기 위해서 all 함수를 사용합니다.

val people = listOf(Person("Alice", 27), Person("Bob", 31))
// 모든 원소의 age 프로퍼티가 27 이하인지 
people.all { it.age <= 27 }

any

술어를 만족하는 원소가 하나라도 있는지 판단하기 위해서 any 함수를 사용합니다.

val people = listOf(Person("Alice", 27), Person("Bob", 31))

// 원소 중 하나라도 age 프로퍼티의 값이 27 이하인지
people.any { it.age <= 27 }

none

술어를 만족하는 원소가 하나도 없는지를 판단하기 위해서 none 함수를 사용합니다.

val people = listOf(Person("Alice", 29), Person("Bob", 31), Person("Carol", 31))

// age 프로퍼티가 29 미만인 원소가 없는지
people.none { it.age < 29 }

count

술어를 만족하는 원소의 개수를 구하려면 count 함수를 사용합니다.

val people = listOf(Person("Alice", 27), Person("Bob", 31))

// 원소 중 age 프로퍼티의 값이 27 이하인 개수를 구함
people.count { it.age <= 27 }

count 함수를 사용하지 않고 컬렉션을 필터링한 결과의 크기를 가져오는 경우가 있을 수 있습니다. 하지만 아래와 같이 filter 함수로 처리하면 조건을 만족하는 원소가 들어가는 중간 컬렉션이 생깁니다. 반면 count는 조건을 만족하는 원소의 개수만을 추적하지 조건을 만족하는 원소를 따로 저장하지 않기에 count가 훨씬 더 효과적입니다.

people.filter { it.age <= 27 }.size

find, first, last

술어를 만족하는 원소를 하나 찾고 싶으면 find 함수를 사용하면 됩니다. 조건을 만족하는 원소가 하나라도 있는 경우 가장 먼저 조건을 만족한다고 확인된 원소를 반환하며, 만족하는 원소가 전혀 없는 경우 null을 반환합니다.

first는 아무런 인자도 없이 사용하면 첫 번째 아이템을 반환하지만, 람다식을 통해 조건을 넣으면 조건에 맞는 아이템을 반환합니다. 단, first는 조건에 맞는 원소가 없을 경우 예외가 발생합니다. 이 때는 firstOrNull 함수를 사용하면 아이템이 없을 경우 null을 반환해줍니다. 이는 find와 같습니다. last는 first와 반대로 뒤에서부터 조건에 맞는 아이템을 찾고 반환합니다.


takeIf와 takeUnless

takeIf는 수신 객체가 술어를 만족하는지 검사해서 만족할 때 수신 객체를 반환하고, 불만족할 때 null을 반환합니다.

takeUnless는 takeIf의 반대로, 술어를 뒤집은 takeIf와 같습니다. 즉, 술어를 만족하면 null을 반환하고, 불만족할 때 수신 객체를 반환합니다.

val srcOfKotlin: Any = File("src").takeIf { it.exists() } ?: File("kotlin")

groupBy

groupBy는 특성을 파라미터로 전달하면 컬렉션을 자동으로 구분해줍니다. 즉, 리스트를 여러 그룹으로 이뤄진 맵으로 변경할 수 있습니다.

val people = listOf(Person("Alice", 29), Person("Bob", 31), Person("Carol", 31))

println(people.groupBy { it.age })

결과 : {
29=[Person(name=Alice, age=29)],
31=[Person(name=Bob, age=31), Person(name=Carol, age=31)]
}

각 그룹은 리스트로 groupBy의 결과 타입은 Map<Int,List<Person>> 입니다. 필요하면 이 맵을 mapKeys나 mapValues 등을 사용해 변경할 수 있습니다.


forEach와 onEach

forEach

컬렉션을 이터레이터하면서 주어진 코드를 수행시킵니다.

val people = listOf(Person("Alice", 29), Person("Bob", 31), Person("Carol", 31))

people.forEach {
    println(it.name)
}

onEach

forEach와 비슷하지만 onEach는 컬렉션이나 시퀀스를 다시 반환하기 때문에 메소드 연쇄 호출이 가능합니다. forEach는 반환값이 Unit이므로 메소드 연쇄 호출이 불가능합니다.

listOf(1,2,3,4,5).onEach { println(it) }.map{ it * it }.joinToString(",")

결과: 1,4,9,16,25


flatMap과 flatten

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고(또는 매핑하기) 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 모읍니다(또는 펼치기). 해당 예를 하나 살펴보겠습니다.

val strings = listOf("abc", "def")
println(strings.flatMap { it.toList() })

결과: [a, b, c, d, e, f]

위의 코드는 toList 함수를 문자열에 적용하여 그 문자열에 속한 문자로 이뤄진 리스트를 만듭니다. 그렇게 되면 위의 그림에서 가운데 줄에 표현한 것처럼 문자로 이뤄진 리스트로 이뤄진 리스트가 생깁니다. flatMap 함수는 그 다음으로 리스트의 리스트에 들어있던 모든 원소로 이뤄진 단일 리스트를 반환합니다.

만약 특별히 변환해야 할 내용이 없다면 리스트의 리스트를 평평하게 펼치기만 하면 됩니다. 그런 경우 listOfLists.flatten() 처럼 flatten() 함수를 사용하면 됩니다.


ifEmpty와 ifBlank

ifEmpty

컬렉션, 맵, 배열, 시퀀스 등에 추가된 ifEmpty를 쓰면 해당 컬렉션이 비어있는 경우에 대한 대안을 제시할 수 있습니다.

fun printAllUppercase(data: List<String>) {
    val result = data
        .filter { it.all { c -> c.isUpperCase() } }
        .ifEmpty { listOf("<대문자로만 이뤄진 원소 없음>") }
    result.forEach { println(it) }
}

printAllUppercase(listOf("foo", "Bar"))
// 결과 <대문자로만 이뤄진 원소 없음>
printAllUppercase(listOf("FOO", "BAR"))
// 결과 FOO, BAR

ifBlank

Char sequence나 String 타입에는 ifBlank라는 함수가 있는데, 공백으로 이루어져 있는지를 확인할 수 있습니다.

val s = "    \n"
println(s.ifBlank { "<blank>" })
// 결과 <blank>
println(s.ifBlank { null })
// 결과 null

추가

  • chunked : 컬렉션을 정해진 크기의 덩어리로 나눠줍니다.

  • windowed : 정해진 크기의 슬라이딩 윈도우를 만들어서 컬렉션을 처리할 수 있게 해줍니다.

  • zipWithNext : 컬렉션에서 연속한 두 원소에 대해 람다를 적용한 결과를 얻습니다.

val l = listOf(1,2,3,4,5,6,7)

// 그룹
l.chunked(size=3).forEach { println(it) }
// 결과 [1,2,3] [4,5,6] [7]

// 슬라이딩 윈도우
l.windowed(size=3, step=1).forEach { println(it) }
// 결과 [1,2,3] [2,3,4] [3,4,5] [4,5,6] [5,6,7]

// 연속된 2 원소씩 쌍
l.zipNext { x, y -> println("$x, $y") }
// 결과 (1,2) (2,3) (3,4) (4,5) (5,6) (6,7)
  • fill : 변경 가능 리스트의 모든 원소를 지정한 값으로 바꿉니다.

  • replaceAll : 변경 가능 리스트의 모든 원소를 람다를 적용한 결과 값으로 갱신합니다. map을 제자리에서 수행한다고 생각하면 됩니다.

  • shuffle : 변경 가능한 리스트의 원소를 임의로 뒤섞습니다.

  • shuffled : 변경 불가능한 리스트의 원소를 임의로 뒤섞은 새 리스트를 반환합니다.

val items = (1..5).toMutableList()

items.shuffle()
// 결과 [5, 3, 1, 4, 2]

items.replaceAll { it * 2 }
// 결과 [10, 6, 2, 8, 4]

items.fill(5)
// 결과 [5, 5, 5, 5, 5]

참조
Kotlin in action
코틀린의 takeIf와 takeUnless는 언제 사용하는가?

틀린 부분 댓글로 남겨주시면 수정하겠습니다..!!

좋은 웹페이지 즐겨찾기