안드로이드 개발하는 kancho입니다.
이번 포스팅에서는 코틀린의 람다(lambda)와 고차 함수(high order function)에 대해 알아보고자 합니다.
'코틀린 완벽 가이드' 책을 참고하였습니다.
람다와 고차 함수에 대해 알아보기 전에
먼저 함수형 프로그래밍에 대해 짧게 알아보자.
함수형 프로그래밍은 프로그래밍의 패러다임 중 하나이다.
불변 값을 변환하는 함수의 합성으로 구성할 수 있다는 아이디어를 바탕으로 한다.
즉, 자료처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 패러다임이다.
함수형 프로그래밍에서는 우리가 흔히 말하는 함수를 일급 시민(first class)으로 취급한다.
변수에 값을 set, get 할 수 있고 함수에 값을 전달하거나 함수가 값을 반환할 수 있다는 것이다.
즉, 함수를 다른 일반적인 타입의 값과 똑같이 취급한다는 뜻이다.
함수형 프로그램의 이런 특징은
함수인 값을 데이터와 마찬가지로 조작할 수 있는 고차 함수라는 함수를 정의할 수 있게 해준다.
고차 함수(higher-order function)
함수를 인자로 받거나 결과로 함수를 반환하는 함수
코드를 통해 이해해보자.
만약 어떤 정수형 배열 안에 있는 값들의 합을 구해야 한다면
아래와 같이 sum 함수를 정의해 사용할 수 있다.
fun sum(numbers: IntArray): Int {
var result = 0
numbers.forEach {
result += it
}
return result
}
fun main() {
println(sum(intArrayOf(1, 2, 3))) // 6
}
위에서 합을 구하는 함수를 정의했다면 이번에는 배열값들의 곱을 구해야 하면 어떻게 해야할까?
곱셈을 구하는 연산인 multiply 함수를 정의해 사용할 수 있다.
fun multiply(numbers: IntArray): Int {
var result = 1
numbers.forEach {
result *= it
}
return result
}
fun main() {
println(multiply(intArrayOf(1, 2, 3))) // 6
}
합과 곱 이외에도 다른 집계 함수를 만들어야 한다면 비슷하면서도 중복된 코드가 있는 함수를 여러 개 만들어야 한다.
코틀린은 이를 위해 함수의 파라미터로 적당한 연산을 제공할 수 있다.
아래 코드는 aggregate 함수의 파라미터로 operation이라는 연산을 제공한 코드이다.
operation 파라미터는 타입이 (Int, Int) -> Int 인 함수 타입이다.
이 형식은 operation 파라미터를 함수처럼 호출할 수 있다는 뜻이다.
/*
operation 파라미터는
Int 값을 한 쌍 받아서 Int를 리턴하는 함수이다
*/
fun aggregate(
numbers: IntArray,
operation: (Int, Int) -> Int
): Int {
var result = numbers.firstOrNull()
?: throw IllegalArgumentException("Empty array")
numbers.forEach {
result = operation(result, it)
}
return result
}
aggregate 고차 함수를 사용할 때에는 아래 코드와 같이
operation 함수 타입에 함숫값을 표현하는 람다식을 인자로 넘긴다.
/*
aggregate를 호출할 때 함숫값을 표현하는 람다식인
{ result, op -> result + op }을 인자로 넘긴다
*/
fun sum(numbers: IntArray) =
aggregate(numbers, { result, op -> result + op })
fun main() {
println(sum(intArrayOf(1, 2, 3)))
}
이제까지 간단하게 고차 함수와 구조에 대해 알아보았다.
다음은 함수 타입과 람다식에 대해 알아보자.
람다식
단순한 형태의 문법을 사용해 정의하는 이름이 없는 지역 함수
람다식의 구조
{ result, op -> result + op }
result 와 op는 각각 aggregate 함수 내의 operation 함수의 (Int, Int) 를 가리키며
result + op 는 결과를 계산하는 식이 된다.
파라미터의 타입은 추론이 가능하다.
fun aggregate(
numbers: IntArray,
operation: (Int, Int) -> Int
): Int { ... }
aggregate(numbers, { result, op -> result + op })
함수 타입
함수처럼 쓰일 수 있는 값들을 표시하는 타입
1. 파라미터 목록은 함숫값에 전달될 데이터의 종류와 수 (괄호는 필수)
2. 반환 타입은 함숫값을 호출하면 받는 리턴값 (반환 타입은 반드시 명시)
구조
(Int, Int) -> Boolean
인자로 Int형 정수를 한 쌍 받아 결과로 Boolean 값을 리턴하는 함수이다.
인자 타입 목록과 반환 타입 사이를 -> 로 구분한다
함수가 인자를 받지 않는 경우에는 함수 타입의 파라미터 목록에 빈 괄호를 사용한다.
fun aggregate(numbers: IntArray, operation: () -> Unit): Long { ... }
호출 방법
1. operation(.. , ..)
2. operation.invoke(.., ..)
numbers.forEach {
result = operation(result, it)
}
numbers.forEach {
result = operation.invoke(result, it)
}
코틀린 1.4부터는 interface 앞에 fun을 붙이면 코틀린 인터페이스를 SAM 인터페이스로 취급한다.
이 기능을 사용하면 interface를 람다로 인스턴스화 할 수 있다.
fun interface StringConsumer {
fun accept(s: String)
}
fun main() {
val consume = StringConsumer { s -> println(s) }
consume.accept("Hello")
}
함수 타입의 값을 함수의 파라미터에만 사용할 수 있는 것은 아니다.
/*
함수값을 lessThan 변수에 저장
*/
fun main() {
val lessThan: (Int, Int) -> Boolean = { a, b -> a < b }
println(lessThan(1, 2)) // true
/*
error
변수 타입을 생략하면 컴파일러가 람다 파라미터 타입을 추론할 수 없다
*/
val moreThan = { a, b -> a > b }
println(moreThan(1, 2))
}
함수 타입도 nullable이 올 수 있다.
이런 경우 타입 전체를 괄호로 둘러싼 다음 물음표를 붙인다.
만약 괄호로 둘러싸지 않으면 다른 의미가 된다.
() -> Unit? 이 되기 때문에 Unit? 타입의 값을 반환하는 함수가 된다.
fun measureTime(action: (() -> Unit)?): Long {
val start = System.nanoTime()
action?.invoke()
return System.nanoTime() - start
}
fun main() {
println(measureTime(null))
}
다음은 함수형 타입의 구체적인 값을 만드는 방법을 알아본다.
첫번째는 함수를 묘사하되 이름을 지정하지 않는 람다식을 이용한다.
람다
구조
{ result, op -> result + op }
result , op : 파라미터
result + op : 람다식의 결과
리턴 타입은 자동추론 된다.
파라미터 목록은 괄호로 둘러싸지 않는다.
fun sum(numbers: IntArray): Int =
aggregate(numbers, { result, op -> result + op })
fun main() {
println(sum(intArrayOf(1, 2, 3)))
}
람다가 함수의 마지막 파라미터인 경우
함수를 호출할 때 괄호 밖에 람다를 위치시킬 수 있다.
fun sum(numbers: IntArray): Int =
aggregate(numbers) { result, op -> result + op }
fun aggregate(
numbers: IntArray,
operation: (Int, Int) -> Int
): Int { ... }
또한, 람다에 인자가 없으면 화살표는 생략 가능하다.
fun measureTime(action: () -> Unit)): Long {
val start = System.nanoTime()
action?.invoke()
return System.nanoTime() - start
}
fun main() {
val time = measureTime { 1 + 2 }
println(time)
}
만약 인자가 하나라면
파라미터 목록과 화살표를 생략하고 it를사용할 수 있다.
fun check(s: String, condition: (Char) -> Boolean): Boolean {
s.forEach {
if (condition(it).not()) return false
}
return true
}
fun main() {
println(check("Hello") { c -> c.isLetter() }) // true
println(check("Hello") { it.isLowerCase() }) // false
}
익명함수
함수형 타입의 구체적인 값을 만드는 다른 방법은
익명함수를 사용하는 것이다.
특징
- 이름을 지정하지 않는다. fun 바로 뒤에 파라미터 목록
- 파라미터 타입 추론
- 익명 함수는 식이라서 인자로 함수에 넘기거나 변수에 대입하는 등 일반 값처럼 사용 가능
- 리턴 타입 표현 가능
- 인자 밖으로 뺄 수 없다
- 지역 함수와 마찬가지로 자신을 포함하는 외부 선언에 정의된 변수에 접근 가능
fun sum(numbers: IntArray): Int =
aggregate(numbers, fun(result, op) = result + op)
fun sum(numbers: IntArray): Int =
aggregate(numbers, fun(result, op): Int { return result + op})
// 인자 밖으로 뺄 수 없다!
fun sum(numbers: IntArray): Int =
aggregate(numbers) { fun(result, op) = result + op } // error
이제까지 함숫값을 만드는 방법을 알아보았다.
다음은 이미 함수 정의가 있고 함숫값처럼 고차 함수에 넘기는 방법을 알아본다.
호출 가능 참조 (callable reference)
위에서 본 람다식 코드처럼 전달할 수 있지만, 코틀린은 단순한 방법도 제공한다.
함수 이름 앞에 :: 을 붙이면 호출 가능 참조를 얻는다.
아래 코드를 보면
::isCapitalLetter 식은 isCapitalLettter() 함수와 같은 동작을 하는 함숫값을 표현한다.
fun check(s: String, condition: (Char) -> Boolean): Boolean {
s.forEach {
if (condition(it).not()) return false
}
return true
}
fun isCapitalLetter(c: Char) = c.isUpperCase() && c.isLetter()
fun main() {
println(check("Hello") { isCapitalLetter(it) }) // false
println(check("Hello", ::isCapitalLetter)) // false
}
가장 간단한 호출 가능 참조는 최상위나 지역함수를 가리키는 참조이다.
fun evalAtZero(f: (Int) -> Int) = f(0)
fun inc(n: Int) = n + 1
fun dec(n: Int) = n - 1
fun main() {
println(evalAtZero(::inc))
println(evalAtZero(::dec))
}
:: 를 클래스에 적용하면 생성자에 대한 호출 가능 참조를 얻는다.
class User(var firstName: String, var lastName: String) {
fun hasNameOf(name: String) =
name.equals(firstName, ignoreCase = true)
}
fun main() {
val user = ::User
user("kancho", "park")
}
어떤 instance에서 멤버 함수를 호출하고 싶을 때는 바인딩된 호출 가능 참조(bound callable reference)를 사용한다.
fun main() {
val isKancho = User("Kancho", "Park")::hasNameOf
println(isKancho("Kancho")) // true
}
호출 가능 참조는 오버로딩된 함수를 구분할 수 없다.
구분하려면 컴파일러가 알 수 있도록 타입 지정을 해야 한다.
fun max(a: Int, b: Int) = if (a > b) a else b
fun max(a: Double, b: Double) = if (a > b) a else b
fun main() {
val refWithType: (Int, Int) -> Int = ::max // ok
val ref = ::max // error: overload resolution ambiguity
}
프로퍼티에 대한 호출 가능 참조도 만들 수 있다.
여기서의 참조 자체는 함숫값이 아닌 프로퍼티 정보를 담고 있는 리플렉션 객체다.
class User(var firstName: String, var lastName: String)
fun main() {
val user = User("Kancho", "")
val firstName = user::firstName.getter
val setLastName = user::lastName.setter
println(firstName()) // Kancho
setLastName("Park")
println(user.lastName) // Park
}
인라인(inline) 함수와 프로퍼티
이제까지 알아본 고차 함수와 함숫값을 사용하면
함수가 객체로 표현되기 때문에 성능 차원에서 부가 비용이 발생할 수 있다.
또한 익명 함수나 람다가 외부 영역 변수를 사용하면
고차 함수에 함숫값을 넘길 때마다 외부 영역의 변수를 포획할 수 있는 구조도 만들어서 넘겨야 한다.
하지만 코틀린은 함수값을 사용할 때 발생하는 런타임 비용을 줄이는 방법을 제공한다.
Inline 변경자
함수 앞에 inline을 붙이면
함숫값을 사용하는 고차 함수를 호출하는 부분을 해당 함수의 본문으로 대체한다.
inline fun forEach(a: IntArray, action: (Int) -> Unit) {
for (n in a) action(n)
}
fun main() {
var sum = 0
forEach(intArrayOf(1, 2, 3, 4)) { sum += it }
println(sum)
}
forEach 함수가 inline일 경우의 컴파일된 자바 코드를 보면
main() 함수 안에 inline 된 forEach() 함수의 내용이 대체된 것을 볼 수 있다.
public static final void main() {
int sum = 0;
int[] a$iv = new int[]{1, 2, 3, 4};
int $i$f$forEach = false;
int[] var3 = a$iv;
int var4 = a$iv.length;
for(int var5 = 0; var5 < var4; ++var5) {
int n$iv = var3[var5];
int var8 = false;
sum += n$iv;
}
System.out.println(sum);
}
인라인 함수를 쓰면 컴파일된 코드의 크기는 커지게 된다.
하지만 잘 사용하면 성능을 크게 높일 수 있다.
특히, 함수가 상대적으로 작은 경우 성능이 크게 향상된다.
inline이 붙은 함수는 파라미터로 전달받은 함숫값도 inline된다.
그래서 inline 함수는 변수에 저장되거나 inline 함수가 아닌 함수에 전달될 수 없다.
또한, inline 함수는 nullable한 함수 타입의 인자를 받을 수 없다.
이런 경우 특정 람다를 inline하지 못하도록 noinline 변경자를 붙이면 된다.
하지만 inline 할 수 있는 인자가 없다면 함수 본문으로 대체해도 런타임에 크게 이득이 없다.
따라서 inline의 필요성이 없게 된다.
var lastAction: () -> Unit = { }
inline fun runAndMemorize(action: () -> Unit) {
action()
lastAction = action // error
}
inline fun forEach(a: IntArray, noinline action: ((Int) -> Unit)?) {
if (action == null) return
for (n in a) action(n)
}
또한 캡슐화를 위해 인라인 함수에 비공개 멤버는 넘길 수 없다.
class User(
private val firstName: String,
private val lastName: String
) {
inline fun sendMessage(message: () -> String) {
// error : Public-API inline function cannot access non-public-API
println("$firstName $lastName")
}
}
비지역적 제어 흐름
고차 함수를 사용하면 return 문 등의 제어 흐름을 깨는 명령을 사용할 때 문제가 생긴다.
또한 고차 함수가 inline 될 수 있는 람다를 받고
함수에서 람다를 직접 호출하지 않으며 다른 곳(지역 함수, 지역 클래스의 메서드 등)에서 간접적으로 호출할 수도 있다.
이런 경우 람다를 inline 할 수 있지만
이후 람다에서 사용하는 return문이 고차 함수를 호출하는 쪽의 함수를 반환시킬 수 없다.
람다의 return과 람다를 실행해주는 함수가 서로 다른 실행 스택 프레임을 차지하기 때문이다.
inline fun forEach(a: IntArray, action: (Int) -> Unit) = object {
fun run() {
for (n in a) {
/* error
Can't inline 'action' here: it may contain non-local return
*/
action(n)
}
}
}
이런 호출을 허용하려면 함수형 파라미터 앞에 crossline 변경자를 붙여야 한다.
crossline은 함숫값을 inline 하도록 남겨두는 대신
람다 안에서 비지역 return을 사용하지 못하게 하는 역할을 한다.
inline fun forEach(
a: IntArray,
crossinline action: (Int) -> Unit
) = object {
fun run() {
for (n in a) {
action(n)
}
}
}
'코틀린' 카테고리의 다른 글
[코틀린] Data 클래스 (1) | 2022.10.23 |
---|---|
[코틀린] Enum 클래스 (0) | 2022.10.21 |
[코틀린] Scope function (영역 함수) (0) | 2022.10.10 |
[코틀린] 확장 (Extension) (0) | 2022.10.10 |
[코틀린] Lazy 초기화 (0) | 2022.10.09 |