재미로 보는 언어별 스트림 처리 비교
재미로 보는 언어별 스트림 처리 비교
NBA를 즐겨보는데 갑자기 역대 MVP가 궁금해서 찾아보니 https://www.nba.com/history/awards/mvp 여기에서 볼 수 있었다. 연도별로 주욱 나열되어 있는데 수상횟수 기준으로 집계해서 내림차순으로 보고 싶었다.
그래서 연습도 할 겸 자바로 먼저 짜봤는데 다른 언어로 짜주신 분들도 계셔서 재미로 비교를 해보려 한다. 언어의 전반적인 우열을 가리는 목적이 전혀 아니므로 구경하는 재미로만 참고하면 좋겠다.
자바 외에 다른 언어에 대한 지식이 많지 않으므로 잘못된 정보가 포함될 수도 있으며 이 부분은 댓글로 바로잡아주시면 정말 감사드리며 바로 반영하겠다.
자 이제 시작해보자. 연도별 NBA MVP 명단 데이터는 다음과 같다.
1 | String MVPS = "" + |
구현 비교
Java
Java 8의 Stream API를 활용해서 작성한 코드는 다음과 같다.
1 | public static void main(String[] args) { |
큰 흐름은 키(선수 이름) 추출
, groupBy
, 수상횟수 집계
, 정렬
, 출력
, 전체 합계
로 구성된다.
자바에는 튜플이 없다. 그래서 groupingBy()
의 결과는 Map으로 collect되고 이 과정에서 최초의 스트림이 종결된다. 그리고 Map에서 entrySet().stream()
을 통해 새 스트림을 만들고 정렬, 출력, 합계 등을 처리해야 한다. 따라서 수상횟수 집계
와 정렬
사이에 스트림 생성이 하나 추가된다고 볼 수 있다.
값(MVP 수상횟수) 기준 내림차순 정렬은 .sorted(comparingByValue(reverseOrder()))
로 깔끔하게 처리할 수는 있지만 조금 장황해보이기도 하다. 앞으로 살펴볼 다른 언어 구현체와 비교하면 실제로도 좀 길긴 하다.
peek()
이 있어서 스트림의 데이터를 완전 소비(consume)하지 않으면서도 출력할 수 있어서, 출력과 합계를 하나의 스트림으로 처리할 수 있다.
한 줄 평: Stream API 덕에 깔끔한 구조로 작성할 수 있음에도 불구하고 메서드 이름이 길어서 전반적으로 장황해보인다.
Scala
스칼라 버전은 Kevin Lee 님께서 작성해주신 걸 살짝 다듬었다.
1 | val lines = MVPS.split("\n") |
스칼라에서도 큰 흐름은 키(선수 이름) 추출
, groupBy
, 수상횟수 집계
, 정렬
, 출력
, 전체 합계
로 달라진 것은 없다.
키 추출이 자바와 조금 다르게 구현되었지만 장황한 점은 비슷해 보이고, 자바의 groupingBy()
에서 키 별 분류와 수상횟수 집계를 함께 처리할 수 있었지만, 스칼라의 groupBy()
는 키 별 분류만 가능하고 수상횟수의 집계는 mapValues(_.size)
를 통해 따로 처리하는 점이 다르다.
정렬하기 전에 리스트로 만드는 과정(toList
)이 하나 추가되는 것도 절차상으로는 entrySet().stream()
으로 스트림을 새로 만드는 자바와 비슷하고, 정렬 과정도 자바와 비슷하다.
(_.drop(12))
, (_.size)
, (_._2)
같은 표현법으로 ->
없이도 람다식을 표현할 수 있고, getKey()
, getValue()
대신 ._1
, ._2
로 짧게 표현할 수 있어서 비슷한 구조임에도 자바보다 훨씬 간결해보인다. 하지만 _
를 쓰지 않는다면 map { case (name, count) => ... }
에서처럼 =>
뿐 아니라 case
도 나오면서 조금 복잡해진다.
자바의 peek()
같은 메서드가 없어서인지 출력과 전체 합계를 하나의 스트림으로 처리하지 못하고 별도로 처리하는 것도 다르다.
한 줄 평:
_
를 잘 쓰면 자바보다 훨씬 간결하게 짤 수 있다.
Kotlin
코틀린 버전은 부종민 님께서 짜주신 걸 살짝 개선해봤다.
1 | fun main() { |
코틀린은 흐름이 스칼라와 거의 동일하다.
substring()
은 자바와 동일하고 원한다면 스칼라처럼 drop()
, takeWhile()
을 쓸 수도 있다.
groupingBy {}
에서는 스칼라와 마찬가지로 키 기준 분류만 가능하고 집계는 eachCount()
로 따로 해줘야 한다. 정렬하기 전에 toList()
로 리스트를 만드는 것도 동일하다.
스칼라에 튜플이 있다면 코틀린에는 Pair
가 있다. 그래서 자바처럼 getKey()
, getValue()
같은 메서드를 쓰지 않고 it.first
, it.second
와 같은 형식으로 짧게 표현 가능하다. 그래도 first
나 second
보다는 _1
, _2
처럼 더 짧게 표현 가능한 스칼라 쪽이 더 나아 보인다.
람다식을 { }
를 써서 인자로 받는 것에서 약간의 이질감이 느껴지지만, it
라는 built-in 키워드를 써서 ->
없이도 람다식을 짧게 표현할 수 있는 점도 스칼라와 비슷하다.
자바의 peek()
과 동일한 역할을 하는 onEach()
가 있어서 출력과 전체 합계를 하나의 스트림에서 모두 처리할 수 있다는 점은 스칼라와 다르다.
한 줄 평: 스칼라와 거의 비슷한 작성 방식과 간결함을 보여주면서도, 스칼라에 없는
onEach()
를 가지고 있어서 자바의 장점도 가지고 있다.
뽕 맞은 자바(feat. jooL)
박성철 님께서 바닐라 자바는 쓰는 게 아니라고 하시면서 jooL 라이브러리
(https://github.com/jOOQ/jOOL) 를 사용해서 작성해주신 버전을 보면 ‘오~~ 이게 정녕 자바란 말씀입니까?’란 소리가 나올 정도로 감동적이다. 스칼라나 코틀린보다 더 간결하다!!
1 | public static void main(String[] args) { |
스칼라나 코틀린처럼 튜플이 있어서 t.v2
같은 간략한 표현이 가능해졌고, 특히 값 기준 내림차순 정렬을 sorted(t -> - t.v2)
로 간단 명료하게 표현할 수 있는 점이 눈에 띈다. 그리고 groupBy 후에 entrySet().stream()
이나 toList
없이도 클로저처럼 바로 정렬할 수 있다.
한 줄 평: 바닐라 자바는 쓰는 게 아니다.
Clojure
클로저는 버전이 두 개인데 ->>
매크로를 사용한 버전은 박상규 님께서 만들어주셨다.
짧을 줄은 알았지만 생각보다 더 짧다..
1 | (run! prn |
클로저는 앞에서 다룬 언어와는 좀 다르게 Lisp 계열의 언어이며, 함수의 chaining이 아니라 nesting을 사용하므로 코드의 흐름이 의식의 흐름에 역행하는 것으로 보여서 이질감이 매우 크다. 하지만 이를 보완할 수 있는 방법이 있으니 바로 ->>
매크로다.
1 | (->> lines |
이렇게 ->>
매크로를 사용하면 클로저 특유의 간결함을 유지하면서도 의식의 흐름과 코드 흐름의 불일치를 극복할 수도 있다.
map
은 다른 언어와 다를 게 없다. #()
를 써서 익명 함수를 만들 수 있고, %
로 익명 함수의 인자를 표현할 수 있는데 %1
, %2
와 같이 두 개 이상의 인자도 표현 가능하다.
frequencies
groupingBy(key, counting())
groupBy(key).mapValues(_.size)
groupingBy { key }.eachCount()
위 네 가지는 모두 동일한 역할을 담당하는데, 코드의 양으로 보나 의미 전달력으로 보나 클로저의 frequencies
에 가장 높은 점수를 줄 수 있겠다.
코드에 나타나지 않은 가장 큰 차이점이 하나 있다. 자바, 스칼라, 코틀린에서는 groupBy를 한 후 다시 리스트로 만들어줘야 정렬을 할 수가 있는데, 클로저에서는 frequencies
가 반환하는 맵 자체를 대상으로 값 기준 정렬이 가능하다.
sort-by val >
sorted(comparingByValue(reverseOrder()))
sortBy(_._2)(Ordering.Int.reverse)
sortedByDescending { it.second }
위 네 가지도 모두 동일한 역할을 하는데, 이번에도 클로저의 sort-by val >
에 가장 높은 점수를 줄 수 있겠다. 값(val) 기준 내림차순(>)으로 정렬하라(sort-by)는 의미를 sort-by val >
로 정말 간단 명료하게 표현하고 있다.
다만 클로저도 스칼라와 마찬가지로 자바의 peek()
이나 코틀린의 onEach()
같은 기능의 함수가 없어서 출력과 합계를 하나의 스트림에서 처리하지 못하는 것은 단점이다. 스칼라처럼 Lazy Sequence를 변수에 담아두고 출력에 사용하고, 합계 계산에 사용하면 될텐데 그 부분은 아예 작성을 못 했다. ㅋㅋ
한 줄 평: 간결함으로는 타의 추종을 불허하지만, Lisp에 익숙해지기는 쉽지 않다.
Rust
스택오버플로우 기준 올해까지 4년 연속 가장 사랑받는 언어 러스트도 한 번 시도해봤다.
1 | fn main() { |
러스트는.. 자세한 설명은 생략.. 하고 싶지만..
다른 언어와 가장 크게 다른 점은 group_by
전에 반드시 sorted()
를 해줘야 제대로 키 기준 분류가 작동한다는 점이다. 예를 들어 [a, a, b, a, c, a]
에 group_by
와 map
으로 키 별 분류와 집계를 적용하면 [(a, 4), (b, 1), (c, 1)]
이 나올 것이 예상되고 다른 언어는 모두 예상대로 동작하지만, 러스트는 [(a, 2), (b, 1), (a, 1), (c, 1), (a, 1)]
라는 결과를 반환한다. 따라서 키 기준으로 먼저 정렬을 해줘야 예상대로 동작하게 된다.
자바의 peek()
, 코틀린의 onEach()
처럼 러스트에도 inspect()
가 있어서 출력과 전체 합계를 하나의 스트림에서 처리할 수 있다.
한 줄 평: 러스트는 사랑스러울지언정 어렵고 어지럽다.
문서 비교
사실 다른 언어는 잘 모르는 채로 시도해봤던 거라 검색과 문서에 크게 의존할 수 밖에 없었다. 개인 취향에 따라 모두 다를테지만, 이왕 비교한 거 구현 말고 문서도 구경하기 수준으로 비교해보자. 그래도 공정한 비교를 위해 groupBy
를 설명하는 문서로 비교한다.
Java
무시무시한 저 메서드 시그너처로 시작해서 구구절절 설명을 이어간다. 그래도 이 groupingBy
는 고맙게도 실제 사용 예제를 하나 던져주셨는데 모든 메서드에 대해 이런 예제가 제공되는 것은 아니다. 그래서 예제가 풍부하게 제공되는 www.baeldung.com 같은 사이트를 많이 참고하게 된다.
Scala
스칼라는 문서도 간결하다. 너무 간결해서 예제도 없다..
Kotlin
코틀린은 예제를 단순한 텍스트가 아니라 실행 가능한 환경과 함께 제공한다!! 그래서 요리조리 직접 바꿔가며 실행 결과를 바로 확인해 볼 수 있다!! 이런 참신함에는 높은 점수를 주고 싶다. 그런데 예제가 풍부하지는 않다.
위에 작성한 코드에는 groupBy
가 키 분류만 가능하게 되어 있는데 문서를 보니 키 분류뿐 아니라 항목 집계도 가능한 걸로 보인다. 하지만 관련 예제가 없어서 어떻게 하면 원하는 대로 바꿀 수 있을지 바로 알 수는 없었다.
Clojure
클로저는 문서도 가장 간결하다.
그런데 그보다 더 중요한 것은 다른 언어 문서들과 달리 공식 문서가 오픈 방식이라는 것이다. 로그인만 하면 사용자가 직접 다양한 예제를 추가할 수 있게 되어 있을뿐 아니라 화면에는 안 나왔지만 질의 응답도 가능하게 완전히 열려있다.
덕분에 클로저를 거의 모르는 나같은 문외한도 MVP 수상횟수 집계 예제 정도는 클로저 문서 사이트만으로도 그럭저럭 짤 수 있었다. 실용성 면에서는 가장 높은 점수를 줄 만하다.
Rust
일단 위 예제에서 사용한 group_by
는 러스트 자체에 포함된 것은 아니고, itertools
라는 크레이트(crate, 그냥 라이브러리 라고 이해해도 크게 틀리지 않음)에서 제공하는 것인데, 문서의 포맷은 러스트의 공식 문서 사이트와 동일하다.
적절한 설명과 함께 예제가 제공된다. 예제에 주석으로 고마운 설명이 달려있긴 하지만 클로저처럼 오픈 방식이 아니라서 예제가 다양하지는 않다.