본문 바로가기

스터디/다재다능 코틀린 프로그래밍

10. 람다를 사용한 함수형 프로그래밍

다재다능 코틀린 프로그래밍 책으로 코틀린 스터디를 진행하면서 발표를 위해 준비했던 글입니다.


함수형 스타일

명령형 프로그래밍 → 모든 단계를 직접 다룬다.

fun findName(names: List<String>, target: String): Boolean {
    for (name in names) {
        if (name == target) {
            return true
        }
    }
    return false
}

선언적 스타일 → 캡슐화되어있는 함수를 호출

fun findName2(names: List<String>, target: String): Boolean {
    return names.contains(target)
}

함수형 스타일은 선언적 스타일에서 태어났다. 선언적 스타일의 한 종류인 것

함수형 스타일로 짝수를 필터링해서 리스트에 2배로 만들어서 추가하는 예제를 살펴보자.

val doubleOfEven = (1..10)
    .filter { e -> e % 2 == 0 }
    .map { e -> e * 2 }

함수형 스타일은 변화하는 부분이 적고 읽기 쉽다.

함수형 스타일은

  • 코드가 연산에 집중하고 있을 때 쓰면 뮤터빌리티의 부작용을 피할 수 있다.
  • 많은 입출력이 존재해 뮤테이션과 부작용을 피할 수 없고 많은 예외를 처리해야 한다면 명령형 스타일이 더 좋은 선택이다.

람다 표현식

코틀린의 람다 문법 → 중괄호를 사용한다.

{ paramter list -> body }

람다 전달

fun isPrime(n: Int) = n > 1 && (2 until n).none { i -> n % i == 0 }
println(isPrime(5))    // true

암시적 파라미터 사용

람다가 하나의 파라미터만 받는다면 파라미터 정의를 생략하고 it 이라는 암시적 파라미터를 사용할 수 있다.

파라미터 정의와 화살표를 생략하고 변수의 이름으로 it 을 사용해보자.

fun isPrime2(n: Int) = n > 1 && (2 until n).none { n % it == 0 }

람다가 매우 짧을 때만 사용하자. 여러 줄로 이루어진 람다에서 사용하면 유지보수가 어려워진다.

람다 받기

람다를 함수의 파라미터로 받아보자.

fun walk1To(action: (Int) -> Unit, n: Int) = (1..n).forEach{ action(it) }
walk1To({ i -> print(i) }, 5)    // 12345

함수 호출 부분 코드 읽기가 어렵다. 코틀린에서는 람다를 마지막 파라미터로 받으면 규칙을 풀어주고 읽기 좋은 코드로 만들 수 있다.

fun walk1To(n: Int, action: (Int) -> Unit) = (1..n).forEach{ action(it) }
walk1To(5) { print(it) }

함수 참조 사용

람다를 패스스루로 사용하면 훨씬 더 읽기 편해진다.

함수를 람다로 패스스루할 땐 ::키워드를 붙이고 람다를 패스스루할 땐 필요없다.

위에서 살펴봤던 예제를 더 깔끔하게 바꿔보자.

fun walk1To(n: Int, action: (Int) -> Unit) = (1..n).forEach(action)
walk1To(5, ::print)

action으로 보내기만하는 중개인을 제거하고 ::키워드로 print함수를 넘겼다.

자바의 메소드레퍼런스처럼 사용할 수도 있다.

walk1To(5, System.out::println)

함수를 리턴하는 함수

val names = listOf("pam", "paul", "paula")
println(names.find { name -> name.length == 4 })    // paul
println(names.find { name -> name.length == 5 })    // paula

위 예제에는 중복 코드가 있다. 함수를 리턴하는 함수로 중복을 제거해보자.

fun predicate(length: Int): (String) -> Boolean {
    return { input: String -> input.length == length }
}

println(names.find(predicate(4)))    // paul
println(names.find(predicate(5)))    // paula

코틀린의 타입추론을 사용해서 predicate함수를 더 간결하게 바꿔보자.

fun predicate2(length: Int) = { input: String -> input.length == length }

람다와 익명함수

한 람다를 여러곳에서 호출해야 할 경우 중복 코드가 발생한다. 람다나 익명 함수를 사용해서 중복을 피할 수 있다.

람다를 변수에 저장해보자.

val checkLength5 = { input: String -> input.length == 5 }
println(names.find(checkLength5))    // paula

위와 같은 방식으로 람다를 재사용할 수 있다.

위 예제에선 람다의 파라미터 타입을 지정하고 변수(checkLength5) 타입을 추론하게 했다. 반대로 변수의 타입을 지정하고 람다의 파라미터 타입을 추론하게 할 수도 있다. 아래 예제를 보자.

val checkLength5: (String) -> Boolean = { input -> input.length == 5 }

이 케이스에선 람다의 리턴타입이 변수에서 지정해놓은 타입과 다르면 컴파일 에러가 발생한다.

변수와 람다 파라미터에 모두 타입을 지정하는 방식은 바람직하지 못하다.

람다의 리턴타입을 고정하고 싶으면 변수에 타입을 정의하고

리턴타입을 타입추론으로 사용하고 싶다면 파라미터 타입을 정의하자.

익명 함수를 사용하면 변수의 타입은 타입추론으로 사용하고 리턴타입만 지정할 수 있다.

위의 예제를 익명 함수로 바꿔보자.

val checkLength5 = fun(name: String): Boolean { return name.length == 5 }
names.find(checkLength5)
// 변수에 지정하지 않고 익명 함수를 직접 인자로 넘길수도 있다.
names.find(fun(name: String): Boolean { return name.length == 5 })

가독성이 떨어지기에 예외적인 상황(Ch16. 비동기 프로그래밍에서 다룬다)을 제외하면 람다 대신 익명 함수를 쓸 이유는 없다.

클로저와 렉시컬 스코핑

클로저(Closure)는 상위 함수의 영역의 변수를 접근할 수 있는 함수

아래의 람다는 클로저이다.

val factor = 2
val doubleIt = { e: Int -> e * factor }

렉시컬 스코핑은 함수를 처음 선언할 때 함수 내부 변수를 자기 스코프로부터 가장 가까운 곳에 있는 변수를 참조하는 것이다.

위 예제에서 factor는 지역 변수가 아니다. 컴파일러 렉시컬 스코핑으로 클로저의 바디가 정의된 곳을 살펴보고 찾지 못했으면 클로저가 정의된 곳이 정의된 곳으로 스코프를 확장하고 또 못찾으면 계속 범위를 확장한다.

함수형 프로그래밍에서 뮤터블리티는 금기사항이다. 결과를 예상하기 힘들고 코드 읽기가 힘들어지기 때문이다.

하지만 코틀린에서는 클로저 안에서 참조한 외부변수를 변경해도 경고를 해주지 않기 때문에 뮤터블 변수 사용을 최소화해야한다.

비지역성과 라벨 리턴

람다에서 return은 허용되지 않는게 기본이지만 특별한 상황에서는 사용할 수 있다.

왜 허용되지 않는지 예제를 통해 알아보자.

fun invoke(n: Int, action: (Int) -> Unit) {
    println("enter invoke $n")
    action(n)
    println("exit invoke $n")
}

fun caller() {
    (1..3).forEach { i ->
        invoke(i) {
            println("enter for $it")
            if (it == 2) {
                return    // ERROR
            }
            println("exit for $it")
        }
    }
    println("end caller")
}
caller()
println("after return from caller")

if문 return 라인에서 컴파일 실패를 일으킨다. 왜냐하면 컴파일러가 return이 무슨 의미인지를 알 수 없기 때문이다.

  1. 즉시 람다에서 빠져나와라.
  2. for 루프를 빠져나와라.
  3. caller 함수를 빠져나와라.

이러한 혼란을 피하기 위해 코틀린은 return 키워드를 람다에서 허용하지 않지만 예외를 2개 만들어놨다.

바로 라벨 리턴과 논로컬 리턴이다.

라벨 리턴

람다에서 즉시 나가고싶을 땐 라벨 리턴을 사용하면 된다.

label@ 문법을 이용해서 만들 라벨을 return@label 형태로 넣으면 된다.

라벨 리턴을 적용한 위 예제를 보자.

fun invoke(n: Int, action: (Int) -> Unit) {
    println("enter invoke $n")
    action(n)
    println("exit invoke $n")
}

fun caller() {
    (1..3).forEach { i ->
        invoke(i) here@{
            println("enter for $it")
            if (it == 2) {
                return@here
            }
            println("exit for $it")
        }
    }
    println("end caller")
}
caller()
println("after return from caller")

/*
enter invoke 1
enter for 1
exit for 1
exit invoke 1
enter invoke 2
enter for 2
exit invoke 2
...
*/

라벨 리턴으로 컴파일러가 return의 의미를 정확히 파악할 수 있다. continue처럼 사용한 것이다. 라벨리턴은 스코프 바깥의 곳을 향하지 못한다.

@here 같이 명시된 라벨을 사용하는 대신 함수 이름같은 암시적인 라벨을 사용할 수도 있다.

here@라벨을 제거하고 return@invoke를 사용하면 된다.

암시적 라벨보다 명시적 라벨이 의도를 명확하게 보이고 코드를 쉽게 이해할 수 있게 도와주기에 명시적 라벨을 권장한다.

논로컬 리턴

라벨 리턴은 오직 현재 람다만 벗어날 수 있다. 논로컬 리턴은 람다와 함께 구현된 현재 함수(예제의 forEach)에서 나갈 때 유용하다.

위 예제의 caller 함수를 논로컬 리턴으로 바꿔보자.

fun caller() {
    (1..3).forEach { i ->
        println("in forEach for $i")
        if (i == 2) {
            return
        }
        invoke(i) {
            ...
        }
    }
    println("end caller")
}

forEach 람다에서 invoke로 가기 전에 if문을 작성해서 caller 함수자체를 return 시키는 것이다.

그렇다면 왜 전달한 invoke 함수 람다에는 라벨이 없으면 return이 안되고 forEach에 전달한 람다에는 return이 허용될까??

forEach는 inline 함수이기 때문이다. 자세히 알아보자.

람다를 이용한 인라인 함수

코틀린은 람다를 쓸 때 호출 오버헤드를 제거하고 성능을 향상시키기 위해 inline 키워드를 제공한다.

inline 람다는 forEach 같은 함수에서 return으로 논로컬 흐름 제어를 위해 사용되고 구체화된 타입 파라미터(reified)를 전달하기 위해 사용한다.

  • 구체화된 타입 파라미터
    • 제네릭에서 특정 타입의 첫 번째 인스턴스를 찾는 로직을 자바식으로 코딩하면 아래와 같이 코딩했을 것이다.
    fun <T> findFirst(books: List<Book>, ofClass: Class<T>): T {
        val selected = books.filter { book -> ofClass.isInstance(book) }
        if (selected.size == 0 {
            throw RuntimeException("Not Found")
        }
        return OfClass.cast(selected[0])
    }
    
    • 코드가 굉장히 어지럽다. 코틀린의 reified로 리팩토링해보자.
    inline fun <reified T> findFirst(books: List<Book>): T {
        val selected = books.filter { book -> book is T }
        if (selected.size == 0 {
            throw RuntimeException("Not Found")
        }
        return selected[0] as T
    }
    
    • reified로 파라미터 타입 T를 선언하면 함수 안에서 T를 타입 체크와 캐스팅용으로 사용 가능하다.
    • 호출하는 코드마저 가독성이 좋아진다.

인라인 최적화

함수가 inline으로 선언되어있으면 함수를 호출하는 대신 함수의 바이트코드가 함수를 호출하는 위치에 들어간다.

하지만 함수가 호출되는 모든 부분에 바이트 코드가 위치하기에 바이트코드가 커지게된다. 즉 긴 함수를 인라인으로 사용하는건 좋은 생각이 아니다.

인라인은 눈에띄는 성능 향상이 있을 때만 사용하자. 그리고 반드시 측정하고 최적화하자. 덮어놓고 최적화하면 안된다.

인라인 함수를 사용했을 때 이득이 없는 경우라면 코틀린이 경고를 해준다.

인텐토리 → 앱의 지면, 배달, 배민1

선택적 noinline 파라미터

noinline 키워드는 inline함수에만 파라미터에 사용할 수 있다.

inline fun invoke(
    n: Int,
    action1: (Int) -> Unit,
    noinline action2: (Int) -> Unit
): (Int) -> Unit {

invoke 함수를 inline으로 만들어서 action1 함수는 인라인이 되지만 nolnline 키워드를 사용한 action2 함수는 최적화에서 제외된다.

인라인 람다에서만 논로컬 리턴이 가능하다.

위 예제인 invoke 함수에서 인라인인 action1 함수는 논로컬 리턴과 라벨 리턴이 둘 다 허용되지만 noinline 키워드를 사용한 action2 함수는 라벨 리턴만 허용된다. 인라인 람다는 해당 함수 내에서 확장되지만 인라인이 아닌 경우엔 다른 함수 호출을 사용하고 더 많은 스택 레벨이 있기 때문이다.

fun callInvoke() {
    invoke(1, { i ->
        if (i == 1) {
            return
        }
    }, { i -> 
        if (i == 2) {
            return    // ERROR
        }
    })
}

원한다면 클래스의 메소드나 속성도 인라인으로 만들 수 있다.

크로스인라인 파라미터

crossinline 키워드는 호출한 쪽으로 인라인을 전달하도록 함수에게 요청하는 키워드이다.

예제를 통해 이해해보자.

inline fun invokeCross(
    n: Int,
    action1: (Int) -> Unit,
    action2: (Int) -> Unit
): (Int) -> Unit {
    action1(n)
    return { input: Int -> action2(input) }    // ERROR
}

인라인 함수인 invokeCross의 파라미터인 action1, action2 함수도 인라인 함수가 된다.

하지만 action2 함수는 직접 호출하지 않기 때문에 인라인 함수가 될 수 없고 nolnline 키워드도 없어서 컴파일 에러가 발생한다.

컴파일 에러는 해결하려면 noinline 키워드나 crossinline 키워드를 사용하면 된다.

noinline 키워드를 사용하면 인라인 함수가 아니기에 성능상의 이득이 없고 논로컬 리턴을 사용할 수 없다.

crossinline 키워드를 사용하면 action2 함수는 invokeCross 함수가 아닌 호출되는 부분에서 인라인이 되고 최적화 된다.

crossinline 키워드도 마찬가지로 논로컬 리턴은 사용할 수 없다. 왜냐하면 호출되는 부분으로 넘어가서 실행되기 전에 이미 전달된 함수로 부터 빠져나갔을 수도 있기 때문이다.