Kotlin - 6.함수형 프로그래밍 다루기

Kotlin 문법 배우기

코틀린의 함수형 프로그래밍 (Functional Programming) 다루기

배열과 컬렉션 다루기

배열

이펙티브 자바에서도 나온 내용인데 프로덕션에서 배열은 잘 사용하지 않는다. 문법만 간단히 짚고 넘어가보자.

fun main() {
    val array = arrayOf(100, 200, 300)

    for (i in array.indices) {    // 배열에 값을 가져오는 첫 번째 방법  
        println("$i ${array[i]}")
    }

    for ((idx, value) in array.withIndex()) {   // 배열에 값을 가져오는 두 번째 방법 - 인덱스와 값을 동시에 가져오기
        println("$idx $value")
    }

    array.plus(400)     // 배열에 값 추가하기
}
컬렉션 - List, Set, Map

코틀린에서 컬렉션을 만들어 줄 때도 이 컬렉션이 불변인지, 가변인지를 설정해줘야 한다.
코틀린에서의 컬렉션을 보면 List, Set, Map 이라는 3가지의 불변 컬렉션과 MutableList, MutableSet, MutableMap 이라는 3가지의 가변 컬렉션이 존재한다.
불변 컬렉션: 컬렉션에 element를 추가, 삭제할 수 없다.
가변 컬렉션: 컬렉션에 element를 추가, 삭제할 수 있다.

중요한 부분은 불변 컬렉션이라 하더라도 Reference Type인 Element의 필드는 바꿀 수 있다. 예를 들어 Person이라는 객체의 불변 리스트가 있다고 가정해보면, 이 리스트는 불변 리스트라 값을 추가하거나 제거할 수는 없지만 list.get(0)으로 리스트 안의 하나의 Person 객체에 접근해 그 객체의 필드값을 변경할 수는 있다는 의미이다.

코틀린에서 자바의 Arrays.asList(element1, element2)와 같이 리스트를 만드는 방법은 다음과 같다.

fun main() {
    val numbers = listOf(100, 200, 300)  // 값이 존재하는 리스트 초기화
    val emptyList = emptyList<Int>()    // 비어있는 리스트 초기화, emptyList<타입> 과 같이 타입을 명시해줘야 함

    println(numbers[0])     // 리스트 안의 값 하나 가져오기

    for (number in numbers) {       // for - in 사용하기
        println(number)
    }

    for ((idx, number) in numbers.withIndex()) {    // index와 value 동시에 가져오기
        println("$idx $number")
    }
}

위에서 만든 리스트는 불변 리스트이다. 그렇다면 가변 리스트를 사용하고자 할 때는 어떻게 할까? 다음과 같이 사용할 수 있다.
기본적으로 mutableList는 ArrayList가 기본 구현체이다. 그래서 자바의 ArrayList에서 구현되어 있는 메서드를 모두 지원해주고 있다.

fun main() {
    val numbers = mutableListOf(100, 200, 300)  // 가변 리스트 추가
    numbers.add(400)        // 값 추가

    for (number in numbers) {
        println(number)
    }
    
    numbers.removeLast()    // 마지막 값 제거
}

Set은 리스트와 다르게 순서가 없고 같은 element는 하나만 존재할 수 있는, 즉 중복을 허용하지 않는 컬렉션이다. 자료구조적인 의미만 제외하면 모든 기능이 리스트와 비슷하다.

val numbers = setOf(100, 200, 300)      // 불변 Set 생성

for (number in numbers) {
    println(number)
}

for ((idx, number) in numbers.withIndex()) {
    println("$idx $number")
}

만약 가변 Set을 만들고 싶다면 가변 리스트를 만드는 것과 비슷하게 생성할 수 있다.

val numbers = mutableSetOf(100, 200, 300)
numbers.add(400)

다음은 Map 컬렉션을 살펴보자. Java에서는 Map을 생성하는 방법이 크게 2 가지가 존재했는데, put()으로 값을 입력해주는 방법과 JDK 9 버전부터 추가된 of()로 만들어주는 방법이다.

Map<Integer, String> map = new HashMap<>();
map.put(1, "Red");
map.put(2, "Blue");

// JDK 9 부터
Map.of(1, "Red", 2, "Blue");

