[Kotlin] 8장. 고차함수: 파라미터와 반환 값으로 람다 사용

sonnie
lucky-sonnie
Published in
18 min readFeb 3, 2021

8.1 고차 함수 정의

고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나 람다나 함수 참조를 반환하는 함수다. 물론 함수를 인자로 받는 동시에 함수를 반환하는 함수도 고차 함수다. 예를 들어 표준 라이브러리 함수인 filter는 술어 함수(predicate: true/ false를 판단할 수 있는 식이나 boolean 값을 리턴하는 함수)를 인자로 받으므로 고차 함수다.

list.filter { x > 0}

8.1.1 함수 타입

val sum = {x:Int, y:Int -> x+y}
val action = {println(42)}

이 경우 컴파일러는 sum과 action이 함수 타입임을 추론한다. 이제는 각 변수에 구체적인 타입 선언을 추가하면 어떻게 되는지 살펴보자.

val sum: (Int, Int) -> Int= { x,y -> x+y }
val action: () -> Unit = { println(42) }

함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고, 그 뒤에 화살표(->)를 추가한 다음, 함수의 반환 타입을 지정하면 된다. 그냥 함수를 정의한다면 함수의 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만, 함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit을 빼먹어서는 안 된다.

이렇게 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있다. 따라서 람다 식 안에서 굳이 파라미터 타입을 적을 필요가 없다.

다른 함수와 마찬가지로 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정할 수 있다.

var canReturnNull: (Int, Int) -> Int? = {x, y => null}
var funOrNull: ((Int, Int) -> Int)? = null

canReturnNull의 타입과 funOrNull의 타입 사이에는 큰 차이가 있다. funOrNull 의 타입을 지정하면서 괄호를 빼먹으면 널이 될 수 있는 타입이 아니라 널이 될 수 있는 타입이 아니라 널이 될 수 있는 반환 타입을 갖는 함수 타입을 선언하게 된다.

8.1.2 인자로 받은 함수 호출

fun twoAndThree(operation: (Int, Int)-> Int){
val result = oepration(2, 3)
println("The result is $result")
}
>>> twoAndThree(a, b -> a + b)
The result is 5
>>> twoAndThree(a, b -> a * b)
The result is 6

filter 함수는 술어를 파라미터로 받는다. predicate 파라미터는 문자(Char)를 파라미터로 받고 Boolean 결과 값을 반환한다. 술어는 인자로 받은 문자가 filter 함수가 돌려주는 결과 문자열에 남아있기를 바라면 true, 아니면 false를 반환한다. 아래는 filter 함수를 구현한 방법이다.

fun String.filter(predicate: (Char) -> Boolean): String{
val sb = StringBuilder()
for(index in 0 until length){
val element = get(index)
if(predicate(element)) sb.append(element)
}
return sb.toString()
}
>>> println("ab1c".filter{it in 'a'..'z'})
abc

8.1.3 자바에서 코틀린 함수 타입 사용

컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀐다. 즉 함수 타입의 변수는 FunctionN 인터페이스를 구현하는 객체를 저장한다. 코틀린 표준 라이브러리는 함수 인자의 개수에 따라 Function0<R> (인자가 없는 함수), Function1<P1, R> (인자가 1개인 함수) 등의 인터페이스를 제공한다. 각 인터페이스에는 invoke 메소드 정의가 하나 들어있다. invoke를 호출하면 함수를 실행할 수 있다. 함수 타입인 변수는 인자 개수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메소드 본문에는 람다의 본문이 들어간다.

fun processTheAnswer(f: (Int) -> Int){
println(f(42))
}
/* 자바 */
>>> processTheAnswer(number -> number+1)
43

자바에서 코틀린 표준 라이브러리가 제공하는 람다를 인자로 받는 확장 함수를 쉽게 호출할 수 있다. 하지만 수신 객체를 확장 함수의 첫 번재 인자로 명시적으로 넘겨야 하므로 코틀린에서 확장 함수를 호출할 때처럼 코드가 깔끔하지는 않다.

/* 자바 */
>>> List<String> strings = new ArrayList();
>>> strings.add("42");
>>> CollectionsKt.forEach(strings, s -> {
System.out.println(s);
return Unit.INSTANCE;
});

