Kotlin In Action 5장

람다 프로그래밍

람다식 또는 람다는 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각을 의미한다.

코틀린 표준 라이브러리는 람다를 아주 많이 사용한다.

람다를 주로 사용하는 경우는 컬렉션 처리를 예로 들 수 있겠다.

이 장에서는 컬렉션을 처리하는 패터을 표준 라이브러리 함수에 람다로 넘기는 방식,

자바 라이브러리와 람다를 함께 사용하는 방법도 살펴본다.

그리고 수신 객체 지정 람다 (lambda with receiver)에 대해서도 살펴본다.

람다의 등장

자바 8에서 가장 다이나믹하게 바뀐 점을 꼽으라면 람다의 도입과 함수형 프로그래밍이 아닐까 싶다.

코틀린 또한 자바와 마찬가지로 람다를 지원하고 이는데, 코틀린에서는 어떻게 람다를 표현했는지 살펴보자.

람다 소개

이벤트가 발생하면 이 핸들러를 실행하자! 라던지,

데이터 구조의 모든 원소에 이 연산을 적용하자!와 같은 생각을 코드로 표현하기 위해 일련의 동작을 변수에 저장하거나 다른 함수에 넘겨야하는 경우가 자주 있었다.

자바에서는 무명 내부 클래스를 통해 이러한 목적을 달성하였으나 번거롭다는 단점이 있었다.

함수형 프로그래밍에서는 함수를 값처럼 다루는 접근 방법 (일급 시민)을 사용함으로써 더욱 간결한 코드를 만들 수 있었다. (e.q. Scala)

람다와 컬렉션

코드에서 중복을 제거하는 것은 프로래밍 스타일을 개선하는 중요한 방법 중 하나다.

컬렉션을 다룰 때 수행하는 대부분의 작업은 몇 가지 일반적인 패턴을 갖는다.

따라서 이러한 패턴은 라이브러리 안에 존재해야한다. 하지만 람다가 없다면 컬렉션을 편리하게 처리할 수 있는 라이브러리를 제공하기가 어렵다.

따라서 과거 자바에서는(8이전) 쓰기 편한 컬렉션 라이브러리가 적었고, 그에 따라 자바 개발자들은 필요한 컬렉션 기능을 직접 작성하였다.

코틀린에서는 이러한 습관을 버려야 한다.

람다 식의 문법

{ x: Int, y: Int -> x+  y }

->를 기준으로 전자와 후자로 나누었다고 가정하자.

전자의 경우 파라미터이며, 후자의 경우 본문이라고 할 수 있다.

여기서 우리가 기억해야할 사실은 ->가 인자 목록과 람다 본문을 구분해준다는 사실이다.

코틀린에서는 아주 중요한 람다 문법이 존재하는데 아래와 같다.

people.maxBy({p:Person -> p.age})

people이라는 컬렉션 내부에서 가장 나이가 많은 원소만 찾는 코드이다.

하지만 코드가 상당히 번잡함을 알 수 있다.

이를 개선하면 아래와 같이 표현할 수 있다.

people.maxBy() {p:Person -> p.age}

코틀린에서는 함수 호출 시 맨 뒤에 있는 인자가 람다식이라면 그 람다를 괄호 밖으로 빼낼 수 있다는 문법적 관습이 존재한다. 즉 모르면 못쓴다는 얘기. 꼭 알아두자.

이 코드를 한번 더 개선할 수 있는데, 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호를 없애도 된다.

people.maxBy {p:Person -> p.age}

또한 코틀린 컴파일러는 로컬 변수와 마찬가지로 람다 파라미터의 타입도 추론이 가능하다.

그래서 다음과 같이 타입을 명시하지 않아도 괜찮다.

people.maxBy {p -> p.age}

그리고 위의 예제와 같이 람다의 파라미터가 하나이고 컴파일러가 타입 추론이 가능한 경우라면 아래와 같이 더 간결하게 작성할 수 있다.

