Kotlin

확장함수와 람다

seokzoo 2022. 9. 14. 00:06
반응형

Kotlin에서는 확장함수, 람다 식, 고차함수를 적절하게 사용하면 코드가 깔끔해지고, 공통 코드 구조를 뽑아낼 수 있다.

확장 함수의 기본

확장 함수는 간단하다! 확장 함수는 어떤 클래스의 멤버 메소드인 것처럼, 즉 원래 클래스 안에 선언되어 있는 것처럼 호출이 가능하지만 사실은 그 클래스 밖에 선언된 함수다.

아주 쉬운 예제로 확인을 해보면, 한 문자열의 가장 마지막 문자를 출력해보는 예제이다.
fun String.lastChar(): Char = this.get(this.length - 1)
확장 함수는 일반 함수의 선언처럼 fun 키워드로 시작한다. 이후에 확장할 클래스 이름( 이를 수신 객체 타입이라고 한다 )을 적고 점(.) 뒤에 함수의 이름을 적어주고, 함수 답게! 괄호 ()를 넣어주고, 마지막 리턴타입을 작성해준다.

뒤에는 꼭 =이 아니여도 된다! 중괄호 ({ })로 함수의 몸체를 적어주어도 된다.
여기서 this는 수신 객체로 확장 함수가 호출되는 대상이 되는 값을 뜻한다.
즉 이 확장함수를 통해 "abcde".lastChar()이라는 코드를 실행하면, "abcde"가 수신 객체가 된다!

위의 내용을 수행하면 "abcde"length -1 , 즉 마지막 인덱스를 get하여 마지막 단어를 리턴해준다.

이 처럼 확장 함수를 사용하면 String 클래스에 없는 lastChar() 함수를 마치 String 클래스 안에 있는 것 처럼 사용이 가능하다!

또한 일반 함수 처럼 수신 객체 멤버에 this 없이 접근도 가능하다!
fun String.lastChar(): Char = get(length - 1)

이를 통해 다양한 함수를 만들어 보는 것도 재밌을 것이다! 아주 간단한 예제를 통해 어떤 함수인지 유추해보자! (물론 대부분 함수 이름을 보면 유추가 가능하다.)

1.
fun Iterable<Int>.addAll(): Int {
    var result = 0
    this.forEach {
        result += it
    }
    return result
}

val listData = listOf(1, 2, 3, 4, 5)
println(listData.addAll())

2. 
// import문을 통해 함수를 import 할 수 있고, as를 통해 원하는 이름으로 변경 또한 가능하다!
import strings.splitData as SD

fun String.splitData(): String {
    val text = this.split(" ")
    var result = ""
    for ((index, data) in text.withIndex()) {
        if (index > 0) {
            result += "$data "
        }
    }
    return result
}

val data = "Spring Summer Winter Fall are you still"
println(data.SD())   

3.
fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
): String {
    val result = StringBuilder(prefix)

    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

val list = listOf(1, 2, 3)
println(list.joinToString(separator = " "))

람다의 기본

람다는 기본적으로 다른 함수에 넘길 수 있는 "작은 코드 조각"을 뜻한다.
이전 Java에서는 SAM방식의 인터페이스를 무명 내부 클래스를 사용하여 구현했었는데, 단 하나의 함수를 구현하는데도 꽤 길고 복잡한 코드가 필요했다. SAM방식의 대표인 onClickListener의 예로 보자.

button.setOnClickListener(new OnClickListener(){
    @Override
    public void onClick(View v){
          System.out.println("너무 길다")
        }
    });

간단한 ClickListener지만 꽤 길지만, 코틀린에서는 더 간결하게 가능하다!

button.setOnClickListener{ 
     println("너무 짧다")
  }

람다의 기본 문법

이처럼 람다 식은 항상 중괄호{} 안에 존재한다! 또한 위의 Java와는 다르게 중괄호 앞 뒤에 괄호( )가 존재하지 않는다! 그리고 위의 예제에서는 확인이 불가하지만, 화살표->를 이용해 인자와 람다의 본문을 구분해준다. 인자는 타입과 함께 화살표의 앞에 적어주며 한개 이상의 인자가 있을 경우 ,를 이용해 구분해준다. 아주 쉬운 예제를 통해 람다를 확인해보자!

val sum = { x: Int, y: Int -> x + y }
println(sum(1,2))
 => 3

이처럼 람다 식을 변수에 할당해 줄 수도 있다.
또한 람다 식은 본문이 여러줄인 경우 가장 마지막 줄이 람다의 결과 값이 된다.

val sum = { x: Int, y: Int ->
    print(" x와 y의 합은: ")
        x + y
    }
println(sum(1,2))
-> x와 y의 합은: 3

또한 람다식을 더 짧게 만들어주는 기능을 컬렉션에 있는 maxBy 함수를 통해 예시로 보자!

val people = listOf(Person("KeiG", 29), Person("Coco", 21))
println(people.maxBy({ p: Person -> p.age })

위에 나온 maxBy({ p: Person -> p.age})는 더 짧게 표현이 가능하다.

코틀린에서 함수 호출 시에 람다 식이 인자의 가장 마지막에 있다면 람다 식을 괄호 밖으로 꺼낼 수 있다! maxBy() { p: Person -> p.age}와 같이.

그리고, 위의 코드처럼 함수의 유일한 인자가 람다식이며, 괄호 뒤에 썼다면 괄호를 없애주어도 된다. 즉 maxBy { p: Person -> p.age } 가 된다.

그리고 people.maxBy에서 people을 통해 타입을 컴파일러가 추론할 수 있기 때문에
maxBy{ p -> p.age }로 축약이 가능하며, 마지막으로 it을 통해 maxBy{ it.age }로 표현이 가능하다.

길었던 람다 식이 코틀린에서는 people.maxBy { it.age } 와 같은 짧은 코드로 동일한 결과를 낼 수 있게 된다!

멤버 참조

이렇게 다른 함수(maxBy) 에 코드 블록을 인자로 넘기는 방법을 알아봤다. 그런데 이미 그 코드가 함수로 선언되어 있다면 어떻게 할까? 그 함수를 호출하는 람다를 만들면 되지만, 이는 중복이다. 그런 경우 함수를 직접 넘기면 되는데, 코틀린에서는 함수를 값으로 바꿀 수 있다. 이때 이중 콜론을 사용한다.

::를 사용하는 경우를 멤버 참조라고 부른다. 멤버 참조는 프로퍼티 또는 메소드를 단 하나만 호출하는 함수 값을 만들어 준다.

val getAge = Person::age // Person 클래스의 age를 호출

또한 이러한 참조는 멤버 뿐 아니라 최상위에 선언 된 함수를 참조할 수도 있다! (이는 뒤에 고차함수에서도 사용되니 알아두어야 한다!)

fun sayHello() = println("Hello Hello")
run(::sayHello)
-> Hello Hello

이 처럼 :: 를 이용해 함수 참조 또한 가능하다! run은 인자로 받은 람다를 호출해준다.

함수형 프로그래밍과 람다

이러한 람다 식을 이용한 프로그래밍은 함수형 프로그래밍에서 컬렉션을 다룰 때 매우 편리함을 준다!
그 중 기본이 되는 몇 가지 함수를 보자!

filter

val list = listOf(1,2,3,4)
println(list.filter{ it % 2 == 0 } => 짝수만 출력
[2, 4]

이 filter 함수는 컬렉션을 이터레이션하면서 주어진 람다에 각 원소를 넘겨 람다가 true를 반환하는 원소만 모은다. 그리고 그 결과는 안의 조건을 만족하는 원소로만 이루어진 새로운 컬렉션을 반환해준다.
이처럼 filter 함수를 통해 원하지 않는 원소는 제거, 원하는 원소 값만 뽑아낼 수 있다.
하지만 새로운 값을 만들어내기 위해 원소를 "변환" 하고싶다면

map

map 함수를 이용한다. map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다. 다음과 같이 하면 숫자로 이뤄진 리스트를 각 숫자의 제곱이 모인 리스트로 바꿀 수 있다.

val list = listOf(1,2,3,4)
println(list.map{ it * it } )
[1, 4, 9, 16]

이 처럼 변환된 새로운 컬렉션을 필요로 한다면 map을 사용한다.
또한 key와 value를 저장하는 Map에도 filter와 map을 적용할 수 있다!( 이 부분은 찾아보길 권장한다!!! )

all

이 함수는 컬렉션이 모든 원소가 어떤 조건을 모두 만족하는지 판단하는 연산자이다!
나이가 27 이하인지 판단하는 람다 변수를 선언한 뒤 모두 만족하는지 all을 통해 확인해보자.

val canBeInClub27 = { p: Person -> p.age <= 27 }
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.all(canBeInClub27))
=> false

any

any는 하나라도 만족한다면 true를 반환한다!

val canBeInClub27 = { p: Person -> p.age <= 27 }
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.any(canBeInClub27))
=> true

count

count는 술어(조건식)를 만족하는 원소의 개수를 구한다!

val canBeInClub27 = { p: Person -> p.age <= 27 }
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.count(canBeInClub27))
=> 1

find

find는 술어를 만족하는 원소를 하나 찾아(가장 앞의) 반환하고, 없을 경우 null을 반환한다.

val canBeInClub27 = { p: Person -> p.age <= 27 }
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.find(canBeInClub27))
=> Person(name=Alice, age=27)

groupBy

groupBy는 리스트를 여러 그룹으로 이루어진 맵으로 변경해주는 함수다. 예를 들어 사람의 나이에 따라 그룹을 만들고 싶다면,

val people = listOf(Person("Alice", 27), Person("Bob", 31), Person("Chris",31))
println(people.groupBy{ it.age })
=> { 27=[Person("Alice", 27)], 31=[Person("Bob", 31), Person("Chris",31)] }

지연 계산 컬렉션 연산

그런데 map이나 filter같은 컬렉션 함수들은 결과를 즉시 생성한다. 즉, 매 단계 연산마다 중간 결과를 새로운 컬렉션에 임시로 담는다. 이는 매우 불필요하다. 그래서 Sequence를 사용하면 중간 임시 컬렉션을 생성하지 않고 처리가 가능하다!

이를 코드로 보면 people.map(Person::name).filter{ it.startsWith("A") } 이 예시는 filter와 map에서 리스트를 총 2개를 만들어 낸다. 그런데 이러한 원소의 개수가 수 백만개가 된다면 매우 비효율적일 수 있다. 그럴때는 Sequence를 사용하자.

people.asSequence().map(Person::name).filter{ it.startsWith("A") }.toList()
이 코드는 기능은 똑같으나 중간 결과를 저장하는 컬렉션이 생성되지 않아 성능이 훨씬 더 좋다.

이러한 시퀀스 연산은 중간 연산, 최종 연산이 있다. 중간 연산은 항상 지연 계산된다. 이는 무슨 뜻이냐면,

listOf(1, 2, 3, 4).asSequence()
.map{ print("map $(it) "); it* it }
.filter{ print("filter $(it) "); it % 2 == 0 }

이는 아무런 출력도 하지 않는다. 즉, Sequence에서 map 또는 filter같은 중간 연산은 결과를 얻을 필요가 있을 때, 결과를 사용해야할 때 적용이 된다. (출력은 모두 되지만 리스트에는 filter의 조건에 맞는 값만 들어가게 된다!)

listOf(1, 2, 3, 4).asSequence()
.map{ print("map $(it) "); it* it }
.filter{ print("filter $(it) "); it % 2 == 0 }
.toList()
=> map(1) filter(1) map(2) filter(4) map(3) filter(9) ...

일반 이터레이터였다면 map에 대해 모든 원소가 동작하고, 후에 모든 원소에 대해 filter가 적용되겠지만 Sequence는 다르다. 이를 예제를 통해 보면, 리스트의 값을 제곱한 뒤 3보다 큰 값을 찾는다고 해보자.

println(listOf(1, 2, 3, 4).asSequence().map{ it * it }.find{ it > 3 })
=> 4

원래의 컬렉션이라면 1,2,3,4를 모두 1, 4, 9, 16으로 변환한 뒤 find를 하여 4를 찾아냈겠지만,
Sequence는 1을 제곱한 1, 그리고 find하여 false인 것을 확인,
2를 제곱한 4, 그리고 find하여 true인것을 확인, 4를 반환하고 끝낸다.


