함수형 스타일함수형 스타일은 왜, 언제 사용해야 하는가람다 표현식람다의 구조암시적 파라미터 사용람다 받기람다를 마지막 파라미터로 사용하기함수 참조 사용함수를 리턴하는 함수람다와 익명 함수익명 함수클로저와 렉시컬 스코핑비지역성(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 을 보고 무슨 의미인지 모른다
- 즉시 람다에서 빠져나오고 invokeWith() 함수의 action(n) 이후의 나머지를 실행하라는 것인지
- for 루프를 빠져나오라는 것인지
- 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이 아닌 경우만 논로컬 리턴을 사용할 수 있다.