2022. 1. 9. 23:09ㆍKotlin
지난 글(클래스, 객체, 인터페이스)에 이어 Kotlin in Action 이라는 책을 보면서 매주 공부한 내용을 블로그에 기록한다.
이번 주는 8장 고차함수와 inline function 에 대한 내용이다. 책 내용은 어마어마하게 많기 때문에.. 내 기준으로 새로 알게 된 것들, 평소에는 모르고 지나쳤던 부분 또는 한번 짚고 넘어가면 좋은 내용들 위주로 정리한다.
목차
고차 함수 정의
고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나 람다나 함수 참조를 반환하는 함수.
고차 함수 예시 👇
list.filter { x > 0 }
// 람다를 인자로 받는 filter 함수도 고차함수
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
return filterTo(ArrayList<T>(), predicate)
}
람다 인자의 타입은 아래와 같이 선언한다.
(파라미터 타입, ...) -> 반환 타입
// 파라미터 2개를 받아서 Int 값을 반환하는 함수
val sum: (Int, Int) -> Int = { x, y -> x + y }
val action:() -> Unit = { println(42) }
fun twoAndThree(operation: (Int, Int) -> Int) {
val result = operation(2, 3) // 함수 타입인 파라미터를 호출
println("The result is $result")
}
자바에서 코틀린 함수 타입 사용
컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀐다. 즉 함수 타입의 변수는 FunctionN 인터페이스를 구현하는 객체를 저장한다. 코틀린 표준 라이브러리는 함수 인자의 개수에 따라 Function0<R>(인자가 없는 함수), Function1<P1, R>(인자가 하나인 함수) 등의 인터페이스를 제공한다. 각 인터페이스에는 invoke 메소드 정의가 하나 들어 있다. invoke를 호출하면 함수를 실행할 수 있다. 함수 타입인 변수는 인자 개수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메소드 본문에는 람다의 본문이 들어간다.
위의 twoAndThree() 함수를 컴파일한 예시 👇
public final class TestKt {
public static final void twoAndThree(@NotNull Function2 operation) {
Intrinsics.checkNotNullParameter(operation, "operation");
int result = ((Number)operation.invoke(2, 3)).intValue();
String var2 = "The result is " + result;
boolean var3 = false;
System.out.println(var2);
}
}
함수에서 함수를 반환
fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
if (delivery == Delivery.EXPEDITED) {
return { order -> 6 + 2.1 * order.itemCount }
}
return { order -> 1.2 * order.itemCount }
}
val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
val cost = calculator(Order(3)) // 반환받은 함수 호출
람다를 활용한 중복 제거
고차 함수는 코드 구조를 개선하고 중복을 없앨 때 쓸 수 있는 아주 강력한 도구다.
코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다. 코드의 일부분을 복사해 붙여 넣고 싶은 경우가 있다면 그 코드를 람다로 만들면 중복을 제거할 수 있을 것이다.
아래에서는 람다를 활용한다고 코드가 항상 더 느려지지는 않는다는 사실을 설명하고 inline 키워드를 통해 어떻게 람다의 성능을 개선하는지 보여준다.
inline function: 람다의 부가 비용 없애기
5장에서는 코틀린이 보통 람다를 무명 클래스로 컴파일하지만 그렇다고 람다 식을 사용할 때마다 새로운 클래스가 만들어지지는 않는다는 사실을 설명했고, 람다가 변수를 capture하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다는 사실도 설명했다. 이런 경우 실행 시점에 무명 클래스 생성에 따른 부가 비용이 든다. 따라서 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 덜 효율적이다.
그렇다면 효율적으로 람다를 사용할 수는 없을까? inline 변경자를 사용하면 가능하다. inline 변경자를 함수에 붙이면, 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기해준다.
대체 위 문단에서 뜻하는게 뭘까? 말은 너무 어렵다. 직접 코드로 작성해보고 컴파일 해보았다.
실제로 컴파일 되었을 때 무명 클래스 객체가 생기는지 확인하기 위한 예시 👇
fun nonInlineGeneralFun(a: Int, b: Int) {
println("람다 없는 일반 함수")
}
fun nonInlineFun(operation: (Int, Int) -> Int) {
operation(2, 3)
println("람다 있는 non-inline 함수")
}
inline fun inlineFun(operation: (Int, Int) -> Int) {
operation(2, 3)
println("람다 있는 inline 함수")
}
fun main() {
// 람다가 캡쳐할 변수
var captured = 0
nonInlineGeneralFun(1, 1)
nonInlineFun { i, i2 ->
println("[non-inline 함수] 람다 내 캡쳐된 변수: $captured")
i + i2
}
inlineFun { i, i2 ->
println("[inline 함수] 람다 내 캡쳐된 변수: $captured")
i + i2
}
}
위 코드를 컴파일 했을 때의 main() 함수 👇
public static final void main() {
final IntRef captured = new IntRef();
captured.element = 0;
// 일반 함수
nonInlineGeneralFun(1, 1);
// non-inline 함수에서 캡쳐된 변수를 사용했을 때, 아래처럼 무명함수 객체 생긴 것 확인
nonInlineFun((Function2)(new Function2() {
public Object invoke(Object var1, Object var2) {
return this.invoke(((Number)var1).intValue(), ((Number)var2).intValue());
}
public final int invoke(int i, int i2) {
String var3 = "[non-inline 함수] 람다 내 캡쳐된 변수: " + captured.element;
boolean var4 = false;
System.out.println(var3);
return i + i2;
}
}));
// inline 함수라서 함수 내용이 그대로 복사됨.
int $i$f$inlineFun = false;
int i2 = 3;
int i = 2;
int var4 = false;
String var5 = "[inline 함수] 람다 내 캡쳐된 변수: " + captured.element;
boolean var6 = false;
System.out.println(var5);
int var10000 = i + i2;
String var7 = "람다 있는 inline 함수";
boolean var8 = false;
System.out.println(var7);
}
인라이닝이 작동하는 방식
어떤 함수를 inline 으로 선언하면 그 함수의 본문이 인라인 된다. 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신 함수 본문을 번역한 바이트 코드로 컴파일한다는 뜻.
👉 인라이닝(inlining)된다 : 함수의 본문이 코드에 그대로 들어간다.
inline 예시 👇
synchronized 함수 본문뿐 아니라 synchronized 에 전달된 람다의 본문도 함께 인라이닝 된다
// inline 함수 예시
inline fun <T> synchronized(lock:Lock, action: () -> T): T {
lock.lock()
try {
return action()
} finally {
lock.unlock()
}
}
// synchronized 사용 예시
fun foo(lock: Lock) {
println("Before sync")
synchronized(lock) {
println("Action")
}
println("After sync")
}
// foo 함수를 컴파일한 버전
fun __foo__(lock: Lock) {
println("Before sync")
// 아래는 synchronized 함수가 인라이닝된 코드
lock.lock()
try {
println("Action") // 람다 코드의 본문이 인라이닝된 코드
} finally {
lock.unlock()
}
println("After sync")
}
한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝 된다. 인라인 함수의 본문 코드가 호출 지점에 복사되고 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.
inline function 의 한계
인라이닝을 하는 방식으로 인해 람다를 사용하는 모든 함수를 인라이닝 할 수는 없다.
함수 본문에서 파라미터로 받은 람다를 호출한다면 그 호출을 쉽게 람다 본문으로 바꿀 수 있다. 하지만 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝할 수 없다.
예를 들어, 시퀀스에 대해 동작하는 메소드 중에는 람다를 받아서 모든 시퀀스 원소에 그 람다를 적용한 새 시퀀스를 반환하는 함수가 많다.
public fun <T> Sequence<T>.filter(predicate: (T) -> Boolean): Sequence<T> {
return FilteringSequence(this, true, predicate)
}
이 filter 함수는 predicate 파라미터로 전달받은 함수 값을 호출하지 않는 대신, FilteringSequence 라는 클래스의 생성자에게 그 함수 값을 넘긴다. FilteringSequence 생성자는 전달받은 람다를 프로퍼티로 저장한다. 이런 기능을 지원하려면 filter 에 전달되는 predicate 인자를 일반적인(인라이닝하지 않은) 함수 표현으로 만들 수밖에 없다. 즉, 여기서는 predicate 을 함수 인터페이스를 구현하는 무명 클래스 인스턴스로 만들어야만 한다.
둘 이상의 람다를 인자로 받는 함수에서 일부 람다만 인라이닝하고 싶을 때도 있다. noinline 을 통해 가능
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {
...
}
Collection 연산 인라이닝
컬렉션에 filter 와 map 을 같이 쓸 경우, 아래에서 사용한 filter 와 map 은 인라인 함수다. 따라서 두 함수의 본문은 인라이닝되며, 추가 객체나 클래스 생성은 없다. 하지만 이 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다. filter 함수에서 만들어진 코드는 원소를 그 중간 리스트에 추가하고, map 함수에서 만들어진 코드는 그 중간 리스트를 읽어서 사용한다.
people.filter{ it.age > 30 }
.map(Person::name)
처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용이 커지기 때문에, asSequence를 통해 리스트 대신 시퀀스를 사용하면 이런 부가 비용이 줄어든다. 이때 각 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출한다. 따라서 위에서 설명한 대로 시퀀스는 람다를 인라인 하지 않는다(람다를 저장해야 하므로). 따라서 지연 계산을 통해 성능을 향상하려는 이유로 모든 컬렉션 연산에 asSequence 를 붙여서는 안 된다. 시쿼스 연산에서는 람다가 인라이닝되지 않기 때문에 크기가 작은 컬렉션은 오히려 일반 컬렉션 연산이 더 성능이 나을 수 있다. 따라서, 컬렉션 크기가 큰 경우에만 시퀀스를 통해 성능을 향상할 수 있다.
함수를 inline 으로 선언해야 하는 경우
항상 inline 키워드를 사용하는 것이 무조건 좋은 것은 아니다! 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다.
일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다. JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다. 이런 과정은 바이트코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다. 이런 JVM의 최적화를 활용한다면 바이트코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고, 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다. 반면 코틀린 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대체하기 때문에 코드 중복이 생긴다. 게다가 함수를 직접 호출하면 스택 트레이스가 더 깔끔해진다.
반면 람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다.
- 인라이닝을 통해 없앨 수 있는 부가 비용이 상당하다. 함수 호출 비용을 줄이고 + 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요도 없어진다.
- 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 못하다.
- 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있다. 그중 하나는 non-local return 이 있다.
하지만, inline 변경자를 함수에 붙일 때는 코드 크기에 주의해야 한다! 인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트코드를 모든 호출 지점에 복사해 넣고 나면 바이트코드가 전체적으로 아주 커질 수 있다. 그런 경우 람다 인자와 무관한 코드를 별도의 noinline 함수로 빼낼 수도 있다. 코틀린 표준 라이브러리가 제공하는 inline 함수를 보면 모두 크기가 아주 작다는 사실을 알 수 있다.
자원 관리를 위해 인라인 된 람다 사용
람다로 중복을 없앨 수 있는 일반적인 패턴 중 한 가지는 어떤 작업을 하기 전에 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원 관리다. 여기서 자원(resource)은 파일, 락, 데이터베이스 트랜잭션 등 여러 다른 대상을 가리킬 수 있다.
코틀린 표준 라이브러리에는, 자바 try-with-resource와 같은 기능을 제공하는 use 라는 함수가 있음.
use 함수는 Closeable 을 구현한 객체에 한해서만 사용할 수 있다. use 는 람다를 호출한 다음에 자원을 닫아준다. 이때 람다가 정상 종료한 경우는 물론 람다 안에서 예외가 발생한 경우에도 자원을 확실히 닫는다. use 역시 inline 함수로, 성능에는 영향이 없다.
fun readFirstLineFromFile(path: String): String {
BufferedReader(FileReader(path)).use { br ->
return br.readLine()
}
}
/**
* Executes the given block function on this resource and
* then closes it down correctly whether an exception is thrown or not.
*/
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
...
try {
return block(this)
} catch (e: Throwable) {
exception = e
throw e
} finally {
when {
apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
this == null -> {}
exception == null -> close()
else ->
try {
close()
} catch (closeException: Throwable) {
// cause.addSuppressed(closeException) // ignored here
}
}
}
}
고차 함수 안에서 제어 흐름
람다 안의 return 문: 람다를 둘러싼 함수로부터 반환
fun lookForAlice(people: List<Person>) {
people.forEach {
if (it.name == "Alice") {
return // lookForAlice 함수를 return 하는 것.
}
}
}
위 코드에서 return 은 람다로부터만 반환되는 게 아니라 그 람다를 호출하는 함수(lookForAlice)에 대해 반환하는 것이다. 이렇게 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return문을 non-local return 이라 부른다.
람다를 인자로 받는 함수가 인라인 함수일 경우에만 non-local return 이 가능하다.
람다를 인자로 받는 함수가 inline 함수인 경우에만 람다 안에서 바깥 함수를 return 시킬 수 있다.
👉 이유: 인라이닝 되지 않은 함수는 람다를 변수에 저장할 수 있고, 바깥쪽 함수로부터 반환된 뒤에 저장해 둔 람다가 호출될 수도 있다. 그런 경우 람다 안의 return이 실행되는 시점이 바깥쪽 함수를 반환시키기엔 너무 늦은 시점일 수 있다. (대박 👵🏼)
fun nonInlineFun(action: () -> Unit) {
action()
}
inline fun inlineFun(action: () -> Unit) {
action()
}
// inlineFun 테스트 결과:
// before
// in inlineFun
fun test() {
println("before")
inlineFun {
println("in inlineFun")
return // 바깥 함수 test() 를 return 시킴
}
println("after")
}
// nonInlineFun 테스트 결과:
// compile error: 'return' is not allowed here
fun test() {
println("before")
nonInlineFun {
println("in inlineFun")
return
}
println("after")
}
무명 함수: 기본적으로 로컬 return
무명 함수? 아래 코드에서 fun (person) { ... } 가 무명 함수다. 말 그대로, 이름이 없어 함수가!
fun lookForAlice(people: List<Person>) {
people.forEach(fun (person){ // 람다 식 대신 무명 함수를 사용!
if (it.name == "Alice") {
return // 가장 가까운 함수를 가리키는데, 이 위치에서 가장 가까운 함수는 무명 함수다.
}
})
}
요약
- 함수 타입을 사용해 함수에 대한 참조를 담는 변수나 파라미터나 반환 값을 만들 수 있다.
- 고차 함수는 다른 함수를 인자로 받거나 함수를 반환한다. 함수의 파라미터 타입이나 반환 타입으로 함수 타입을 사용하면 고차 함수를 선언할 수 있다.
- 인라인 함수를 컴파일할 때 컴파일러는 그 함수의 본문과 그 함수에게 전달된 람다의 본문을 컴파일한 바이트코드를 모든 함수 호출 지점에 삽입해준다. 이렇게 만들어지는 바이트코드는 람다를 활용한 인라인 함수 코드를 풀어서 직접 쓴 경우와 비교할 때 부가 비용이 들지 않는다.
- 고차 함수를 사용하면 컴포넌트를 이루는 각 부분의 코드를 더 잘 재사용할 수 있다. 또 고차 함수를 활용해 강력한 재네릭 라이브러리를 만들 수 있다.
- 인라인 함수에서는 람다 안에 있는 return 문이 바깥쪽 함수를 반환시키는 non-local return을 사용할 수 있다.
- 무명 함수는 람다 식을 대신할 수 있으며 return 식을 처리하는 규칙이 일반 람다 식과는 다르다. 본문 여러 곳에서 return 해야 하는 코드 블록을 만들어야 한다면 람다 대신 무명 함수를 쓸 수 있다.
'Kotlin' 카테고리의 다른 글
[Kotlin in Action] 10장. Annotaion 과 Reflection (0) | 2022.02.20 |
---|---|
[Kotlin in Action] 9장. 제네릭스(Generics) - reified / variance / in / out (0) | 2022.01.30 |
[Kotlin in Action] 4장. 클래스, 객체, 인터페이스 (0) | 2021.11.03 |
[Kotlin in Action] 3장. 함수 정의와 호출 (0) | 2021.10.09 |
[Kotlin] static, object, companion object 차이 (1) | 2021.06.27 |