10 분 소요

kotlin6_image1.jpg

람다 식 또는 람다는 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각을 뜻한다. 람다를 사용하면 쉽게 공통 코드 구조를 라이브러리 함수로 뽑아낼 수 있다.

5.1 람다 식과 멤버 참조

왜 람다가 그렇게 중요할까? 이번 절에서는 람다의 유용성을 보여주고 코틀린 람다 식 구문이 어떻게 생겼는지 알아본다.

5.1.1 람다 소개: 코드 블록을 함수 인자로 넘기기

클래스를 선언하고 그 클래스의 인스턴스를 함수에 넘기는 대신 함수형 언어에서는 함수를 직접 다른 함수에 전달할 수 있다. 람다 식을 사용하면 코드가 더욱 더 간결해진다. 람다 식을 사용하면 함수를 선언할 필요가 없고 코드 블록을 직접 함수의 인자로 전달할 수 있다.

button.setClickListener(new OnClickListener(){
	@override
	public void onClick(View view){
		/* 동작 */
	}
})
button.setOnClickListener{ /* 동작 */ }

5.1.2 람다와 컬렉션

코드에서 중복을 제거하는 것은 프로그래밍 스타일을 개선하는 중요한 방법 중 하나다. 람다가 없다면 컬렉션을 편리하게 처리할 수 있는 좋은 라이브러리를 제공하기 힘들다.

data class Person(val name: String, val age: Int)
/* Java */
fun findTheOldest(people: List<Person>) {
    var maxAge = 0
    var theOldest: Person? = null
    for (person in people) {
        if (person.age > maxAge) {
            maxAge = person.age
            theOldest = person
        }
    }
    println(theOldest)
}

fun main(args: Array<String>) {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    findTheOldest(people)
}

/* Kotlin */
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.maxBy { it.age })
>>> println(people.maxBy (Person::age)) //위의 코드와 동일
Person(name=Bob, age=31)

이런 식으로 단지 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치할 수 있다. 람다나 멤버 참조를 인자로 받는 함수를 통해 개선한 코드는 더 짧고 더 이해하기 쉽다.

5.1.3 람다 식의 문법

코틀린 람다 식은 항상 중괄호로 둘러싸여 있다.

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

람다 식을 변수에 저장할 수 있다. 람다가 저장된 변수를 다른 일반 함수와 마찬가지로 다룰 수 있다.

val sum = {x: Int, y: Int -> x + y}

>>>println(sum(1,2))
3

실행 시점에 코틀린 람다 호출에는 아무 부가 비용이 들지 않으며, 프로그램의 기본 구성 요소와 비슷한 성능을 낸다.

5.1.4 현재 영역에 있는 변수에 접근

자바 메소드 안에서 무명 내부 클래스를 정의할 때메소드의로컬 변수를 무명 내부 클래스에서 사용할 수 있다. 다음 리스트는 메시지의 목록을 받아 모든 메시지에 똑같은 접두사를 붙여서 출력해준다.

fun printMessagesWithPrefix(messages: Collection<String>, prefix: String) {
    messages.forEach {
        println("$prefix $it")
    }
}

코틀린에서는 자바와 달리 람다에서 람다 밖 함수에 있는 파이널이 아닌 변수에 접근할 수 있고, 그 변수를 변경할 수도 있다.

fun printProblemCounts(responses: Collection<String>) {
    var clientErrors = 0
    var serverErrors = 0
    responses.forEach {
        if (it.startsWith("4")) {
            clientErrors++
        } else if (it.startsWith("5")) {
            serverErrors++
        }
    }
    println("$clientErrors client errors, $serverErrors server errors")
}

어떻게 그런 동작이 가능할까? 파이널 변수를 포획한 경우에는 람다 코드를 변수 값과 함께 저장한다.

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

한 가지 꼭 알아둬야 할 함정이 있다. 람다를 이벤트 핸들러나다른 비동기적으로 실행되는 코드로 활용하는 경우 함수 호출이 끝난 다음에 로컬 변수가 변경될 수도 있다.

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

onClick 핸들러는 호출될 때마다 clicks의 값을 증가시키지만 그 값의 변경을 관찰할 수는없다. 핸들러는 tryToCountButtonClicks가 clicks를 반환한 다음에 호출되기 때문이다.

5.1.5 멤버 참조

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

코틀린에서는 자바 8과 마찬가지로 함수를 값으로 바꿀 수 있다. 이때 이중 콜론(::)을 사용한다.

::를 사용하는 식을 멤버 참조라고 부른다. 멤버 참조는 프로퍼티나 메소드를 단 하나만 호출하는 함수 값을 만들어준다. ::는 클래스 이름과 참조하려는 멤버(프로퍼티나 메소드) 이름 사이에 위치한다.

Person::age 

5.2 컬렉션 함수형 API

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

5.2.1 필수적인 함수: filter와 map

filter 함수는 컬렉션에서 원치 않는 원소를 제거한다. 하지만 filter는 원소를 변환할 수는 없다. 원소를 변환하려면 map 함수를 사용해야 한다. map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다.