일반 Collection과 Sequence에서의 진행 순서의 차이에 대해서 잘 설명되어있는 그림이다.

이를 잘 이해하고 활용하면 큰 용량의 컬렉션에서 메모리 이점을 취할 수 있을 것이다.

람다의 끝, 수신 객체 지정 람다 - with, apply

맨 앞에서 봤던 확장 함수에서 수신 객체가 기억이 나는가?(안난다면 다시 보고오길!) 지금부터는 수신 객체를 명시하지 않고, 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있다! 그러한 람다를 수신 객체 지정 람다라고 부르고, 먼저 with부터 보자.

with

나는 개인적으로 with를 정말 좋아하고, 자주쓴다! 너무나도 편하다. 특히나 안드로이드 개발시에 binding을 자주쓰게 되는데, 이 with를 이용하면 모든 코드에 binding. 을 써주지 않고도 그 객체에 대하여 다양한 연산을 수행이 가능하다. 아래의 코드는 with를 사용하는 것과 안하는 것의 차이를 보도록 한다.

1. 
fun alphabet(): String{
    val result = StringBuilder()
    for (letter in 'A'..'Z'){
        result.append(letter)
    }
    result.append("\nNow I know the alphabet! ")
    return result.toString()
}

2.
fun alphabet() = with(StringBuilder()){
    for (letter in 'A'..'Z'){
        append(letter)
    }
    append("\nNow I know the alphabet! ")
    this.toString()
}

1번과 2번의 차이를 보면, with(StringBuilder())를 통해 StringBuilder의 선언부, 그리고 앞에 수신 객체를 모두 사용하지 않고 처리할 수 있게 됐다!
또한 식으로 만들어 마지막의 this.toString으로 "값"을 반환하여 식을 본문으로 하는 함수로 표현이 가능하다. 너무나도 간결하고 편하다.
추가적으로, 마지막의 this 또한 그 수신 객체에 접근할 수 있으며 생략 또한 가능하다.

apply

그런데 이런 with가 반환하는 값은 람다 코드를 실행한 "결과"이며, 그 "결과"는 람다식 본문에 있는 마지막 "값"이다. 그런데 이런 결과보다는 수신 객체가 필요한 경우에는 apply를 사용한다.
apply는 내부적으로 확장 함수로 구현되어 있다.(T.apply로 선언부가 구현되어 있다!)

그리고 with와 유일한 차이점은 항상 자신에게 전달된 객체를 반환한다는 점이다. apply를 이용해 위의 alphabet()을 리팩토링 해보자!

fun alphabet() = StringBuilder().apply{
    for (letter in 'A'..'Z'){
        append(letter)
    }
    append("\nNow I know the alphabet! ")
}.toString()

이러한 apply함수는 보통 객체의 인스턴스를 만들면서, 프로퍼티 일부를 초기화 할때 가장 유용하다. 안드로이드에서 사용하기 쉽게, TextView 컴포넌트를 생성하며 특성 일부를 지정해보자.

fun createViewWithCustomAttributes(context: Context) =
    TextView(context).apply{
        text = "Sample Text"
        textSize = 20.0
        setPadding(10, 0, 0, 0)
    }

이는 새로운 TextView 인스턴스를 만들고, 즉시 그 인스턴스를 apply에 넘긴다. apply에 전달된 람다 안에서 TextView가 수신 객체가 된다.
따라서 TextView의 메소드를 호출하거나 프로퍼티를 설정할 수 있다. 그리고 후에 apply는 람다에 의해 초기화된 TextView의 인스턴스를 반환한다. 그 인스턴스는 함수 createViewWithCustomAttributes의 결과가 된다.

마무리

다음에는 이를 활용해 고차 함수에 대해 포스팅 해보겠다!!

출처 및 참고:

https://choheeis.github.io/newblog//articles/2020-12/kotlinHigherOrderFunctionAndLambda
https://developer.android.com/kotlin/learn?hl=ko#anonymous
Dmitry Jemerov 『Kotlin in Action』, 에이콘, p103-140, p197-242.

반응형