코틀린 Unit 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야 한다. (String) -> Unit처럼 반환 타입이 Unit인 함수 타입의 파라미터 위치에 void를 반환하는 자바 람다를 넘길 수는 없다.

8.1.4 디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터

파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다. joinToString을 예시로 들어보겠다.

fun<T> Collection<T>.joinToString(
separator: String=",",
prefix: String="",
postfix: String=""
): String{
val result = StringBuilder(prefix)
for((index, element) in this.withIndex()){
if(index > 0) result.append(separator)
result.append(element)
}
result.append(postfix)
return result.toString()
}

이 구현은 유연하지만 핵심 요소를 제어할 수 없다는 단점있다. 그 핵심 요소는 컬렉션의 각 원소를 문자열로 변환하는 방법이다. 코드는 StringBuilder.append(o: Any?)를 사용하는데, 이 함수는 항상 객체를 toString 메소드를 통해 문자열로 바꾼다. toString으로 충분한 경우도 많지만 그렇지 않을 때도 있다. 이럴 때 함수 타입의 파라미터에 대한 디폴트 값을 지정하면 이런 문제를 해결할 수 있다. 디폴트 값으로 람다 식을 넣으면 된다.

fun<T> Collection<T>.joinToString(
separator: String=",",
prefix: String="",
postfix: String="",
transform: (T) -> String={it.toString()}
): String{
val result = StringBuilder(prefix)
for((index, element) in this.withIndex()){
if(index > 0) result.append(separator)
result.append(transform(element))
}
result.append(postfix)
return result.toString()
}

8.1.5 함수를 함수에서 반환

함수가 함수를 인자로 받아야 할 필요가 있는 경우가 훨씬 더 많다.

enum class Delivery{ STANDARD, EXPEDITED}

class Order(val itemCount: Int)

fun getShippingCostCalculator(
delivery: Delivery
): (Order) -> Double{
if(delivery == Delivery.EXPEDITED){
return { order -> 6 +1.2 * order.itemCount}
}
return {order -> 1.2 * order.itemCount }
}

다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 한다. 위의 코드에서 getShippingCostCalculator 함수는 order 을 받아서 double 을 반환하는 함수를 반환한다.

8.1.6 람다를 활용한 중복 제거

// 복잡한 하드코딩 필터를 사용한 방문 데이터
val
averageMobileDuration = log
.filter { it.os in setOf(OS.IOS, OS.ANDROID) }
.map (SiteVisit::duration)
.average()
// 고차 함수를 사용해 중복 제거
fun List
<SiteVisit>.averageDurationFor(
predicate: (SiteVisit) -> Boolean) =
filter(predicate).map(SiteVisit::duration).average()

코드 중복을 줄일 때 함수 타입이 도움된다. 코드의 일부분을 복사해 붙여넣고 싶은 경우가 있다면 그 코드를 람다로 만들면 중복을 제거할 수 있을 것이다.

8.2 인라인 함수: 람다의 부가 비용 없애기

람다가 변수를 포획하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다. 이러 경우 실행 시점에 무명 클래스 생성에 따른 부가 비용이 든다. 반복되는 코드를 별도의 라이브러리 함수로 빼내되 컴파일러가 자바의 일반 명령문만큼 효율적인 코드를 생성하게 만들 수는 없을까? 사실 코틀린 컴파일러에서는 가능하다. inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기 해준다.

8.2.1 인라이닝이 작동하는 방식

어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인된다. 함수를 호출하는 코드를 함수를 호출하는 바이트 코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일한다는 뜻이다.

inline fun <T> synchronized(lock : Lock, action: () -> T): T{
lock.lock()
try {
return action()
}finally {
lock.unlock()
}
}

이 함수를 호출하는 코드는 자바의 synchronized 문과 똑같아 보인다. 차이는 자바에서는 임의의 객체에 대해 synchronized 를 사용할 수 있지만 이 함수는 Lock 클래스의 인스턴스를 요구한다는 점뿐이다.

fun foo(l : Lock){
println("before sync")
synchronized(l){
println("action")
}
println("after sync")
}
========================
// foo 함수를 컴파일한 버전
fun __foo__ (l : Lock){
println("before sync")
l.lock()
try {
println("action")
}finally {
l.unlock()
}
println("after sync")
}

