HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🤩
개발
/Kotlin/
람다를 사용한 함수형 프로그래밍

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

함수형 스타일함수형 스타일은 왜, 언제 사용해야 하는가람다 표현식람다의 구조암시적 파라미터 사용람다 받기람다를 마지막 파라미터로 사용하기함수 참조 사용함수를 리턴하는 함수람다와 익명 함수익명 함수클로저와 렉시컬 스코핑비지역성(non-local)과 라벨(labeled)리턴리턴은 허용되지 않는게 기본라벨 리턴논로컬 리턴람다를 이용한 인라인 함수인라인 최적화선택적 노인라인 파라미터크로스인라인 파라미터

함수형 스타일

// 명령형 스타일 var doubleOfEven = mutableListOf<Int>() for (i in 1..10) { if (i % 2 == 0) { doubleOfEven.add(i * 2) } } // 선언적 스타일. 함수형 스타일 val doubleOfEven = (1..10) .filter { e -> e % 2 == 0 } .map { e -> e * 2} println(doubleOfEven) // [ 4, 8, 12, 16, 20]
  • 함수형 스타일에서는 코드를 실행하는 동안 뮤터블한 변수를 하나도 사용하지 않음. 이게 바로 변화하는 부분이 적다는 것

함수형 스타일은 왜, 언제 사용해야 하는가

  • 명령형 스타일은 익숙하다. 하지만 복잡하다. 익숙함 때문에 쓰기는 쉽지만 읽기가 매우 어렵다.
  • 함수형 스타일은 좀 덜 친숙하다. 하지만 단순하다. 익숙하지 않기 때문에 작성하기는 어렵지만, 읽기가 쉽다
    • 함수형 스타일은 코드가 연산에 집중하고 있을 때 써야 한다. 그러면 뮤터빌리티와 그 부작용을 피할 수 있다.
    • 하지만 많은 입출력이 존재해 뮤테이션이나 부작용을 피할 수 없거나 코드가 많은 수의 예외를 처리해야 한다면 명령형 스타일이 더 좋은 선택이다.

람다 표현식

람다의 구조

일반적으로 함수는 이름, 리턴 타입, 파라미터 리스트, 바디 4개 부분으로 나눌 수 있다.
람다는 필수적인 부분만 갖고 있는데, 파라미터 리스트와 바디 2개임
{ parameter list -> body } // 주어진 숫자가 소수인지 아닌지를 알려주는 함수 fun isPrime(n: Int) = n > 1 && (2 until n).none({i: Int -> n % i == 0 })
  • 코틀린이 파라미터에는 타입추론을 적용하지 않기 때문에 함수에 전달되는 각 파라미터의 타입이 필요. 그러나 람다의 파라미터에는 타입을 필요로 하지 않음. 생략 가능. 람다가 전달된 함수의 파라미터를 기반으로 타입을 추론할 수 있음
    • fun isPrime(n: Int) = n > 1 && (2 until n).none({i -> n % i == 0}) // none()이 하나의 파라미터만 받기에 괄호를 생략할 수 있음 fun isPrime(n: Int) = n > 1 && (2 until n).none { i -> n % i == 0 }
💡
타입과 괄호를 사용하지 않아도 되는 곳에서는 사용하지 말자.

암시적 파라미터 사용

이전 예제의 i 처럼 함수에 전달된 람다가 하나의 파라미터만 받는다면 파라미터 정의를 생략하고 it 이라는 이름의 특별한 암시적 파라미터를 사용할 수 있다.
fun isPrime(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, {i -> print(i) }) // 콤마 제거하고, 람다를 괄호 밖에 놓을 수 있음 walk1To(5) { i -> print(i) } // 암시적 변수 it 을 사용 walk1To(5) { print(it) }

함수 참조 사용

({ x -> someMethod(x)}) (::someMethod) fun walk1To(n: Int, action: (Int) -> Unit) = (1..n).forEach { action(it) } // 함수 참조로 변경 fun walk1To(n: Int, action: (Int) -> Unit) = (1..n).forEach(action) walk1To(5) { print(it) } // 함수 참조로 변경 walk1To(5, ::print) // 함수 참조 예시 object Terminal { fun write(value: Int) = println(value) } walk1To(5) { i -> Terminal.write(i) } walk1To(5, Terminal::write)

함수를 리턴하는 함수

fun predicateOfLength(length: Int): (String) -> Boolean { return { input: String -> input.length == length } } // 타입 추론 이용 fun predicateOfLength(length: Int) = { input: String -> input.length == legnth } println(names.find(predicateOfLength(5))) println(names.find(predicateOfLength(4)))

