[Kotlin] 고차함수
1. 코틀린을 활용한 함수형 프로그래밍
코드를 작성하면서 가변 변수들을 사용하면서 생길 수 있는 문제들을 배제하려는 프로그래밍 방식
1. 고차 함수
고차함수란, 함수를 함수의 매개변수로 받을 수 있고, 함수를 반환할 수 있는 함수이다.
어떤 정수 배열의 원소의 합계를 계산하는 함수를 살펴보자.
fun sum(numbers: IntArray): Int {
var result = numbers.firstOrNull()
?: throw IllegalArgumentException("Empty array")
for (i in 1..numbers.lastIndex) result += numbers[i]
return result
}
fun main() {
println(sum(intArrayOf(1, 2, 3))) // 6
}
이 함수를 더 일반화해서 곱셈이나 최댓값/최솟값처럼 다양한 집계 함수를 사용하게 하려면 어떻게 해야할까?
→ 함수 자체의 기본적인 루프 로직은 그대로 두고 중간 값들을 함수의 파라미터로 추출
→ 일반화한 함수를 호출할 때 이 파라미터에 적당한 연산을 제공
fun aggregate(numbers: IntArray, op: (Int, Int) -> Int): Int {
var result = numbers.firstOrNull()
?: throw IllegalArgumentException("Empty array")
for (i in 1..numbers.lastIndex) result = op(result, numbers[i])
return result
}
fun sum(numbers: IntArray) =
aggregate(numbers, { result, op -> result + op})
fun max(numbers: IntArray) =
aggregate(numbers, { result, op -> if (op > result) op else result})
fun main() {
println(sum(intArrayOf(1, 2, 3))) // 6
println(max(intArrayOf(1, 2, 3))) // 3
}
op 파라미터는 함수타입이며 호출도 함수처럼 할 수 있다. 이 말은 이 op를 함수처럼 호출할 수 있다는 뜻이다.
이 예제에서 op파라미터는 Int 값을 한 쌍 받아서 Int를 결과로 내놓는다.
sum( )과 max( ) 함수를 보면 aggregate를 호출하는 쪽에서는 함숫값을 표현하는 람다식을 인자로 넘긴다는 사실을 알 수 있다.
람다식은 기본적으로 단순한 형태의 문법을 사용해 정의하는 이름이 없는 지역 함수다.
{ result , op -> result + op }
result와 op는 함수 파라미터 역할을 하며 -> 다음에 오는 식은 결과를 계산하는 식이다
2. 함수 타입
: 함수처럼 쓰일 수 있는 값들을 표시하는 타입
함수 타입을 정의하려면 함수 파라미터의 타입을 괄호에 넣고, 그 뒤에 화살표(->)를 추가한 다음, 함수의 반환 타입을 지정하면 된다.
함수 타입은 함수처럼 쓰일 수 있는 값들을 표시하는 타입이며 두 가지 부분으로 구성된다.
- 괄호로 둘러싸인 파라미터 타입 목록은 함숫값에 전달될 데이터의 종류와 수를 정의한다.
- 반환 타입은 함수 타입의 함숫값을 호출하면 돌려받게 되는 값의 타입을 정의한다.
반환값이 없는 함수라도 반환 타입을 반드시 명시해야 한다. 이런 경우는 Unit을 반환 타입으로 사용한다.
함수 정의와 달리 함수 타입 표기에서는 인자 타입 목록과 반환 타입 사이를 :이 아닌 ->로 구분한다.
it 키워드
인자가 1개인 익명 함수는 매개변수 이름을 지정하지 않고 it 키워드를 사용할 수 있다.
인자가 2개 이상이면 it 키워드를 사용할 수 없다.
fun main(args: Array<String>) {
val helloFunction: (String) -> String = {
"Hello current Year : $it"
}
println(helloFunction("2020"))
}
함수를 인자로 받은 함수 정의하기
인자로 받은 함수를 호출하는 구문은 일반함수를 호출하는 구문과 같다.
fun twoAndThree(operation: (Int, Int) -> Int) {
val result = operation(2, 3)
println("The result is $result")
}
fun main(args: Array<String>) {
twoAndThree { a, b -> a + b }
twoAndThree { a, b -> a * b }
}
단축문법
함수에서 마지막 매개변수로 함수 타입을 받을 때는 익명 함수 인자를 둘러싼 괄호를 생략할 수 있다
[인자가 1개 - 단축 문법 적용 전]
printHelloFunction({ year:String, age:Int ->
"Hello current Year : $year \nyour age is $age"
})
[인자가 1개 - 단축 문법 적용 후]
printHelloFunction { year: String, age: Int ->
"Hello current Year : $year \nyour age is $age"
}
[인자가 2개 - 단축 문법 적용 전]
printHelloFunction (2000,{ year: String, age: Int ->
"Hello current Year : $year \nyour age is $age"
})
[인자가 2개 - 단축 문법 적용 후]
printHelloFunction (2000){ year: String, age: Int ->
"Hello current Year : $year \nyour age is $age"
}
invoke() 메서드 호출
코틀린에는 invoke라는 특별한 함수, 정확히는 연산자가 존재한다. invoke연산자는 이름 없이 호출될 수 있다. 이름 없이 호출된 다는 의미를 파악하기 위해 아래의 코드를 보자.
object MyFunction {
operator fun invoke(str: String): String {
return str.toUpperCase() // 모두 대문자로 바꿔줌
}
}
MyFunction이라는 오브젝트 하나가 있다. obeject키워드로 만들었기 때문에 MyFunction은 하나의 객체처럼 사용될 수 있다. 즉, 하나의 객체이기 때문에 객체 안의 메서드를 호출하기 위해서 아래와 같이 호출하고 싶을 것이다.
MyFunction.invoke("hello") // HELLO
물론 잘 동작하지만, kotlin에서 invoke라는 이름으로 만들어진 함수는 특별한 힘을 갖는다. 이름 없이 실행될 수 있는 힘이다. 즉, 아래와 같이 호출이 가능하다.
MyFunction("hello") // HELLO
MyFunction은 객체다. 그렇기 때문에 MyFunction을 print해보면 MyFunction의 주소값만 출력될 뿐이다. 그런데 MyFunction안에 invoke()함수가 정의 되어있으므로 MyFunction에서 메서드 이름 없이 바로 호출한 것이다. 물론 파라미터를 받을 창구가 있어야 하므로 ()안에 파라미터를 넣어서 실행이 가능하다.
op(result, numbers[i])
op.invoke(result, numbers[i]) // 위와 아래는 같은 함숫값을 호출한다.
함수가 인자를 받지 않는 경우
함수가 인자를 받지 않는 경우에는 함수 타입의 파라미터 목록에 빈 괄호를 사용한다.
val test3: () -> Unit = { println("test") }
파라미터 타입을 둘러싼 괄호는 필수이므로 함수 타입이 파라미터를 하나만 받거나 전혀 받지 않는 경우에도 괄호는 꼭 쳐야 한다.
타입 추론 지원
변수 타입을 생략하면 정보가 충분하지 못해 컴파일러가 람다 파라미터의 타입을 추론할 수 없다.
이런 경우 파라미터의 타입을 명시하면 된다.
val lessThan = { a: Int, b: Int -> a < b } // Ok
변수 타입을 생략하면 컴파일러가 람다 파라미터의 타입을 추론할 수 없다.
널이 될 수 있는 함수 타입 파라미터
다른 타입과 마찬가지로 함수 타입도 널이 될 수 있는 타입으로 지정할 수 있다.
이럴 때는 함수 타입 전체를 괄호로 둘러싼 다음에 물음표를 붙인다.
※ 괄호로 함수 타입을 둘러싸지 않으면 물음표의 효과가 완전히 달라진다.
( ) -> Unit?는 Unit 타입의 값을 반환하는 함수를 표현하는 타입이라는 점에 유의하자.
fun measureTime(action: (() -> Unit)?): Long {
val start = System.nanoTime()
action()?.invoke()
return System.nanoTime() - start
}
fun main() {
println(measureTime(null))
}
고차함수 타입 정의
함수 타입을 다른 함수 타입 안에 내포시켜서 고차 함수 타입을 정의할 수 있다.
fun main() {
val shifter: (Int) -> (Int) -> Int = { n -> { i -> i + n } }
val inc = shifter(1)
val dec = shifter(-1)
println(inc(10)) // 10
println(dec(10)) // 9
}
이 타입은 Int 값을 인자로 받아서 함수를 반환하는 함수를 뜻한다.
fun aggregate(
numbers: IntArray,
op: (resultSoFar: Int, nextValue: Int) -> Int
): Int {...}
함수 타입의 파라미터 목록에 파라미터 이름을 포함시킬 수도 있다. 이런 경우 파라미터 이름은 그냥 문서화를 위한 것이며, 타입이 표현하는 함숫값에는 전혀 영향을 미치지 못한다.
자바(자바 8부터)는 단일 추상 메서드(Single Abstract Method, SAM) 인터페이스를 문맥에 따라 적절히 함수 타입처럼 취급하기 때문에 람다식이나 메서드 참조로 SAM 인터페이스를 인스턴스화할 수 있다. 하지만 코틀린에서 함숫값은 항상 (P1, .., Pn) -> R 형태의 함수 타입에 속하기 때문에 임의의 SAM 인터페이스로 암시적으로 변환할 수 없다.
// Consumer.java
interface Consumer<T> {
void accept(T t);
}
// Main.java
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
Consumer<String> consume = s ->System.out.println(s);
consume.accept("Hello"); // Hello
}
}
import java.util.function.Consumer
fun main() {
// Error: type mismatch
val consume: Consumer<String> = { s ->println(s) }
consume.accept("Hello")
}
하지만 자바와의 상호 운용성을 위해 코틀린은 자바에 정의된 SAM 인터페이스 대신 코틀린 함수 타입을 쓸 수 있게 변환해준다.
코틀린 1.4부터는 코틀린 인터페이스 앞에 fun을 붙이면 코틀린 인터페이스를 SAM 인터페이스로 취급한다.
이 기능을 사용하면 자바의 SAM 인터페이스와 마찬가지로 코틀린 인터페이스를 람다로 인스턴스화할 수 있다.
Kotlin의 SAM 변환을 활용하면 다음과 같은 코드를 대신 작성할 수 있다.
fun interface IntPredicate {
fun accept(i: Int): Boolean
}
fun main() {
val isEven = IntPredicate { it % 2 == 0 }
println(isEven.accept(10)) // true
println(isEven.accept(11)) // false
}
SAM 변환을 사용하지 않는 경우 다음과 같은 코드를 작성해야 한다.
val isEven = object : IntPredicate {
override fun accept(i: Int): Boolean {
return i % 2 == 0
}
}
3. 람다와 익명 함수
함수형 타입의 구체적인 값을 어떻게 만들 수 있을까?
→ 한 가지 방법은 함수를 묘사하되 이름을 지정하지는 않는 람다식을 사용하는 것이다.
람다 대수는 함수를 단순하게 표현할 수 있도록 도와주는 개념이다. 중괄호로 묶어 사용할 수 있다.
고차함수의 인자로 사용할 수 있어서 고차함수와 꼭 같이 알아야 한다. 또한 함수가 이름을 가질 필요가 없는 익명함수이기도 하다.
{변수1: 타입, 변수2: 타입 -> 변수1 + 변수2}
{ result, op -> result + op } 라는 식을 람다식이라고 부른다. 람다식 정의는 다음 요소로 이뤄진다.
- 파라미터 목록: result, op
- 람다식의 본문이 되는 식이나 문의 목록: result + op
람다 본문에서 맨 마지막에 있는 식이 람다의 결괏값이 되며 람다의 본문으로부터 반환 타입이 자동으로 추론되기 때문에 반환 타입을 지정할 필요가 없다. 람다의 파라미터 목록은 괄호로 둘러싸지 않으며, 괄호로 감싸게 되면 구조 분해(destucturing)로 선언된다.
람다가 함수의 마지막 파라미터인 경우, 함수를 호출할 때 인자를 둘러싸는 괄호 밖에 이 람다를 위치할 수 있다.
fun sum(numbers: IntArray) =
aggregate(numbers) { result, op -> result + op }
fun max(numbers: IntArray) =
aggregate(numbers) { result, op -> if (op > result) op else result }
람다에 인자가 없는 경우
람다에 인자가 없으면 화살표 기호(->)를 생략할 수 있다.
fun measureTime(action: () -> Unit): Long {
val start = System.nanoTime()
action()
return System.nanoTime() - start
}
val time = measureTime { 1 + 2 }
람다에 인자가 하나인 경우
람다 인자가 하나인 경우, 파라미터 목록과 화살표 기호를 생략하고, 미리 정해진 it이라는 이름을 사용해 가리킬 수 있다.
fun check(s: String, condition: (Char) -> Boolean): Boolean {
for (c in s) {
if (!condition(c)) return false
}
return true
}
fun main() {
println(check("Hello") { c -> c.isLetter() }) // true
println(check("Hello") { it.isLowerCase() }) // false
}
사용하지 않는 람다 파라미터가 있는 경우
또한, 사용하지 않는 람다 파라미터를 밑줄 기호(_)로 지정할 수 있다
fun check(s: String, condition: (Int, Char) -> Boolean): Boolean {
for (i in s.indices) {
if (!condition(i, s[i])) return false
}
return true
}
fun main() {
println(check("Hello") { _, c -> c.isLetter() }) // true
println(check("Hello") { i, c -> i==0 || c.isLowerCase() }) // true
println(check("Hello", fun(_: Int, c: Char): Boolean {
return c.isLetter()
})) // true
}
인텔리J 코틀린 플러그인은 람다와 익명 함수를 자동으로 변환해주는 기능을 제공한다.
람다를 여는 중괄호나 익명 함수의 fun에 위치하고 Alt + Enter를 누르면 된다.
Convert to ( anonymous function, lambda expression )
익명 함수는 함숫값을 만드는 또다른 방법이다.
fun sum(numbers: IntArray) =
aggregate(numbers, fun(result, op) = result + op)
- 익명 함수에는 이름이 없다.
- 파라미터 타입을 추론할 수 있으면 타입을 지정하지 않는다.
- 익명 함수는 식이기 때문에 인자로 함수에 넘기거나 변수에 대입하는 등 일반 값처럼 쓸 수 있다.
익명 함수는 람다와 달리 반환 타입을 적을 수 있다.
함수 본문이 식인 경우, 반환 타입을 생략할 수 있고, 함수 본문이 블록인 경우, 명시적으로 반환 타입을 지정해야 한다.
fun sum(numbers: IntArray) =
aggregate(numbers, fun(result, op): Int { return result + op } // :Int 를 생략해도 된다.
익명 함수는 람다와 달리 인자 목록의 밖으로 내보낼 수는 없으며, 지역 함수와 마찬가지로 익명함수나 람다도 자신을 포함하는 외부 선언에 정의된 변수에는 접근할 수 없지만 외부 영역의 가변 변수 값을 변경할 수는 있다. (자바 람다는 불가능)
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) // 10
}
익명함수 vs 람다
익명함수는 함수의 리턴값의 자료형을 지정해 줄 수 있고 람다 표현식은 그렇지 못하다
람다 표현식
{ x: Int, y: Int -> x + y }
익명함수
fun(x: Int, y: Int): Int = x + y
4. 호출 가능 참조
이미 함수 정의가 있고, 이 함수 정의를 함숫값처럼 고차 함수에 넘기고 싶다면 람다식으로 감싸서 전달할 수 있다.
fun check(s: String, condition: (Char) -> Boolean): Boolean {
for (c in s) {
if (!condition(c)) return false
}
return true
}
fun isCapitalLetter(c: Char) = c.isUpperCase() && c.isLetter()
fun main() {
println(check("Hello") { c -> isCapitalLetter(c) }) // false
println(check("Hello") { isCapitalLetter(it) }) // false
}
하지만 코틀린에는 이미 존재하는 함수 정의를 함수 타입의 식으로 사용할 수 있는 더 단순한 방법이 있다.
호출 가능 참조를 사용하면 된다.
fun main() {
println(check("Hello", ::isCapitalLetter)) // false
}
::isCapitalLetter라는 식은 이 식이 가리키는 isCapitalLetter( ) 함수와 같은 동작을 하는 함숫값을 표현해준다.
호출 가능 참조는 최상위나 지역 함수를 가리키는 참조다. 함수 이름 앞에 ::을 붙이면 함수를 가리키는 참조를 만들 수 있으며,
::을 클래스 이름 앞에 붙이면 클래스의 생성자에 대한 호출 가능 참조를 얻는다.
class Person(val firstName: String, val familyName: String)
fun main() {
val createPerson = ::Person
createPerson("John","Doe")
}
주어진 클래스 인스턴스의 안에 있는 멤버 함수를 호출하고 싶을 때는 바인딩된 호출 가능 참조라는 사용법을 사용한다.
class Person(val firstName: String, val familyName: String) {
fun hasNameOf(name: String): Boolean = name.equals(firstName, ignoreCase = true) // ignoreCase는 디폴트값
}
fun main() {
val isJohn = Person("John","Doe")::hasNameOf // 바인딩된 호출 가능 참조
println(isJohn("JOHN")) // true
println(isJohn("JAKE")) // false
}
※ 호출 가능 참조 자체는 오버로딩된 함수를 구분할 수 없다는 점에 유의하자.
오버로딩된 함수 중 어떤 함수를 참조할지 명확히 하려면 컴파일러에게 타입을 지정해줘야 한다
호출 가능 참조를 직접 호출하고 싶은 경우
fun max(a: Int, b: Int) = if (a > b) a else b
fun max(a: Double, b: Double) = if (a > b) a else b
val f: (Int, Int) -> Int = ::max // Ok
val g = ::max // Error
호출 가능 참조를 직접 호출하고 싶다면 참조 전체를 괄호로 둘러싼 다음 인자를 지정해야 한다.
fun max(a: Int, b: Int) = if (a > b) a else b
fun main() {
println((::max)(1, 2)) //2
println(::max(1, 2)) // error
}
코틀린 프로퍼티에 대한 호출 가능 참조를 만들 수도 있다.
이런 참조 자체는 실제로는 함숫값이 아니고, 프로퍼티 정보를 담고 있는 리플렉션 객체다.
이 객체의 getter 프로퍼티를 사용하면 게터 함수에 해당하는 함숫값에 접근할 수 있다. var 선언의 경우에는 리플렉션 객체의 setter 프로퍼티를 통해 세터 함수에 접근할 수 있다.
class Person(var firstName: String, var familyName: String)
fun main() {
val person = Person("John", "Doe")
val readName = person::firstName.getter // 게터 참조
val writeFamily = person::familyName.setter // 세터 참조
println(readName()) // John
writeFamily("Smith")
println(person.familyName) // Smith
}
5. 인라인 함수와 프로퍼티
- 람다를 사용하면 유연성이 좋은 프로그램을 작성 가능하나, JVM에서는 객체로 생성 및 람다가 사용되는 곳에서 마다 메모리 할당을 수행하므로 메모리 소비가 심하다.
- 위와 같은 상황에 람다 사용 부담을 없애는 인라인(inline)이라는 최적화 방법을 제공한다
- 인라인을 사용하면 람다의 객체 사용과 변수의 메모리 할당을 JVM이 하지 않아도 된다.
- 인라인을 사용하려면 아래 코드와 같이 inline 키워드를 앞에 추가만 해주면 된다.
정수 배열에서 주어진 술어(predicate)를 만족하는 값을 찾는 함수가 있다.
inline fun indexOf(numbers: IntArray, condition: (Int) -> Boolean): Int {
for (i in numbers.indices) {
if (condition(numbers[i])) return i
}
return -1
}
fun main() {
println(indexOf(intArray(4, 3, 2, 1)) { it < 3 }) // 2
}
인라인 키워드를 추가하면 indexOf가 호출될 때 람다가 객체로 전달되지 않는다. (코틀린 컴파일러가 바이트코드를 생성 시 람다 코드가 포함된 함수를 전체 복사 후
이 함수를 호출하는 코드에 붙여 넣기 하여 교체하기 때문이다.)
컴파일러는 인라인 함수 호출을 함수의 본문으로 대체한다. main() 함수가 다음과 같이 번역된다는 뜻이다.
fun main() {
val numbers = intArray(4, 3, 2, 1)
var index = -1
for (i in numbers.indices) {
if (numbers[i] < 3) {
index = i
break
}
}
println(index)
}
인라인 함수를 쓰면 컴파일된 코드의 크기가 커지지만 잘 쓰면 성능을 크게 높일 수 있다. 특히 대상 함수가 상대적으로 작은 경우 성능이 크게 향상된다.
인라인이 될 수 있는 람다를 사용해 할 수 있는 일은 람다를 호출하거나 다른 인라인 함수에 인라인이 되도록 넘기는 두 가지 경우 뿐이다.
inline 변경자가 붙은 함수 뿐만 아니라 함수의 파라미터로 전달된 함수값도 인라인 되기 때문이다.
val lastAction: () -> Unit = {}
inline fun runAndMemorize(action: () -> Unit) {
action()
lastAction = action // Error
}
inline fun forEach(a: IntArray, action: ((Int) -> Unit)?) { // Error
if (action == null) return
for (n in a) action(n)
}
이러한 이유로 인라인 함수가 널이 될 수 있는 함수 타입의 인자를 받을 수도 없다. 이런 경우 noinline 변경자를 붙이면 가능하다.
inline fun forEach(a: IntArray, noinline action: ((Int) -> Unit)?) { // Error
if (action == null) return
for (n in a) action(n)
}
공개 인라인 함수에 비공개 멤버를 넘길 수 없다. 인라인 함수의 본문이 호출 지점을 대신하게 되므로, 외부에서 캡슐화를 깰 수 있기 때문이다.
class Person(private val firstName: String, private val familyName: String) {
inline fun sendMessage(message: () -> String) {
println("$firstName $familyName ${message()}") // Error
}
}
뒷받침하는 필드가 없는 프로퍼티와 프로퍼티 접근자를 인라인할 수도 있다. 프로퍼티 자체를 인라인하게 되면 게터와 세터 모두 인라인하게 된다.
class Person(val firstName: String, val familyName: String) {
var fullName1
inline get() = "$firstName $familyName" // inline 게터
set(value) { ... } // inline 세터
inline var fullName2 // inline 게터와 세터
get() = "$firstName $familyName"
set(value) { ... }
inline var age = 0 // error: 뒷받침하는 필드가 있다.
}
6. 비지역적 제어 흐름
람다 자체로부터 제어 흐름을 반환하고 싶다면 break나 continue에 대해 레이블을 사용했던 것처럼, return문에 문맥 이름을 추가하면 된다.
val action: (Int) -> Unit = myFun@ {
if (it < 2 || it > 3) return@myFun
println(it)
}
람다를 고차함수의 함수의 인자로 넘기는 경우에는 레이블을 선언하지 않아도 함수 이름을 문맥으로 사용할 수 있다.
forEach(intArrayOf(1, 2, 3, 4)) {
if (it < 2 || it > 3) return@forEach
println(it)
|
람다가 인라인될 경우에는 인라인된 코드를 둘러싸고 있는 함수에서 반환할 때 return 문을 사용할 수 있다. 둘러싸고 있는 함수를 반환되는 것이므로 사용에 유의해야 한다.
inline fun forEach(a: IntArray, action: (Int) -> Unit) { ... }
fun main() {
forEach(intArray(1, 2, 3, 4)) {
if (it < 2 || it > 3) return // main에서 반환됨
println(it)
}
}
고차 함수가 인라인이 될 수 있는 람다를 받는데, 이 고차 함수의 본문에서 람다를 직접 호출하지는 않고 지역 함수나 지역 클래스의 메서드 등의 다른 문맥에서 간접적으로 호출할 수도 있다. 이런 경우에도 람다를 인라인할 수는 있지만, 인라인을 한 이후 람다에서 사용하는 return 문이 고차 함수를 호출하는 쪽의 함수를 반환시킬 수는 없다.
inline fun forEach(a: IntArray, action: (Int) -> Unit) = object {
fun run() {
for (n in a) {
action(n) // error
}
}
}
이런 호출을 허용하려면 함수형 파라미터 앞에 crossinline 변경자를 붙여야 한다. 이 변경자는 함숫값을 인라인시키도록 남겨두는 대신 람다 안에서 비지역 return을 사용하지 못하게 막는 역할을 한다.
inline fun forEach(
a: IntArray, crossinline action: (Int) -> Unit
) = object {
fun run() {
for (n in a) {
action(n) // Ok
}
}
}
fun main() {
forEach(intArrayOf(1, 2, 3, 4)) {
if (it < 2 || it > 3) return // Error
println(it)
}
}