Kotlin Lambdas



개인 광고 영역

Kotlin을 활용하여 개발을 하면 무조건 만나는 Lambdas 식.

기존 Android 개발에서는 Java 7을 주로 사용하다 보니 Lambda 식을 사용할 일은 없었다.

하지만 Backport 활용으로 Lambda을 적용할 수 있었는데 retrolambda이다.

현재는 Android Studio 3.0 이상에서 구글이 직접 Lambda을 제공하여, 간단하게 적용하는 게 가능하다.

오늘은 Kotlin Lambda 식에 대해서 살펴본다.


Lambda 식

Lambda 식은 쉽게 말해 function에 function을 전달하고, 이를 콜 하게 하는 것을 말한다. 자바에서 흔하게 볼 수 있는 샘플은 아래와 같다.

FloatingActionButton fab = findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // Something
    }
});

View 내부의 setOnClickListener에는 아래와 같은 코드를 담고 있다.

/**
 * Interface definition for a callback to be invoked when a view is clicked.
 */
public interface OnClickListener {
    /**
     * Called when a view has been clicked.
     *
     * @param v The view that was clicked.
     */
    void onClick(View v);
}

public void setOnClickListener(@Nullable OnClickListener l) {
    if (!isClickable()) {
        setClickable(true);
    }
    getListenerInfo().mOnClickListener = l;
}

어떠한 click event가 발생하면 이를 내가 등록한 Listener에 전달해주기를 원하는 단순한 형태이다.

Java 7에서는 람다식이 없기에 위와 같이 new View.OnClickListener을 정의해야 하지만 java 8 이상이나 Kotlin에서는 아래와 같이 단순하게 정의할 수 있다.

fab.setOnClickListener { /* Something */ }

위와 같은 Lambda 식은 Kotlin에서 제공하는 SAM(Single Abstract Method) 정의에 의해 동작하고 있다.

SAM(Single Abstract Method)은 interface에 1개의 Methods만 있을 경우 동작하며, 이러한 정의는 위에서 본 setOnClickListener와 OnClickListener interface 정의와 같이 Java 파일에서만 존재해야 SAM이 동작한다.

Kotlin에서는 Higher-Order Function이 제공되고 있으므로, 중요치 않으며 처음 설계부터 위와 같이 동작하도록 만들어두었다.


Lambda collection

람다의 활용은 단순 Listener을 편하게 사용하는 것만은 아니다. collection을 이용할 때 좀 더 유용한데 stream을 이용하는 방법이다.

Java에서 단순하게 for loop을 이용하여 가장 큰 값을 찾는 함수이다.

private int findMax(ArrayList<Integer> list) {
    int max = -1;
    for (Integer integer : list) {
        if (integer > max) {
            max = integer;
        }
    }
    return max;
}

리스트에서 가장 큰 값을 찾는 위의 메소드를 kotlin에서는 maxBy() 라이브러리를 활용할 수 있다.

private fun findMax(list: ArrayList<Int>) =
    list.maxBy { it } ?: 0

maxBy의 내부 코드를 확인하면 아래와 같다.

리스트를 넘겨받고, selector: (T) -> R의 람다식을 Higher-Order Function으로 전달하고 있다.

람다식으로 전달받은 block의 조건을 확인하여, 최댓값을 찾아주는 내부 코드를 확인할 수 있다.

/**
 * Returns the first element yielding the largest value of the given function or `null` if there are no elements.
 */
public inline fun <T, R : Comparable<R>> Iterable<T>.maxBy(selector: (T) -> R): T? {
    val iterator = iterator()
    if (!iterator.hasNext()) return null
    var maxElem = iterator.next()
    var maxValue = selector(maxElem)
    while (iterator.hasNext()) {
        val e = iterator.next()
        val v = selector(e)
        if (maxValue < v) {
            maxElem = e
            maxValue = v
        }
    }
    return maxElem
}


람다 문법

위와 같이 단순하게 람다식을 표현해보았다. 이번엔 좀 더 각각을 구분해보자.

maxBy에 접근하는 람다 표현은 {}안에 값을 정의하고 있는데 원래 maxBy에 접근하는 코드는 아래와 같다.

private fun findMax(list: ArrayList<Int>) =
        list.maxBy({it})

위의 코드에서 보았듯 원래 표현은 ({ /* Something */ })을 해야 한다.

복잡해 보이기도 한다. 단순 maxBy(selector: (T) -> R)에 접근하기 위한 람다식을 써야 하는데 말이다.

그렇지만 Kotlin에서는 메소드에 정의한 Higher-Order Function의 마지막 {}() 밖에다가 값을 정의할 수 있다.

그래서 ()을 생략하고 아래와 같이 정의하는 게 가능한 것이다.

