728x90
반응형
✅ 고차 함수 정의
- 고차 함수란 다른 함수를 인자로 받거나 함수를 반환하는 함수를 말한다.
- 코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있다.
- 따라서 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나 람다나 함수 참조를 반환하는 함수이다.
- 이번 절에서는 이러한 고차 함수를 정의하는 방법에 대해 살펴보며 정의하기 전에 함수 타입에 대해 먼저 알아보자.
📌 함수 타입
- 람다를 인자로 받는 함수를 정의하려면 먼저 람다 인자의 타입을 어떻게 선언할 수 있는지 알아야 한다.
- 인자 타입을 정의하기 전에 더 단순한 경우로 람다를 로컬 변수에 대입하는 경우를 살펴보자.
- 코틀린의 타입 추론으로 인해 변수 타입을 지정하지 않아도 람다를 변수에 대입할 수 있다.
- 각 변수에 구체적인 타입 선언을 추가해 어떻게 되는지 살펴보자.
val sum: (Int, Int) -> Int = {x, y -> x + y} // Int 파라미터를 2개 받아서 Int 값ㅇ을 반환하는 함수
val action: () -> Unit = { println(42) } // 아무 인자도 받지 않고 아무 값도 반환하지 않는 함수
- 함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고, 그 뒤에 화살표( -> )를 추가한 다음, 함수의 반환 타입을 지정하면 된다.
- 위의 예제에서 Unit 타입은 리턴 값이 없는 void와 같은 타입이다.
- 그냥 함수를 정의한다면 함수의 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만, 함수 타입을 선언할 때는 반환 타입(Unit)을 명시해줘야 한다.
- 이렇게 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있기 때문에 람다 식 안에 파라미터 타입을 적을 필요가 없다.
- { x, y -> x + y } 처럼 x와 y의 타입을 생략해도 된다.
- 다른 함수와 마찬가지로 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정할 수 있다.
var canReturnNull: (Int, Int) -> Int?= { x, y => null }
- 물론 널이 될 수 있는 함수 타입 변수를 정의할 수도 있다.
- 다만 함수의 반환 타입이 아닌 함수 타입 전체가 널이 될 수 있는 타입임을 선언하기 위해 함수 타입을 괄호로 감싸고 그 뒤에 물음표를 붙여야만 한다.
var funOrNull: ((Int, Int) -> Int)? = null
- 또한, 함수 타입에서 파라미터 이름을 지정할 수도 있다.
fun performRequest (
url: String,
callback: (code: Int, content: String) -> Unit // 함수 타입의 각 파라미터에 이름을 붙임
) {
/* ... */
}
val url = "http://kotl.in"
performRequest(url) { code, content -> /* ... */ } // API에서 제공하는 이름을 람다에 사용할 수 있다.
performRequest(url) { code, page -> /* ... */ } // 하지만 그냥 원하는 다른 이름을 붙여도 된다.
- 파라미터 이름은 타입 검사 시 무시되기 때문에 함수 타입의 람다를 정의할 때 파라미터 이름이 꼭 함수 타입 선언의 파라미터 이름과 일치하지 않아도 된다.
- 가독성 좋은 이름을 선언해 사용이 가능하다.
📌 인자로 받은 함수 호출
- 앞서 함수 타입을 선언하는 방법에 대해서 배웠다. 이제는 고차 함수를 어떻게 구현하는지 살펴보자.
/* 간단한 고차 함수 정의하기 */
fun twoAndThree(operation: (Int, Int) -> Int) { // 함수 타입인 파라미터를 선언한다.
val result = operation(2, 3) // 함수 타입인 파라미터를 호출한다.
println("The result is $result")
}
twoAndThree { a, b -> a + b }
// The result is 5
twoAndThree { a, b -> a * b }
// The result is 6
- 인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같다.
- 함수 이름 뒤에 괄호를 붙이고 괄호 안에 원하는 인자를 콤마(,)로 구분한다.
- 더 흥미로운 예제로 표준 라이브러리 함수인 filter를 다시 구현해보자.
- 예제를 단순히 하기 위해 String에 대한 filter를 구현한다.
- 위의 예시에서 filter 함수는 술어를 파라미터로 받는다.
- predicate 파라미터는 Char를 파라미터로 받고 Boolean 결과 값을 반환한다.
- 술어는 인자로 받은 문자가 filter 함수가 돌려주는 결과 문자열에 남아 있기를 바라면 true를 반환하고 문자열에서 사라지기를 바라면 false를 반환하면 된다.
- 아래 예제를 통해 위 예시의 구현 방법을 살펴보자.
/* filter 함수를 단순하게 만든 버전 구현하기 */
fun String.filter(predicate: (Char) -> Boolean): String {
val sb = StringBuilder()
for (index in 0 until length) {
val element = get(index)
if(predicate(element)) sb.append(element) // "predicate" 파라미터로 전달받은 함수를 호출
}
return sb.toString()
}
println("ab1c".filter { it in 'a'..'z' }) // 람다를 "predicate" 파라미터로 전달
// abc
📌 자바에서 코틀린 함수 타입 사용
- 함수 타입을 사용하는 코틀린 함수를 자바에서도 쉽게 호추랗ㄹ 수 있다.
- 자바 8 람다를 너김녀 자동으로 함수 타입의 값으로 변환된다.
/* 코틀린 선언 */
fun procssTheAnswer(f: (Int) -> Int) {
println(f(42))
}
/* 자바 */
processTheAnswer(number -> number + 1);
// 43
- 위와 같이 자바에서 코틀린의 확장 함수를 쉽게 호출할 수 있다.
- 하지만 수신 객체를 확장 함수의 첫 번째 인자로 명시적으로 넘겨야하므로 코틀린에서 확장 함수를 호출할 때처럼 코드가 깔끔하지는 않다.
- 또한 아래 예제와 같이 반환 타입이 Unit인 함수나 람다를 자바로 작성할 수도 있다.
- 하지만 코틀린 Unit 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야 한다.
- (String) -> Unit처럼 반환 타입이 Unit인 함수 타입의 파라미터 위치에 void를 반환하는 자바 람다를 넘길 수는 없다.
/* 자바 */
List<String> strings = new ArrayList();
strings.add("42");
CollectionsKt.forEach(strings, s -> { // strings는 확장 함수의 수신 객체로 코틀린 표준 라이브러리에서
System.out.println(s); // 가져온 함수를 자바 코드에서 호출할 수 있다.
return Unit.INSTANCE; // Unit 타입의 값을 명시적으로 반환해야 한다.
});
📌 디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터
- 파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다.
- 예제를 통해 함수 타입 파라미터의 디폴트 값이 유용한 경우를 살펴보자.
- 다음은 하드 코딩을 통해 toString 사용 관례를 따르는 joinToString 예제이다.
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
posfix: String = ""
) : String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(element) // 기본 toString 메소드를 사용해 객체를 문자열로 변환
}
result.append(postfix)
return result.toString()
}
- 위의 구현은 유연할 수는 있지만 기본동작으로 충분한 경우에는 매번 람다를 넘기게 된다는 문제가 있다.
- 이러한 문제를 해결하기 위해선 함수 타입의 파라미터에 대한 디폴트 값을 지정하면 된다.
- 디폴트 값으로 람다 식을 넣기만 하면 된다.
/* 함수 타입의 파라미터에 대한 디폴트 값 지정하기 */
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
posfix: String = "",
transform: (T) -> String = { it.toString() } // 함수 타입 파라미터를 선언하면서 람다를 디폴트 값으로 지정
) : String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(transform(element)) // "transform" 파라미터로 받은 함수를 호출한다.
}
result.append(postfix)
return result.toString()
}
val letters = listOf("Alpha", "Beta")
println(letters.joinToString()) // 디폴트 변환 함수를 사용
// Alpha, Beta
println(letters.joinToString { it.toLowerCase() }) // 람다를 인자로 전달
// alpha, beta
println(letters.joinToString(separator="! ", postfix = "! ",
... transform = { it.toUpperCase() })) // 이름 붙은 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달
// ALPHA! BETA!
- 다른 접근 방법으로 널이 될 수 있는 함수 타입을 사용할 수도 있다.
- 널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없다는 점에 유의해야 한다.
- invoke 메소드를 활용하면 짧게 구현이 가능하며, 일반 메소드처럼 invoke도 안전 호출 구문으로 callback?.invoke() 처럼 호출할 수 있다.
/* 널이 될 수 있는 함수 타입 파라미터를 사용하기 */
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
posfix: String = ""
transform: ((T) -> String)? = null // 널이 될 수 있는 함수 타입의 파라미터를 선언
) : String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
val str = transform?.invoke(element) // 안전 호출을 사용해 함수를 호출
?: element.toString() // 엘비스 연산자를 사용해 람다를 인자로 받지 않은 경우를 처리
result.append(str)
}
result.append(postfix)
return result.toString()
}
📌 함수를 함수에서 반환
- 함수가 함수를 반환할 필요가 있는 경우보다 함수가 함수를 인자로 받아야 할 필요가 있는 경우가 훨씬 많다.
- 하지만 함수를 반환하는 함수도 유용하게 사용할 수 있다.
- 아래 예시와 같이 프로그램의 상태나 다른 조건에 따라 달라질 수 있는 로직이 있다고 생각해보자.
- 사용자가 선택한 배송 수단에 따라 배송비를 계산하는 방법이 달라질 수 있다.
- 이럴 때 적절한 로직을 선택해서 함수로 반환하는 함수를 정의해 사용할 수 있다.
- 다음 코드는 예시에 대해 표현한 것이다.
/* 함수를 반환하는 함수 정의하기 */
enum class Delivery { STANDARD, EXPEDITED }
class Order(val itemCount: Int)
fun getShippingCostCalculator(
delivery: Delivery) : (Order) -> Double { // 함수를 반환하는 함수를 선언
if(delivery == Delivery.EXPEDITED) {
return { order -> 6 + 2.1 * order.itemCount } // 함수에서 람다를 반환
}
return order -> 1.2 * order.itemCount } // 함수에서 람다를 반환
}
val calculator = getShippingcostCalculator(Delivery.EXPEDITED)
println("Shipping costs ${calculator(Order(3))}") // 반환받은 함수를 호출
// Shipping costs 12.3
- 이처럼 다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 한다.
- 함수를 반환하려면 return 식에 람다나 멤버 참조나 함수 타입의 값을 계산하는 식 등을 넣으면 된다.
📌 람다를 활용한 중복 제거
- 함수 타입과 람다 식은 재활용하기 좋은 코드를 만들 때 사용하기에 좋은 도구이다.
- 람다를 사용할 수 없는 환경에서는 아주 복잡한 구조를 만들어야만 피할 수 있는 코드 중복도 람다를 활용하면 간결하고 쉽게 제거할 수 있다.
- 웹사이트 방문 기록을 분석하는 예시를 살펴보자.
- 먼저 SiteVisit 데이터 클래스에는 방문한 사이트의 경로, 사이트에 머문 시간, 사용자의 운영체제가 들어있다.
- 이때, 운영체제의 경우 enum을 사용해 표현한다.
/* 사이트 방문 데이터 정의 */
data class SiteVisit(
val path: String,
val duration: Double,
val os: OS
)
enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }
val log = listOf(
SiteVisit("/", 34.0, OS.WINDOWS),
SiteVisit("/", 22.0, OS.MAC),
SiteVisit("/login", 12.0, OS.WINDOWS),
SiteVisit("/signup", 8.0, OS.IOS),
SiteVisit("/", 16.3, OS.ANDROID)
)
- 윈도우 사용자의 평균 방문 시간을 출력하고 싶은 경우 average 함수를 사용하면 쉽게 작업을 수행할 수 있다.
/* 사이트 방문 데이터를 하드 코딩한 필터를 사용해 분석하기 */
val averageWindowsDuration = log
.filter { it.os == OS.WINDOWS }
.map(SiteVisit::duration)
.average()
println(averageWindowsDuration)
// 23.0
- 이제 맥 사용자에 대한 같은 통계를 구하고 싶다. 이때, 중복을 피가히 위해 OS를 파라미터로 뽑아낼 수 있다.
/* 일반 함수를 통해 중복 제거하기 */
fun List<SiteVisit>.averageDurationFor(os: OS) = // 중복 코드를 별도 함수로 추출
filter { it.os == os }.map(SiteVisit::duration).average()
println(log.averageDurationFor(OS.WINDOWS))
// 23.0
println(log.averageDurationFor(OS.MAC))
// 22.0
- 위와 같이 함수를 확장으로 정의함에 따라 가독성이 좋아진 것을 볼 수 있다.
- 이 함수가 어떤 함수 내부에서만 쓰인다면 이를 로컬 확장 함수로 정의 할 수도 있다.
- 하지만 이 함수는 충분히 강력하지는 않다.
- 모바일 디바이스(Android, iOS)의 평균 방문 시간을 구하는 방법까지 고려하려면 어떻게 해야할까?
/* 복잡하게 하드코딩한 필터를 사용해 방문 데이터 분석하기 */
val averageMobileDuration = log
.filter { it.os in setOf(OS.IOS, OS.ANDROID) }
.map(SiteVisit::duration)
.average()
println(averageMobileDuration)
// 12.15
- 플랫폼을 표현하는 간단한 파라미터로는 위의 상황을 처리하기는 쉽지 않다.
- 게다가 "iOS 사용자의 /signup 페이지 평균 방문 시간은?"과 같이 더 복잡한 질의를 사용해 방문 기록을 본석하고 싶을 때도 처리하기 어렵게 된다.
- 이럴 때 람다가 유용하게 쓰인다.
- 함수 타입을 사용하면 필요한 조건을 파라미터로 뽑아낼 수 있다.
/* 고차 함수를 사용해 중복 제거하기 */
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
filter(predicate).map(SiteVisit::duration).average()
println(log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) })
// 12.15
println(log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })
// 8.0
- 예제에서 확인할 수 있듯이 코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다.
- 코드의 일부분을 복사해 붙여넣고 싶은 경우가 있다면 그 코드를 람다로 만들면 중복을 제거할 수 있을 것이다.
- 변수, 프로퍼티, 파림터 등을 사용해 데이터의 중복을 없앨 수 있는 것처러 람다를 사용하면 코드의 중복을 없앨 수 있다.
728x90
반응형