서른한 번째, 고차 함수와 람다입니다.
Kotlin의 함수는 일급(first-class)입니다. 이 말은 함수가 변수와 데이터 구조에 저장될 수 있고, 다른 고차 함수에 인수로 전달 되거나 그로부터 반환될 수 있다는 의미입니다. 함수가 아닌 값들에 가능한 연산은 함수에도 모두 가능합니다.
이를 위해 Kotlin은 정적 타입 프로그래밍 언어로서 함수를 표현하기 위한 함수 타입 패밀리를 사용하고 람다 표현식 같은 특별한 언어 구조 집합을 제공합니다.
고차 함수 (Higher-order functions)
고차 함수는 매개변수로 함수를 갖거나 함수를 반환하는 함수입니다.
고차 함수의 좋은 예는 컬렉션을 위한 함수형 프로그래밍에서의 관용구 fold입니다. fold는 초기 누산기 값(accumulator)과 결합 함수를 받아서, 각 컬렉션 요소와 현재 누산기 값을 연속적으로 결합하여 반환 값을 구성합니다. 이 과정에서 누산기 값은 매번 새로운 값으로 대체됩니다.
※ fold는 익히 알고 있는 reduce입니다. 상세 설명은 링크 걸린 위키 페이지를 참고해 주세요.
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 이라는 함수 타입(밑에서 설명합니다)입니다. 그러므로, combine은 타입 R과 T 두 개의 매개변수를 받아 R 타입을 반환하는 함수를 받습니다. combine은 for 루프에서 불려지고(invoke), 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 })
// 고차 함수 호출에 함수의 참조를 사용할 수 있습니다. Int::times
val product = items.fold(1, Int::times)
println("joinedToString = $joinedToString")
println("product = $product")
}
/* 결과
acc = 0, i = 1, result = 1
acc = 1, i = 2, result = 3
acc = 3, i = 3, result = 6
acc = 6, i = 4, result = 10
acc = 10, i = 5, result = 15
joinedToString = Elements: 1 2 3 4 5
product = 120
*/
함수 타입
Kotlin은 함수를 다루는 선언(예, val onClick: () -> Unit = ...)에 (Int) -> String 같은 함수 타입을 사용합니다.
함수 타입은 함수의 시그니처 - 매개변수와 반환 값에 해당하는 특별한 표기법을 가지고 있습니다.
- 모든 함수 타입은 괄호로 묶은 매개변수 타입 목록과 반환 타입을 가지고 있습니다. (A, B) -> C는 타입이 각각 A와 B인 두 개의 매개변수를 갖고 반환 타입은 C인 함수 타입을 나타냅니다. 매개변수 타입 목록이 없는 경우에는 () -> A 식으로 괄호만 표기합니다. Unit 반환 타입은 생략될 수 없습니다.
- 함수 타입은 선택적으로 추가적인 수신자(receiver) 타입을 가질 수 있는데, 이 타입은 표기법의 마침표 앞에 표시합니다. A.(B) -> C는 수신 객체 A에 대해 매개변수가 B이고 반환 타입이 C인 함수가 호출된다는 것을 나타냅니다. (밑에서 설명하는) 수신자를 지정한 함수 리터럴은 때때로 이런 유형에 사용됩니다.
- 함수 타입의 특별한 유형에 속하는 일시 중단 함수(suspending functions)는 표기법에 suspend 수정자를 갖습니다. 예) suspend () -> Unit, suspend A.(B) -> C
함수 타입은 선택적으로 (x: Int, y: Int) -> Point 처럼 매개변수의 이름을 포함할 수 있습니다. 이런 이름은 문서화시 매개변수의 의미를 나타낼 때 사용할 수 있습니다.
함수 타입이 널 가능하다는 것을 나타내기 위해서는 다음과 같이 괄호와 물음표를 사용합니다. ((Int, Int) -> Int)?
함수 타입은 괄호를 사용하여 다른 함수 타입과 구분될 수 있습니다. (Int) -> ((Int) -> Unit)
※ 화살표 표기법은 오른쪽으로 연관(right-associative)되므로 위의 예는 (int) -> (int) -> Unit 과 같습니다. 하지만, ((Int) -> (Int)) -> Unit 과는 다릅니다. 앞은 int 타입을 받고 함수를 반환한다는 것이고, 이 것은 함수를 받아서 Unit을 반환한다는 얘기입니다.
타입 별칭을 사용해 함수 타입에 이름을 붙일 수도 있습니다.
typealias ClickHandler = (Button, ClickEvent) -> Unit
수신자(receiver)
공식 문서에 보면 수신자라는 용어가 곳곳에 나오고 있지만, 수신자에 대한 설명은 아래에 나오는 수신자를 가진 함수 리터럴에서 약간 나오는 정도입니다. 아마, 단어 자체로 설명이 된다고 생각해서 그런거 같기도 합니다만, 처음 접하는 경우에는 짐작은 가지만 명확하지는 않아서 애매하기도 합니다.
수신자는 특정한 것(보통 함수)을 받는 대상입니다. 예를 들어, '확장 함수의 수신자'라고 하면 확장 함수를 받게 되는 대상입니다. 수신자를 지정한 함수가 호출될 때는 해당 함수는 내부적으로 수신자 객체(또는 수신 객체, receiver object)를 갖게되고 이를 this로 접근 가능하게 됩니다. this로 접근할 수 있으니 (당연히) 수신 객체의 멤버들을 this 없이도 접근 가능합니다.
수신자와 관련해서는 함수 타입 선언, 확장 함수, DSL, let() 같은 범위 함수(scope)에서 (묵시적이나 명시적인 지정에 의해서) 수신자 객체가 누구로 결정되는지(resolving)가 (또 아는 것이) 중요합니다.
함수 타입 인스턴스화
함수 타입의 인스턴스를 얻는 몇가지 방법이 있습니다.
- 다음 형태 중 하나의 함수 리터럴을 갖는 코드 블록 사용
- 람다 표현식 : { a, b -> a + b }
- 익명 한수 : fun (s: String): Int { return s.toIntOrNull() ?: 0 }
수신자를 갖는 함수 리터럴은 수신자를 가진 함수 타입을 인스턴스화 할 때 사용할 수 있습니다.
- 기존 선언의 호출 참조(callable reference) 사용
- 최상위 수준, 지역, 멤버, 확장 함수 : ::isOdd, String::toInt
- 최상위 수준, 멤버, 확장 프로퍼티 : List<Int>::size
- 생성자 : ::Regex
여기에는 모두 특정 인스턴스의 멤버를 가리키는 foo::toString 같은 경계가 있는 호출 참조가 포함됩니다.
- 함수 타입을 인터페이스로 하여 구현하는 맞춤화된 클래스(custom class)의 인스턴스 사용
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")
}
수신자가 없는 함수 타입은 기본적으로 추론됩니다. 심지어, 변수가 확장 함수의 참조로 초기화 됐더라도 그렇습니다. 이를 변경하기 위해서는 명시적으로 변수 타입을 지정합니다.
이와 관련된 설명이 stack overflow에 잘 나오고 있습니다. 여기를 참고해 주세요.
함수 타입 인스턴스 호출 (Invoking a function type instance)
함수 타입의 값은 invoke(...) 연산자로 f.invoke(x)나 간단히 f(x) 식으로 호출(invoke)할 수 있습니다.
값이 수신자를 가지고 있는 경우에는 수신 객체를 첫번째 인수로 넘겨야 합니다. 수신자가 있는 함수 타입의 값을 호출하는 또 다른 방법은 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)) // extension-like call
}
/* 결과
<-->
Hello, world!
2
3
5
*/
인라인 함수
때때로, 인라인 함수를 사용하면 이점이 있는데, 이는 고차 함수를 위한 유연한 흐름 제어를 제공합니다.
람다 표현식과 익명 함수
람다 표현식과 익명 함수는 함수 리터럴입니다. 함수 리터럴은 선언되지 않고 표현식으로 즉시 넘겨지는 함수입니다. 다음의 예를 살펴보겠습니다.
max(strings, { a, b -> a.length < b.length })
함수는 max는 고차 함수로서 두 번째 인수로 함수 값을 취합니다. 이 두 번째 인수가 그 자체로 함수인 표현식으로 함수 리터럴이라고 부릅니다. 이는 다음과 같은 이름이 있는 함수 선언과 동일합니다.
fun compare(a: String, b: String): Boolean = a.length < b.length
람다 표현식 구문 (syntax)
람다 표현식의 전체 구문적 형태는 다음과 같습니다.
val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }
- 람다 표현식은 항상 중괄호로 묶입니다.
- 온전한 구문 형태에서 매개변수 선언은 중괄호 안에 위치하고 = 왼쪽 부분에서 지정하는 타입 정보는 선택 사항입니다.
- 몸체는 -> 뒤에서 시작합니다.
- 람다의 추론되는 반환 타입이 Unit가 아닌 경우에는 람다 몸체 내의 마지막 표현식이 반환 값이 됩니다. 이렇게 반환값이 추론될 수 있으면 반환 타입도 생략 가능합니다.
모든 선택 사항들을 제외하면 다음과 같은 형태가 됩니다.
val sum = { x: Int, y: Int -> x + y }
후행 람다 전달 (Passing trailing lambdas)
코틀린 관례에 따르면, 함수의 마지막 매개변수가 함수인 경우 해당 매개변수 상응하는 람다 표현식 인수는 괄호 바깥에 위치할 수 있습니다.
val product = items.fold(1) { acc, e -> acc * e }
이러한 구문은 또한 후행 람다(trailing lambda)라고 알려져 있습니다.
람다가 호출의 유일한 인수인 경우에는 괄호를 완전히 생략할 수 있습니다.
run { println("...") }
it : 단일 매개변수의 묵시적 이름
람다 표현식이 매개변수 하나만 갖는 것은 아주 일반적입니다.
컴파일러가 어떠한 매개변수 없이 시그니처를 파싱할 수 있다면, 매개변수는 선언될 필요가 없고, -> 도 생략할 수 있습니다. 매개변수는 묵시적으로 it 이라는 이름으로 선언될 것입니다.
ints.filter { it > 0 } // '(it: Int) -> Boolean' 타입에 대한 리터럴입니다.
람다 표현식에서 값의 반환
람다에서는 명시적으로 한정된 반환 구문을 사용하여 값을 반환할 수 있습니다. 명시적으로 반환하지 않은 경우에는 마지막 표현식의 값이 묵시적으로 반환됩니다.
그러므로, 다음의 두 코드 조각은 동일합니다.
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() }
※ LINQ(Language Integrated Query) 스타일은 질의문(query) 자체를 언어에 녹여 넣는 스타일을 얘기합니다. 언어에 추가되므로 단순 텍스트의 질의문일 때와는 다르게 타입 체크 등 여러 검사를 할 수 있고, 그 외에 이점들이 있습니다. LINQ는 .NET쪽에 추가돼 있고, 해당 스타일을 갖는 Java쪽 라이브러리에는 Jooq 같은 것이 있습니다. 상세한 내용은 위에 있는 링크의 내용을 참고해 주세요.
사용되지 않는 변수를 위한 밑줄
람다의 매개변수가 사용되지 않는 경우에는 이름 대신 밑줄을 사용할 수 있습니다.
map.forEach { (_, value) -> println("$value!") }
람다에서 구조 분해
람다에서 구조 분해는 구조 분해 선언의 한 부분으로 설명돼 있습니다.
익명 함수
위에 있는 람다 표현식 구문은 하나 부족한 부분이 있는데, 함수의 반환 타입을 지정하는 능력입니다. 대부분의 경우 반환 타입은 자동으로 추론될 수 있기 때문에 반환 타입을 지정하는 것은 불필요합니다. 하지만, 명시적으로 지정해야 한다면, 그 대안 구문으로 익명 함수(anonymous function)를 사용할 수 있습니다.
fun(x: Int, y: Int): Int = x + y
익명 함수는 이름이 생략됐다는 것 외에는 일반적인 함수 선언과 매우 유사하게 보입니다. 익명 함수의 몸체는 (위와 같은) 표현식이나 블록일 수 있습니다.
fun(x: Int, y: Int): Int {
return x + y
}
문맥을 통해 매개변수 타입을 추론할 수 있는 경우를 제외하고는, 매개 변수와 반환 타입을 일반적인 함수와 동일한 방법으로 지정합니다.
ints.filter(fun(item) = item > 0)
익명 함수의 반환 타입 추론은 일반적인 함수에 대해서 추론하는 것과 같게 동작합니다. 즉, 표현식 몸체를 갖는 익명함수는 자동으로 반환 타입이 추론됩니다. 하지만, 블록 몸체를 갖는 익명 함수는 반환 타입을 명시적으로 지정하거나 Unit을 반환하는 것으로 추정돼야 합니다.
익명 함수를 인수로 전달할 때는 괄호 안에 두어야 합니다. 괄호 밖에 두는 단축 구문은 람다 표현식에만 허용됩니다.
람다 표현식과 익명 함수의 또 하나의 다른 점은 지역적이지 않은 반환 동작입니다. 라벨이 없는 return 문은 항상 fun 키워드로 선언한 함수로부터 반환합니다. 이 말은 람다 표현식 내에서의 return은 람다를 감싸고 있는 함수를 벗어나게 됩니다. 반면에, 익명 함수 안에서의 return은 익명 함수 자체를 벗어나게 됩니다.
클로저 (Closures)
람다 표현식과 익명 함수(그리고, 지역 함수와 객체 표현식도)는 그들의 클로저에 접근할 수 있습니다. 이는 다른 범위(scope)에 선언된 변수에 접근할 수 있다는 것을 포함합니다. 클로저에서 캡처된 변수는 람다에서 수정할 수 있습니다.
var sum = 0
ints.filter { it > 0 }.forEach {
sum += it
}
print(sum)
함수형 언어에서 클로저는 함수와 제반 환경을 같이 저장하는 레코드라고 얘기하고 있습니다. 즉, 대상이 되는 함수가 무엇이고 해당 함수에서 접근 가능한 환경이 어떤 것인지를 가지고 있는 것이 클로저입니다(상세 내용은 위키피디아 내용을 참고하세요). Kotlin에서 클로저 역시 이러한 개념이며, 후에 나오게 될 인라인 함수 부분에서는 "함수의 몸체에서 접근할 수 있는 변수들의 유효 범위"라고 설명하고 있습니다.
코틀린에서는 람다 표현식, 익명 함수 등은 특정 함수 타입의 인스턴스(객체)입니다. 이 함수 객체들은 클로저를 갖게(capture) 됩니다. 클로저에는 이 객체(함수 인스턴스)가 접근 가능한 외부 범위의 변수가 포착됩니다. 그래서, 캡처라는 단어를 사용합니다. 결국, 이 함수에서 변수를 참조할 때, 대상 범위는 해당 함수의 클로저에 캡처된 변수까지 확장되는 것이고, 그에 따라 외부 범위의 변수까지 접근 가능하게 됩니다.
경우에 따라서는 (또는 언어에 따라서) 함수 인스턴스와 클로저가 결함된 상태를 클로저라고 부르기도 합니다. 즉, 함수 인스턴스를 클로저라고 부르기도 합니다. 예를 들어, 아래와 같은 코드에서 next를 클로저라고 부르는 경우도 있습니다.
fun counter(): () -> Int { var count = 0 return { count += 1 count } } fun main() { val next = counter() println(next()) println(next()) println(next()) }
그러므로, 내부 함수에서 외부 영역(의 변수)에 접근할 수 있다는 개념과 그런 것이 가능하도록 접근 가능한 변수를 캡처하여 가지는 것이 각각의 내부 함수쪽에 만들어진다는 개념을 중심으로 상황에 맞게 용어를 사용하면 됩니다.
부가적으로, 위의 코드로 생성된 JVM용 바이트 코드를 Java로 역컴파일 해 보면 다음과 같습니다.
import kotlin.Metadata; import kotlin.jvm.functions.Function0; import kotlin.jvm.internal.Ref; import org.jetbrains.annotations.NotNull; @Metadata( mv = {1, 9, 0}, k = 2, d1 = {"\u0000\u0012\n\u0000\n\u0002\u0018\u0002\n\u0002\u0010\b\n\u0000\n\u0002\u0010\u0002\n\u0000\u001a\f\u0010\u0000\u001a\b\u0012\u0004\u0012\u00020\u00020\u0001\u001a\u0006\u0010\u0003\u001a\u00020\u0004¨\u0006\u0005"}, d2 = {"counter", "Lkotlin/Function0;", "", "main", "", "knotebook"} ) public final class WKt { @NotNull public static final Function0 counter() { final Ref.IntRef count = new Ref.IntRef(); count.element = 0; return (Function0)(new Function0() { // $FF: synthetic method // $FF: bridge method public Object invoke() { return this.invoke(); } public final int invoke() { ++count.element; return count.element; } }); } public static final void main() { Function0 next = counter(); int var1 = ((Number)next.invoke()).intValue(); System.out.println(var1); var1 = ((Number)next.invoke()).intValue(); System.out.println(var1); var1 = ((Number)next.invoke()).intValue(); System.out.println(var1); } // $FF: synthetic method public static void main(String[] var0) { main(); } }
지역변수 count는 Ref.IntRef로 대체되고 counter()가 반환하는 인스턴스에서는 이 참조를 가지게 됩니다. 그리고, 이 참조를 가지고 값을 변경하기 때문에 즉각적으로 바깥 영역 변수 값의 변경이 반영됩니다.
Kotlin 문법으로 얘기해 보면, JVM에서는 함수 타입 인스턴스에다가 해당 인스턴스에서 접근하는 외부 변수들을 모두 참조로 변환하여 접근하게 하는 식으로 구현하고 있다고 할 수 있습니다.
수신자가 있는 함수 리터럴 (Function literals with receiver)
A.(B) -> C 같이 수신자가 있는 함수 타입은 함수 리터럴의 특별한 형태, 즉 수신자가 있는 함수 리터럴로 인스턴스화 할 수 있습니다.
위에서 언급했던 것처럼, Kotlin은 수신자가 있는 함수 타입의 인스턴스에 수신자 객체(receiver object)를 전달하며 호출할 수 있는 능력을 제공합니다.
함수 리터럴의 몸체 내부에서, 호출시 전달된 수신자 객체는 묵시적인 this가 됩니다. 그래서, 수신 객체의 멤버를 어떠한 추가적인 한정자(qualifier) 없이 접근하거나 this 표현식을 사용하여 접근할 수 있습니다.
이러한 동작은 확장 함수와 유사합니다. 확장 함수도 수신 객체의 모든 멤버에 접근할 수 있습니다.
다음은 (타입이 지정된) 수신 객체가 있는 함수 리터럴의 예입니다. 몸체 안의 plus는 수신 객체의 멤버입니다.
val sum: Int.(Int) -> Int = { other -> plus(other) }
익명 함수 구문은 함수 리터럴의 수신자 타입을 직접 지정하는 것을 허용합니다. 이것은 수신자를 가진 함수 타입의 변수를 선언해 놓고 나중에 사용할 때 유용합니다.
val sum = fun Int.(other: Int): Int = this + other
수신자 타입이 문맥에 의해서 추론될 수 있을 때는 람다 표현식을 수신자가 있는 함수 리터럴처럼 사용할 수 있습니다. 이런 사용과 관련된 가장 중요한 예 중에 하나가 타입 안전한 빌더입니다.
class HTML {
fun body() { ... }
}
fun html(init: HTML.() -> Unit): HTML {
val html = HTML() // 수신자 객체 생성
html.init() // 람다에 수신자 객체 전달
return html
}
html { // 수신자가 있는 람다는 여기서 시작.
// HTML.() -> Unit 이라는 함수 타입에 적합하며. 수신자는 html()의 내부를 통해
// 추론 가능하므로 수신자 지정 함수 리터럴 대신 이렇게 람다 표현식 사용 가능
body() // 수신자 객체의 메소드 호출
}
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 33. Operator overloading (2) | 2024.03.15 |
---|---|
공식 문서로 배우는 코틀린 - 32. Inline functions (0) | 2024.03.14 |
공식 문서로 배우는 코틀린 - 30. Functions (0) | 2024.03.13 |
공식 문서로 배우는 코틀린 - 29. Type aliases (0) | 2024.03.13 |
공식 문서로 배우는 코틀린 - 28. Delegated properties (0) | 2024.03.12 |