fun main(args: Array<String>) {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    println(people.filter { it.age > 30 })
}

fun main(args: Array<String>) {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    println(people.map { it.name })
}

fun main(args: Array<String>) {
    val numbers = mapOf(0 to "zero", 1 to "one")
    println(numbers.mapValues { it.value.toUpperCase() })
}

5.2.2 all, any, count, find: 컬렉션에 술어 적용

컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산이 있다. 코틀린에서는 all과 any가 이런 연산이다.

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

모든 원소가 만족하는지 궁금하면 all, 만족하는 원소가 하나라도 있는지 궁금하면 any를 사용한다.

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

val canBeInClub27 = { p: Person -> p.age <= 27 }

fun main(args: Array<String>) {
    val people = listOf(Person("Alice", 27), Person("Bob", 31))
    println(people.all(canBeInClub27))
}

5.2.3 groupBy: 리스트를 여러 그룹으로 이뤄진 맵으로 변경

groupBy 함수는 컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 자동으로 구분해준다.

val people = listOf(Person("Alice",31),Person("Bob",29),Person("Carol",31))
println(people.groupBy{it.age})

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

5.2.4 flatMap과 flatten: 중첩된 컬렉션 안의 원소 처리

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고, 람다를 적용한 결과 얻어지는 여러리스트를 한 리스트로 한데 모은다.

fun main(args: Array<String>) {
    val strings = listOf("abc", "def")
    println(strings.flatMap { it.toList() })
}

// result
[a, b, c, d, e, f]
fun main(args: Array<String>) {
    val books = listOf(Book("Thursday Next", listOf("Jasper Fforde")),
                       Book("Mort", listOf("Terry Pratchett")),
                       Book("Good Omens", listOf("Terry Pratchett","Neil Gaiman")))
    println(books.flatMap { it.authors }.toSet())
}
// result
[Jasper Fforde, Terry Pratchett, Neil Gaiman]

컬렉션을 다루는 코드를 작성할 경우에는 원하는 바를 어떻게 일반적인 변환을 사용해 표현할 수 있는지 생각해보고 그런 변환을 제공하는 라이브러리 함수가 있는지 살펴보라.

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

map이나 filter 같은 몇 가지 컬렉션 함수를 살펴봤다. 그런 함수는 결과 컬렉션을 즉시 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 뜻이다.

원본 리스트에 원소가 2개밖에 없다면 리스트가 2개 더 생겨도 큰 문제가 되지 않겠지만, 원소가 수백만 개가 되면 훨씬 더 효율이 떨어진다. 이를 더 효율적으로 만들기 위해서는 각 연산이 컬렉션을 직접 사용하는 대신 시퀀스를 사용하게 만들어야 한다.

시퀀스(sequence)를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다.

fun main(args: Array<String>) {
    listOf(1, 2, 3, 4).asSequence() // 원본 컬렉션을시퀀스로 변환한다. 
            .map { print("map($it) "); it * it } // 시퀀스도 컬렉션과 똑같은 API를 제공한다. 
            .filter { print("filter($it) "); it % 2 == 0 }
            .toList() // 결과 시퀀스를 다시 리스트로 변환한다. 
}

위의 예제는 중간 결과를 저장하는 컬렉션이 생기지 않기 때문에 원소가 많은 경우 성능이 눈에 띄게 좋아진다.

코틀린 지연 계산 시퀀스는 Sequence 인터페이스에서 시작한다. Sequence 안에는 iterator라는 단 하나의 메소드가 있다. 그 메소드를 통해 시퀀스로부터 원소 값을 얻을 수 있다.

시퀀스의 원소는 필요할 때 비로소 계산된다. 따라서 중간 처리 결과를 저장하지 않고도 연산을 연쇄적으로 적용해서 효율적으로 계산을 수행할 수 있다.

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

시퀀스에 대한 연산은 중간 연산과 최종 연산으로 나뉜다. 중간 연산은 다른 시퀀스를 반환한다. 그 시퀀스는 최초 시퀀스의 원소를 변환하는 방법을 안다. 최종 연산은 결과를 반환한다.

fun main(args: Array<String>) {
    listOf(1, 2, 3, 4).asSequence()
            .map { print("map($it) "); it * it }
            .filter { print("filter($it) "); it % 2 == 0 }
            .toList()
}

// 결과
>>> map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)

시퀀스의 경우 모든 연산은 각 원소에 대해 순차적으로 적용된다. 즉 첫 번째 원소가 처리되고, 다시 두 번째 원소가 처리되며, 이런 처리가 모든 원소에 대해 적용된다.

시퀀스를 사용하면 지연 계산으로 인해 원소 중 일부의 계산은 이뤄지지 않는다.

5.3.2 시퀀스 만들기

지금까지 살펴본 시퀀스 예제는 모두 컬렉션에 대해 asSequence()를 호출해 시퀀스를 만들었다.

시퀀스를 만드는 다른 방법으로 generateSequence() 함수를 사용할 수 있다. 이 함수는 이전의 원소를 인자로 받아 다음 원소를 계산한다.

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