people.maxBy {it.age}

it은 람다 파라미터의 디폴트 이름이다.

람다 파라미터의 이름을 별도로 지정하지 않으면 it이라는 이름이 자동으로 만들어진다.

val getAge = {p:Person -> p.age}
people.maxBy(getAge)

당연히 람다식 또한 함수이기 때문에 변수에 넘겨줄 수 있다.

물론 이 경우 타입 추론이 불가능하기 때문에 타입을 명시해주어야한다.

현재 영역에 있는 변수 접근

자바 메소드 안에서 무명 내부 클래스를 정의할 때 메소드의 로컬 변수를 무명 내부 클래스에서 사용할 수 있었다.

람다 안에서도 같은 일이 가능하다.

코틀린에서는 자바와 달리 람다 내부에서 파이널 변수가 아닌 변수에 접근이 가능하며, 람다 안에서 바깥 변수를 변경해도 괜찮다.

코틀린에서는 자바와 달리 람다 밖 함수에 있는 파이널이 아닌 변수에 접근할 수 있고, 변경도 가능하다니… 매우 편리한 기능이다.

람다 안에서 사용하는 외부 변수를 사용하는 것을 람다가 포획(captuer)한 변수라고 부른다.

그렇다면 이 편리한 기능을 자바에서도 동일하게 사용할 수 있다는 얘기인데,

어떻게 구현되어있길래 final 키워드가 붙은 변수만 사용할 수 있는 자바에서도 이러한 caputure를 사용할 수 있는 것일까?

파이널이 아닌 변수를 포획한 경우 변수를 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음, 래퍼에 대한 참조를 람다 코드와 함께 저장한다.

그 구현 예시는 아래와 같다.

class Ref<T>(var value: T)
val counter = Ref(0)
val inc = {counter.value++}

자바에서는 파이널 변수만 포획할 수 있다. 하지만 교묘한 속임수를 통해 변경가능한 변수를 포획할 수 있다.

변경 가능한 변수를 저장하는 원소가 단 하나 뿐인 배열을 선언하거나, 변경 가능한 변수를 필드로 하는 클래스를 선언하는 것이다.

내부 원소는 변경가능할지라도 해당 배열이나 클래스의 인스턴스에 대한 참조를 final로 만들면 자바에서도 final이 아닌 변수의 값을 참조하거나 변경하는 것이 가능한 것이다.

jj1Jd

만든 사람이 너무 똑똑해서 감탄만 나옴

한 가지 반드시 알아둬야할 함정은 아래와 같이 람다를 이벤트 핸들러나 다른 비동기적으로 실행되는 코드로 활용하는 경우

함수 호출이 끝난 다음에 로컬 변수가 변경될 수 있는 경우다.

fun tryToCountButtonClicks(button:Button) : Int {
    var clicks = 0
    button.onClick{ clicks++ }
    return clicks
}

이 함수는 항상 0을 반환한다.

onClick 핸들러는 호출될 때 마다 clicks 변수의 값을 증가시키지만, 그 값의 변경을 관찰할 수 없다.

핸들러는 tryToCountButtonClicksclicks를 반환한 다음에 호출이 되기 때문이다.

멤버 참조

람다를 사용해 코드 블록을 다른 함수에게 인자로 넘길 수 있었다.

하지만 넘기려는 코드가 이미 함수로 선언된 경우라면 어떻게 해야할까?

물론 해당 함수를 호출하는 람다를 만들면되지만 이는 중복이다.

함수를 직접 넘길 수 없을까?

코틀린에서는 자바 8과 마찬가지로 함수를 값으로 바꿀 수 있다.

이때 이중 콜론(::)을 사용한다.

val getAge = Person::age

::를 사용하는 식을 멤버 참조라고 부른다.

Person클래스이며 age멤버이다.

이 두 사이를 ::으로 구분하고 있을 뿐이다.

