Kotlin - 05. Part2. 람다로 프로그래밍

Kotlin 의 람다 Lambda


지연 계산(lazy) 컬렉션 연산

코틀린의 ‘Sequence’

코틀린의 함수형 API들은 연산 과정에서 생성되는 새로운 중간 컬렉션을 즉시 생성한다. 그런 경우 중간 결과 객체가 많아지면서 메모리 이슈가 발생하고 성능 저하와 같은 장애가 발생할 수 있다. 코틀린에서는 이런 장애를 방지하기 위해 컬렉션 연산을 지연 계산할 수 있는 시퀀스 sequence API를 제공한다.

먼저 왜 중간 결과 객체가 장애를 발생할 수 있는지 살펴보겠다.

// `people` 컬렉션의 `Person` 클래스의 `name` 중 "A" 로 시작하는 원소 찾는다.
people
    .map(Person::name)
    .filter { it.startWith("A") }

위 코드의 문제점은 people 리스트의 모든 원소를 .map() 반복하면서 새로운 String 객체를 리스트의 사이즈만큼 다시 생성한다.

이런 코드는 수백만 개의 리스트 컬렉션을 처리할 때 문제가 발생할 수 있다. 그렇다면, 시퀀스 sequence 는 어떤 처리를 하는 것일까?

people
    .asSequence()
    .map(Person::name)
    .filter { it.startWith("A") }

시퀀스 API 는 일반 컬렉션의 함수형 API를 그대로 구현하고 똑같이 활용 가능하지만, 큰 차이점은 바로 중간 결과 객체를 생성하지 않는다.

과연 “중간 결과 객체를 생성하지 않는다.” 라는 의미는 무엇일까?

시퀀스 연산 실행: 중간 연산과 최종 연산

시퀀스 에 대해 제대로 이해하기 전에, 먼저 중간intermediate 연산최종termainal 연산 에 대한 이해를 먼저 필요하다.

중간 연산

중간 연산 은 연산은 통한 다른 시퀀스를 반환하게 되고, 최초 시퀀스의 원소를 반환하는 방법을 알고 있다. 그리고, 항상 지연 계산 된다.

listOf(1, 2, 3, 4)
    .asSequence()
    .map { print("map($it) "); it * it }
    .filter { print("filter($it) "); it % 2 == 0 }

위 코드를 실행하면 print() 내용은 출력되지 않는다. .map().filter() 변환이 늦춰지면서 결과를 얻을 필요가 있을 때, 연산이 적용되면서 최종 결과를 계산하고 반환하게 된다.

최종 연산

최종 연산 은 말그대로 연산에 대한 결과를 반환한다. 최종 연산을 호출하면 연기됐던 모든 계산을 수행한다.

listOf(1, 2, 3, 4)
    .asSequence()
    .map { print("map($it) "); it * it }
    .filter { print("filter($it) "); it % 2 == 0 }
    .toList()

코틀린의 시퀀스를 활용하면, 지연 계산으로 중간 객체 생성없이 계산된 결과를 반환할 수 있다.

지연 계산

일반 컬렉션에서 즉시 계산 방식은 매번 연산에 대한 결과 중간 컬렉션이 생성이 된다.

listOf(1, 2, 3, 4)              // list1 = [1, 2, 3, 4]
    .map { it * it }            // list2 = [1, 4, 9, 16]
    .filter { it % 2 == 0 }     // list3 = [4, 16]

즉시 계산은 위와 같이 하나의 컬렉션에 대한 연산 처리를 위해 총 3개의 컬렉션이 생기게 된다.

지연 계산 은 새로운 중간 컬렉션 객체를 생성하지 않고, 모든 연산이 각 원소에 대해 순차적으로 적용된다.

listOf(1, 2, 3, 4)
    .asSequence()
    .map { it * it }
    .find { it % 2 == 0 }

코틀린의 지연 계산

위 그림처럼 즉시 계산(왼쪽) 방식은 컬렉션의 크기만큼(또는 연산된 컬렉션 크기만큼) 새로운 객체를 생성하지안, 지연 계산(오른쪽) 방식은 각 원소에 대한 연산 처리 후 반환하게 된다. 그러면 .find() 로 찾은 4 라는 결과값이 있으니 더이상 나머지 컬렉션에 대한 원소를 연산하지 않는다.

시퀀스 의 지연 계산은 이런 방식을 통해 프로그램의 OOM 이나 메모리 이슈를 방지할 수 있는 안전 장치 역할을 하게 된다.

컬렉션 연산 순서로 성능 개선 방법
val people = listOf(Person("KIM", 29), Person(JIM, 33))
                .map(Person::age)
                .filter { it > 30 }

위 코드는 모든 원소에 대한 .map() 을 적용하고 .filter() 를 통해 조건을 적용하게 된다. 이렇게 되면 최초 컬렉션의 크기만큼 중간 컬렉션이 생성되지만, 아래와 같은 코드는 그렇지않다.

val people = listOf(Person("KIM", 29), Person(JIM, 33))
                .asSequence()
                .filter { it.age > 30 }
                .map(Person::age)

위 코드는 시퀀스와 연산의 순서를 .filter() 부터 적용하고, .map() 을 적용하였다. 이런 순서라면, 먼저 조건에 부합된 원소만 남겨지고 .map() 의 함수가 적용되니 훨씬 연산 수가 줄어들게 된다.

이와 같이 코틀린에서는 시퀀스 와 컬렉션 람다 함수의 연산 순서에 따라 성능 개선을 손쉽게 할 수 있는 장점이 있다.


자바 함수형 인터페이스 코틀린에서 사용

코틀린의 람다는 다향한 코드를 공통화하여 가독성과 코드의 유지보수성을 높일 수 있다. 하지만 코틀린이 자바 기반으로 나온 프로그래밍의 언어이고, 자바와 계속 상호 운용할 수 있는 언어가 되기 위해서, 코틀린은 자바에서도 코틀린의 람다를 활용할 수 있도록 지원하고 있다.

button.setOnClickListener { view -> ... }

위와 같은 코틀린의 람다를 활용하는 .setOnClickListener() 함수를 자바에서는 아래와 같이 활용할 수 있다.

public interface OnClickListener {
    void onClick(View view);
}

자바는 함수형 인터페이스 또는 SAM 인터페이스 라고 추상 메소드가 단 하나만 있는 인터페이스을 통해 코틀린의 람다를 활용할 수 있다.

SAM 생성자

SAM Sigle Abstract Method 은 단일 추상 메소드란 의미로, 자바 API 중에는 Runnable 이나 Callable 과 같은 함수형 인터페이스를 제공하는 역할을 한다. SAM 생성자 는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성하는 함수다.


수신 객체 지정 람다 사용

수신 객체 지정 람다 는 수신 객체를 명시하지 않고, 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 한다.

with 함수

어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다.

fun alphabet() = with(StringBuilder()) {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    toString()
}

with 함수는 첫 번째 인자로 받은 객체를, 두 번째 인자로 받은 람다의 수신 객체로 만든다.

apply 함수

applywith 함수와 거의 비슷하지만, 항상 자신에게 전달된 객체를 반환한다.

fun alphabat() = StringBuilder().apply {
    for (letter in 'A'...'Z') {
        append(letter)
    }
}.toString()

apply 함수는 확장 함수로 정의되어 있고, 수신 객체가 전달받은 람다의 수신 객체가 된다. 실행 결과 반환되는 타입은 수신 객체의 타입이된다.

apply 함수는 객체의 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화하는 경우 유용하다.

val person = Person("JIM").apply {
    age = 33
}

출처