private fun findMax(list: ArrayList<Int>) =
        list.maxBy { it }

하지만 1개 이상의 Higher-Order Function인 경우는?

1개 이상의 Higher-Order Function이 생길 수 있는데 아래와 같은 something 메소드를 정의해보았다.

2개의 Higher-Order Function을 정의한 것인데,

private fun something(funOne: () -> Unit, funTwo: () -> Unit) {
    // ...
}

람다에 접근할 때는 아래와 같이해야 한다.

// 모두 () 안에 표현하기
something({ /* Something function one */ }, { /* Something function two */ })

// 하나는 () 안에 표현하고, 하나는 () 밖에 표현하기
something({ /* Something function one */ }) { /* Something function two */ }

위와 같이 1개 이상인 경우라도, 마지막 람다 표현식은 () 밖에 정의하는 게 가능하다.

값의 전달

람다 표현식에서 아래와 같은 람다 표현이 있다고 가정하자.

val sum = { a: Int, b: Int -> a + b}

위의 람다식을 접근할 때 아래와 같이 한다.

println(sum(1, 2))

// 3

sum에 1, 2을 넘겨주고 이를 a + b해서 리턴해주는 단순한 함수를 만든 것이다.

원래 함수로는 아래와 같겠지만 람다 표현을 통해 {a: Int, b: Int -> a + b}로 표현함으로 단순해질 수 있다.

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

다시 maxBy로 돌아와서 아래와 같이 위에서 it을 사용한 부분을 하나하나 풀어보겠다.

총 3가지 방법으로 변수를 표현할 수 있는데 완전한 형태는 아래와 같이 { value: Int -> }로 표현할 수 있다.

value의 변수 타입이 Int임을 명시하고, 이를 활용하도록 ->을 추가하여 아래와 같이 표현할 수 있다.

private fun findMax(list: ArrayList<Int>) =
    list.maxBy { value: Int -> value } ?: 0

kotlin은 언제나 변수 타입을 추론할 수 있다.(모든 부분에서 추론할 수 있는 건 아니지만) 그래서 아래와 같이 추론 가능한 형태로 다시 정의할 수 있다.

private fun findMax(list: ArrayList<Int>) =
    list.maxBy { value -> value } ?: 0

kotlin은 변수가 1개만 존재하는 경우 또 한번 줄일 수 있는 it 키워드를 제공하고 있다. 이를 통해 처음 maxBy 함수처럼 정의할 수 있는 것이다.

private fun findMax(list: ArrayList<Int>) =
    list.maxBy { it } ?: 0

it 좋긴 하지만 꼭 변수 타입을 명시해야 하는 경우는 팀 내부에서 정의하여 쓰는 게 좋다. 무조건 줄이는 건 간혹 도움이 안 될 수 있으니 말이다.

다만 RxJava와 함께 사용하는 경우에는 오히려 유용할 때가 많다. Stream을 통해 내려오는 값이 쉽게 변할 수 있어, 하위를 수정하지 않고도 빠른 적용이 가능한 경우도 생긴다.


Closure

람다식을 사용하면 Closure 활용이 아주 쉽다. Closure는 함수를 쓸모 있는 1급 시민으로 만들고, 포획한 변수를 제대로 처리할 필요가 있어야 한다.(써놓고 보니 어렵다)

그래서 코드를 준비했다. 아래와 같이 onCreate 내부에 count라는 변수를 만들고, Button의 Click이 발생할 때마다 count++하도록 만들었다.

기본적으로는 이러한 형태가 제공되지 않지만, final을 포함하는 경우 이러한 변숫값도 함께 메모리에 저장하여 count 활용도를 높일 수 있다.

하지만 아래 코드가 정상 동작하려면 다른 방법이 필요하다.

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
  // ...
  final int count = 0;

  FloatingActionButton fab = findViewById(R.id.fab);
  fab.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
          count++;
      }
  });
}

단순 변수가 아닌 다른 메모리에 count을 담고 있도록 만들어야 하는데 [] 배열 형태의 count을 만들어서 처리할 수 있다.

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
  // ...
  final int[] count = 0;

  FloatingActionButton fab = findViewById(R.id.fab);
  fab.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
          count[0]++;
      }
  });
}

이러면 read/write 모두 가능한 count 변수를 만들 수 있다. 내부의 OnClickListener와 함께 onCreate 함수가 끝나더라도, 메모리에 남아있어 유용하게 사용할 수 있다.

kotlin과 Java 8에서는 이러한 부분을 좀 더 편하게 제공하는데 그냥 아래와 같이 작성하면 Closure 사용이 가능하다.

override fun onCreate(savedInstanceState: Bundle?) {
  // ...
  var count = 0
  fab.setOnClickListener {
      count++
  }
}

