Kotlin

코틀린 - 고차함수와 람다함수

sh1mj1 2022. 8. 29. 15:19

코틀린은 함수형 프로그래밍을 지원하는 언어이다.

이것은 계속해서 알고 있던 사실이다. 그런데 함수형 프로그래밍이 도대체 무엇이냐고 하면 쉽게 대답하지 못했다....

그래서 함수형 프로그래밍에 대해 알아보고 코틀린에서의 강력한 기능인 고차함수람다함수를 알아보도록 할 것이다.

함수형 프로그래밍

함수형 프로그래밍(functional programming)이란, 자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다. —(위키백과)

즉, 부수효과가 없는 순수 함수를 1급 객체로 간주하여 패러미터로 넘기거나 반환값으로 사용할 수 있으며 참조 투명성을 지킬 수 있다.

참고로 1급 객체는 아래 특징을 가진다.

  1. 일급 객체는 함수의 매개변수가 될 수 있다.
  2. 일급 객체는 함수의 return 값이 될 수 있다.
  3. 일급 객체를 변수에 (=) 값을 할당할 수 있다.
  4. 일급 객체는 고유한 구별이 가능하다. (equaliy 비교)

여기까지 읽어보았을 때는 쉽게 개념이 와닿지 않는다. Java 와 비교해서 생각해보면 kotlin 의 특징을 조금 더 쉽게 알 수 있을 것이다.

  • Java
    • Java 는 완벽한 객체지향 프로그래밍 언어이다. (물론 java 8 이후 함수형 프로그래밍, 람다, Stream Api 이 추가되기는 했다.)
    • 모든 코드는 class 을 설계하고 메서드를 만들어주고 클래스를 통해 객체를 생성해서 사용해야 한다.

  • kotlin
    • 반면!!! kotlin은 함수만 만들어 사용하는 것(함수형 프로그래밍)을 지원한다.
    • 그래서 코틀린은 함수 사용을 더 편리하게 사용할 수 있다.

어떠한 기능을 만들 때 항상 클래스를 만들어야 한다면 불필요한 클래스가 많아지고 추후에 코드 유지, 보수가 어려워질 것이다.

고차함수와 람다함수에 대해 알아보기 전에 먼저 함수에 대해 알아봅시다.

Function (함수)

함수는 특정한 동작을 하거나 원하는 결과값을 연산하는데 사용하는 기능이다.

Single-Expression function

함수를 더 간단하게 기술할 수 있도록 표현식을 작성하는 것은 단일 표현식 함수 (Single-Expression function) 이라고 한다.

아래 함수는 Int 타입 패러미터 3개의 합을 반환하는 아주 간단한 함수이다.

fun add (a: Int, b: Int, c: Int): Int {
    return a + b + c
}

이 함수는 아래처럼 표현할 수 있다.

fun add (a: Int, b: Int, c: Int) = a + b + c

이 때 반환값의 타입 추론이 가능하다!

코틀린에서 함수는 내부적으로 기능을 가진 형태이지만 외부에서 볼 때는 파라미터를 넣는다는 점 외에는 자료형이 결정된 변수라는 개념으로 접근하는 것이 좋다.

그래야 함수형 언어라는 코틀린의 중요한 특징을 이해하기 쉬울 것이다.

이제 진짜 고차함수와 람다함수에 대해서 알아봅시다.

Higher-Order function 고차함수

고차함수는 함수를 마치 클래스에서 만들어낸 인스턴스처럼 취급하는 것을 말한다.

이 때 함수를 패러미터로 넘겨줄 수도 있고 결과값으로 반환할 수도 있다. 그리고 코틀린에서는 모든 함수를 고차함수로 사용할 수 있다.

이렇게 말로 하면 잘 와닿지 않을 것이다.

아래 예시를 봅시다. main 이라는 함수와 함수 a, b 을 만들 것이다. 이 때 함수 a는 패러미터로 String 타입을 가지고 리턴이 없는 함수이다. 그리고 함수 b는 패러미터로 함수를 가지려고 한다.

fun main() {

}
fun a(str: String) {
    println("$str 함수 a")
}

// 함수를 패러미터로 넣고 싶을 때 자료형은 어떻게??????????
fun b(function:) 

함수 b에 패러미터로 함수 a를 넣고 싶을 때는 아래 방식으로 한다.

fun b (function: (String) -> Unit) {
    function("b가 호출함")
}

패러미터로 함수가 들어가므로 그 함수가 받는 패러미터의 타입을 나열하고 화살표 뒤에 리턴의 타입을 적어주면 된다.