이번 절에서는 어떻게 코틀린 람다를 자바 API에 활용할 수 있는지 살펴본다.

5.4.1 자바 메소드에 람다를 인자로 전달

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

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

// 프로그램 전체에서 Runnable의 인스턴스는 단 하나만 만들어진다.
postponComputation(1000) { println(42) } 

람다와 무명 객체 사이에는 차이가 있다. 객체를 명시적으로 선언하는 경우 메소드를 호출할 때마다 새로운 객체가 생성된다. 람다는 다르다. 정의가 들어있는 함수의 변수에 접근하지 않는 람다에 대응하는 무명 객체를 메소드를 호출할 때마다 반복 사용한다.

그러나 람다가 주변 영역의 변수를 포획한다면 매 호출마다 같은 인스턴스를 사용할 수 없다. 그런 경우 컴파일러는 매번 주변 영역의 변수를 포획한 새로운 인스턴스를 생성해준다.

// handlerComputation을 호출할 때마다 새로 Runnable 인스턴스를 만든다. 
fun handlerComputation(id: String) {
		postponeComputation(1000) { println(id) } 
}

코틀린 inline으로 표시된 코틀린 함수에게 람다를 넘기면 아무런 무명 클래스도 만들어지지 않는다. 대부분의 코틀린 확장 함수들은 inline 표시가 붙어있다. 이에 대해서는 8장에서 설명한다.**

5.4.2 SAM 생성자: 람다를 함수형 인터페이스로 명시적으로 변경

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

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

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

5.5 수신 객체 지정 람다: with와 apply

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

5.1.1 with 함수

어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다. 다양한 언어가 그런 기능을 제공한다.

// with를 사용하지 않은 함수
fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'..'Z') {
         result.append(letter)
    }
    result.append("\\nNow I know the alphabet!")
    return result.toString()
}
// with를 사용하여 중복된 변수명을 제거한 함수 사용
fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) {
        for (letter in 'A'..'Z') {
            this.append(letter)
        }
        append("\\nNow I know the alphabet!")
        this.toString()
    }
}

with 함수는 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다. 인자로 받은 람다 본문에서는 this를 사용해 그 수신 객체에 접근할 수 있다.

with가 반환하는 값은 람다 코드를 실행한 결과며, 그 결과는 람다 식의본문에 있는 마지막 식의 값이다. 하지만 때로는 람다의 결과 대신 수신 객체가 필요한 경우도 있다. 그럴 때는 apply 라이브러리 함수를 사용할 수 있다.

5.5.2 apply 함수

apply 함수는 거의 with과 같다. 유일한 차이란 apply는 항상 자신에게 전달된 객체(직 수신 객체)를 반환한다는 점뿐이다.

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

with와 apply는 수신 객체 지정 람다를 사용하는 일반적인 예제 중 하나다. 더 구체적인 함수를 비슷한 채턴으로 활용할 수 있다. 예를 들어 표준 라이브러리의 buildString 함수를 사용하면 alphabet 함수를 더 단순화할 수 있다.

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

buildString 함수는 StringBuilder를 활용해 String을 만드는 경우 사용할 수 있는 우아한 해법이다.

요약

  • 람다를 사용하면 코드 조각을 다른 함수에게 인자로 넘길 수 있다.
  • 코틀린에서는 람다가 함수 인자인 경우 괄호 밖으로 람다를 빼낼 수 있고, 람다의 인자가 단 하나뿐인 경우 인자 이름을 지정하지 않고 it이라는 디폴트 이름으로 부를 수 있다.
  • 람다 안에 있는 코드는 그 람다가 들어있는 바깥 함수의 변수를 읽거나 쓸 수 있다.
  • 메소드, 생성자, 프로퍼티의 이름 앞에 ::을 붙이면 각각에 대한 참조를 만들 수 있다. 그런 참조를 람다 대신 다른 함수에게 넘길 수 있다.
  • filter, map, all, any 등의 함수를 활용하면 컬렉션에 대한 대부분의 연산을 직접 원소를 이터페이션 하지 않고 수행할 수 있다.
  • 시퀀스를 사용하면 중간 결과를 담는 컬렉션을 생성하지 않고도 컬렉션에 대한 여러 연산을 조합할 수 있다.
  • 함수형 인터페이스(추상 메소드가 단 하나뿐인 SAM 인터페이스)를 인자로 받는 자바 함수를 호출할 경우 람다를 함수형 인터페이스 인자 대신 넘길 수 있다.
  • 수신 객체 지정 람다를 사용하면 람다 안에서 미리 정해둔 수신 객체의 메소드를 직접 호출할 수 있다.
  • 표준 라이브러리의 with 함수를 사용하면 어떤 객체에 대한 참조를 반복해서 언급하지 않으면서 그 객체의 메소드를 호출할 수 있다. apply를 사용하면 어떤 객체라도 빌더 스타일의 API를 사용해 생성하고 초기화할 수 있다.

참조

드미트리 제메로프 · 스베트라나 이사코바, 『Kotlin IN ACTION』, 오현석 옮김, 에이콘(2017), P197-242.

카테고리:

업데이트: