Kotlin - 함수형 프로그래밍(2): 고차 함수, 리스트 조작, Map 생성


코틀린 여러 함수 기능에 대해 알아본다.

소스는 github 에 있습니다.


목차


개발 환경

  • 언어: kotlin 1.9.23
  • IDE: intelliJ
  • SDK: JDK 17
  • 의존성 관리툴: Gradle

1. 고차 함수 (high-order function)

함수를 다른 함수의 인자로 넘길 수 있거나, 함수가 반환값으로 함수를 돌려줄 수 있으면 언어가 고차 함수를 지원하는 것이다.

예를 들어 filter(), map(), any() 등이 고차 함수이다.

람다는 참조에 저장할 수 있다.

// 람다를 저장한 변수의 타입이 함수 타입임
val isPlus: (Int) -> Boolean = { it > 0 }

fun main() {
    // isPlus 는 함수를 반환값으로 돌려줌
    val result = listOf(1, 2, -3).any(isPlus)
    println(result) // true
}

위에서 (Int) -> Boolean 은 함수 타입이다.
함수 타입은 0 개 이상의 파라메터 타입 목록을 둘러싼 괄호로 시작하고, 화살표 -> 가 따라오며, 화살표 뒤엔 반환 타입이 온다.

참조를 통해 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 동일하다.

val hello: () -> String = { "Hello~" }
val sum: (Int, Int) -> Int = { x, y -> x + y }

fun main() {
    println(hello()) // Hello~
    println(sum(1, 2)) // 3
}

1.1. 함수 인자로 람다나 함수 참조 전달

함수가 함수 파라메터를 받는 경우 인자로 람다나 함수 참조를 전달할 수 잇다.

아래는 표준 라이브러리의 any() 를 직접 구현하는 예시이다.

// 여러 타입의 List 에 대해 호출할 수 있도록 제네릭 List<T> 타입의 확장 함수 정의
fun <T> List<T>.customAny(
    customPredicate: (T) -> Boolean, // customPredicate 함수를 리스트의 원소에 적용할 수 있어야 하므로 이 함수는 파라메터 타입 T 를 인자로 받는 함수이어야 함
): Boolean {
    for (ele in this) {
        if (customPredicate(ele)) { // customPredicate 함수를 적용하면 선택 기준을 ele 가 만족하는지 알 수 있음
            return true
        }
    }
    return false
}

fun main() {
    val ints = listOf(1, 2, 3)
    val result1 = ints.customAny { it > 0 }
    println(result1) // true

    val strings = listOf("abc", " ")
  
    // 함수 인자로 람다 전달
    val result2 = strings.customAny { it.isBlank() }

    // 함수 인자로 함수 참조 전달
    val result3 = strings.customAny(String::isNotBlank)
    println(result2) // true
    println(result3) // true
}

아래는 repeat() 의 사용 예시이다.

fun main() {
    val result = repeat(3) { println("hi") }

    //hi
    //hi
    //hi
    println(result)
}

위의 repeat() 을 직접 구현해본다.

fun customRepeat(
    times: Int,
    action: (Int) -> Unit, // (Int) -> Unit 타입의 함수를 action 파라메터로 받음
) {
    for (index in 0 until times) {
        action(index) // 현재의 반복 횟수를 index 를 사용하여 호출
    }
}

fun main() {
    val result = customRepeat(3, { println("#$it") })
    val result2 = customRepeat(3) { println("#$it") }

    // #0
    // #1
    // #2
    println(result)
    // #0
    // #1
    // #2
    println(result2)
}

1.2. 함수의 반환 타입이 null 인 타입: toIntOrNull(), mapNotNull()

fun main() {
    // 리턴값이 null 이 될 수도 있음
    val trans: (String) -> Int? =
        { s: String -> s.toIntOrNull() }

    val result1 = trans("123")
    val result2 = trans("abc")

    println(result1) // 123
    println(result2) // null

    val x = listOf("123", "abc")
    val result3 = x.mapNotNull(trans)
    val result4 = x.mapNotNull { it.toIntOrNull() }
    println(result3) // [123]
    println(result4) // [123]
}
  • toIntOrNull()
    • null 을 반환할 수 있음
  • mapNotNull()
    • List 의 각 원소를 null 이 될 수 있는 값으로 변환 후 변환 결과에서 null 을 제외시킴
    • map() 을 호출하여 얻은 결과 리스트에서 filterNotNull() 을 호출한 것과 동일함

1.3. 반환 타입이 nullable 타입 vs 함수 전체의 타입이 nullable