위 함수에는 함수 b의 패러미터의 패러미터가 한 개만 존재하지만 만약 여러 개 여도 아래처럼 적으면 된다.

fun functionName ( function: (String, Int, String) → String ) {
    // code
}

만약 함수의 리턴이 없다면 리턴 타입은 Unit 이 된다.

그 후 main 함수에서 함수 b를 호출하는 데 b의 패러미터는 함수 a로 해보자.

fun main() {
    b(::a)
}

고차함수 형태로 넘기려면 함수 이름 앞에 콜론을 두개 붙여주어야 한다. :: 이것은 일반 함수를 고차 함수로 변경해주는 연산자이다.

  1. 메인 함수가 a함수를 b 함수의 패러미터로 넘겼고
  2. b 함수는받아온 a 함수에 b가 호출함 이라는 값을 넘겨서 a 함수를 호출한다.
  3. 최종적으로 a라는 함수가 실행되면서 “b가 호출한 함수 a” 라는 문자열이 출력되는 것을 확인할 수 있다.

그런데 위처럼 함수 a 를 패러미터로 넘기기만 할 때 사용한다면 무언가 조금 이상하다.

🤔 항상 패러미터로 넘길 함수를 굳이 이름까지 붙여서 따로 만들 필요가 있을까?????

이런 상황에서는 함수를 람다식으로 표현하는 람다함수(Lamda function)을 사용한다.

Lamda function

람다함수이름이 없어도 함수의 역할을 하는 익명 함수의 형태이다.

람다함수는 일반 함수와는 다르게 그 자체가 고차함수이기 때문에 별도의 연산자 없이도 변수에 담을 수 있다.

타입에는

함수의 패러미터타입 (→) 리턴 타입 이렇게 작성한다.

위와 똑같은 상황에서 a 함수가 c라는 람다함수가 된다면

val c: (String) -> Unit = {str: String -> println("$str 람다함수")

원래는 위처럼 등호 오른쪽에 있는 중괄호 안에서도 타입을 작성해야 하지만 c의 타입을 적을 때 패러미터의 타입을 작성했으므로(String) 중괄호 안에서는 타입을 추론해준다. 즉,

val c: (String) -> Unit = {str -> println("str: $str 람다함수")

실행해보면 동일한 구조로 문자열이 출력되는 것을 확인할 수 있따.

이를 타입 추론으로 아래처럼 더 축약해서 적을 수 있다. 함수의 형식을 적지 않고 바로 중괄호 안에 직접 패러미터의 자료형만 작성하면 패러미터와 리턴값을 자동으로 추론하여 이에 맞는 함수 형식의 객체로 변수에 저장할 수 있다.

val c = {str: String -> println("str : $str 람다함수")}

위의 예시를 보고 나면 몇가지 의문이 생길 수 있다.

🤔 람다함수는 여러 구문을 사용할 수 없나요??

람다함수도 여러 구분을 사용할 수 있다!

val c:(String) -> Unit = { str ->
        println("$str 람다함수")
        println("여러 구문을")
        println("사용 가능합니다.")

참고로 람다함수가 여러줄이 되는 경우, 마지막 줄의 결과값이 리턴된다.

val calculate: (Int, Int) -> Int = {a, b ->
        println(a)
        println(b)
        a + b
}

🤔 함수가 들어갈 때 만약 패러미터가 없으면 어떻게 하나요??

패러미터가 없는 람다함수는 실행할 구문만 나열하면 된다!

val a: () -> Unit = {println("There's no parameters")}

그리고 아래에서 배울 Scope function 에서 중요한 부분에 대해서도 알아둡시다.

  1. 패러미터가 하나뿐이라면 it을 사용한다!!.
val c: (String) -> Unit = {println("$it 람다함수")}

고차함수와 람다함수를 사용하면 함수를 일종의 변수로 사용할 수 있다는 편의성도 있지만 아래에서 적을 스코프 함수의 사용에 도움이 된다. 또한 컬렉션의 조작에도 도움이 된다.

fun main() {
    println("1번")
    b(::a)
    println("2번 람디식으로 해본 것.")
    val c: (String) -> Unit = {str -> println("str : $str . 람다함수")}
    b(c)
}

fun a (str: String) {
    println("str: $str .함수 a")
}

fun b (function: (String) -> Unit) {
    function("b가 호출함")
}

Scope function

위에서 람다함수에 대해 배울 때 계속해서 Scope function 에 대해 언급했는데 그래서 도대체 스코프 함수가 뭔데??

코틀린 공식문서부터 찾아보자.

The Kotlin standard library contains several functions whose sole purpose is to execute a block of code within the context of an object. When you call such a function on an object with a lambda expression provided, it forms a temporary scope. In this scope, you can access the object without its name. Such functions are called scope functions. There are five of them: let, run, with, apply, and also


코틀린 표준 라이브러리는 객체의 컨텍스트 내에서 코드 블록을 실행하는 것을 유일한 목적으로 하는 여러 함수를 포함한다. 람다식이 제공된 개체에서 이러한 함수를 호출하면 임시 범위가 형성된다. 이 범위에서는 개체 이름 없이 개체에 엑세스할 수 있다. 이를 스코프 함수라고 한다.....

이렇게 먼저 정의만 살펴보니 와닿지 않는다.

  • 스코프 함수는 람다함수를 이용한 코틀린의 특별한 함수이다.
  • 이는 함수형 언어의 특징을 더 편리하게 사용할 수 있도록 기본 제공하는 함수이다.
  • 클래스의 인스턴스를 스코프 함수로 전달하면 인스턴스의 속성이나 함수를 더 깔끔하고 편하게 사용할 수 있다.

이 정도만 알아둔 채로 다섯 가지 apply, run, with, also, let 의 스코프 함수를 구체적으로 알아 보면 위 정리가 이해가 될 것이다.

💡 이 다섯 개의 함수들은 꽤 비슷한 기능을 수행하지만 두가지 주요 차이가 존재한다.

Context 객체를 나타내는 말이 this 인지 it 인지.

반환값이 무엇인지가 그 차이이다.

apply

apply는 인스턴스를 생성한 후 변수에 담기 전에 초기화 과정을 수행하는 스코프 함수이다.

Book 이라는 클래스를 만들어 생성자로 이름, 가격을 받고 discount 라는 함수가 가격을 2000원 낮춰준다고 해보자.

class Book(var name: String, var price: Int) {
    fun discount(){
        price -= 2000
    }
}

main에서 책을 만드는데 이 때 책 이름에 [초특가] 라는 문자열을 앞에 추가해주고, discount()을 바로 실행해보자.

기존의 경우

fun main() {
    var a = Book("책 이름~" , 10000)
    a.name = "[초특가]" + a.name
    a.discount
}

이런 식으로 한 작업마다 한 줄 한 줄 작성해 주어야 한다.

하지만 apply을 사용하여 인스턴스를 생성하자마자 참조연산자를 사용하여 apply을 붙이고 중괄호로 람다함수를 만들어서 조작할 수 있다.

apply인스턴스 자신을 다시 반환하므로 생성되자마자 조작된 인스턴스를 변수에 바로 넣어줄 수 있다.

var a = Book("bookTitle", 10000).apply{
    name = "[초특가]" + name
    discount()
    // this 생략 가능. this가 인스턴스 자신임.
    // this.name = "[초특가]" + this.name
    // this.discount()
}
  • apply의 Context object : this. (생략 가능)
  • return 은 인스턴스 자신.

👍 apply와 같은 스코프 함수를 사용하면 main 함수와 별도의 scope에서 인스턴스의 변수와 함수를 조작하므로 코드가 깔끔해진다는 장점이 있다.

run

apply처럼 run 스코프 안에서 참조연산자를 사용하지 않아도 된다는 것은 같지만 일반 람다함수처럼 인스턴스 대신

마지막 구문에 결과값을 반환한다는 차이가 있다.

따라서 이미 인스턴스가 만들어진 후, 인스턴스의 함수나 속성을 scope 내에서 사용해야 할 때 유용하다.

a.run{
        println("name: ${name}, price: ${price}")
//        println("name: ${this.name}, price: ${this.price}")
// 이 경우에도 this 생략 가능. this는 인스턴스 자신임.
}
  • run 의 Context object : this. (생략 가능)
  • return 은 마지막 코드

with

run과 동일한 기능을 가지지만 단지 인스턴스를 참조연산자 대신 패러미터로 받는다는 차이만 존재한다.

a.run {
        ...
}
with(a) {
        ...
        println("name: ${this.name}, price: ${this.price}")
//        println("name: ${name}, price: ${price}")
}
  • with 의 Context object : this. (생략 가능)
  • return 은 마지막 코드

also & let

alsoapply와 매우 비슷하게 처리가 끝나면 인스턴스 자신을 반환한다.

letrun과 비슷하게 처리가 끝나면 최종값을 반환한다.

이 때 차이는

applylet 은 참조연산자를 this로 (혹은 this을 생략하여) 인스턴스의 변수와 함수를 사용할 수 있었다면

also 는 마치 패러미터로 인스턴스를 넘긴 것처럼 it 을 사용해서 인스턴스를 사용할 수 있다.

이는 같은 이름의 변수나 함수가 scope 바깥에 중복되어 있는 경우 혼란을 방지하기 위함이다.

테스트하기 위해서 아래처럼 main 함수 내에 Book 클래스의 속성 이름과 같은 이름의 변수 price 를 선언하고 5000 을 할당해보자.

fun main() {

    var price = 5000 // 함수 내에서 선언한 변수 price

    var a = Book("책 이름~~", 10000).apply {
       name = "[초특가]" + name
        discount()
    }

    a.run{
                println("이 코드는 마지막 코드가 아닙니다. 출력이 안될 겁니다.")
        println("name: ${name}, price: ${price}")
    }

}

class Book(var name: String, var price: Int) {

    fun discount(){
        price -= 2000
    }

}

name: [초특가]책 이름, price: 5000


😮?? 우리는 분명히 a 라는 객체에 10000 - 2000 을 하도록 discount() 를 실행했는데 왜 결과는 5000 이 나오지?

5000 이라는 값은 main 함수에서 만든 변수 값이다.

run 함수가 인스턴스 내의 price 속성보다 run이 속해있는 main 함수의 price 변수를 우선시 하고 있기 때문이다.

이 때는 run에서 name 대신 this.name, price 대신 this.name 로 대신해주면 인스턴스의 값이 정상적으로 출력된다.

혹은 run을 대체하는 let을 사용하고 name 대신 it.name , price 대신 it.price로 대신해주면 인스턴스의 값이 정상적으로 출력되는 것을 볼 수 있다.

fun main() {

    var name = "O__O"
    var price = 5000 // Book 클래스의 속성 이름과 같은 이름의 변수 price 

    var a1 = Book("책 이름~~", 10000).apply {
       name = "[초특가]" + name
       discount()
    }

    var a2 = Book("책 이름~~", 10000).apply {
       this.name = "[초특가]" + this.name
       discount()
    }    

    a1.run{
                println("이 코드는 마지막 코드가 아닙니다. 출력이 안될 겁니다.")
        println("apply에서 this 없이, run에서 this 사용 ---- name: ${this.name}, price: ${this.price}")
    }

    a1.run{
                println("이 코드는 마지막 코드가 아닙니다. 출력이 안될 겁니다.")
        println("apply에서 this 없이, run 에서 this 없이 ---- name: ${name}, price: $price}")
    }

    a2.run{
                println("이 코드는 마지막 코드가 아닙니다. 출력이 안될 겁니다.")
        println("apply에서 this 사용, run 에서 this 사용 ---- name: ${this.name}, price: ${this.price}")
    }

    a2.run{
                println("이 코드는 마지막 코드가 아닙니다. 출력이 안될 겁니다.")
        println("apply에서 this 사용, run 에서 this 없이 ---- name: ${name}, price: $price}")
    }


}

class Book(var name: String, var price: Int) {

    fun discount(){
        price -= 2000
    }

}

apply 에서 this 없이, run 에서 this 사용 - name: 책 이름, price: 8000
apply 에서 this 없이, run 에서 this 없이 - name: [초특가]O_O, price: 5000}
apply 에서 this 사용, run 에서 this 사용 - name: [초특가]책 이름, price: 8000
apply 에서 this 사용, run 에서 this 없이 - name: [초특가]O_O, price: 5000}


이러한 경우 this을 실수로 생략하거나 할 때 문제가 생길 수 있다. 위처럼 하나만 달라져도 결과가 완전히 다르게 나오는 것을 볼 수 있다.

letalso 의 경우 Context object 가 it 이다. 그리고 이것은 생략할 수 없다. 그러므로 개발 도중 의도하지 않게 다른 변수를 참조하지 않을 수 있다.

apply도 같은 경우가 발생한다면 also로 대체하여 사용하면 된다.

var a3 = Book("책 이름~~", 10000).also {
        it.name = "[초특가]" + it.name
        it.discount()
    }

a3.let{
                println("이 코드는 마지막 코드가 아닙니다. 출력이 안될 겁니다.")
        println("also 와 let 사용 ==== name: ${it.name}, price: ${it.price}")
}

 

  • also
    • Context object : this
    • return : 자기 자신.
  • let
    • Context object : it
    • return : 마지막 코드

정리

Function Object reference Return value Is extension function
let it Lambda result Yes
run this Lambda result Yes
run - Lambda result No: called without the context object
with this Lambda result No: takes the context object as an argument.
apply this Context object Yes
also it Context object Yes

스코프 함수는 인스턴스의 속성이나 함수를 scope 내에서 깔끔하게 분리하여 사용할 수 있다는 점 때문에 코드의 가독성을 향상시킨다는 장점이 있다!!!

실제 개발에서 이 스코프 함수를 어떻게 자주 활용하는지 살펴보자.

스코프 함수의 활용

let

  • let 은 null 이 아닐 때만 실행한다.
  • 그래서 보통 safe call operator (?.) 와 함께 사용된다.
fun main() {

    val str1: String? = "Hello"
    var str2: String? = null
    val len1 = str1?.let {
        println("let() called at $it")
        it.length
    }
    println("str1 의 길이 : $len1")

    val len2 = str2?.let {
        println("let() called at $it")
        it.length
    }
    println("str2 의 길이 : $len2")

}

let() called at Hello
str1 의 길이 : 5
str2 의 길이 : null


str2 는 null 이기 때문에 내부 과정들이 실행되지 않는다.

run

  • let 과 마찬가지로 null-safe 하기 때문에 safe call operator(?.) 와 함깨 사용하는 경우가 많다.
val str1: String? = "Hello"
    var str2: String? = null
    val len1 = str1?.run {
        println("let() called at $this")
        length // this 생략
    }
    println("str1 의 길이 : $len1")

    val len2 = str2?.run {
        println("let() called at $this")
        length // this 생략
    }
    println("str2 의 길이 : $len2")

let() called at Hello
str1 의 길이 : 5
str2 의 길이 : null


마찬가지로 str2 는 null 이기 때문에 len2 안에 println 구문이 실행되지 않는다.

apply

  • Intent 에 extra 를 넣는 등 주로 속성 등을 할당할 때 사용하게 된다.
val intent = Intent().apply {
    putExtra("Key", "value")
    putExtra("Key1", "value1")
}

also

  • 주로 다른 함수나 표현식 등의 결과에 추가 작업을 할 때 사용한다. 거의 apply 와 같다.
var formattedPrice = ""

    val price = 10.plus(5).also{
        formattedPrice = "Price is ${it * 2}"
    }
    println("formattedPrice: $formattedPrice")
    println("price : $price")

formattedPrice: Price is 30
price : 15


with

  • 주로 binding을 떼기 위해 사용한다.
binding.name.text = person.name
binding.age.text = person.age
binding.city.text = person.age
// 👇

with(binding){
    name = person.name
    age = person.age
    city = person.city
}

만약 프래그먼트에서 binding을 하게 되면 주로 아래와 같이 nullable 한 값으로 된다.

private var binding: FragmentMainBinding? = null

이 경우 with 를 사용하게 되면 null 체크를 하기 위해 모든 곳에 ? 를 붙여야 한다.

with(binding) {
    this?.root?.isVisible = true
}

이때는 let 이나 run 을 사용하면 편하다.

// let 사용
binding?.let {
    it.root.isVisible = true 
        // 이 경우 안드로이드 스튜디오에서 자동완성을 해줄 때도 있음. (option + Enter)
}

// run 사용
binding?.run {
    root.isVisible = true
}

 

함수형 프로그래밍 언어는 앞으로 계속 많이 쓰일 언어이며 함수형 언어를 사용하는 개발자의 연봉이 항상 상위권이라고 한다.

(물론 코틀린은 상위권은 아니지만....)

앞으로 코틀린의 함수형 프로그래밍 특징도 공부해 나갑시다. 화이팅

 

참고한 링크

https://www.youtube.com/watch?v=eMfQycxjAsg&list=PLQdnHjXZyYadiw5aV3p6DwUdXV2bZuhlN&index=4

https://www.youtube.com/watch?v=mvfU-7tdLWs&list=PLQdnHjXZyYadiw5aV3p6DwUdXV2bZuhlN&index=14

https://youtu.be/QGDWWL6qA3I?list=PLQdnHjXZyYadiw5aV3p6DwUdXV2bZuhlN

https://kotlinlang.org/docs/scope-functions.html

https://proandroiddev.com/dont-abuse-kotlin-s-scope-functions-6a7480fc3ce9

https://latte-is-horse.tistory.com/295