고차 함수와 람다로 시작하는 함수형 프로그래밍
고차 함수와 람다로 시작하는 함수형 프로그래밍
Kotlin의 함수는 일급(first-class) 값이에요. 변수나 자료 구조에 저장할 수 있고, 다른 함수의 인자로 넘기거나 반환값으로 돌려줄 수도 있어요. 함수를 숫자나 문자열처럼 취급할 수 있다는 뜻이죠. 이렇게 함수를 값처럼 다루는 방식이 Kotlin의 함수형 프로그래밍 기반이 돼요.
이걸 가능하게 하려고 Kotlin은 함수의 형태를 나타내는 함수 타입과, 함수를 만들기 위한 특별한 문법인 **람다 표현식(lambda expression)**을 제공해요. 이 페이지에서는 함수를 인자로 받거나 결과로 돌려주는 고차 함수가 무엇인지, 그리고 함수 타입과 람다를 어떻게 쓰는지 살펴볼게요.
고차 함수란 무엇인가
**고차 함수(higher-order function)**는 함수를 매개변수로 받거나, 함수를 반환하는 함수예요.
대표적인 예가 컬렉션 처리의 함수형 관용구인 fold예요. 초기 누적값(accumulator)과 결합 함수를 받아, 현재 누적값을 각 요소와 차례로 결합하며 누적값을 갱신한 뒤 최종 값을 만들어내죠.
fun <T, R> Collection<T>.fold(
initial: R,
combine: (acc: R, nextElement: T) -> R
): R {
var accumulator: R = initial
for (element: T in this) {
accumulator = combine(accumulator, element)
}
return accumulator
}
위 코드에서 combine 매개변수의 함수 타입은 (R, T) -> R이에요. R 타입과 T 타입의 인자 두 개를 받아 R 타입 값을 반환하는 함수를 받아들인다는 뜻이죠. 그 함수는 for 루프 안에서 호출되고, 반환값이 다시 accumulator에 할당돼요.
fold를 호출할 때는 함수 타입의 인스턴스를 인자로 넘겨야 해요. 이때 람다 표현식이 흔히 쓰여요.
fun main() {
val items = listOf(1, 2, 3, 4, 5)
// 람다는 중괄호로 감싼 코드 블록이에요.
items.fold(0, {
// 람다에 매개변수가 있으면 먼저 쓰고 '->'를 붙여요.
acc: Int, i: Int ->
print("acc = $acc, i = $i, ")
val result = acc + i
println("result = $result")
// 람다의 마지막 표현식이 반환값이 돼요.
result
})
// 매개변수 타입은 문맥에서 유추되면 생략할 수 있어요.
val joinedToString = items.fold("Elements:", { acc, i -> acc + " " + i })
// 함수 참조도 고차 함수 호출에 사용할 수 있어요.
val product = items.fold(1, Int::times)
println("joinedToString = $joinedToString")
println("product = $product")
}
함수 타입
Kotlin은 함수를 다루는 선언에서 (Int) -> String 같은 함수 타입을 사용해요. 예를 들어 val onClick: () -> Unit = ...처럼요.
함수 타입은 시그니처(매개변수와 반환 타입)에 대응하는 특별한 표기법을 가져요.
- 모든 함수 타입은 괄호로 묶인 매개변수 타입 목록과 반환 타입을 가져요.
(A, B) -> C는A,B두 인자를 받아C를 반환하는 함수를 나타내죠. 매개변수 목록은 비어 있을 수도 있어요(() -> A).Unit반환 타입은 생략할 수 없어요. - 함수 타입은 추가로 **수신자 타입(receiver type)**을 가질 수 있어요.
A.(B) -> C는 수신자 객체A에 대해 호출되며B를 매개변수로 받아C를 반환하는 함수예요. 이런 타입과 함께 수신자가 있는 함수 리터럴이 자주 쓰여요. - 일시 중단 함수는 표기법에
suspend수식어가 붙는 특별한 함수 타입이에요.suspend () -> Unit이나suspend A.(B) -> C처럼요.
함수 타입에는 매개변수 이름을 붙일 수도 있어요. (x: Int, y: Int) -> Point처럼요. 이름은 매개변수의 의미를 문서화하는 데 쓰면 돼요.
함수 타입이 널 허용이도록 하려면 ((Int, Int) -> Int)?처럼 괄호로 감싸면 돼요. 괄호를 사용해 함수 타입을 조합할 수도 있어요: (Int) -> ((Int) -> Unit).
함수 타입에 별칭을 붙이고 싶다면 타입 별칭(type alias)을 써요.
typealias ClickHandler = (Button, ClickEvent) -> Unit
함수 타입 인스턴스 만들기
함수 타입의 인스턴스를 얻는 방법은 여러 가지가 있어요.
- 함수 리터럴 내의 코드 블록을 사용하는 방법이 있어요. 두 가지 형태가 있죠.
- 람다 표현식:
{ a, b -> a + b } - 익명 함수:
fun(s: String): Int { return s.toIntOrNull() ?: 0 }
- 람다 표현식:
- 기존 선언에 대한 호출 가능 참조(callable reference)를 쓰는 방법이 있어요.
- 함수 타입을 인터페이스로 구현하는 커스텀 클래스의 인스턴스를 사용하는 방법이 있어요.
class IntTransformer: (Int) -> Int {
override operator fun invoke(x: Int): Int = TODO()
}
val intFunction: (Int) -> Int = IntTransformer()
컴파일러는 정보가 충분하면 변수의 함수 타입을 유추하기도 해요. val a = { i: Int -> i + 1 }처럼 쓰면 추론된 타입은 (Int) -> Int예요.
수신자가 있든 없든, 함수 타입의 리터럴이 아닌 값은 서로 바꿔 쓸 수 있어요. 수신자가 첫 번째 매개변수 자리를 차지하거나 그 반대가 될 수 있죠. 즉 (A, B) -> C 타입의 값은 A.(B) -> C가 기대되는 자리에 넘기거나 할당할 수 있어요.
fun main() {
val repeatFun: String.(Int) -> String = { times -> this.repeat(times) }
val twoParameters: (String, Int) -> String = repeatFun // OK
fun runTransformation(f: (String, Int) -> String): String {
return f("hello", 3)
}
val result = runTransformation(repeatFun) // OK
println("result = $result")
}
함수 타입 인스턴스 호출하기
함수 타입의 값은 invoke(...) 연산자로 호출할 수 있어요. f.invoke(x) 또는 그냥 f(x)로요.
값이 수신자 타입을 가진다면 수신자 객체를 첫 번째 인자로 넘겨야 해요. 수신자 타입 값을 호출하는 또 다른 방법은 확장 함수처럼 수신자 객체를 앞에 붙이는 거예요: 1.foo(2).
fun main() {
val stringPlus: (String, String) -> String = String::plus
val intPlus: Int.(Int) -> Int = Int::plus
println(stringPlus.invoke("<-", "->"))
println(stringPlus("Hello, ", "world!"))
println(intPlus.invoke(1, 1))
println(intPlus(1, 2))
println(2.intPlus(3)) // 확장 함수처럼 호출
}
때로는 인라인 함수(inline functions)가 유연한 제어 흐름을 제공해서 고차 함수에 더 유리할 수 있어요.
람다 표현식과 익명 함수
람다 표현식과 익명 함수는 **함수 리터럴(function literal)**이에요. 함수 리터럴은 별도로 선언하지 않고 값으로 바로 전달되는 함수를 뜻하죠.
max(strings, { a, b -> a.length < b.length })
여기서 max는 두 번째 인자로 함수 값을 받으므로 고차 함수예요. 그 두 번째 인자는 그 자체로 함수인 표현식, 즉 함수 리터럴이며 다음 이름 있는 함수와 같아요.
fun compare(a: String, b: String): Boolean = a.length < b.length
suspend 키워드를 붙이면 일시 중단 람다 표현식도 만들 수 있어요. 일시 중단 람다는 함수 타입 suspend () -> Unit을 가지며, 다른 일시 중단 함수를 호출할 수 있어요.
val suspendingTask = suspend { doSuspendingWork() }
람다 표현식 문법
람다 표현식의 완전한 문법은 다음과 같아요.
val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }
- 람다 표현식은 항상 중괄호로 감싸요.
- 완전한 문법에서 매개변수 선언은 중괄호 안에 있고, 타입 표기는 선택적이에요.
- 본문은
->뒤에 온답니다. - 람다의 추론된 반환 타입이
Unit이 아니라면, 본문 안의 마지막(또는 유일한) 표현식이 반환값으로 취급돼요.
선택적 표기를 다 지우면 이렇게 되죠.
val sum = { x: Int, y: Int -> x + y }
트레일링 람다와 it
Kotlin 관례에 따르면, 함수의 마지막 매개변수가 함수라면 대응하는 인자인 람다 표현식을 괄호 밖에 둘 수 있어요.
val product = items.fold(1) { acc, e -> acc * e }
이런 문법을 **트레일링 람다(trailing lambda)**라고 불러요. 그 호출에서 람다가 유일한 인자라면 괄호를 아예 생략할 수도 있어요.
run { println("...") }
람다는 매개변수가 하나인 경우가 아주 흔해요. 컴파일러가 시그니처를 매개변수 없이 해석할 수 있다면 매개변수를 선언하지 않고 ->도 생략할 수 있어요. 그러면 매개변수가 it이라는 이름으로 암시적으로 선언돼요.
ints.filter { it > 0 } // 이 리터럴의 타입은 '(it: Int) -> Boolean'
람다에서 값을 명시적으로 반환하려면 레이블로 돌아가기(qualified return) 문법을 써요. 그렇지 않으면 마지막 표현식의 값이 암시적으로 반환돼요. 그래서 다음 두 코드는 동일해요.
ints.filter { val shouldFilter = it > 0; shouldFilter }
ints.filter { val shouldFilter = it > 0; return@filter shouldFilter }
이 관례는 괄호 밖에 람다를 두는 방식과 함께 LINQ 스타일의 코드를 가능하게 해요.
strings.filter { it.length == 5 }.sortedBy { it }.map { it.uppercase() }
람다 매개변수를 쓰지 않는다면 이름 대신 밑줄을 놓을 수 있어요.
map.forEach { (_, value) -> println("$value!") }
익명 함수와 클로저
람다 표현식 문법에는 함수의 반환 타입을 지정하는 기능이 없어요. 대개는 반환 타입이 자동으로 유추되니 문제가 없지만, 명시하고 싶다면 **익명 함수(anonymous function)**라는 대체 문법을 써요.
fun(x: Int, y: Int): Int = x + y
익명 함수는 이름만 빠진 일반 함수 선언처럼 보여요. 본문은 표현식이거나 블록일 수 있어요.
fun(x: Int, y: Int): Int { return x + y }
람다 표현식과 익명 함수의 또 다른 차이는 **비지역 반환(non-local return)**의 동작이에요. 레이블이 없는 return은 항상 fun 키워드로 선언된 함수에서 반환돼요. 즉 람다 안의 return은 감싸고 있는 함수에서 반환하지만, 익명 함수 안의 return은 익명 함수 자신에서 반환해요.
람다 표현식과 익명 함수(그리고 로컬 함수와 객체 표현식)는 자신의 **클로저(closure)**에 접근할 수 있어요. 여기에는 바깥 스코프에서 선언된 변수가 포함되고, 그 변수는 람다 안에서 수정될 수 있어요.
var sum = 0
ints.filter { it > 0 }.forEach { sum += it }
print(sum)
수신자가 있는 함수 리터럴
A.(B) -> C 같은 수신자가 있는 함수 타입은 특별한 형태의 함수 리터럴, 즉 수신자가 있는 함수 리터럴로 인스턴스화할 수 있어요.
함수 리터럴의 본문 안에서는 호출로 넘어온 수신자 객체가 암시적 this가 돼요. 그래서 추가적인 한정자 없이 그 수신자 객체의 멤버에 접근할 수 있고, this 표현식으로 수신자 객체에 접근할 수도 있어요. 이 동작은 확장 함수와 비슷한데, 확장 함수도 함수 본문 안에서 수신자 객체의 멤버에 접근할 수 있죠.
val sum: Int.(Int) -> Int = { other -> plus(other) }
람다 표현식은 수신자 타입을 문맥에서 유추할 수 있을 때 수신자가 있는 함수 리터럴로 쓰일 수 있어요. 가장 중요한 용례는 타입 안전 빌더(type-safe builders)예요.
class HTML {
fun body() { ... }
}
fun html(init: HTML.() -> Unit): HTML {
val html = HTML() // 수신자 객체 생성
html.init() // 람다에 수신자 객체 전달
return html
}
html { // 수신자가 있는 람다 시작
body() // 수신자 객체의 메서드 호출
}
더 알아보기
- 함수 타입을 사용하는 고차 함수 기반의 확장 함수는 확장 함수 문서에서 확인해요.
- 일시 중단 함수 타입과 코루틴은 Coroutine basics에서 다뤄요.
- 인라인 함수와 비지역 반환은 inline functions 문서를 참고하세요.