synchronized 함수의 본문뿐 아니라 synchronized에 전달된 람다의 본문도 함게 인라이닝된다는 점에 유의하자. 람다의 본문에 의해 만들어지는 바이트코드는 그 람다를 호출하는 코드(synchronized)정의의 일부분으로 간주되기 때문에 코틀린 컴파일러는 그 람다를 함수 인터페이스를 구현하는 무명 클래스로 감싸지 않는다.

인라인 함수를 호출하면서 람다를 넘기는 대신에 함수 타입의 변수를 넘길 수도 있다. 아래와 같은 경우 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알 수 없다. 따라서 람다 본문은 인라이닝 되지 않고 synchronized 함수의 본문만 인라이닝된다. 따라서 람다는 다른 일반적인 경우와 마찬가지로 호출된다.

class LockOwner(val lock: Lock){
fun runUnderLock(body: () -> Unit){
synchronized(lock, body)
}
// runUnderLock을 컴파일 한 코드
fun __runUnderLock__(body:()->Unit){
lock.lock()
try {
body()
}finally {
lock.unlock()
}
}
}

한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝된다. 인라인 함수의 본문 코드가 호출 지점에 복사되고 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

8.2.2 인라인 함수의 한계

함수가 인라이닝될 때 그 함수에 인자로 전달된 람다 식의 본문은 결과 코드에 직접 들어갈 수 있다. 하지만 이렇게 람다가 본문에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 사용하는 방식이 한정될 수밖에 없다. 함수 본문에서 파라미터로 받은 람다를 호출한다면 그 호출을 쉽게 람다 본문으로 바꿀 수 있다. 하지만 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝할 수 없다.

fun <T, R> Sequence<T>.map(transform: (T) -> R) : Sequence<R> {
return TransformingSequence(this, transform)
}

이 map 함수는 transform 파라미터로 전달받은 함수 값을 호출하지 않는 대신, TransformingSequence라는 클래스의 생성자에게 그 함수 값을 넘긴다. TransformingSequence생성자는 전달 받은 람다를 프로퍼티로 저장한다. 이런 기능을 지원하려면 map에 전달되는 transform 인자를 일반적인 (인라이닝하지 않는) 함수 표현으로 만들 수밖에 없다. 즉, 여기서는 transform을 함수 인터페이스를 구현하는 무명 클래스 인스턴스로 만들어야 한다.

둘 이상의 람다를 인자로 받는 함수에서 일부 람다만 인라이닝하고 싶을 때도 있다. 인라이닝하면 안 되는 람다를 파라미터로 받는다면 noinline 변경자를 파라미터 이름 앞에 붙여서 인라이닝을 금지할 수 있다.

inline fun foo2(inlined: () -> Unit, noinline notInlined: () -> Unit){
// ..
}

8.2.3 컬렉션 연산 인라이닝

data class Person(val name: String, val age: Int)
val people = listOf(Person("Alice", 29), Person("Bob", 31))
fun main() {
people.filter { it.age < 30 }
}

코틀린의 filter 함수는 인라인 함수다. 따라서 filter 함수의 바이트코드는 그 함수에 전달된 람다 본문의 바이트코드와 함께 filter를 호출한 위치에 들어간다.

filter, map 처럼 중간 리스트를 사용하는 함수를 중복하여 사용할 때, 처리할 원소가 많아지면 중간 리스트를 사용하는 부가비용도 커진다. asSequence 를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다. 이때 각 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출한다.

지연 계산을 통해 성능을 향상시키려는 이유로 모든 컬렉션 연산에 asSequence 를 붙여서는 안된다. 시퀀스 연산에서는 람다가 인라이닝 되지 않기 때문에 크기가 작은 컬렉션은 오히려 일반 컬렉션 연산이 더 성능이 나을 수도 있다.

8.2.4 함수를 인라인으로 선언해야 하는 이유

inline 키워드를 사용해도 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 있다. 다른 경우에는 주의 깊게 성능을 측정하고 조사해봐야 한다.

일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다. JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다. 이런 과정은 바이트코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다. 이런 JVM의 최적화를 활용한다면 바이트코드에서는 각 함수 구현이 정확히 1번만 있으면 되고 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다. 반면 코틀린 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 생긴다. 게다가 함수를 직접 호출하면 스택 트레이스가 더 깔끔해진다.

반면 람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다. 첫째로 인라이닝을 통해 없앨 수 있는 부가 비용이 상당하다. 함수 호출 비용을 줄일 수 있을 뿐 아니라 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요도 없어진다. 둘째로 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 못하다. 마지막으로 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇가지 기능을 사용할 수 있다. 그런 기능 중에는 non-local 변환이 있다. (뒷장에서 다시 나옴)

8.2.5 자원 관리를 위해 인라인된 람다 사용

람다로 중복을 없앨 수 있는 일반적인 패턴 중 한 가지는 어떤 작업을 하기 전에 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원관리다. 자원 관리 패턴을 만들 때 보통 사용하는 방법은 try / finally 문을 사용하되 try 블록을 시작하기 직전에 자원을 획득하고 finally 블록에서 자원을 해제하는 것이다.

코틀린에서는 함수 타입의 값을 파라미터로 받는 함수를 통해 자원관리를 편리하게 할 수 있다. 자바 try-with-resource와 같은 기능을 제공하는 use 라는 함수를 사용하면 된다.

fun readFirstLineFromFile(path: String): String{
BufferedReader(FileReader(path)).use { br -> return br.readLine() }
}

use 함수는 닫을 수 잇는 자원에 대한 확장 함수며, 람다를 인자로 받는다. use 는 람다를 호출한 다음에 자원을 닫아준다. 이때 람다가 정상 종료한 경우는 물론 람다 안에서 예외가 발생한 경우에도 자원을 확실히 닫는다. 물론 use 함수도 인라인 함수다. 따라서 use를 사용해도 성능에는 영향이 없다.

8.3 고차 함수 안에서 흐름 제어

8.3.1 람다 안의 return문: 람다를 둘러싼 함수로부터 반환

fun lookForAlice(people: List<Person>){
people.forEach{
if(it.name == "Alice"){
println("found!")
return
}
}
println("Alice is not found")
}

람다 안에서 return을 사용하면 람다로부터만 반환되는 게 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반복된다. 그렇게 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return문을 넌로컬(non-local) return이라고 부른다.

synchronized 블록 안이나 for 루프 안에서 return은 synchronized블록이나 for루프를 끝내지 않고 메소드를 반환시킨다. 코틀린에서는 언어가 제공하는 기본 구성 요소가 아니라 람다를 받는 함수로 for나 synchronized와 같은 기능을 구현한다. 코틀린은 그런 함수 안에서 쓰이는 return이 자바의 return과 같은 의미를 갖게 허용한다.

이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 때는 람다를 인자로 받는 함수가 인라인 함수인 경우뿐이다. 인라이닝되지 않는 함수는 람다를 변수에 저장할 수 있고, 바깥쪽 함수로부터 반환된 뒤에 저장해 둔 람다가 호출될 수도 있다. 그런 경우 람다 안의 return이 실행되는 시점이 바깥쪽 함수를 반환시키기엔 너무 늦은 시점일 수도 있다.

8.3.2 람다로부터 반환: 레이블을 사용한 return

람다식에서도 로컬 return을 사용할 수 있다. 람다 안에서 로컬 return은 for루프의 break 와 비슷한 역할을 한다. 로컬 return은 람다의 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어간다. 로컬 return과 넌로컬 return을 구분하기 위해 label을 사용해야 한다.

fun lookForAlice2(people: List<Person>){
people.forEach label@{
if(it.name=="Alice") return@label // 앞에서 정의한 레이블을 참조한다.
}
println("Alice might be somewhere")
}

8.3.3 무명 함수: 기본적으로 로컬 return

// 무명 함수
fun
lookForAlice4(people: List<Person>){
people.forEach(fun (person) {
if(person.name == "Alice") return // return 은 가장 가까운 함수를 가리키는데 이 위치에서 사장 가까운 함수는 무명함수다.
println
("${person.name} is not Alice")
})
}

// 식이 본문인 무명 함수 사용하기
people
.filter(fun (person) = person.age < 30)

return에 적용되는 규칙은 단순히 return은 fun 키워드를 사용해 정의된 가장 안쪽 함수를 반환시킨다는 점이다. 람다식은 fun을 사용해 정의되지 않으므로 람다 본문의 return은 람다 밖의 함수를 반환시킨다.

출처: 코틀린 인 액션

--

--