하지만 아래와 같은 경우는 아무런 의미가 없는 코드이다. count가 ++하는 걸 기다리지 않기 때문에 아래 함수는 영원히 0을 리턴한다.

fun getCount(): Int {
    var count = 0

    fab.setOnClickListener {
        count++
    }
    return count
}


멤버 참조

람다식을 활용하면 메소드를 전달받고, 그 메소드를 호출할 수 있다. 하지만 넘기려는 메소드가 이미 함수 형태로 파라미터도 동일하다면 어떻게 할 수 있을까?

또 함수를 호출하는 게 맞을까??

다행이도 코틀린과 자바 8에서는 이중 콜론 ::을 제공하고 있는데 아래와 같이 maxBySampleData::value을 넘겨준다.

data class SampleData(val value: Int)

@Test
fun unitTest() {
    val list = mutableListOf(SampleData(1), SampleData(2), SampleData(3))
    println(list.maxBy(SampleData::value))
}

위의 코드는 아래와 같은 일을 한다.

println(list.maxBy(SampleData::value))
println(list.maxBy { value -> value })
println(list.maxBy { it })

다시 돌아가서 넘겨주려는 함수가 이미 함수 형태로 만들어져있고, 이를 다시 넘겨주는 작업을 하고 싶지 않을 경우 아래와 같이 처리할 수 있다.

print 함수에서는 sum(Int, Int)의 결과를 println하고 있다.

아래의 람다식에서는 a, b에 대한 변수 타입을 생략할 수 없는데, 함수를 구현한 것이라고 보면 편한다.

private val sum = { a: Int, b: Int -> a + b }

private fun print() {
    println(sum(20, 40))
}

@Test
fun unitTest() {
    printSum = ::print
    printSum()
}

private lateinit var printSum: () -> Unit

그리고 외부에 있는 lateinit var printSum이 이를 필요로 하고 있다. 원래대로라면 아래와 같이 구현했겠지만, 위의 코드처럼 할 수 있다.

printSum = { print() }


laze collection

Lambda 스트림에는 map, filter 같은 유용하게 사용할 수 있는 collection을 제공하고 있다.

@Test
fun unitTest() {
  val list = mutableListOf(1, 2, 3, 4, 5)
  list.filter { it > 3 }.map { println(it) }
}

위와 같은 컬렉션을 활용하여, filter 후 map에서 출력을 할 수 있는데, 즉시 루프를 돌려서 결과를 얻어오게 된다.

하지만 RxJava에서처럼 subscribe 할 때 값을 필요로 할 수 있다. 결국 어디선가 subscribe 하면 이 동작이 수행했으면 하는 것이다.

다행히도 kotlin에서도 lazy collection이 제공되는데 asSequence()을 활용하는 방법이다.

그리고 중간 filtermap 처리 시에 각각 별도의 temp collection을 활용하지만 asSequence을 활용하면 그 부분이 없다고 한다.

위의 코드에 asSequence() 만 추가하면 되겠다.

@Test
fun unitTest() {
  val list = mutableListOf(1, 2, 3, 4, 5)
  list.asSequence().filter { it > 3 }.map { println(it) }
}

위의 코드는 실행하여도 결과를 알 수 없다. 마지막에 .toList()를 해야 결과를 얻을 수 있다.

그래서 위의 코드를 RxJava 활용에서처럼 활용할 수 있는데 아래처럼 만들 수 있다.

@Test
fun unitTest() {
    val list = mutableListOf(1, 2, 3, 4, 5)
    list.lazyCollection
}

private val MutableList<Int>.lazyCollection
    get() = this.asSequence().filter { it > 3 }.map { println(it) }

필요할 때 지연하여 값을 얻을 수 있으니 유용하게 사용하는 게 가능하다.

asSequence의 성능 체크

asSequence와 기존 filter/map의 성능 체크를 해볼 수 있다.

measureNanoTime을 이용하여 성능을 확인할 수 있는데 10만 번을 돌렸을 때 asSequence에서 더 좋은 성능을 확인할 수 있다.

@Test
fun unitTest() {
    val list = 1..100_000

    measureNanoTime {
        list.filter { it > 3 }.map { it.toString() }
    }.let { println(it) }
    measureNanoTime {
        list.asSequence().filter { it > 3 }.map { it.toString() }.toList()
    }.let { println(it) }
}

// nona time
/*
 * 37174503
 * 26564360
 */

단순하게 작업한다면 일반적으로 asSequence가 더 좋으나, 적절한 역할을 제공하지 않으면 오히려 단순 필터가 더 좋기도 하다.


마무리

일부 Lambda 표현식을 알아보았다. 람다 표현식의 종류가 많지만 기존에 작성해둔 글에서도 확인할 수 있어 링크로 대체한다.


참고자료



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments