Kotlin - Collections와 Sequences의 차이점

Kotlin에서 Iterator, Collection, List, Map, Set를 Collections이라고 합니다. Sequences도 Collections와 같은 Iterable한 자료구조인데 연산방식에서 차이점이 있습니다.

Collections는 기본적으로 Eager evaluation으로 동작하며, Sequences는 Lazy evaluation으로 동작합니다.

Eager, Lazy evaluation을 설명할 때 Java Stream과 비교하며 설명하는데요, Java Stream은 Lazy evaluation입니다. 예제와 함께 Lazy와 Eager evaluation을 이해하고 Collections와 Sequences의 차이점을 알아보겠습니다.

  • Kotlin Collections : Eager evaluation
  • Kotlin Sequences : Lazy evaluation
  • Java Stream : Lazy evaluation

1. Lazy evaluation과 Eager evaluation의 차이점

Lazy evaluation은 지금 하지 않아도 되는 연산은 최대한 뒤로 미루고, 어쩔 수 없이 연산이 필요한 순간에 연산을 수행하는 방식입니다.

아래 예제는 리스트의 요소들 중에 길이가 5 이상인 요소 1개를 찾는 코드입니다. Lazy evaluation이기 때문에 각 요소에 대해서 filter()map()을 연속적으로 수행합니다. 1개의 아이템만 찾으면 되기 때문에 모든 요소에 대해서 filter()를 모두 수행할 필요는 없기 때문입니다.

val fruits = listOf("apple", "banana", "kiwi", "cherry")
fruits.asSequence()
    .filter {
        println("checking the length of $it")
        it.length > 5
    }
    .map {
        println("mapping to the length of $it")
        "${it.length}"
    }
    .take(1)

Eager evaluation는 수행해야 할 연산이 있으면 미루지 않고 바로 처리하는 방식을 말합니다.

예를 들면, 아래 코드는 모든 리스트의 요소에 대해서 filter()의 연산을 수행합니다. filter의 조건을 충족하는 요소들에 대해서 map()을 수행합니다. 1개의 요소만 찾으면 되지만, Collections가 Eager evaluation으로 동작하기 때문에 모든 요소에 대해서 filter()를 모두 수행한 뒤에 map()을 수행합니다.

val fruits = listOf("apple", "banana", "kiwi", "cherry")

fruits.filter {
        println("checking the length of $it")
        it.length > 5
    }
    .map {
        println("mapping to the length of $it")
        "${it.length}"
    }
    .take(1)

2. Kotlin Collections과 Java Stream의 차이점

함수형 프로그래밍 관점에서 Kotlin의 Collections는 Java의 Stream과 비슷합니다.

하지만 Kotlin Collections는 Eager evaluation으로 동작하고, Java Stream은 Lazy evaluation으로 동작합니다.

2.1 Java Stream (Lazy evaluation)

List를 예로 들면, Java는 기본적으로 List가 함수형 함수(Functional functions)를 제공하지 않습니다. 대신 Stream을 사용하면 map(), collect()를 사용할 수 있습니다.

다음은 Java의 Stream을 사용하는 예제입니다.

List<String> fruits = Arrays.asList("apple", "banana", "kiwi", "cherry");
fruits.stream()
      .filter(name -> {
          System.out.println("checking the length of " + name);
          return name.length() > 5;
      })
      .map(name -> {
          System.out.println("mapping to the length of " + name);
          return "" + name.length();
      })
      .collect(Collectors.toList());

수행 결과를 보면, 4개의 아이템에 대해서 filter()를 모두 수행하고 map()을 수행하지 않습니다. filter() -> map() 순서로 동작하며 filter()에서 누락된 아이템은 map()이 호출되지 않습니다.

checking the length of apple
checking the length of banana
mapping to the length of banana
checking the length of kiwi
checking the length of cherry
mapping to the length of cherry

2.2 Kotlin Collections (Eager evaluation)

kotlin은 기본적으로 List가 함수형 함수를 제공합니다. Stream을 사용하지 않아도 map() 등의 함수를 사용할 수 있습니다.

다음은 코틀린을 사용하여 위와 동일한 구현을 하였습니다.

val fruits = listOf("apple", "banana", "kiwi", "cherry")

fruits.filter {
    println("checking the length of $it")
    it.length > 5
}
.map {
    println("mapping to the length of $it")
    "${it.length}"
}
.toList()

수행 결과를 보시면, 모든 아이템에 대해서 filter()를 수행하고, 그 다음에 filter()를 통과한 아이템에 대해서만 map()을 수행합니다.

checking the length of apple
checking the length of banana
checking the length of kiwi
checking the length of cherry
mapping to the length of banana
mapping to the length of cherry

따라서 위의 예제로 Java의 Stream은 Lazy evaluation으로 동작하고, kotlin의 Collections는 Eager evaluation으로 동작한다는 것을 볼 수 있었습니다.