이러한 멤버 참조를 가장 대표적으로 사용하는 예시는 아래와 같다.

list.forEach(System.out::println)

forEach 람다함수 내부의 인자로 System.outprintln을 넘겨주는 모습이다.

당연히 확장함수 또한 멤버 참조가 가능하다.

컬렉션 함수형 API

함수형 프로그래밍 스타일을 사용하면 컬렉션을 다룰 때 매우 편리하다.

그래서 요즘 등장하는 인싸(?) 언어들은 기본적으로 함수형 프로그래밍을 지원하며 특히 이는 콜렉션 데이터를 다룰 때 그 진가가 발휘된다.

간결하고 직관적이며 이해하기 쉬운 코드를 작성할 수 있는 컬렉션 함수형 API에 대해서 살펴보자.

filter, map

filtermap은 컬렉션을 다룰 때 기반이 되는 가장 대표적인 함수들이다.

filter를 사용하는 대표적인 예시는 아래와 같다.

val list = listOf(1,2,3,4)
println(list.filter{ it % 2 == 0 }) // 짝수만 남음
[2,4]

map을 사용하는 대표적인 예시는 아래와 같다.

val list = listOf(1,2,3,4)
println(list.map{ it * 2 }) // 모두 2씩 곱해짐
[2,4,6,8]

all, any, count, find

컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산이다.

코틀린에서는 all과 any가 이런 연산이다.

count는 조건을 만족하는 개수를 반환하며, find는 조건을 만족하는 첫 번째 원소를 반환한다.

전체 조건을 만족해야 true를 반환하고 그렇지 않은 경우 false를 반환하는 all 함수는 아래와 같이 사용할 수 있다.

// Person 클래스 선언
class Person(val name: String, val age: Int)
val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.all{ it.age <= 27})
false

조건 중 하나라도 만족하면 true 전체 다 만족하지 않은 경우 false를 반환하는 any 함수는 아래와 같이 사용할 수 있다.

val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.any{ it.age <= 27})
true

익히 잘 알고 있는 드 모르강의 법칙에 따라 !all의 수행 결과는 any와 동일하다.

조건을 만족하는 원소의 개수를 구하고 싶다면 count 함수를 사용할 수 있다.

val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.count{ it.age <= 27})
1

조건을 만족하는 원소를 하나 찾고 싶은 경우 find 함수를 사용할 수 있다.

조건을 만족하는 원소의 개수를 구하고 싶다면 count를 사용할 수 있다.

val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.find{ it.age <= 27})
Person@2f92e0f4

이러한 객체의 주소를 반환하는 이유는 객체 내부에 toString이 정의되어있지 않기 때문인데, 앞 시간에 배운 data class를 활용하여 다시 시도해보자.

// data 키워드 추가
data class Person(val name: String, val age: Int)
Person(name=Alice, age=27)

제대로 출력이 되는 것을 확인할 수 있다.

groupby

가령 컬렉션의 모든 원소를 어떤 특정에 따라 여러 그룹으로 나누고 싶은 경우가 있다고 가정하자. 생각보다 이런 경우는 꽤 많다.

Person 클래스에서 나이를 기준으로 Map으로 만들어주는 함수가 있다면 정말 편리하지 않을까?

groupby는 이러한 기능을 갖고 있다.

val people = listOf(Person("Alice",27), Person("Bob",31),Person("Harry",27))
println(people.groupBy{ it.age})
{27=[Person(name=Alice, age=27), Person(name=Harry, age=27)], 31=[Person(name=Bob, age=31)]}

출력 결과를 보면 나이를 Key로 갖는 Map 자료형이 반환되는 것을 알 수 있다.

flatMap과 flatten

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

Book이라는 클래스가 존재한다.

이 책에는 여러 저자들이 기재되어 있다.

도서관에는 이러한 책들이 여러권 존재한다.

