uncategorized

고차 함수(파라미터와 반환 값으로 람다 사용)

고차 함수

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수다. 코틀린의 고차 함수가 인자나 반환값으로 함수를 사용하기 위해서는 함수 타입이 필요하다.

함수 타입

자바에서 람다 식을 매개변수나 반환값으로 사용하기 위해서는 함수형 인터페이스를 사용했다. 코틀린에서는 함수 타입이 존재한다.

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

위 코드를 보면 함수 타입에 대해 알 수 있다. 마치 람다 식처럼 생겼다.

널을 반환하거나 함수 타입 자체가 널이 될 수 있다.

1
2
val canReturnNull: (Int) -> Int? = { null }
val funOrNull: ((Int, Int) -> Int)? = null

함수 타입이 파라미터로 활용될 때 함수 타입의 파라미터에 이름을 정해줄 수 있다.
고차 함수에서 함수 타입 호출은 일반 함수 호출처럼 괄호 안에 인자를 넣어서 한다.

1
2
3
4
5
6
7
fun doSomething(callback: (code:Int, content:String) -> Unit) {
callback(1, "content")
}

fun main() {
doSomething { code, content -> println("code = $code, content = $content") }
}

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

코틀린의 함수 타입은 자바에서 FunctionN이라는 인터페이스로 바뀐다. 매개 변수 갯수에 따라 FunctionN<R>, Function<R, P1> 등으로 바뀐다. 이렇게 바뀐 인터페이스 안에는 invoke 메서드가 정의되어 있다. invoke를 통해 함수를 실행시킬 수 있다.

이때 Unit을 반환하는 함수 타입을 자바에서도 사용할 수 있으나 자바에서 void를 반환하는 람다를 Unit을 반환하는 함수 타입에 전달해줄 수 없다. 코틀린의 Unit은 값이 있지만 자바의 void는 값이 없기 때문이다.

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

고차 함수에 함수 타입 파라미터에 디폴트 값을 넣어줄 수 있다. 또한 널이 될 수 있도록 타입을 정할 수 있다. 다만 이런 경우 고차 함수에서 함수 타입에 해당하는 객체를 바로 호출할 수 없고 안전하게 호출해야 한다.

1
2
3
4
5
6
7
8
fun doSomethingWithDefault(callback: ((code: Int, content: String) -> Unit)? = null) {
callback?.invoke(1, "content")
?: run { println("input null") }
}

fun main() {
doSomethingWithDefault() // input null
}

람다를 활용한 중복 제거

사이트 방문 데이터에 대항 정보를 통계내는 코드를 작성할 때 람다로 중복을 제거해보자.

일단 데이터는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum class OS {
WINDOWS, MAC, IOS, ANDROID
}

data class SiteVisit(
val path: String,
val duration: Double,
val os: OS
)

val log = listOf(
SiteVisit("/", 1.0, OS.WINDOWS),
SiteVisit("/hello", 2.0, OS.MAC),
SiteVisit("/welcome", 3.0, OS.ANDROID),
SiteVisit("/goodbye", 4.0, OS.ANDROID),
SiteVisit("/welcome", 5.0, OS.IOS),
)

이제 안드로이드 사용자들의 평균 방문 시간을 출력해보자.

하드 코딩한 필터를 사용한 예시

1
2
3
4
5
val averageAndroidDuration = log
.filter { it.os == OS.ANDROID }
.map(SiteVisit::duration)
.average()
println(averageAndroidDuration)

이렇게 구현하면 다른 os의 평균을 구할 때마다 중복된 코드를 적어줘야 한다. 이를 개선해보자.

개선한 예시

1
2
3
4
fun List<SiteVisit>.averageDurationFor(os: OS) =
filter { it.os == os }
.map(SiteVisit::duration)
.average()

이제 디바이스 별로 방문시간 통계를 내는 상황을 코드로 짜보자.

하드코딩한 예시

1
2
3
4
5
val averageMobileDuration = log
.filter { it.os in setOf(OS.IOS, OS.ANDROID) }
.map(SiteVisit::duration)
.average()
println(averageMobileDuration)

이를 고차 함수를 활용해서 개선해보자.

개선한 예시

1
2
3
4
5
6
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
filter(predicate)
.map(SiteVisit::duration)
.average()

println(log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) })

와우! 재활용이 엄청 잘되겠다~

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

코틀린은 람다를 보통 무명 클래스로 컴파일 하지만 람다 식을 사용할 때마다 클래스를 만들지는 않는다.
람다가 변수를 포획하면 람다가 생성되는 시점에 무명 클래스 객체가 생긴다.
따라서 람다는 같은 작업을 하는 일반 함수에 비해 덜 효율적인 것 같다.

하지만 inline 변경자를 통해 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트 코드로 바꾸치기 한다.

인라이닝이 작동하는 방식

어떤 함수를 inline으로 선언하면 해당 함수를 호출하는 코드 대신 해당 함수의 본문이 바이트 코드로 컴파일된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
inline fun doSomething2(callback: () -> Unit) {
println("start to do Something")
callback()
println("end of something")
}

fun foo() {
println("it's foo function!")
doSomething2 { println("do foo things~!") }
println("end of foo function!")
}

foo()

이런 식으로 inline을 적용하는 경우 foo 함수는 다음 코드와 같은 바이트 코드로 컴파일 된다.

1
2
3
4
5
6
7
fun foo() {
println("it's foo function!")
println("start to do Something")
println("do foo things~!")
println("end of something")
println("end of foo function!")
}

위 예시를 유심히 보면 함수 본문과 전달받은 람다가 인라이닝 된 것을 볼 수 있다.
이때 람다 대신 함수 타입의 변수에 람다를 담아서 전달하는 경우 해당 변수에 어떤 값이 담긴지 모르기 때문에 람다 부분은 인라이닝이 되지 않고 함수 본문만 인라이닝 된다.

만약 특정 람다를 인라이닝하고 싶지않으면 noinline 변경자를 파라미터 이름 앞에 붙이면 된다.

1
2
3
4
5
6
inline fun doSomething2(noinline callback: () -> Unit, otherCallback: () -> Unit) {
println("start to do Something")
callback()
otherCallback()
println("end of something")
}

모듈 밖이나 자바에서 인라인 함수를 호출하는 경우는 컴파일러는 일반 호출로 컴파일한다.

컬렉션 연산 인라이닝

filter, map과 같은 컬렉션 연산은 람다를 매개변수로 받아 사용하는 인라인 함수이다. 이 경우 전달받은 람다와 컬렉션 연산 함수의 본문이 인라인된다!
하지만 컬렉션 연산은 매번 연산마다 연산 결과를 컬렉션으로 만들어내서 여러 연산을 하는 경우 비효율적이다.

이런 비효율을 줄이는 게 시퀀스인데, 시퀀스를 사용하면 중간 시퀀스가 전달 받은 람다를 필드로 저장하는 객체로 표현되며 최종 연산이 진행될 때 중간 시퀀스에 있는 여러 람다를 연쇄 호출하는 방식으로 진행된다.

즉 시퀀스는 람다를 저장해야 하므로 람다를 인라인하지 않는다. 따라서 지연 계산을 통해 성능 개선하려고 모든 컬렉션 연산을 시퀀스로 하면 안된다. 컬렉션의 크기가 작은 경우 컬렉션 연산을 통한 인라이닝이 더 성능에 도움이 될 수 있다. 컬렉션 크기가 매우 큰 경우에만 시퀀스를 활용하자.

함수를 인라인으로 선언해야 하는 경우

inline 키워드를 남용하면 안된다. 왜냐면 일반 함수 호출은 JVM이 이미 강력하게 인라이닝하기 때문이다.

JVM은 바이트코드가 기계어로 번역되는 과정에서 일어난다. JVM의 최적화는 바이트코드 상에서 호출된 함수 부분에서 따로 함수 코드가 중복되지 않는다. 반면 코틀린의 인라인 함수는 바이트코드에서 함수 호출 지점을 함수 본문으로 변환해서 코드 중복이 생긴다. 게다가 함수 직접 호출은 스택 트레이스가 더 깔끔해진다.

반면 람다를 인자로 받는 함수를 인라이닝하면 이익이 많다. 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요가 없어진다. JVM이 함수 호출과 람다를 인라이닝할 정도로 똑똑하지는 않다. 인라이닝을 사용하면 일반 람다에서 사용 못하는 기능을 사용할 수 있다.(넌로컬)

인라이닝을 적용할 때는 해당 함수의 크기를 고려하자. 길이가 매우 긴 함수를 인라이닝하면 바이트코드의 용량이 매우 커질 수 있다. 이런 경우 매개변수의 람다를 선택적으로 인라이닝하지 않거나 하는 방식을 고려해볼 수 있다.

고차 함수 안에서 흐름 제어

람다를 호출하는 함수에서 return을 호출하면 어떻게 될까?
사람들 중 Alice라는 이름의 사람을 찾는 코드 예시로 흐름 제어 사례를 살펴보자.

명령형 코드 예시

1
2
3
4
5
6
7
8
9
10
11
val people = listOf(Person("Alice", 27), Person("Bob", 25))

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

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

1
2
3
4
5
6
7
8
9
fun lookForAlice2(people: List<Person>) {
people.forEach {
if (it.name == "Alice") {
println("FOUND!!")
return
}
}
println("Alice is not found...")
}

람다 안에서 return을 사용하면 람다로부터만 반환되느게 아니라 람다를 호출하는 함수가 실행을 끝내고 반환된다. 이렇게 나를 감싼 블록보다 더 바깥의 블록을 반환하게 만드는 return문을 넌로컬 return이라 부른다. 마치 자바 for 루프 내부에서 return을 하면 해당 루프를 사용하는 함수가 종료되는 것과 같은 논리이다.

이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 경우는 람다를 인자로 받는 함수가 인라인 함수인 경우에만 그렇다!!!!!!!

인라인 함수가 아닌 함수는 변수에 저장되어 호출 시점이 바깥 함수 호출 시점과 분리될 수 있다. 그래서 함수 내부의 return이 바깥쪽 함수 반환을 시킬 수 없게 된다.

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

람다 식에서 for 루프의 break과 비슷한 역할을 하는 로컬 return이 있다. break 시킬 람다나 함수를 레이블로 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun lookForAlice3(people: List<Person>) {
people.forEach label@{
if (it.name == "Alice") {
println("FOUND!!")
return@label
}
}
}

fun lookForAlice4(people: List<Person>) {
people.forEach {
if (it.name == "Alice") {
println("FOUND!!")
return@forEach
}
}
}

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

넌로컬 반환문을 여럿 사용해야 하는 경우 무명 함수로 코드를 더 쉽게 만들 수 잇다.

1
2
3
4
5
6
fun lookForAlice4(people: List<Person>) {
people.forEach(fun (person) {
if (person.name == "Alice") return
println("${person.name} is not Alice")
})
}

무명 함수 내부 레이블이 붙지 않은 return 식은 무명 함수 자체를 반환시킨다. (무명 함수 바깥 함수를 반환시키지 않는다!!!) 애초에 return은 fun으로 정의된 가장 안쪽 함수를 반환시키는 데, 람다식은 fun으로 정의되지 않아 람다 밖의 함수를 반환시킨 것이다. 무명 함수는 fun으로 함수 선언되었으니 바깥 함수를 종료시키지 않을 수 있다.

Share