람다와 익명 함수

같은 람다가 여러번 사용된다면 람다를 변수에 담아 재사용 할 수 있음. 그러나 여기엔 주의사항이 있는데, 람다가 아규먼트가 되어 함수로 전달될 때, 코틀린은 파라미터의 타입을 추론한다. 그런데 변수를 람다를 저장하기 위해 사용했다면 코틀린은 타입에 대한 정보를 알 수가 없다.
그래서 람다를 변수에 지정할 때는 타입 정보를 제공해주어야 함
// 람다를 변수에 담기 // String을 파라미터로 받는 것을 통해 리턴타입은 코틀린이 추론 val checkLength5 = { name : String -> name.length == 5 } println(names.find(checkLength5)) // Paula // 변수의 타입을 지정해놓고 람다의 파라미터의 타입을 추론하게 할 수 있음 val checkLength5 : (String) -> Boolean = { name -> name.length == 5 } // 변수와 람다 모두에 타입 지정해 놓는건 바람직하지 x val checkLength5: (String) -> Boolean = { name : String -> name.length == 5 } // Not Preferred
  • 람다의 리턴 타입을 고정하고 싶을 때는 변수에 타입 정의 리턴 타입을 타입추론으로 사용하고 싶다면 람다의 파라미터에 타입 정의

익명 함수

val checkLength5 = fun(name: String): Boolean { return name.length == 5}
  • 특정 소수의 예외적인 상황 제외하며 람다 대신 익명함수를 함수 호출에 사용할 이유 없음

클로저와 렉시컬 스코핑

함수형 프로그래밍 개발자들은 람다와 클로저에 대해 이야기한다. 많은 개발자가 두 개념을 상호교환해 가며 사용한다. 두 개념을 교환해서 사용하는 것은 가능하지만 차이점에 대해 잘 알고 문맥상 어떤 것이 더 적합한지 알아야 함
  • 람다에는 상태가 없음
  • 가끔 우리는 외부 상태에 의존하고 싶어하는데, 이때 람다를 클로저라고 부를 수 있다. 왜냐면 람다는 스코프를 로컬이 아닌 속성과 메소드로 확장할 수 있기 때문이다.
// 람다 val doubleIt = {e: Int -> e * 2} // 클로저 val factor = 2 val doubleIt = { e:Int -> e * factor }
  • 위의 예시에서 factor 는 바디 안에 속해 있지 않기에 로컬 변수가 아니다.
  • 컴파일러는 factor 변수에 대한 클로저의 범위(스코프) 즉, 클로저의 바디가 정의된 곳을 살펴봐야 한다.
  • 만약에 클로저가 정의된 곳에서 factor 변수를 찾지 못했다면 클로저가 정의된 곳이 정의된 곳으로 스코프를 확장하고, 또 못찾는다면 계속 범위를 확장한다. 이게 바로 렉시컬 스코핑
  • 뮤터빌리티는 함수형 프로그래밍의 금기사항임. 하지만 코틀린은 클로저 안에서 뮤터블 변수의 값을 잃거나 변경하는 것을 불평하지 않음
  • 그러나 val 로 꼭 사용하기. var 말고

비지역성(non-local)과 라벨(labeled)리턴

람다는 리턴값이 있더라도 return 키워드를 가질 수 없다. 람다와 익명 함수 사이에는 이런 중대한 차이점이 있다.

리턴은 허용되지 않는게 기본

fun invokeWith(n: Int, action: (Int) -> Unit) { println("enter invokeWith $n") action(n) println("exit invokeWith $n") } fun caller() { (1..3).forEach { i -> invokeWith(i) { println("enter for $it") if ( it == 2) { return } // ERROR, return is not allowed here println("exit for $it") } } println("end of caller") } caller() println("after return from caller")
  • 코틀린은 위의 return 을 보고 무슨 의미인지 모른다
      1. 즉시 람다에서 빠져나오고 invokeWith() 함수의 action(n) 이후의 나머지를 실행하라는 것인지
      1. for 루프를 빠져나오라는 것인지
      1. caller() 함수에서 나오라는 것인지
  • 이런 혼란을 피하기 위해 코틀린은 return 키워드를 허용하지 않는데 예외가 2가지 있다. labeled return 과 non-local return

라벨 리턴

현재 람다에서 즉시 나가고 싶다면 라벨 리턴을 사용하면 된다.
fun caller() { (1..3).forEach { i -> invokeWith(i) here@ { println("enter for $it") if ( it == 2) { return@here } println("exit for $it") } } println("end of caller") }
  • return을 명시하는 람다 함수에 here@ 라벨을 붙이고, return 시 @here 을 같이 붙인다.
  • @here 같이 명시된 라벨을 사용하는 대신 람다가 전달된 함수의 이름같은 암시적인 라벨을 사용할 수도 있다 return@invokeWith 으로 변경 가능
    • 그러나 명시적 라벨이 더 의도 명확
라벨리턴을 이용한 forEach 에서 continue 와 break 하는 방법
// Break val numbers = listOf(1, 2, 3, 4, 5) run loop@{ numbers.forEach { if (it > 3) return@loop // Breaks out of the `forEach` loop println(it) } } println("After the loop") // Continue val numbers = listOf(1, 2, 3, 4, 5) numbers.forEach { if (it % 2 == 0) return@forEach // Skips the current iteration println(it) }

논로컬 리턴

람다에서 기본적으로는 return 키워드를 사용할 수 없지만, 논로컬 리턴을 사용하면 람다와 함께 구현된 현재 함수에서 나갈 수 있다.
fun caller() { (1..3).forEach { i -> println("in forEach for $i") if (i == 2) { return } invokeWith(i) { println("enter for $it") if ( it == 2) { return@invokeWith } println("exit for $it") } } println("end of caller") }
  • forEach 내부의 return 함수는 현재 람다를 빠져나가는 대신 현재 실행중인 함수(caller)를 빠져나간다. ⇒ non-local return 이라 부름
inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
  • non-local return을 사용하기 위해서는 람다를 받는 함수가 inline으로 선언되어 있어야 한다.
💡
return 키워드 정리 - return 은 람다에서 기본적으로 허용이 안된다 - 라벨 리턴을 사용하면 현재 동작중인 람다를 스킵할 수 있다. - 논로컬 리턴을 사용하면 현재 동작중인 람다를 선언한 곳 바깥으로 나갈 수 있다. 하지만 람다를 받는 함수가 inline으로 선언된 경우만 사용 가능

람다를 이용한 인라인 함수

[ Kotlin docs ] Inline functions
💡
inline은 눈에 띄는 성능 향상이 있을 때만 사용하라
  • 논로컬 흐름 제어를 위해 사용 & 구체화된 타입 파라미터를 전달하기 위해 사용

인라인 최적화

  • inline 키워드를 이용해서 람다를 받는 함수의 성능을 향상시킬 수 있다.
  • 함수가 inline으로 선언되어 있으면 함수를 호출하는 대신 함수의 바이트코드가 함수를 호출하는 위치에 들어가게 됨 ⇒ 함수 호출의 오버헤드를 제거하지만 함수가 호출되는 모든 부분에 바이트코드가 위치하기 때문에 바이트코드가 커지게 된다.
    • 일반적으로 긴 함수를 인라인으로 사용하는 건 좋은 생각이 아니다.
    • inline 함수가 매우 클 경우, 그리고 함수를 매우 여러 곳에서 호출한다면 ? inline을 사용하지 않을 때에 비해 바이트코드가 훨씬 커지게 된다. 측정하고 최적화하라

선택적 노인라인 파라미터

inline fun invokeTwo( n: Int, action1: (Int) -> Unit, noinline action2: (Int) -> Unit ): (Int) -> Unit {
  • action2를 호출할때는 최적화가 일어나지 않음. 바이트코드가 들어가는 게 아니라 함수 호출로. 그래서 콜스택도 더 깊음

크로스인라인 파라미터

함수가 인라인으로 마크되었다면 noinline으로 마크되지 않는 람다 파라미터는 inline으로 간주됨. 함수에서 람다가 실행되는 위치에 람다의 바디가 들어가는 것
그런데, 주어진 람다를 호출하지 않고 다른 함수로 전달하거나 콜러에게 다시 돌려준다면 어떻게 될까?
람다가 호출될지 아닐지 모를 때 인라인으로 만들고 싶다면, 호출한 쪽으로 인라인을 전달하도록 함수에게 요청할 수 있다. 이게 바로 crossinline
inline fun invokeTwo( n: Int, action1: (Int) -> Unit, crossinline action2: (Int) -> Unit ): (Int) -> Unit {
💡
- inline은 함수를 인라인으로 만들어서 함수 호출의 오버헤드를 제거해서 함수 성능을 최적화한다 - crossinline도 인라인 최적화를 해준다. 하지만 람다가 전달된 곳이 아니라 실제로 람다가 사용된 곳에서 인라인 최적화가 진행된다. - 파라미터로 전달된 람다가 noinline이나 crossinline이 아닌 경우만 논로컬 리턴을 사용할 수 있다.