만약 도서관의 모든 책의 저자를 모두 모은 집합을 가져오고 싶은 경우가 있을 수 있다.

그때 아래와 같이 flatMap을 사용할 수 있다.

val books = arrayOf(Book("harry potter",listOf("a","b","c","d")),
        Book("Time",listOf("k","g","l")))
println(books.flatMap { it.authors })
[a, b, c, d, k, g, l]

flatMap 함수는 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 한데 모으는 기능을 가진 함수이다.

lazy 연산

함수형 프로그래밍을 이루는 요소 중 성능과 밀접한 관련이 있는 lazy evaluation에 대해 살펴보자.

앞서 살펴본 mapfilter를 살펴보았다. 이러한 함수들은 eagerly(즉시) 연산을 수행한다.

이는 다시 말해서 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이 된다.

Sequence를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다. 이는 자바의 Stream과 유사한 부분이다.

조금 더 자세한 차이를 알고 싶다면 아래의 링크를 참조하자.

https://proandroiddev.com/java-streams-vs-kotlin-sequences-c9ae080abfdc

어쨌든, Sequence를 사용하면 중간 결과를 만들지 않기 때문에 원소 수백만개 짜리 컬렉션을 다루면서 연쇄 호출을 할 경우 성능상 이점을 얻을 수 있다는 말이다.

사용 예제는 아래와 같다.

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

toList()가 호출될 때 비로소 연산을 수행하기 때문에 연쇄적으로 컬렉션 구조를 다룰 때 성능상 이점을 얻을 수 있다.

시퀀스의 연산은 중간 연산최종 연산으로 나누어진다.

중간 연산은 다른 시퀀스를 반환하며 최종 연산은 결과를 반환한다.

이는 Java 8Stream과 상당히 유사한 구조를 갖는다는 것을 알 수 있다.

중간 연산은 항상 지연 계산된다.

자바 함수형 인터페이스 활용

함수형 인터페이스를 인자로 요구하는 자바 메소드에 람다를 전달하는 경우 어떤 일이 벌어질까? 궁금하지않은가?

함수형 인터페이스를 인자로 원하는 자바 메소드에 코틀린 람다를 전달할 수 있다.

가령 아래와 같은 자바 메소드가 있다고 하자.

void postponeComputation(int delay, Runnable computation);

코틀린에서 람다를 이 함수에 넘길 수 있다.

postponeComputation(1000) { println(42)}

컴파일러는 자동으로 람다를 Runnable 인스턴스로 변환해준다.

여기서 Runnable 인스턴스라는 말은 실제로는 Runnable을 구현한 무명 클래스의 인스턴스라는 뜻이다.

컴파일러가 자동으로 그런 무명 클래스와 인스턴스를 만들어준다는 얘기다.

물론 이전 시간에 배운 object를 사용하여 넘겨주는 것 또한 가능하다.

