본문 바로가기

코틀린

코틀린(Kotlin) - 고차 함수 정의

고차 함수

고차 함수는 다른 함수를 파라미터로 받거나 함수를 반환하는 함수입니다.

즉, 람다나 함수 참조를 파라미터로 받거나 람다나 함수 참조를 반환하는 함수입니다.

 

예를 들면 filter 함수는 Boolean값을 반환하는 람다식을 파라미터로 받고 있기 때문에 고차 함수입니다.

 

list.filter { it > 0 }

/* filter 함수 Body */
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
   return filterTo(ArrayList<T>(), predicate)
}

 

이 외에도 map, with 등의 여러 고차 함수들을 이미 알고 계실 텐데요, 이제는 그런 고차 함수를 정의하는 방법에 대해 살펴보도록 하겠습니다.

 

함수 타입

람다를 파라미터로 받는 함수를 정의하려면 먼저 람다 파라미터의 타입을 어떻게 선언할 수 있는지 알아야 합니다.

파라미터의 타입을 정의하기 전에 더 간단한 방법으로 람다를 지역 변수에 대입하는 경우로 한번 살펴보도록 하겠습니다.

코틀린의 타입 추론으로 덕분에 변수의 타입을 지정하지 않더라도 람다를 변수에 대입할 수 있기 때문에, 과연 각 람다는 어떤 타입으로 추론이 되는지 살펴보겠습니다.

 

val sum = { x: Int, y: Int -> x + y } //int 파라미터를 2개 받아서 int 값을 반환하는 함수

 

 

val action = { println("") } //아무 파라미터도 받지 않고 아무 값도 반환하지 않는 함수

 

 

함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고, 그 뒤에 화살표(->)를 추가한 다음, 함수의 리턴 타입을 지정하면 됩니다.

 

알고 계시겠지만, Unit 타입은 리턴 값이 없는 자바의 void와 같은 타입입니다.

반환 값이 없는 함수를 정의할 때는 Unit 리턴 타입을 생략해도 되지만, 함수 타입을 선언할 때는 반드시 반환 타입을 명시해야 하기 때문에 반환 값이 없는 경우에는 Unit으로 타입을 적어줘야 합니다.

 

이렇게 파라미터 타입을 지정해주면, 람다식 안에서 파라미터 타입이 유추되기 때문에, 따라서 람다 식 안에서 굳이 파라미터 타입을 적어줄 필요 없이 

 

{ x, y -> x + y } //앞의 x, y 타입인 Int 생략

 

이렇게 x, y의 타입을 생략해도 됩니다.

 

다른 함수와 마찬가지로 함수 타입에서도 반환 타입을 null이 될 수 있는 타입으로도 지정할 수 있습니다.

 

var canReturnNull: (Int, Int) -> Int? = { x, y -> null }

 

함수 타입에서 파라미터명도 원하는 이름으로 지정할 수도 있습니다.

꼭 기존에 정의되어 있는 파라미터명과 일치하지 않아도 되므로, 좀 더 코드 가독성이 좋은 네이밍으로 얼마든지 자유롭게 변경이 가능합니다. 😀

 

 

파라미터로 받은 함수 호출

앞에서는 함수 타입을 선언하는 방법에 대해 알아보았고, 이제는 이렇게 전달받은 함수 파라미터를 이용해서 고차 함수를 어떻게 구현하는지 살펴보도록 하겠습니다.

 

/* 간단한 고차 함수 정의 예제 */
fun calculate(operation: (Int, Int) -> Int) {
   val result = operation(2, 3)
   println("계산 결과 = ${result}")
}

>>> calculate { a, b -> a + b }
계산 결과 = 5

>>> calculate { a, b -> a * b }
계산 결과 = 6

 

파라미터로 받은 함수를 호출하는 방식은 일반 함수를 호출하는 것과 동일합니다.

일반 함수 호출하듯 각 파라미터 위치에 해당 타입의 값을 구분해서 넣어주기만 하면 됩니다.

 

이제는 그렇다면 아까 초반에 보았던 filter 함수에 대해 다시 분석해보면 금방 원리를 파악할 수 있을 것 같습니다!

예제를 단순히 하기 위해 String에 대한 filter를 예제로 하도록 하겠습니다.

 

/* filter 함수 Body */
public inline fun String.filter(predicate: (Char) -> Boolean): String {
    return filterTo(StringBuilder(), predicate).toString()
}

 

 

filter 함수는 Boolean 함수를 파라미터로 받는 것을 확인할 수 있습니다.

predicate 파라미터는 문자(Char)를 파라미터로 받고, Boolean 값을 반환합니다.

그렇다면 이 filter 함수의 동작은 파라미터로 받은 문자(char)에 대해 우리가 람다식에 작성한 조건문에 부합한다면 true를 반환하여 filter 결괏값에 남을 것이고, 조건문에 부합하지 않는다면 false를 반환하여 걸러지게 되는 원리임을 알 수 있습니다! 😃

 

자바에서 코틀린 함수 타입 사용

자바에서도 코틀린 라이브러리가 제공하는 람다를 파라미터로 받는 확장 함수를 쉽게 호출할 수 있습니다.

다만, 수신 객체를 확장 함수의 첫 번째 파라미터로 넘겨줘야 하기 때문에 코틀린에서 확장 함수 호출할 때만큼 코드가 깔끔하지는 않습니다. 😥

 

/* 자바에서 람다를 파라미터로 하는 확장 함수 호출하는 예제 */
List<String> stringList = new ArrayList();
stringList.add("자바")

CollectionsKt.forEach(stringList, s -> {  //stringList는 확장함수의 수신 객체
   ...
   return Unit.INSTANCE;  //Unit 타입의 값을 명시적으로 리턴해줘야 합니다.
});

 

위와 같이 리턴 값이 없는 Unit인 함수나 람다를 자바에서도 작성할 수 있습니다.

하지만 코틀린 Unit 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야 합니다. 😅

 

디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터

파라미터를 함수 타입으로 선언할 때도 디폴트 값을 지정할 수 있습니다.

함수 타입 파라미터의 디폴트 값이 유용한 경우를 살펴보기 위해, 실제 프로젝트에 적용되어 있는 코드를 예제로 살펴보도록 하겠습니다.

 

/* 함수 타입의 디폴트 파라미터 지정하기 예제 */
fun setCarType(type: String?, carTypeTextView: TextView,
               transform: (String) -> String = { it }) { //람다를 디폴트 값으로 지정
    if (!type.isNullOrEmpty()) {
        val carType = when (type) {
            "lease" -> "리스승계"
            "rent" -> "렌트승계"
            else -> "일반"
        }

        carTypeTextView.text = transform(carType)
    }
}

>>> setCarType(type, carTypeTextView)
리스승계 or 렌트승계 or 일반

// "승계" 라는 단어를 없애기 위한 람다식을 파라미터로 전달
>>> setCarType(type, carTypeTextView) { carType -> carType.replace("승계", "") }
리스 or 렌트 or 일반

 

위와 같이 함수 타입에 대한 디폴트 값을 선언할 때도 다른 디폴트 파라미터들과 마찬가지로 "=" 뒤에 람다를 넣으면 디폴트 값을 선언할 수 있습니다.

 

람다를 활용한 중복 제거

람다 식은 재사용하기 좋은 코드를 만들 때 굉장히 유용합니다.

차량 매물 상세검색을 예로 살펴보겠습니다.

Car 객체에는 제조사, 모델그룹, 주행거리, 가격의 정보가 들어있고, carSearchList에는 검색한 차량 매물 리스트가 들어있습니다.

 

data class Car(
   val manufacturer: String,
   val modelGroup: String,
   val mileage: Int,
   val price: Int
)

val carSearchList = listOf(
   Car("기아", "스포티지", 97000, 1480),
   Car("벤츠", "S클래스", 49000, 9160),
   Car("아우디", "Q7", 9100, 7400),
   Car("볼보", "S90", 12300, 4890)
)

 

가격이 3,000만원 아래인 매물만 상세검색을 하고 싶습니다.

그렇다면 우리는 일반적으로 아래와 같이 코딩을 하게 될 것입니다.

 

val detailSearchList = carList.filter { car -> car.price <= 3000 }

 

만약에 가격이 8,000만원 아래이면서 주행거리가 10만 km 아래인 매물에 대해 상세검색을 하고 싶다면 어떻게 해야 할까요?

거기에 추가로 제조사가 아우디인 매물만 검색하고 싶다면 어떻게 해야 할까요?

 

이와 같이 더 복잡한 질의를 사용해서 상세검색을 하게 되는 경우가 충분히 있습니다.

이럴 때! 람다가 굉장히 유용합니다!

람다 식을 사용하면 필요한 조건을 파라미터로 전달해서 쏙 뽑아낼 수 있습니다! 👍

 

//확장함수
fun List<Car>.detailSearch(action: (Car) -> Boolean) = this.filter(action) //람다식을 파라미터로 받음

//8천만원 이하, 주행거리 10만km 이하
>>> val detailSearchList = carList.detailSearch { car -> 
      (car.price <= 8000) && (car.mileage <= 100000) }

//아우디 매물 중 8천만원 이하, 주행거리 10만km 이하
>>> val detailSearchList = carList.detailSearch { car -> 
      (car.price <= 8000) && (car.mileage <= 100000) && ("아우디" == car.manufacturer) }

 

마무리

지금까지 고차 함수를 만드는 방법을 살펴보았습니다.

고차 함수를 활용하면 전통적인 방식인 for loop와 조건문을 사용할 때보다 더 느리진 않을까? 에 대해 궁금증이 생기실 수도 있을 것 같습니다.

그래서 다음번에는 람다를 활용한다고 해서 성능이 더 느려지지는 않는다는 사실을 설명하고, inline 키워드를 통해 어떻게 람다의 성능을 개선하는지 고차 함수의 성능에 대해서 알아보도록 하겠습니다. 😀