fun main() {
    // 반환 타입을 nullable 타입으로 만듦
    val returnTypeNullable: (String) -> Int? = { null }

    // 함수 전체의 타입을 nullable 타입으로 만듦
    val mightBeNull: ((String) -> Int)? = null

    val result1 = returnTypeNullable("abc")

    // 컴파일 오류, Reference has a nullable type '((String) -> Int)?', use explicit '?.invoke()' to make a function-like call instead
    // val result2 = mightBeNull("abc")

    // if 문을 통해 명시적으로 null 검사를 한 것과 같음
    // mightBeNull 에 저장된 함수를 호출하기 전에 함수 참조 자체가 null 이 아닌지 반드시 검사해야 함
    val result2 = mightBeNull?.let { it("abc") }

    println(result1) // null
    println(result2) // null
}

2. 리스트 조작

zipping 과 flattening 은 List 를 조작할 때 흔히 쓰는 연산이다.


2.1. 묶기 (Zipping): zip(), zipWithNext()

zip() 은 두 List 의 원소를 하나씩 짝짓는 방식으로 묶는다.

fun main() {
    val left = listOf('a', 'b', 'c', 'd')
    val right = listOf('q', 'r', 's')

    // left 와 right 를 zipping 하면 Pair 로 이루어진 List 가 반환됨
    val result1 = left.zip(right)

    // [(a, q), (b, r), (c, s)]
    println(result1)

    val result2 = left.zip(0..5)

    // [(a, 0), (b, 1), (c, 2), (d, 3)]
    println(result2)

    val result3 = (10..100).zip(right)

    // [(10, q), (11, r), (12, s)]
    println(result3)
}

zip() 함수는 만들어진 Pair 에 대해 연산을 할 수도 있다.

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

fun main() {
    val names = listOf("Assu", "Silby")
    val ids = listOf(777, 888)

    val result1 = names.zip(ids)

    // [(Assu, 777), (Silby, 888)]
    println(result1)

    val result2 =
        // name.zip(ids) { ... } 는 name, id Pair 를 만든 후 람다를 각 Pair 에 적용
        names.zip(ids) { name, id ->
            Person(name, id)
        }

    // [Person(name=Assu, id=777), Person(name=Silby, id=888)]
    println(result2)
}

한 List 에서 특정 원소와 그 원소에 인접한 다음 원소를 묶을 때는 zipWithNext() 를 사용한다.

fun main() {
    val list = listOf('a', 'b', 'c', 'd')

    val result1 = list.zipWithNext()

    // [(a, b), (b, c), (c, d)]
    println(result1)

    // 원소를 zipping 한 후 연산을 추가로 적용함
    val result2 = list.zipWithNext { a, b -> "$a$b" }

    // [ab, bc, cd]
    println(result2)
}

2.2. 평평하게 하기 (Flattening)

2.2.1. flatten()

flatten() 은 각 원소가 List 인 List 를 인자로 받아서 원소가 따로따로 들어있는 List 를 반환한다.

fun main() {
    val list =
        listOf(
            listOf(1, 2),
            listOf(3, 4),
            listOf(5, 6),
        )

    val result = list.flatten()
    
    // [1, 2, 3, 4, 5, 6]
    println(result)
}

2.2.2. flatMap()

flatMap() 은 컬렉션에서 자주 사용되는 함수이다.

아래는 특정 범위에 속한 Int 로부터 가능한 모든 Pair 를 생성하는 예시이다.

fun main() {
    val intRange = 1..3

    // map() 은 intRange 에 속한 각 원소에 대응하는 3가지 List 의 정보를 유지
    val result1 =
        intRange.map { a ->
            intRange.map { b -> a to b }
        }

    // [[(1, 1), (1, 2), (1, 3)], [(2, 1), (2, 2), (2, 3)], [(3, 1), (3, 2), (3, 3)]]
    println(result1)

    // flatten() 을 이용하여 결과를 펼처서 단일 List 생성
    // 하지만 이런 작업을 해야 하는 경우가 빈번하므로 코틀린은 한번 호출하면 map() 과 flatten() 을 모두 수행해주는 flatMap() 이라는 합성 연산을 제공함
    val result2 =
        intRange.map { a ->
            intRange.map { b -> a to b }
        }.flatten()

    // [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]
    println(result2)

    // flatMap() 사용
    val result3 =
        intRange.flatMap { a ->
            intRange.map { b -> a to b }
        }

    // [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]
    println(result3)
}

flatMap()map()flatten() 을 적용한 결과 (= 단일 List) 를 반환한다.

아래는 또 다른 예시이다.

class Book(
    val title: String,
    val author: List<String>,
)

fun main() {
    val books =
        listOf(
            Book("Harry", listOf("aa", "bb")),
            Book("Magic", listOf("cc", "dd")),
        )

    // map() 과 flatten() 사용
    val result1 = books.map { it.author }.flatten()

    // [aa, bb, cc, dd]
    println(result1)

    // flatMap() 사용
    val result2 = books.flatMap { it.author }

    // [aa, bb, cc, dd]
    println(result2)
}

아래는 map(), flatMap() 을 사용하는 예시이다.

import kotlin.random.Random

enum class Suit {
    Spade,
    Club,
    Hear,
    Diamond,
}

enum class Rank(val faceValue: Int) {
    Ace(1),
    Two(2),
    Three(3),
}

class Card(val rank: Rank, val suit: Suit) {
    override fun toString() = "$rank of ${suit}s."
}

val deck: List<Card> =
    // flatMap() 이 아닌 map() 으로 하면 아래와 같은 컴파일 오류남
    // Type mismatch. Required: List<Card>  Found: List<List<Card>>
    
    // 따라서 deck 이 List<Card> 가 되기 위해서는 여기서 map() 이 아닌 flatMap() 을 사용해야 함
    Suit.values().flatMap { suit ->
        Rank.values().map { rank ->     // map() 은 List 4개를 생성하며, 각 List 는 각 Suit 에 대응함
            Card(rank, suit)
        }
    }

fun main() {
    val rand = Random(26)
    
    // 코틀린 Random 은 seed 가 같으면 항상 같은 난수 시퀀스를 내놓으므로 결과는 항상 동일함
    repeat(7) { println("'${deck.random(rand)}'") }
}

3. Map 생성

Map 을 사용하면 key 를 사용하여 value 에 빠르게 접근할 수 있다.


3.1. groupBy()

groupBy() 는 Map 을 생성하는 방법 중 하나이다.
groupBy() 의 파라메터는 원본 컬렉션의 원소를 분류하는 키를 반환하는 람다이다.
원본 컬렉션의 각 원소에 이 람다를 적용하여 key 값을 얻은 후 Map 에 넣어준다.
이 때 key 가 같은 값이 둘 이상 있을 수 있으므로 Map 의 value 는 원본 컬렉션의 원소 중 key 에 해당하는 값의 List 가 되어야 한다.

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

val names = listOf("Assu", "Sibly", "JaeHoon")
val ages = listOf(20, 2, 20)

fun people(): List<Person> = names.zip(ages) { name, age -> Person(name, age) }

fun main() {
    // groupBy() 로 Map 생성
    val map: Map<Int, List<Person>> =
        people().groupBy(Person::age)

    // {20=[Person(name=Assu, age=20), Person(name=JaeHoon, age=20)], 2=[Person(name=Sibly, age=2)]}
    println(map)

    // [Person(name=Assu, age=20), Person(name=JaeHoon, age=20)]
    println(map[20])

    // null
    println(map[9])
}


3.2. associateWith(), associateBy()

List 에 associateWith() 를 사용하면 List 원소를 key 로 하고, associateWith() 에 전달된 함수 (혹은 람다) 를 List 에 원소에 적용한 반환값을 value 로 하는 Map 을 생성한다.

associateBy()associateWith() 가 생성하는 연관 관계를 반대 방향으로 하여 Map 을 생성한다.
즉, 셀렉터가 반환한 값이 key 가 된다.

associateBy() 의 셀렉터는 유일한 key 값을 만들어 내야 한다. 만일 key 값이 유일하지 않으면 원본값 중 일부가 사라진다.
만일 key 값이 유일하지 않으면 같은 key 를 가진 value 중 컬렉션에서 맨 나중에 나타나는 원소가 Map 에 포함된다.

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

val names1 = listOf("Assu", "Sibly", "JaeHoon")
val ages1 = listOf(20, 2, 20)

fun people1(): List<Person1> = names1.zip(ages1) { name, age -> Person1(name, age) }

fun main() {
    // associateWith() 사용
    val map: Map<Person1, String> = people1().associateWith { it.name }

    // {Person1(name=Assu, age=20)=Assu, Person1(name=Sibly, age=2)=Sibly, Person1(name=JaeHoon, age=20)=JaeHoon}
    println(map)

    // associateBy() 사용, key 값이 유일함
    val map2: Map<String, Person1> = people1().associateBy { it.name }

    // {Assu=Person1(name=Assu, age=20), Sibly=Person1(name=Sibly, age=2), JaeHoon=Person1(name=JaeHoon, age=20)}
    println(map2)

    // associateBy() 사용, key 값이 중복되서 원본이 사라짐
    val map3: Map<Int, Person1> = people1().associateBy { it.age }

    // {20=Person1(name=JaeHoon, age=20), 2=Person1(name=Sibly, age=2)}
    println(map3)
}

3.3. getOrElse(), getOrPut(), toMutableMap()

getOrElse() 는 Map 에서 value 를 찾는다.
key 가 없을 때 디폴트 value 를 계산하는 방법이 담긴 람다를 인자로 받는데, 이 파라메터가 람다이기 때문에 필요할 때만 디폴트 value 를 계산할 수 있다.

getOrPut() 은 MutableMap 에만 적용 가능하다.
getOrPut() 은 key 가 있으면 연관된 value 를 반환하고, key 가 없으면 value 를 계산한 후 그 value 를 key 와 연관시켜서 Map 에 저장한 후, 저장한 value 를 반환한다.

fun main() {
    val map = mapOf(1 to "one", 2 to "two")

    val result1 = map.getOrElse(0) { "zero" }
    val result2 = map.getOrElse(1) { "zero" }

    println(result1) // zero
    println(result2) // one

    // immutableMap 을 mutableMap 으로 변환
    val mutableMap = map.toMutableMap()
    
    // 0 이라는 key 가 없으므로 Map 에 {0=zero} 추가 후 zero 라는 value 반환
    var result3 = mutableMap.getOrPut(0) { "zero" }
    
    // 1 이라는 key 가 있으므로 1에 해당하는 value 인 one 반환
    var result4 = mutableMap.getOrPut(1) { "zero" }

    println(result3) // zero
    println(result4) // one

    println(mutableMap) // {1=one, 2=two, 0=zero}
}

3.4. filter(), filterKeys(), filterValues()

Map 의 여러 연산은 List 가 제공하는 연산과 겹친다.

fun main() {
    val map = mapOf(1 to "one", 2 to "two", 3 to "three", 4 to "four")

    // filterKeys() 사용
    val result1 = map.filterKeys { it % 2 == 1 }

    // {1=one, 3=three}
    println(result1)

    // filterValues() 사용
    val result2 = map.filterValues { it.contains('o') }

    // {1=one, 2=two, 4=four}
    println(result2)

    // Map 에 filter() 사용
    val result3 =
        map.filter { entry ->
            entry.key % 2 == 1 && entry.value.contains('o')
        }

    // {1=one}
    println(result3)
}

3.5. map(), mapKeys(), mapValues()

Map 에 map() 을 적용한다는 말은 동어 반복인 듯 하지만 ‘map’ 은 두 가지를 뜻한다.

  • 컬렉션 변환
  • key-value 쌍을 저장하는 데이터 구조
fun main() {
    val even = mapOf(2 to "two", 4 to "four")

    // map() 사용, List 반환
    // map() 은 Map.Entry 인자를 받는 람다를 파라메터로 받음
    // Map.Entry 의 내용을 it.key, it.value 로 접근 가능
    val result1 = even.map { "${it.key}=${it.value}" }

    // [2=two, 4=four]
    println(result1)

    // 구조 분해 사용
    val result2 = even.map { (key1, value1) -> "$key1=$value1" }

    // [2=two, 4=four]
    println(result2)

    // 파라메터를 사용하지 않을 때는 밑줄을 사용하여 컴파일러 경고를 막음
    // mayKeys(), mapValues() 는 모든 key 나 value 가 변환된 새로운 Map 을 반환함
    val result3 =
        even.mapKeys { (num, _) -> -num }
            .mapValues { (_, str) -> "minus $str" }

    // {-2=minus two, -4=minus four}
    println(result3)

    // map() 사용
    // map() 은 List 를 반환하므로 새로운 Map 을 생성하려면 명시적으로 toMap() 을 호출해야 함
    val result4 =
        even.map { (key, value) -> -key to "minus $value" }
            .toMap()

    // {-2=minus two, -4=minus four}
    println(result4)
}

3.6. any(), all(), maxByOrNull()

  • any()
    • Map 의 원소 중 주어진 Predicate 를 만족하는 원소가 하나라도 있으면 true 반환
  • all()
    • Map 의 모든 원소가 Predicate 를 만족해야 true 반환
  • maxByOrNull()
    • 주어진 Predicate 에 따라 가장 큰 원소를 반환
    • 가장 큰 원소가 없으면 null 반환
fun main() {
    val map = mapOf(1 to "one", -2 to "minus two")

    val result1 = map.any { (key, _) -> key < 0 }
    val result2 = map.all { (key, _) -> key < 0 }
    val result3 = map.maxByOrNull { it.key }?.value

    println(result1) // true
    println(result2) // false
    println(result3) // one
}

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 브루스 에켈, 스베트라아 이사코바 저자의 아토믹 코틀린을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10