postponeComputation(1000, object : Runnable { // 객체 식을 함수형 인터페이스 구현으로 넘긴다.
    override fun fun() {
        println(42)
    }
}

그러나 람다와 무명 객체 사이에는 차이가 있다.

객체를 명시적으로 선언하는 경우 메소드를 호출할 때마다 새로운 객체가 생성된다.

람다는 다르다.

정의가 들어있는 함수의 변수에 접근하지 않는 람다에 대응하는 무명 객체를 메소드를 호출할 때마다 반복 사용한다.

따라서 명시적인 object을 사용하면서 람다와 동일한 코드는 다음과 같다.

val runnable = Runnable { println(42) }
fun handleComputation() {
    postponeComputation(1000,runnable) // 모든 handleComputation 호출에 같은 객체를 사용
}

만약 람다가 주변 영역의 변수를 포획한다면 어떨까?

그런 경우라면 매 호출마다 같은 인스턴스를 사용할 수 없다.

그 경우 컴파일러는 매번 주변 영역의 변수를 포획한 새로운 인스턴스를 생성한다. (성능이 떨어질 수도 있겠다.)

대부분의 경우에서 컴파일러가 자바의 함수형 인터페이스와 코틀린의 람다를 자동으로 변환해주지만 항상 세상이 그렇게 만만하지만은 않다.

어쩔 수 없이 수동으로 변환을 해야하는 경우가 생기는데 그 경우 어떻게 람다를 처리하는지 알아보자.

SAM 생성자

SAM 생성자는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성한 함수다.

컴파일러가 자동으로 람다를 함수형 인터페이스 무명 클래스로 바꾸지 못하는 경우라면 SAM 생성자를 사용할 수 있다.


fun createAllDoneRunnable() : Runnable {
    return Runnable { println("All done!") }
}

createAllDoneRunnable().run()
// All done!

SAM 생성자의 이름은 사용하려는 함수형 인터페이스의 이름과 같다.

SAM 생성자는 함수형 인터페이스의 유일한 추상 메소드의 본문에 사용할 람다만을 인자로 받아서 함수형 인터페이스를 구현하는 클래스의 인스턴스를 반환한다.

수신 객체 지정 람다 with, apply

코틀린 표준 라이브러리인 withapply를 살펴보자.

ScopeFunction이라고도 불리는 이 함수는 매우 편리하며 많은 사람들이 사용하는 함수이다.

자바의 람다에는 없는 코틀린 람다의 독특한 기능은 람다 내부에서 수신 객체를 지정하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것이다.

그런 람다를 수신 객체 지정 람다라고 부른다.

먼저 with를 살펴보자, with는 수신 객체 지정 람다를 활용한다.

with

with를 사용하는 예제를 직접 살펴보자.

fun alphabet() : String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) { // 메소드를 호출 하려는 수신 객체 지정
        for (letter in 'A'..'Z') {
            this.append(letter) // this를 명시해 지정 수신 객체의 메소드 호출
        }
        append("\nNow I know the alphabet!") // this 생략
        this.toString() // 람다 값 반환
    }
}

얼핏보면 with가 언어에서 제공하는 특별한 구문처럼 보이지만 이는 사실이 아니다.

실제로는 파라미터가 2개인 함수이다. 첫 번째 파라미터는 수신 객체이며 두 번째 파라미터는 람다이다.

마지막 파라미터가 람다인 경우 괄호 밖으로 빼낼 수 있다는 것을 우리는 앞에서 배웠다.

물론 with(stringBuilder, {…})라고 할 수는 있지만 글쎄… 더 가독성이 떨어지니까 이렇게 사용하지는 말자.

조금 더 리팩토링 해서 앞의 코드를 줄이면 다음과 같다.

fun alphabet() = with(StringBuilder()) {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
    toString() // 람다 값 반환
}

this를 생략하고 함수에 with 함수를 대입하였다.

with는 마지막 반환 값이 람다의 결과이다.

그런데 반환값이 수신 객체가 필요한 경우가 존재한다.

이를 위해 등장한 것이 apply이다.

apply

apply는 with와 거의 유사하다.

유일한 차이는 반환 값이 자신을 전달한 수신 객체라는 점뿐이다.

fun alphabet() = StringBuilder().apply {
    for (letter in 'A'..'Z'){
        append(letter)
    }
    append("\nNow I know the alphabet!")
}

apply는 확장 함수로 정의되어있다.

apply 함수의 경우 객체의 인스턴스를 만들면서 프로퍼티를 초기화해야하는 경우 사용하면 유용하다.

자바에서는 보통 별도의 Builder 객체가 이를 담당한다.

그러나 코틀린에서는 어떤 클래스가 정의돼 있는 라이브러리의 특별한 지원 없이도 그 클래스 인스턴스에 대해 apply를 활용할 수 있다.

Reference

Kotlin In Action (드미트리 제메로프, 스베트라나 이사코바)



© 2022. by minkuk

Powered by minkuk