2.3 Kotlin Collections에서 Stream 사용하기 (Lazy evaluation)

이 글의 주제와 벗어나지만, Kotlin Collections를 Stream으로 사용하는 예제를 소개합니다.

아래와 같이 stream()으로 Stream으로 변환할 수 있습니다.

val fruits = listOf("apple", "banana", "kiwi", "cherry")

fruits.stream().filter {
          println("checking the length of $it")
          it.length > 5
      }
      .map {
          println("mapping to the length of $it")
          "${it.length}"
      }
      .collect(Collectors.toList())

위의 Kotlin 코드를 Java로 변환해보면 아래와 같이 Stream을 사용하는 것을 볼 수 있습니다.

List fruits = CollectionsKt.listOf(
    new String[]{"apple", "banana", "kiwi", "cherry"});

fruits.stream()
      .filter((Predicate)null.INSTANCE)
      .map((Function)null.INSTANCE)
      .collect(Collectors.toList());

위 코틀린 코드를 실행해보면, Java의 Stream처럼 Lazy evaluation으로 동작한다는 것을 알 수 있습니다.

checking the length of apple
checking the length of banana
mapping to the length of banana
checking the length of kiwi
checking the length of cherry
mapping to the length of cherry

3. Kotlin Sequences (Lazy evaluation)

Collections의 asSequence() 메소드는 Collections를 Sequences로 변환합니다.

Sequences도 Collections처럼 map() 등의 functional functions를 사용할 수 있습니다. 차이점은 Sequences는 Lazy evaluation으로 동작한다는 것입니다.

다음 코드는 위의 예제와 동일한 리스트를 리턴합니다.

val fruits = listOf("apple", "banana", "kiwi", "cherry")
fruits.asSequence()
    .filter {
        println("checking the length of $it")
        it.length > 5
    }
    .map {
        println("mapping to the length of $it")
        "${it.length}"
    }
    .toList()

결과를 보면 Java의 Stream처럼 Lazy evaluation으로 처리됩니다.

checking the length of apple
checking the length of banana
mapping to the length of banana
checking the length of kiwi
checking the length of cherry
mapping to the length of cherry

4. 다른 예제

kotlinlang.org에 좋은 예제가 있습니다.

연산 과정을 보여주는 다이어그램도 함께 첨부되어있어서 성능 측면에서 Sequences가 좋은 부분을 이해할 수 있습니다.

4.1 Collections 예제

다음은 단어가 저장된 words 리스트에 대해서 filter()map(), take()를 적용하는 코드입니다.

val words = "The quick brown fox jumps over the lazy dog".split(" ")
val lengthsList = words.filter { println("filter: $it"); it.length > 3 }
    .map { println("length: ${it.length}"); it.length }
    .take(4)

println("Lengths of first 4 words longer than 3 chars:")
println(lengthsList)

결과를 출력하면 다음과 같이 Eager evaluation으로 동작합니다.

filter: The
filter: quick
filter: brown
filter: fox
filter: jumps
filter: over
filter: the
filter: lazy
filter: dog
length: 5
length: 5
length: 5
length: 4
length: 4
Lengths of first 4 words longer than 3 chars:
[5, 5, 5, 4]

다이어그램으로 보면 아래와 같은 순서로 처리됩니다. iterable processing

4.2 Sequences 예제

위의 예제와 동일한 내용을 Sequences로 구현한 코드입니다.

val words = "The quick brown fox jumps over the lazy dog".split(" ")
//convert the List to a Sequence
val wordsSequence = words.asSequence()

val lengthsSequence = wordsSequence.filter { println("filter: $it"); it.length > 3 }
    .map { println("length: ${it.length}"); it.length }
    .take(4)

println("Lengths of first 4 words longer than 3 chars")
// terminal operation: obtaining the result as a List
println(lengthsSequence.toList())

결과를 보면 다음과 같이 Lazy evaluation으로 동작합니다.

Lengths of first 4 words longer than 3 chars
filter: The
filter: quick
length: 5
filter: brown
length: 5
filter: fox
filter: jumps
length: 5
filter: over
length: 4
[5, 5, 5, 4]

다이어그램으로 보면 아래와 같은 순서로 처리됩니다. sequences processing

take(4)로 4개의 아이템만 가져오면 되기 때문에, 다른 아이템에 대해서 filter()를 수행할 필요가 없습니다. 그래서 Collections 코드보다 연산이 빨리 끝날 수 있습니다.

5. 정리

경우에 따라서 Lazy evaluation으로 동작하는 Sequences가 적은 연산으로 동일한 결과를 출력할 수 있습니다. 위의 예제에서 보신 것처럼 take(4)와 같이 조건에 맞는 요소 개수가 정해져있을 때, Lazy evaluation으로 동작하는 것이 불필요한 연산을 줄일 수 있기 때문입니다.

References

Loading script...
codechachaCopyright ©2019 codechacha