위처럼 자바에서 Map을 추가하는 방법을 코틀린으로 변환해보면 다음과 같이 작성할 수 있다.

val map1 = mutableMapOf<Int, String>()
map1[1] = "Red"
map1[2] = "Blue"

for (key in map1.keys) {    // Map에 접근하는 첫 번째 방법
    println(key)
    println(map1[key])
}

for ((key, value) in map1.entries) {    // Map에 접근하는 두 번째 방법
    println(key)
    println(value)
}

mapOf(1 to "Red", 2 to "Blue")  // Pair를 받는 방법
컬렉션의 nullable 및 Java와 함께 사용하기

코틀린에서 컬렉션의 nullable을 나타내는 형태는 다음 세 가지가 있다.

List<Int?>

List<Int>?

List<Int?>?

물음표의 위치에 따라 nullable 가능성이 미묘하게 다르다.
List<Int?>: 리스트에 null이 들어갈 수 있지만, 리스트는 절대 null이 아니다.
List<Int>?: 리스트에 null이 들어갈 수 없지만, 리스트는 null일 수 있다.
List<Int?>?: 리스트에 null이 들어갈 수도 있고, 리스트가 null일 수도 있다.

자바와 함께 사용할 때 주의할 점은 자바는 읽기 전용 컬렉션(불변 컬렉션)과 변경 가능 컬렉션(가변 컬렉션)을 구분하지 않는다는 점이다. 그래서 코틀린에서 불변 컬렉션을 만들고, 이 컬렉션을 자바에서 가져오면 자바에서는 해당 컬렉션에 Element를 추가할 수 있다. 그래서 코틀린에서는 자바에서 Element가 추가된 불변 컬렉션으로 인해 예기치 않은 동작을 할 수도 있다.
주의할 또 다른 점은 자바는 nullable 타입과 non-nullable 타입을 구분하지 않는다는 점이다. 코틀린에서 null이 들어갈 수 없는 컬렉션을 만들고, 이 컬렉션을 자바에서 가져와 해당 컬렉션에 null을 추가하면, 코틀린에서는 null이 들어갈 수 없는 컬렉션에 null이 들어가게 되면서 오류가 날 수도 있다.

이러한 문제를 해결하기 위해서는 코틀린 쪽에서 만든 컬렉션이 자바에서 호출되면 컬렉션의 내용이 변경될 수 있음을 감안해야 한다. 따라서 자바에서 호출되는 컬렉션이 있다면 방어 로직을 짜는 식으로 처리를 하거나, 코틀린에서 Collection.unmodifableMap(), Collection.unmodifableList()를 사용하여 변경을 막을 수 있다.

그리고 코틀린에서 자바 컬렉션을 가져다 사용할 때는 플랫폼 타입을 신경써야 한다. 예를 들어 자바에서 List<Integer>라는 컬렉션을 만들고, 코틀린에서 해당 컬렉션을 가져온다면 코틀린에서는 List<Int?> 인건지 List<Int>? 인건지 nullable 상태를 알 수 없다. 따라서 코틀린에서 자바 코드를 불러올 때는 자바 코드에서 맥락을 확인하고, 자바 코드를 가져오는 지점을 wrapping해서 영향 범위를 최소화하는 것이 좋다.

여러 함수 다루기

확장 함수

코틀린은 자바와 100% 호환하는 것을 목표로 하고 있다. 이러한 목표는 기존 자바 코드 위에 자연스럽게 코틀린을 추가할 수는 없을까? 라는 고민이 생기게 되었다. 그러면서 자바로 만들어진 라이브러리를 유지보수 및 확장할 때 코틀린 코드를 추가하고 싶다는 요구가 생기게 된다. 이러한 요구를 해결하기 위해 어떠한 클래스 안에 있는 메서드처럼 호출할 수 있지만 함수는 밖에 만들 수 있게 하자! 라는 개념이 나오게 되었다.
이러한 개념의 함수가 바로 확장 함수이다. 확장 함수의 예제로 String 문자열의 가장 끝에 있는 문자를 가져오는 함수를 만들어보겠다.

fun main() {
    val str = "Hello"
    println(str.lastChar())
}

fun String.lastChar(): Char {   // String의 확장 함수
    return this[this.length - 1]    // this를 통해 불려진 인스턴스에 접근 가능
}

위 확장 함수는 즉 원래 String에 있는 멤버함수처럼 사용할 수 있다.

그렇다면 확장 함수는 public이고, 확장 함수에서 수신객체 클래스의 private 함수를 가져오면 확장 함수에 의해 private로 캡슐화한 게 깨지는 것은 아닌가? 하는 의문이 생길 수 있다. 그래서 이러한 문제가 발생하지 않도록 애초에 확장 함수는 클래스 내 private 또는 protected 멤버를 가져올 수 없다.

또 그렇다면 멤버 함수와 확장 함수의 시그니처가 같으면 어떻게 될까?

class Person(val firstName: String, var age: Int) {
    fun nextYear(): Int {
        println("Person의 멤버 함수")
        return age + 1
    }
}

fun Person.nextYear(): Int {
    println("Person의 확장 함수")
    return this.age + 1
}

fun main() {
    val person = Person("Brown", 30)
    person.nextYear()
}

이러한 경우에는 멤버 함수가 우선적으로 호출된다. 그래서 확장 함수를 추가했는데 다른 기능의 똑같은 시그니처인 멤버 변수가 추가가 된다면 예기치 못한 문제가 발생할 수 있다.

다음으로, 확장 함수가 오버라이드된다면 어떻게 될까?

open class Animal(
    val species: String,
    val legCount: Int
)

fun Animal.isLegFour(): Boolean {
    println("Animal의 확장 함수")
    return this.legCount == 4
}

class Parrot : Animal("앵무새", 2)

fun Parrot.isLegFour(): Boolean {
    println("Parrot의 확장 함수")
    return this.legCount == 4
}

이런 경우 확장 함수는 어떻게 호출이 될까? 이는 실제 명시한 타입에 대한 확장 함수가 호출이 된다. 확장 함수는 해당 변수의 현재 타입(정적인 타입)에 의해 어떤 확장 함수가 호출될지 결정된다.

val animal = Animal()
animal.isLegFour()  // Animal의 확장 함수

val parrot1: Animal = Parrot()
parrot1.isLegFour() // Animal의 확장 함수

val parrot2: Parrot = Parrot()
parrot2.isLegFour() // Parrot의 확장 함수

만약 자바에서 코틀린의 확장 함수를 가져다 사용하고 싶을때는 어떻게 할까? 자바에서는 마치 정적 메서드를 호출하는 것처럼 사용 가능하다. 이는 파일 외부에 만들어놓은 함수가 자바로 변환하면 정적 메서드가 되는 것과 같은 원리이다.

public static void main(String[] args){
    StringUtilsKt.lastChar("Hello");
}

마지막으로 확장 함수는 확장 프로퍼티처럼 사용 가능하다. 확장 프로퍼티는 확장 함수 + 커스텀 getter라고 볼 수 있다.

fun String.lastChar(): Char {
    return this[this.length - 1]
}

val String.lastChar: Char
    get() = this[this.length - 1]
infix 함수

중위함수, 중위함수는 함수를 호출하는 새로운 방법이다. 코틀린의 반복문에서 downTo, step과 같은 함수가 바로 중위 함수이다.
함수 호출의 새로운 방법이라는 의미는 무엇이냐면, 기존 함수를 호출할 때는 변수.함수이름(인자)와 같은 식으로 호출하는데 이 대신 변수.함수이름 인자와 같이 호출하는 식이다.
다음 예시는 Int 타입에 확장 함수를 만들면서 하나는 일반 함수를, 다른 하나는 중위 함수를 만들고 각각을 호출하는 예시를 작성했다. 해당 예제에서는 확장 함수에 중위 함수를 만들었지만, 멤버 함수도 중위 함수로 만들 수 있다.

fun Int.add1(num: Int): Int {   // Int의 확장 함수 add1
    return this + num
}

infix fun Int.add2(num: Int): Int { // Int의 확장 함수인 중위함수 add2
    return this + num
}

fun main() {
    2.add1(4)
    
    3.add2(5)   // 중위 함수는 기존 방식대로도 함수 호출 가능
    4 add2 6    // 새로운 함수 호출 형태
}
inline 함수

inline 함수는 함수가 호출되는 대신 함수를 호출한 지점에 함수 본문을 그대로 복사/붙여넣기 하는 함수이다.

inline fun Int.add(num: Int): Int {
    return this + num
}

fun main() {
    3.add(5)
}

위 예제의 코틀린을 자바 코드로 변환해보면 3.add(5) inline 함수를 호출하는 부분이 다음과 같이 변환된다.

public static final void main() {
    byte $this$add$iv = 3;
    int num$iv = 5;
    int $i$f$add = false;
    int varxxx = $this$add$iv + num$iv;
}

$this$add$iv + num$iv;과 같이 실제 덧셈하는 로직이 그대로 들어가게 되는 것을 볼 수 있다.

그럼 이 inline 함수는 왜 사용할까? 주로 함수를 파라미터로 호출할 때의 오버헤드를 줄이기 위해 사용한다. 또한 inline 함수를 사용할 때는 성능 측정과 함께 신중하게 사용하여야 한다.
오버헤드: 프로그램 실행 도중 동떨어진 위치의 코드를 실행시켜야 할 때, 추가적으로 자원이 사용되는 현상

지역 함수

지역 함수는 함수 안에 함수를 선언할 수 있는 걸 말한다. 다음 예시를 보자.

fun createPerson(firstName: String, lastName: String): Person {
    if (firstName.isEmpty()) {
        throw IllegalArgumentException("firstName은 비어있을 수 없습니다!")
    }
    
    if (lastName.isEmpty()) {
        throw IllegalArgumentException("lastName은 비어있을 수 없습니다!")
    }
    
    return Person(firstName, lastName, 1)
}

위 코드에서는 throw IllegalArgumentException() 을 수행하는 부분이 중복인 점을 볼 수 있다. 따라서 이 중복을 최소화하기 위해 다음과 같이 수정해줄 수 있다.

fun createPerson(firstName: String, lastName: String): Person {
    fun validateName(name: String, fieldName: String) {
        if (name.isEmpty()) {
            throw IllegalArgumentException("${fieldName}은 비어있을 수 없습니다!")
        }
    }
    
    validateName(firstName, "firstName")
    validateName(lastName, "lastName")
    
    return Person(firstName, lastName, 1)
}

코드를 살펴보면 validateName() 이라는 함수를 createPerson() 함수 안에서 만든 것을 볼 수 있다. 여기서 validateName() 함수가 지역 함수인 것이다.
지역 함수는 함수로 추출하면 좋을 것 같지만, 현재 함수 내에서만 사용할 것 같을 때 사용할 수 있다. 하지만, fun 안에 fun 이 들어가는 식으로 depth가 깊어지기도 하고 코드가 그렇게 깔끔한 느낌은 아니기 때문에 실무에서 많이 쓰이는 방법은 아니다.
위 코드도 사실 validateName() 지역 함수를 사용하는 것보다 Person 클래스 내에 private validateName 이라는 코드를 가지고, 거기서 이름을 검증해주는 방법이 나을 것이다.

람다 다루기

코틀린에서의 람다

코틀린에서의 함수는 그 자체로 값이 될 수 있다. 따라서 변수에 할당할수도, 파라미터로도 넘길 수 있는 것이다. 이러한 특징을 갖는 객체를 1급 객체라고도 한다.
다음은 람다(익명 함수)를 변수에 할당하는 예제이다.

fun main() {
    val fruits = listOf(
        Fruit("사과", 1_000),
        Fruit("사과", 1_200),
        Fruit("사과", 1_200),
        Fruit("사과", 1_500),
        Fruit("바나나", 3_000),
        Fruit("바나나", 3_500),
        Fruit("바나나", 2_800),
        Fruit("귤", 5_000),
    )
    
    val isApple1: (Fruit) -> Boolean = fun(fruit: Fruit): Boolean {  // 변수에 함수 할당하기 - 첫 번째 방법
        return fruit.name == "사과"
    }
    
    val isApple2: (Fruit) -> Boolean = { fruit: Fruit -> fruit.name == "사과"}    // 변수에 함수 할당하기 - 두 번째 방법
    
    isApple1(fruits[0])     // 변수에 할당한 함수 호출하기 - 첫 번째 방법
    isApple1.invoke(fruits[1])  // 변수에 할당한 함수 호출하기 - 두 번째 방법
}

예제를 살펴보면 변수에 함수를 할당할 때는 이름없는 함수, 즉 익명 함수를 할당해주는 것을 볼 수 있다. 그 중 첫 번째 방법함수 이름은 지정하지 않는다는 점 외에 다른 부분은 기존 함수를 작성해주는 것과 동일하다. 두 번째 방법람다를 사용한 방법인데, 중괄호 안에 인자로 받을 값을 작성한 후 -> 기호 작성한 다음 반환할 조건을 작성하는 방법이다.

변수에 할당한 함수를 호출하는 방법으로는 기존 함수를 호출하는 것처럼 이름(인자) 로 호출하는 방법이 하나, 이름.invoke(인자) 처럼 호출하는 방법 두 가지가 있다.

그리고 isApple1isApple2 변수 역시 타입이 존재하는데, 위 코드의 경우에는 Fruit 타입의 인자를 받아 Boolean 타입을 반환하는 함수라는 의미로 (Fruit) -> Boolean 이라고 표기한다.

자바에서는 함수 자체를 파라미터로 받을 수 없다. 그래서 boolean 조건을 반환하는 함수를 파라미터로 넣고자 할 때 보통 Predicate<> 인터페이스를 파라미터에 넣는다. 그러면 자바에서 Predicate<> 인터페이스를 사용해 필터 조건을 만드는 코드를 살펴보고, 이를 코틀린으로 변환해보겠다.

private List<Fruit> filterFruits(List<Fruit> fruits, Predicate<Fruit> fruitFilter) {
    List<Fruit> results = new ArrayList<>();
    for (Fruit fruit : fruits) {
        // fruitFilter.test() 조건에 만족하면 results에 fruit 요소를 더하는 함수
        if (fruitFilter.test(fruit)) {
            results.add(fruit);
        }
    }
    return results;
}

👇

private fun filterFruits(fruits: List<Fruit>, fruitFilter: (Fruit) -> Boolean): List<Fruits> {
    val results = mutableListOf<Fruit>()
    for (fruit in fruits) {
        if (fruitFilter(fruit)) {
            results.add(fruit)
        }
    }
    return results
}

fun main() {
    val isApple: (Fruit) -> Boolean = { fruit: Fruit -> fruit.name == "사과" }
    
    filterFruits(fruits, isApple)   // 실제 filterFruits 함수 사용
}

보는 것과 같이 코틀린에서는 익명 함수를 바로 파라미터로 받고, 해당 함수를 사용할때도 익명 함수를 바로 인자로 넣어 사용하는 것을 볼 수 있다.
익명 함수를 파라미터로 받는 함수를 사용할 때 위 예제처럼 익명 함수를 할당한 변수를 넣어주는 것도 방법이지만, 익명 함수를 직접 인자로 추가할 수도 있는데, 이는 다음과 같이 사용할 수 있다.

private fun filterFruits(fruits: List<Fruit>, fruitFilter: (Fruit) -> Boolean): List<Fruits> {
    val results = mutableListOf<Fruit>()
    for (fruit in fruits) {
        if (fruitFilter(fruit)) {
            results.add(fruit)
        }
    }
    return results
}

fun main() {
    filterFruits(fruits, { it.name == "사과" })   // 첫 번째 방법
    filterFruits(fruits) { it.name == "사과" }   // 두 번째 방법
}

보는 것과 같이 이미 filterFruits() 함수의 파라미터로 (Fruit) -> Boolean 이라는 타입을 받는다는 것을 알고 있으므로 인자로 추가할 익명 함수에서는 타입을 추론할 수 있게 된다. 또한 { fruit -> fruit.name == "사과" }와 같이 람다식 인자의 이름을 지정해줄 수 있지만, 만약 인자가 하나뿐이라면 코틀린에서는 it이라는 이름으로 바로 사용 가능하다.
또한 익명 함수 인자를 사용하여 함수를 호출할 때는 소괄호 밖에 익명 함수를 작성하는 식으로도 사용 가능한데, 이렇게 되면 가장 마지막 파라미터의 인자로 중괄호 안의 익명 함수가 들어가게 된다.

Closure

먼저 다음과 같은 자바 코드를 살펴보자. 다음 예제는 “복숭아” 라는 값이 할당된 targetFruitName 변수가 있었는데, 해당 변수에 “감” 이라는 값으로 바꿔주고 filterFruits()를 호출해주는 코드이다.

String targetFruitName = "복숭아";
targetFruitName = "감";
filterFruits(fruits, (fruit) -> targetFruitName.equals(fruit.getName()));

실제로 IDE에서 해당 코드를 작성해보면 filterFruits(fruits, (fruit) -> targetFruitName.equals(fruit.getName())); 부분에서 targetFruitName에 에러 표시가 날 것이다.
이는 자바에서는 람다를 사용할 때 람다 밖에 있는 변수를 사용하는 경우에 제약이 있기 때문이다. 람다를 사용할 때는 final인 변수 또는 실질적으로 final인 변수만 사용 가능하다.
하지만, 코틀린에서는 그러한 제약 없이 똑같은 형태로 코드를 작성해도 아무런 문제 없이 동작한다.

var targetFruitName = "복숭아"
targetFruitName = "감"
filterFruits(fruits) { it.name == targetFruitName }

코틀린은 람다가 시작하는 지점에 참조하고 있는 변수들을 모두 포획해서 그 정보를 가지고 있기 때문이다. 그래서 위 예제를 살펴보면 { it.name == targetFruitName } 이라는 람다가 불리는 시점에 존재하는 targetFruitName 변수를 포획해서 그 값을 가지고 있는 것이다.
이는 코틀린에서 람다를 일급 객체로 간주하기 위한 특징이며, 이러한 데이터 구조를 Closure라고 한다.

try with resources 대체한 문법

예전 코틀린의 제어문, 반복문, 예외에 대해 다룬 포스팅에서 코틀린에는 try with resources 구문이 없다고 하면서 특정 객체의 use라는 확장 함수를 사용한다고 설명했었다. 이 use 함수에 대해 설명해보겠다.

public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {

내부로 들어가 살펴보면 use 함수는 Closeable 구현체 T에 대한 확장 함수(T.use)임을 볼 수 있다. 또한 inline 함수이며, 받고 있는 파라미터가 block 이라는 이름을 가진 T 타입을 받아 R 타입을 반환하는 함수인 것을 확인할 수 있다.
그래서 이전 포스팅에서 다루었던 예제 코드를 다시 한 번 살펴보면,

fun readFile(path: String) {
    BufferedReader(FileReader(path)).use { reader -> 
        println(reader.readLine())
    }
}

실제로 { reader -> ... } 와 같이 람다를 use 함수의 파라미터 인자로 전달해주고 있는 것을 이제는 확인할 수 있을 것이다.

컬렉션을 함수형으로 다루기

이번 파트는 반드시 알아둬야 한다는 개념은 아니고 이런 기능도 있구나~ 하는 느낌으로 보면 좋다.

필터와 맵
data class Fruit(
    val id: Long,
    val name: String,
    val factoryPrice: Long,
    val currentPrice: Long,
)

위와 같은 Fruit 데이터 클래스가 있고, 사과만 원해요 또는 사과의 가격을 알려주세요 와 같은 요구사항이 들어왔다고 가정해보겠다.
코틀린에서의 함수형 프로그래밍 기능으로 먼저 filter 기능이 있다. 다음과 같이 기능을 작성하여 fruits에 존재하는 사과들만 필터링해줄 수 있는 것이다.

val apples = fruits.filter { fruit -> fruit.name == "사과" }

또한 filterIndexed 라는 기능으로 필터를 하면서 인덱스가 필요한 경우가 있을 때 사용할 수도 있다.

val apples = fruits.filterIndexed { idx, fruit -> 
    println(idx)
    fruit.name == "사과"
}

그 다음 사과의 가격을 알려줘야 하는 요구사항을 만족시키기 위한 경우에는 map 기능으로 얻을 수 있다. 다음은 먼저 과일들 중 사과만 필터링한 다음 필터링된 과일들의 가격을 얻어오는 코드이다.

val applePrices = fruits.filter { fruit -> fruit.name == "사과" }
    .map { fruit -> fruit.currentPrice }

map도 마찬가지로 매핑하면서 인덱스가 필요한 경우가 있을 수 있다. 이 때는 mapIndexed 기능을 사용한다.

val applePrices = fruits.filter { fruit -> fruit.name == "사과" }
    .mapIndexed { idx, fruit -> 
        println(idx)
        fruit.currentPrice
    }

만약 매핑의 결과가 null이 아닌 것만 가져오고 싶은 경우? 이 때는 if 조건문으로 null이 아닌 값만 가져오도록 작성할 수도 있지만 if 문을 사용하지 않고 mapNotNull 기능을 사용하면 비교적 간단하게 작성할 수 있다.

val values = fruits.filter { fruit -> fruit.name == "사과" }
    .mapNotNull { fruit -> fruit.nullOrValue() }

따라서 이러한 filtermap 기능을 사용하면 앞 파트에서 작성했던 filterFruits() 함수를 더 간단하게 작성할 수 있게 된다.

private fun filterFruits(fruits: List<Fruit>, fruitFilter: (Fruit) -> Boolean): List<Fruits> {  // AS-IS
    val results = mutableListOf<Fruit>()
    for (fruit in fruits) {
        if (fruitFilter(fruit)) {
            results.add(fruit)
        }
    }
    return results
}

private fun filterFruits(fruits: List<Fruit>, filterFruit: (Fruit) -> Boolean): List<Fruit> {   // TO-BE
    return fruits.filter(filterFruit)
}
다양한 컬렉션 처리 기능

모든 과일이 사과인가요? 라던가 출고가 10000원 이상의 과일이 하나라도 있나요?와 같은 요구사항의 경우에는 어떻게 처리할까? 이 때는 all기능과 none 기능, 그리고 any을 사용해볼 수 있을 것 같은데,
all: 조건을 모두 만족하면 true 그렇지 않으면 false가 반환되는 기능
none: 조건을 모두 불만족하면 true 그렇지 않으면 false가 반환되는 기능
any: 조건을 하나라도 만족하면 true 그렇지 않으면 false가 반환되는 기능

// 모든 과일이 사과?
val isAllApple = fruits.all { fruit -> fruit.name == "사과" }

// 출고가 10000원 이상의 과일이 하나라도 있나?
val isMoreTenThousand = fruits.any { fruit -> fruit.factoryPrice >= 10_000 }

그렇다면 총 과일 개수는 몇 개인가요?라던가 낮은 가격 순으로 보여주세요 또는 과일은 총 몇 종류가 있나요? 같은 요구가 들어오면 어떨까? 이러한 경우에는 count기능과 sortedBy기능과 sortedByDescending기능, 그리고 distinctBy기능을 사용해볼 수 있을 것이다.
count: 개수를 세는 기능
sortedBy: 오름차순으로 정렬하는 기능
sortedByDescending: 내림차순으로 정렬하는 기능
distinctBy: 변형된 값을 기준으로 중복을 제거하는 기능

// 총 과일은 몇 개?
val fruitCount = fruits.count()

// 낮은 가격 순으로 보여주세요
val fruitsBySorted = fruits.sortedBy { fruit -> fruit.currentPrice }

// 높은 가격 순으로 보여주세요
val fruitBySortedDescending = fruits.sortedByDescending { fruit -> fruit.currentPrice }

// 과일 종류 리스트
val distinctFruitNames = fruits.distinctBy { fruit -> fruit.name }
    .map { fruit -> fruit.name }

그리고 첫 번째 과일만 주세요마지막 과일만 주세요 라는 요구가 들어올 수도 있다. 이 때는 first기능과 last기능을 사용할 수 있다.
first: 첫 번째 값을 가져오는 기능 (무조건 null이 아니어야 함, null이면 Exception 발생)
firstOrNull: 첫 번째 값 또는 null을 가져오는 기능
last: 마지막 값을 가져오는 기능 (무조건 null이 아니어야 함, null이면 Exception 발생)
lastOrNull: 마지막 값 또는 null을 가져오는 기능

// 첫 번째 과일만 주세요
fruits.first()
fruits.firstOrNull()

// 마지막 과일만 주세요
fruits.last()
fruits.lastOrNull()
List to Map

만약 List<과일> 이라는 리스트를 Map<과일이름, 과일리스트> 으로 변경이 필요하다고 해보자. 이 때는 groupBy라는 기능을 사용할 수 있다.

val map: Map<String, List<Fruit>> = fruits.groupBy { fruit -> fruit.name }

위와 같이 groupBy { fruit -> fruit.name } 으로 작성하게 되면 fruit.name을 기준으로 그룹핑이 된다. 즉 fruit.name이 key가 되는 Map이 생성되는 것이다.
그렇다면 리스트를 그룹핑하는 게 아니라 단일 id 값, 즉 중복되지 않은 값을 기준으로 단일 객체를 그룹핑해야 한다면 Map<ID, 객체> 처럼 만들려면 어떻게 할까? 이 때는 associateBy 기능을 사용하면 된다.

val map: Map<Long, Fruit> = fruits.associateBy { fruit -> fruit.id }

또한 key와 value를 다음과 같이 동시에 처리할 수도 있다. 이는 groupBy기능과 associateBy기능 모두 비슷하게 동작한다.

val map: Map<String, List<Long>> = fruits.groupBy({ fruit -> fruit.name }, { fruit -> fruit.factoryPrice })

Map에 대해서도 앞서 컬렉션 처리 기능들을 사용할 수 있다.

val map: Map<String, List<Fruit>> = fruits.groupBy { fruit -> fruit.name }
    .filter { (key, value) -> key == "사과" }

마지막으로 중첩된 컬렉션에 대한 처리를 알아보자. List<List<Fruit>>와 같이 이중 리스트인 데이터가 있다고 해보자.

val fruitsInList: List<List<Fruit>> = listOf(
    listOf(
        Fruit(1L, "사과", 1_000, 1_500),
        Fruit(2L, "사과", 1_200, 1_500),
        Fruit(3L, "사과", 1_200, 1_500),
        Fruit(4L, "사과", 1_500, 1_500),
    ),
    listOf(
        Fruit(5L, "바나나", 3_000, 3_500),
        Fruit(6L, "바나나", 3_300, 3_500),
        Fruit(7L, "바나나", 2_900, 3_500),
    ),
    listOf(
        Fruit(8L, "귤", 10_000, 10_000),
    )
)

이러한 자료 구조에서 출고가와 현재가가 동일한 과일만 골라주세요와 같은 요구사항이 들어왔다고 해보자. 이러한 경우에는 flatMap기능을 사용하면 되는데, 자바에서도 존재하는 flatMap()과 동일한 기능으로 이중 List가 단일 List로 변환된다.

val samePriceFruits = fruitsInList.flatMap { list ->
    list.filter { fruit -> fruit.factoryPrice == fruit.currentPrice }
}

위와 같이 요구사항을 만족할 수 있을 것이다. 위 코드를 보면 { list -> 부분과 { fruit -> 와 같이 람다가 중첩되어 있는데, 이러한 부분을 다음과 같이 리팩토링할 수도 있다.

val samePriceFruits = fruitsInList.flatMap { list -> list.samePriceFilter }

fun List<Fruit>.samePriceFilter() {
    return this.filter(Fruit::isSamePrice)
}

data class Fruit(
    val id: Long,
    val name: String,
    val factoryPrice: Long,
    val currentPrice: Long,
) {
    val isSamePrice: Boolean
        get() = factoryPrice == currentPrice
}

위의 리팩토링한 코드를 살펴보면 List<Fruit> 타입에 samePriceFilter() 확장 함수를 생성하여 이 확장 함수에서 Fruit에 특정 조건으로 필터링하고, flatMap { list -> list.samePriceFilter } 처럼 하나의 람다를 쓰는 것처럼 작성할 수 있게 된다.

만약 그냥 List<List<Fruit>> 타입을 List<Fruit> 로 바꾸면 되는 상황에서는 flatten() 이라는 함수를 사용해서 바꿀 수도 있는데, flatten()을 사용하면 중첩되어 있던 컬렉션이 중첩 해제되면서 평탄화된다.

fruitsInList.flatten()