Java Streams가 왜 일회용입니까?


239

IEnumerable실행 파이프 라인을 원하는만큼 여러 번 실행할 수있는 C #과 달리 Java에서는 스트림을 한 번만 '반복'할 수 있습니다.

터미널 작업을 호출하면 스트림이 닫히고 사용할 수 없게됩니다. 이 '기능'은 많은 힘을 빼앗아갑니다.

나는 그 이유가 기술적 인 것이 아니라고 생각합니다 . 이 이상한 제한 뒤에 디자인 고려 사항은 무엇입니까?

편집 : 내가 말하고있는 것을 보여주기 위해 C #에서 다음 Quick-Sort 구현을 고려하십시오.

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

확실히, 나는 이것이 빠른 정렬의 좋은 구현이라고 주장하지 않습니다! 그러나 스트림 조작과 결합 된 람다 표현의 표현력의 훌륭한 예입니다.

그리고 Java로는 할 수 없습니다! 스트림을 사용할 수 없게 만들지 않고 빈 스트림인지 물어볼 수도 없습니다.


4
하천을 폐쇄하는 것이 "권력을 빼앗는"구체적인 사례를 제시해 주시겠습니까?
Rogério

23
스트림의 데이터를 두 번 이상 사용하려면 해당 데이터를 컬렉션으로 덤프해야합니다. 이것은 거의가 어떻게 일에 : 중 당신은 스트림을 생성하는 계산을 다시해야하거나 중간 결과를 저장해야합니다.
Louis Wasserman

5
좋아,하지만 같은 계산을 다시 실행 동일한 스트림 잘못 들립니다. 반복이 각 반복마다 작성되는 것처럼 계산이 수행되기 전에 주어진 소스에서 스트림이 작성됩니다. 여전히 실제적인 구체적인 예를보고 싶습니다. 결국, C #의 열거 형에 해당하는 방법이 있다고 가정하면 use-once 스트림으로 각 문제를 해결할 수있는 확실한 방법이 있습니다.
Rogério

2
이 질문은 C # IEnumerable을 스트림 과 관련이 있다고 생각했기 때문에 처음에는 혼란 스러웠습니다.java.io.*
SpaceTrucker

9
C #에서 IEnumerable을 여러 번 사용하는 것은 취약한 패턴이므로 질문의 전제에 약간의 결함이있을 수 있습니다. IEnumerable의 많은 구현은 허용하지만 일부는 그렇지 않습니다! 코드 분석 도구는 그러한 일을하지 않도록 경고하는 경향이 있습니다.
Sander

답변:


368

Streams API의 초기 디자인에서 디자인의 이론적 근거를 밝힐 수있는 몇 가지 사항이 있습니다.

2012 년에 우리는 언어에 람다를 추가하고 병렬 처리를 용이하게하는 컬렉션 지향 또는 "대량 데이터"연산 세트를 원했습니다. 게으른 체인 작업의 아이디어는이 시점에서 잘 확립되었습니다. 또한 중간 작업에서 결과를 저장하고 싶지 않았습니다.

우리가 결정해야 할 주요 문제는 체인의 객체가 API에서 어떻게 보이는지와 데이터 소스에 어떻게 연결되어 있는지였습니다. 소스는 종종 수집 이었지만 파일이나 네트워크에서 오는 데이터 또는 난수 생성기 등의 데이터를 즉시 지원하기를 원했습니다.

기존 작업이 디자인에 많은 영향을 미쳤습니다. 더 영향력있는 것은 구글의 구아바 도서관과 스칼라 컬렉션 도서관이었습니다. (구아바의 영향에 대해 아무도 놀라지 않는다면 구아바의 수석 개발자 인 Kevin BourrillionJSR-335 Lambda 에 있다는 점에 유의하십시오. . 전문가 그룹) 스칼라 컬렉션에, 우리는 특히 관심을 마틴 오더 스키하여이 이야기를 발견 Future- 교정 스칼라 컬렉션 : Mutable에서 Persistent, Parallel까지 . (Stanford EE380, 2011 년 6 월 1 일)

당시 우리의 프로토 타입 디자인은 주위에 Iterable있었습니다. 친숙한 작업은 filter, map, 등의 확장자 (기본) 방법이었다 Iterable. 하나를 호출하면 체인에 작업을 추가하고 다른 것을 반환Iterable . 터미널 작업 은 체인을 소스로 count불러 iterator()오며 각 단계의 반복자 내에서 작업이 구현되었습니다.

이것들은 Iterables이므로 iterator() 메소드를 두 번 이상 . 그러면 어떻게됩니까?

소스가 컬렉션 인 경우 대부분 잘 작동합니다. 컬렉션은 반복 가능하며 각 호출은iterator() 은 다른 활성 인스턴스와 독립적 인 고유 한 Iterator 인스턴스 생성하고 각 컬렉션을 독립적으로 통과합니다. 큰.

이제 파일에서 라인을 읽는 것과 같이 소스가 원샷 인 경우 어떻게해야합니까? 첫 번째 Iterator는 모든 값을 가져야하지만 두 번째 이후의 값은 비어 있어야합니다. 반복자 사이에 값을 인터리브해야 할 수도 있습니다. 또는 각 반복자가 동일한 값을 가져와야 할 수도 있습니다. 그렇다면 두 개의 이터레이터가 있고 하나가 다른 반복자보다 앞당기면 어떨까요? 누군가 읽을 때까지 두 번째 Iterator의 값을 버퍼링해야합니다. 더 나쁜 것은, 하나의 Iterator를 얻고 모든 값을 읽은 다음 두 번째 Iterator 얻는 다면 어떨까요? 가치는 지금 어디에서 오는가? 누군가 두 번째 Iterator를 원할 경우를 대비 하여 모두 버퍼링해야 합니까?

분명히 원샷 소스에 여러 반복자를 허용하면 많은 질문이 제기됩니다. 우리는 그들에게 좋은 대답이 없었습니다. 우리는 당신이 전화하면 어떻게 될지 일관되고 예측 가능한 행동을 원했습니다.iterator() 두 번 했습니다. 이로 인해 여러 순회를 허용하지 않고 파이프 라인을 한 번에 만들 수있었습니다.

또한 다른 사람들이 이러한 문제에 부딪 치는 것을 관찰했습니다. JDK에서 대부분의 Iterable은 콜렉션 또는 콜렉션 유사 오브젝트이며 여러 순회를 허용합니다. 어디에도 지정되지 않았지만 Iterables가 다중 순회를 허용한다는 기록되지 않은 기대가있는 것 같습니다. 주목할만한 예외는 NIO DirectoryStream 인터페이스입니다. 사양에는 다음과 같은 흥미로운 경고가 포함됩니다.

DirectoryStream은 Iterable을 확장하지만 단일 Iterator 만 지원하므로 범용 Iterable이 아닙니다. 반복자 메소드를 호출하여 두 번째 또는 후속 반복자를 확보하면 IllegalStateException이 발생합니다.

[원본으로 굵게]

이것은 독특하고 불쾌한 것처럼 보였으며 한 번만 할 수있는 새로운 Iterable을 많이 만들고 싶지 않았습니다. 이로 인해 Iterable을 사용하지 못하게되었습니다.

이시기에 브루스 에켈 (Bruce Eckel)기사가 스칼라와 관련된 문제를 묘사 한 것으로 나타났다. 그는이 코드를 작성했다 :

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

꽤 간단합니다. 텍스트 줄을 Registrant개체 로 구문 분석 하고 두 번 인쇄합니다. 실제로는 한 번만 인쇄합니다. 그는 생각했다registrants 그것이 반복자 일 때 컬렉션 . 두 번째 호출은 foreach모든 값이 소진 된 빈 반복자 를 만나므로 아무것도 인쇄하지 않습니다.

이러한 경험은 여러 번의 순회를 시도 할 경우 명확하게 예측 가능한 결과를 얻는 것이 매우 중요하다는 것을 우리에게 확신 시켰습니다. 또한 데이터를 저장하는 실제 컬렉션과 게으른 파이프 라인 유사 구조를 구별하는 것이 중요하다는 점을 강조했습니다. 결과적으로 게으른 파이프 라인 작업을 새로운 Stream 인터페이스로 분리하고 컬렉션에서 직접 열성적인 변이 작업 만 유지했습니다. Brian Goetz는 이에 대한 이론적 근거를 설명 했습니다.

컬렉션 기반 파이프 라인에는 다중 순회를 허용하지만 컬렉션 기반이 아닌 파이프 라인에는 허용하지 않는 것은 어떻습니까? 일관성이 없지만 합리적입니다. 물론 네트워크에서 값을 읽는다면 당신은 다시 통과 할 수 없다. 여러 번 트래버스하려면 트래버스를 명시 적으로 가져와야합니다.

그러나 컬렉션 기반 파이프 라인에서 여러 순회를 허용하도록하겠습니다. 당신이 이것을했다고 가정 해 봅시다.

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

( into이제 철자가 철자가되었습니다 collect(toList()).)

소스가 콜렉션 인 경우 첫 번째 into()호출은 소스에 반복기 체인을 다시 작성하고 파이프 라인 조작을 실행 한 후 결과를 대상으로 보냅니다. 두 번째 호출 into()은 다른 반복자 체인을 작성하고 파이프 라인 조작을 다시 실행 합니다 . 이것은 분명히 잘못된 것은 아니지만 각 요소에 대해 모든 필터 및 맵 작업을 두 번 수행하는 효과가 있습니다. 많은 프로그래머들이이 행동에 놀랐을 것이라고 생각합니다.

위에서 언급했듯이 우리는 구아바 개발자들과 대화하고있었습니다. 그들이 가지고있는 멋진 것 중 하나는 Idea Graveyard입니다. 여기 에는 이유와 함께 구현 하지 않기로 결정한 기능이 설명되어 있습니다 . 게으른 컬렉션에 대한 아이디어는 꽤 멋지게 들리지만 여기에 그들이 말해야 할 것이 있습니다. 다음 List.filter()을 반환 하는 작업을 고려하십시오 List.

여기서 가장 큰 관심사는 너무 많은 작업이 비싸고 선형적인 시간 제안이된다는 것입니다. 컬렉션이나 Iterable뿐만 아니라 목록을 필터링하고 목록을 다시 가져 오려면을 사용할 수 있습니다 ImmutableList.copyOf(Iterables.filter(list, predicate)).

구체적인 예를 촬영하려면 비용 무엇 get(0)이나 size()목록에이? 와 같이 일반적으로 사용되는 클래스 ArrayList는 O (1)입니다. 그러나 지연 필터링 된 목록에서이 중 하나를 호출하면 백업 목록에서 필터를 실행해야하며 갑자기 이러한 작업은 모두 O (n)입니다. 더 나쁜 것은 모든 작업 에서 지원 목록을 탐색해야한다는 것 입니다.

이것은 우리에게 게으름을 너무 많이 보였다 . 일부 작업을 설정하고 "이동"할 때까지 실제 실행을 연기하는 것이 한 가지입니다. 잠재적으로 많은 양의 재 계산을 숨기는 방식으로 설정하는 것이 또 다른 방법입니다.

비선형 또는 "재사용 불가능"스트림을 허용하지 않기 위해 Paul Sandoz 는 "예기치 않거나 혼란스러운 결과"를 발생 시키는 잠재적 결과 를 설명했습니다 . 또한 병렬 실행으로 인해 작업이 더욱 까다로워 질 것이라고 언급했습니다. 마지막으로, 부작용이 발생한 파이프 라인 작업은 작업이 예기치 않게 여러 번 실행되거나 프로그래머가 예상 한 것과 다른 횟수로 실행되는 경우 어렵고 모호한 버그로 이어질 수 있다고 덧붙였습니다. (그러나 Java 프로그래머는 부작용이있는 람다 식을 쓰지 않습니까? 그렇습니까?)

따라서 Java 8 Streams API 디자인의 기본 이론은 원샷 통과를 허용하고 엄격하게 선형 (분기 없음) 파이프 라인이 필요합니다. 여러 다른 스트림 소스에서 일관된 동작을 제공하고 지연 작업과 지연 동작을 명확하게 분리하며 간단한 실행 모델을 제공합니다.


와 관련하여 IEnumerableC # 및 .NET의 전문가와는 거리가 멀기 때문에 잘못된 결론을 도출하면 (신중하게) 수정되는 것에 감사드립니다. 그러나 IEnumerable여러 순회가 다른 소스와 다르게 동작 할 수있는 것으로 보입니다 . 그리고 중첩 IEnumerable연산 의 분기 구조를 허용하므로 상당한 재 계산이 발생할 수 있습니다. 시스템마다 다른 장단점이 있지만 Java 8 Streams API 디자인에서 피해야 할 두 가지 특성이 있습니다.

OP가 제시 한 퀵 정렬 예제는 흥미롭고 수수께끼이며 다소 무섭습니다. 호출은를 QuickSort가져 와서 IEnumerable를 반환 IEnumerable하므로 최종 IEnumerable이 통과 될 때까지 실제로 정렬이 수행되지 않습니다 . 그러나 호출이하는 것처럼 보이는 IEnumerables것은 퀵 정렬이 실제로 수행하지 않고 분할을 반영 하는 트리 구조를 구축 하는 것입니다. (이것은 결국 게으른 계산입니다.) 소스에 N 개의 요소가 있으면 트리는 가장 넓은 N 개의 요소가되고 lg (N) 레벨의 깊이가됩니다.

다시 한 번 C # 또는 .NET 전문가가 아니므로을 통해 피벗 선택과 같은 무해한 통화가 ints.First()외형보다 비싸 질 것 같습니다. 물론 첫 번째 수준은 O (1)입니다. 그러나 오른쪽 가장자리의 나무 깊이에있는 파티션을 고려하십시오. 이 파티션의 첫 번째 요소를 계산하려면 전체 소스를 순회해야합니다 (O (N) 작업). 그러나 위의 파티션은 게 으르므로 O (lg N) 비교를 요구하여 다시 계산해야합니다. 따라서 피벗을 선택하는 것은 O (N lg N) 작업이 될 것이며 이는 전체 종류만큼 비쌉니다.

그러나 우리는 반환 된을 횡단 할 때까지 실제로 정렬하지 않습니다 IEnumerable. 표준 퀵 정렬 알고리즘에서 각 분할 수준은 분할 수를 두 배로 늘립니다. 각 파티션의 크기는 절반에 불과하므로 각 수준은 O (N) 복잡성으로 유지됩니다. 파티션 트리는 O (lg N) 높이이므로 총 작업량은 O (N lg N)입니다.

게으른 IEnumerables 트리를 사용하면 트리 아래쪽에 N 개의 파티션이 있습니다. 각 파티션을 계산하려면 N 요소의 순회가 필요하며 각 요소는 트리에서 lg (N) 비교가 필요합니다. 트리의 맨 아래에있는 모든 파티션을 계산하려면 O (N ^ 2 lg N) 비교가 필요합니다.

(이것이 맞나요? 나는 이것을 믿을 수 없습니다. 누군가 나를 위해 이것을 확인하십시오.)

어쨌든 IEnumerable복잡한 계산 구조를 구축하기 위해 이런 식으로 사용될 수있는 것은 참으로 시원합니다 . 그러나 내가 생각하는 것만 큼 계산 복잡성을 증가시키는 경우이 방법을 프로그래밍하면 매우 신중하지 않으면 피해야 할 것 같습니다.


35
우선, 위대하고 비협조적인 답변에 감사드립니다! 이것은 내가 얻은 가장 정확하고 요점 설명입니다. QuickSort 예제가 진행되는 한, 당신은 정수에 관한 것 같습니다. 재귀 수준이 증가함에 따라 첫 번째 팽만감. 나는 'gt'와 'lt'를 ​​열심히 (ToArray로 결과를 수집하여) 계산하여 쉽게 해결할 수 있다고 생각합니다. 즉,이 스타일의 프로그래밍은 예상치 못한 성능 가격을 초래할 수 있다는 점을 확실히 뒷받침합니다. (두 번째 코멘트에서 계속)
Vitaliy

18
반면에 C # (5 년 이상)에 대한 나의 경험을 통해 성능 문제에 부딪친 후에 '중복'계산을 근절하는 것이 그렇게 어렵지 않다는 것을 알 수 있습니다. 부작용이 있습니다). C #과 같은 가능성을 희생시키면서 API의 순수성을 보장하기 위해 너무 많은 타협이 이루어진 것처럼 보였습니다. 당신은 확실히 내 관점을 조정하는 데 도움이되었습니다.
Vitaliy 2019

7
@Vitaliy 아이디어를 교환 해 주셔서 감사합니다. 이 답변을 조사하고 작성하여 C # 및 .NET에 대해 조금 배웠습니다.
스튜어트 마크

10
작은 의견 : ReSharper는 C #을 돕는 Visual Studio 확장입니다. 위의 QuickSort 코드를 사용하여 ReSharper는 "IEnumerable의 다중 열거 가능"이라는 각 용도에 대해ints 경고 추가합니다 . 같은 IEenumerable것을 두 번 이상 사용하는 것은 의심스럽고 피해야합니다. 나는 또한이 질문 (내가 대답 한)을 지적 할 것이다. 이것은 .Net 접근법 (성능 저하) 외에 다음과 같은 몇 가지주의 사항을 보여준다 : List <T>와 IEnumerable difference
Kobi

4
@ Kobi ReSharper에 그러한 경고가 있다는 것이 매우 흥미 롭습니다. 답변에 대한 포인터 주셔서 감사합니다. C # /. NET을 모르므로 신중하게 선택해야하지만 위에서 언급 한 디자인 문제와 비슷한 문제가있는 것 같습니다.
스튜어트 마크

122

배경

질문은 단순 해 보이지만 실제 답변에는 이해하기위한 배경이 필요합니다. 결론으로 건너 뛰려면 아래로 스크롤하십시오.

비교 포인트 선택-기본 기능

C #의 IEnumerable개념은 기본 개념을 사용하여 원하는 수의 반복자 를 만들 수있는 JavaIterable 와 더 밀접한 관련 이 있습니다. 을 만듭니다 . 자바의 창조IEnumerablesIEnumeratorsIterableIterators

각 개념의 역사는 모두 점에서 유사하다 IEnumerableIterable스타일 데이터 수집의 구성원에 걸쳐 반복 '는 각각에 대해 -'허용하는 기본 의욕을 가지고있다. 그것들은 단지 그 이상을 허용하고 다른 진행을 통해 그 단계에 도달했기 때문에 지나치게 단순화되었지만, 그것은 공통적 인 특징입니다.

이 기능을 비교해 보겠습니다. 두 언어 모두 클래스가 IEnumerable/를 구현하는 경우 Iterable해당 클래스는 최소한 하나의 메소드를 구현해야합니다 (C # GetEnumerator및 Java의 경우 iterator()). 두 경우 모두 해당 인스턴스 ( IEnumerator/ Iterator) 에서 반환 된 인스턴스 를 사용하면 현재 및 후속 데이터 멤버에 액세스 할 수 있습니다. 이 기능은 for-each 언어 구문에서 사용됩니다.

비교 지점 선택-기능 향상

IEnumerableC #에서는 여러 가지 다른 언어 기능 ( 주로 Linq 관련 ) 을 허용하도록 확장되었습니다 . 추가 된 기능에는 선택, 프로젝션, 집계 등이 포함됩니다. 이러한 확장에는 SQL 및 관계형 데이터베이스 개념과 유사하게 집합 이론에서 사용하는 데 큰 동기가 있습니다.

Java 8에는 Streams 및 Lambdas를 사용하여 어느 정도의 기능 프로그래밍이 가능하도록 기능이 추가되었습니다. Java 8 스트림은 주로 집합 이론이 아니라 기능적 프로그래밍에 의해 동기가 부여됩니다. 그럼에도 불구하고 많은 유사점이 있습니다.

이것이 두 번째 요점입니다. C #의 향상된 기능은 IEnumerable개념 의 향상된 기능으로 구현되었습니다 . 자바에서하지만, 만든 향상은 람다 및 스트림의 새 기본 개념을 창조하고 또한 변환에서 상대적으로 사소한 방법을 작성하여 구현 된 IteratorsIterables스트림에, 그리고 비자의 경우도 마찬가지입니다.

따라서 IEnumerable을 Java의 Stream 개념과 비교하는 것은 불완전합니다. Java에서 결합 된 Streams and Collections API와 비교해야합니다.

Java에서 스트림은 Iterables 또는 Iterator와 동일하지 않습니다.

스트림은 반복자와 같은 방식으로 문제를 해결하도록 설계되지 않았습니다.

  • 반복자는 데이터 시퀀스를 설명하는 방법입니다.
  • 스트림은 일련의 데이터 변환을 설명하는 방법입니다.

를 사용하면 Iterator데이터 값을 가져 와서 처리 한 다음 다른 데이터 값을 얻을 수 있습니다.

Streams를 사용하면 일련의 함수를 함께 연결 한 다음 입력 값을 스트림에 공급하고 결합 된 시퀀스에서 출력 값을 가져옵니다. Java 용어로 각 함수는 단일 Stream인스턴스로 캡슐화됩니다 . Streams API를 사용하면 Stream일련의 변환 표현식을 연결하는 방식으로 일련의 인스턴스 를 연결할 수 있습니다 .

Stream개념 을 완성하려면 스트림을 공급할 데이터 소스와 스트림을 소비하는 터미널 기능이 필요합니다.

스트림에 값을 공급하는 방식은 실제로에서 온 것일 수 Iterable있지만 Stream시퀀스 자체는가 아니며 Iterable복합 함수입니다.

A Stream는 또한 값을 요청할 때만 작동한다는 점에서 게 으르도록 설계되었습니다.

스트림의 다음과 같은 중요한 가정과 기능에 유의하십시오.

  • StreamJava의 A 는 변환 엔진이며 한 상태의 데이터 항목을 다른 상태로 변환합니다.
  • 스트림에는 데이터 순서 나 위치에 대한 개념이 없으며 요청한 내용 만 변환하면됩니다.
  • 스트림에는 다른 스트림, 반복자, 반복 가능 항목, 콜렉션,
  • 스트림을 "재설정"할 수 없습니다. "변환 재 프로그래밍"과 같습니다. 데이터 소스를 재설정하는 것이 좋습니다.
  • 스트림에 논리적으로 하나의 데이터 항목 '비행 중'만 있습니다 (스트림이 병렬 스트림이 아닌 경우 스레드 당 1 개의 항목이 있음). 이것은 스트림에 공급 될 현재 아이템 '준비'이상을 가질 수있는 데이터 소스 또는 다수의 값을 집계하고 감소시켜야하는 스트림 수집기와는 무관하다.
  • 스트림은 언 바운드 (무한), 데이터 소스 또는 콜렉터 (무한도 가능)에 의해서만 제한 될 수 있습니다.
  • 스트림은 하나의 스트림을 필터링 한 결과 인 '체인 가능'이며 또 다른 스트림입니다. 스트림에 입력되고 변환 된 값은 다른 변환을 수행하는 다른 스트림에 제공 될 수 있습니다. 변환 된 상태의 데이터는 한 스트림에서 다음 스트림으로 흐릅니다. 한 스트림에서 데이터를 개입시키고 끌어 올 필요가 없습니다.

C # 비교

Java 스트림이 공급, 스트림 및 수집 시스템의 일부일뿐 아니라 스트림 및 반복자가 종종 콜렉션과 함께 사용된다고 생각할 때, 동일한 개념과 관련이있는 것은 놀라운 일이 아닙니다. 거의 모든 것이 IEnumerableC # 의 단일 개념에 포함되어 있습니다.

IEnumerable (및 밀접한 관련 개념)의 일부는 모든 Java Iterator, Iterable, Lambda 및 Stream 개념에서 분명합니다.

Java 개념이 IEnumerable에서 더 어렵고 그 반대로 할 수있는 작은 일이 있습니다.


결론

  • 여기에는 디자인 문제가 없으며 언어 간의 개념을 일치시키는 데 문제가 있습니다.
  • 스트림은 다른 방식으로 문제를 해결
  • 스트림은 Java에 기능을 추가합니다 (다른 방식으로 작업을 수행하지만 기능을 제거하지는 않습니다)

스트림을 추가하면 문제를 해결할 때 더 많은 선택을 할 수 있습니다. 문제를 '감소', '감소'또는 '제한'하는 것이 아니라 '능력 강화'로 분류하는 것이 좋습니다.

Java Streams가 왜 일회용입니까?

스트림은 데이터가 아니라 함수 시퀀스이기 때문에이 질문은 잘못된 것입니다. 스트림을 공급하는 데이터 소스에 따라 데이터 소스를 재설정하고 동일하거나 다른 스트림을 공급할 수 있습니다.

실행 파이프 라인을 원하는만큼 실행할 수있는 C #의 IEnumerable과 달리 Java에서는 스트림을 한 번만 '반복'할 수 있습니다.

비교 IEnumerableA와 것은 Stream잘못이다. 말하는 데 사용하는 컨텍스트는 IEnumerable원하는 횟수만큼 실행할 수 있으며 원하는 횟수만큼 Iterables반복 할 수있는 Java와 비교하는 것이 가장 좋습니다 . Java StreamIEnumerable개념 의 서브 세트를 나타내며 데이터를 제공하는 서브 세트가 아니므로 '재실행'할 수 없습니다.

터미널 작업을 호출하면 스트림이 닫히고 사용할 수 없게됩니다. 이 '기능'은 많은 힘을 빼앗아갑니다.

첫 번째 진술은 어떤 의미에서는 사실입니다. '권력을 빼앗다'는 말은 아니다. 여전히 IEnumerables와 Streams를 비교하고 있습니다. 스트림의 터미널 작업은 for 루프의 'break'절과 같습니다. 원하는 경우 언제든지 필요한 데이터를 다시 제공 할 수있는 경우 다른 스트림을 자유롭게 이용할 수 있습니다. 다시 말하지만, 이 문장에 대해 IEnumerable더 같다고 생각하면 IterableJava가 잘 수행합니다.

나는 그 이유가 기술적 인 것이 아니라고 생각합니다. 이 이상한 제한 뒤에 디자인 고려 사항은 무엇입니까?

그 이유는 기술적 인 것이며, 단순한 이유 때문에 Stream이 생각하는 것의 일부를 Stream합니다. 스트림 서브 세트는 데이터 공급을 제어하지 않으므로 스트림이 아닌 공급을 재설정해야합니다. 그런 맥락에서 그리 이상하지 않습니다.

빠른 정렬 예

퀵 정렬 예제에는 다음과 같은 서명이 있습니다.

IEnumerable<int> QuickSort(IEnumerable<int> ints)

입력 IEnumerable을 데이터 소스로 취급하고 있습니다 .

IEnumerable<int> lt = ints.Where(i => i < pivot);

또한 리턴 값 IEnumerable도 데이터의 공급이며, 이는 정렬 작업이므로 해당 공급의 순서가 중요합니다. Java Iterable클래스가 이것에 대한 적절한 일치, 특히의 List전문화로 간주되는 경우 IterableList는 순서 또는 반복이 보장되는 데이터 공급이므로 코드와 동등한 Java 코드는 다음과 같습니다.

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

정렬이 중복 값을 정상적으로 처리하지 못한다는 점에서 버그가 있습니다 (유일한 값) 정렬입니다.

또한 Java 코드가 어떻게 데이터 소스 ( List)를 사용 하고 다른 시점에서 개념을 스트리밍하는지, C #에서이 두 '인격'은 그냥로 표현 될 수 있습니다 IEnumerable. 또한 List기본 유형으로 사용했지만 더 일반적인을 사용할 수 있었고 Collection작은 반복자에서 스트림으로 변환하면 더 일반적인을 사용할 수있었습니다.Iterable


9
스트림을 '추천'할 생각이라면 잘못하고있는 것입니다. 스트림은 일련의 변환에서 특정 시점의 데이터 상태를 나타냅니다. 데이터는 스트림 소스로 시스템에 들어간 다음 한 스트림에서 다음 스트림으로 흐르며 마지막에 수집, 축소 또는 덤프 될 때까지 상태가 변경됩니다. A Stream는 '루프 연산'이 아닌 특정 시점 개념입니다 .... (계속)
rolfl

7
스트림을 사용하면 X처럼 보이는 스트림에 들어가고 Y처럼 보이는 스트림을 나가는 데이터가 있습니다. 스트림이 변환을 수행하는 기능이 있습니다. 스트림은 함수를 f(x)캡슐화하고, 흐르는 데이터를 캡슐화하지 않습니다
rolfl

4
IEnumerable또한 임의의 값을 제공하고, 바인드 해제하고, 데이터가 존재하기 전에 활성화 될 수 있습니다.
Arturo Torres Sánchez

6
@Vitaliy : IEnumerable<T>여러 번 반복 될 수있는 유한 컬렉션을 나타낼 것으로 예상 되는 많은 메소드 . 반복 가능하지만 해당 조건을 충족하지 않는 일부 IEnumerable<T>는 청구서에 맞는 다른 표준 인터페이스가 없기 때문에 구현 되지만, 여러 번 반복 될 수있는 유한 컬렉션을 기대하는 메소드는 해당 조건을 준수하지 않는 반복 가능한 항목이 제공되면 충돌하기 쉽습니다. .
supercat

5
귀하의 quickSort그것은 반환 경우가 훨씬 더 간단 할 수있다 Stream; 두 .stream()통화와 한 .collect(Collectors.toList())통화를 저장 합니다. 그런 다음 교체하는 경우 Collections.singleton(pivot).stream()Stream.of(pivot)코드 ... 거의 읽을된다
홀거

22

StreamSpliterator상태 기반의 가변 객체 인 주위에 빌드 됩니다. 그들은 "재설정"동작이 없으며 실제로 이러한 되감기 동작을 지원해야한다면 "많은 힘을 빼앗을 것"입니다. 어떻게 것 Random.ints()같은 요청을 처리하기로한다?

반면에, Stream추적 가능한 원점을 가진 s의 Stream경우 다시 사용할 등가물 을 쉽게 구성 할 수 있습니다. 를 구성하기위한 단계를 Stream재사용 가능한 방법으로 넣으십시오 . 이 단계를 모두 반복하는 것이 비용이 많이 드는 작업이 아니라는 점을 명심하십시오. 실제 작업은 터미널 작업으로 시작하며 실제 터미널 작업에 따라 완전히 다른 코드가 실행될 수 있습니다.

메소드를 두 번 호출하는 것이 무엇을 의미하는지 지정하는 것은 해당 메소드의 작성자에게 달려 있습니다. 수정되지 않은 배열이나 콜렉션에 대해 작성된 스트림이 수행하는 것과 동일한 순서를 정확하게 재현합니까? 임의의 정수 스트림 또는 콘솔 입력 라인 스트림 등과 같은 유사한 의미론이지만 다른 요소


그런데, 혼동을 피하기 위해, 터미널 동작 스트림 을 호출 할 때 와 같이 폐쇄 하는 것과 Stream구별되는 것을 소비 한다 (이는 예를 들어에 의해 생성 된 것과 같은 관련 자원을 갖는 스트림에 필요함 ).Streamclose()Files.lines()


많은 혼란이 비교 misguiding에서 비롯된 것으로 보인다 IEnumerable과를 Stream. 은 IEnumerable실제를 제공 할 수있는 능력을 나타내는 IEnumerator, 그래서 그 같은 Iterable자바있다. 반대로 a Stream는 일종의 반복자이며 이에 필적하기 IEnumerator때문에 .NET에서 이러한 종류의 데이터 유형을 여러 번 사용할 수 있다고 주장하는 것은 잘못 IEnumerator.Reset입니다. 지원 은 선택 사항입니다. 여기서 논의 된 예제는 새로운IEnumerable 것을 가져 오는 데 사용할 수 있고 Java 에서도 작동 한다는 사실을 사용합니다 . 당신은 새로운를 얻을 수 있습니다 . Java 개발자가에 작업 을 추가하기로 결정한 경우 실제로 비교할 수 있으며 동일한 방식으로 작동 할 수 있습니다. IEnumeratorCollectionStreamStreamIterable 직접 중간 작업이 다른 작업을 반환Iterable

그러나 개발자는 이에 대해 결정하고 결정은 이 질문 에서 논의됩니다 . 가장 큰 요점은 간절한 수집 작업과 게으른 스트림 작업에 대한 혼란입니다. .NET API를 살펴보면 개인적으로 그렇습니다. IEnumerable혼자 보는 것이 합리적으로 보이지만 특정 Collection에는 Collection을 직접 조작하는 많은 메소드와 lazy를 반환하는 많은 IEnumerable메소드가 있지만 메소드의 특정 특성이 항상 직관적으로 인식되는 것은 아닙니다. 내가 찾은 최악의 예 (몇 분 만에)는 완전히 모순되는 동작을하면서 상속 List.Reverse()된 이름과 정확히 일치 하는 이름입니다 (확장 방법에 대한 올바른 종말입니까?) Enumerable.Reverse().


물론 이것들은 두 가지 별개의 결정입니다. 첫 번째 StreamIterable/ Collection와 구별되는 유형 을 만들고 두 번째 Stream는 다른 종류의 반복 가능한 것이 아니라 일종의 한 번 반복자를 만드는 것입니다. 그러나 이러한 결정은 함께 이루어졌으며이 두 결정의 분리가 고려되지 않은 경우 일 수 있습니다. .NET을 염두에두고 만들어지지 않았습니다.

실제 API 디자인 결정은 향상된 반복자 유형 인을 추가하는 것이 었습니다 Spliterator. Spliterator구식 Iterable(이것이 개조 된 방식) 또는 완전히 새로운 구현에 의해 제공 될 수있다 . 그런 다음 Stream다소 낮은 수준 Spliterator의 고급 프런트 엔드로 추가되었습니다 . 그게 다야. 다른 디자인이 더 나을지 여부에 대해 논의 할 수도 있지만, 지금은 디자인 방식에 따라 생산적이지 않고 변경되지 않습니다.

고려해야 할 또 다른 구현 측면이 있습니다. Streams는 변경할 수없는 데이터 구조 가 아닙니다 . 각 중간 작업은 Stream이전 인스턴스를 캡슐화 하는 새 인스턴스를 반환 할 수 있지만 대신 자체 인스턴스를 조작하고 자체를 반환 할 수도 있습니다 (동일한 작업에 대해 두 가지를 모두 수행 할 수는 없음). 일반적으로 알려진 예는 작업이 좋아하다 parallel거나 unordered하는 또 다른 단계를 추가하지만, 전체 파이프 라인을 조작하지 않음). 이러한 변경 가능한 데이터 구조를 가지고 재사용을 시도하거나 더 많은 시간을 동시에 사용하는 것은 좋지 않습니다.


완전성을 위해 다음은 Java StreamAPI로 변환 된 빠른 정렬 예제 입니다. 그것은 실제로 "많은 힘을 빼앗아 가지"않는다는 것을 보여줍니다.

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}

그것은처럼 사용할 수 있습니다

List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));

보다 컴팩트하게 쓸 수 있습니다

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}

1
음, 소비 여부에 관계없이 다시 소비하려고하면 스트림이 이미 닫히고 소비되지 않은 예외가 발생합니다 . 말했듯이 임의의 정수 스트림을 재설정 할 때의 문제에 관해서는 재설정 작업의 정확한 계약을 정의하는 것은 라이브러리 작성자에게 달려 있습니다.
Vitaliy

2
아니요, 메시지는“스트림이 이미 작동 중이거나 닫혔습니다.”이며“재설정”작업에 대해 이야기하는 것이 아니라 두 개 이상의 터미널 작업을 호출하는 Stream반면 소스 재설정은 Spliterator암시됩니다. 그리고 그것이 가능하다면 확신합니다.“왜 매번 count()두 번 전화를 걸 Stream때마다 다른 결과 가 나옵니다”등과 같은 질문이있었습니다 .
Holger

1
count ()가 다른 결과를 제공하는 것은 절대적으로 유효합니다. count ()는 스트림에 대한 쿼리이며 스트림이 변경 가능하면 (또는 더 정확하면 스트림이 변경 가능한 콜렉션에 대한 쿼리 결과를 나타냄) 예상됩니다. C #의 API를 살펴보십시오. 그들은이 모든 문제를 우아하게 처리합니다.
Vitaliy

4
“절대적으로 유효한”이라고 부르는 것은 반 직관적 인 행동입니다. 결국 스트림을 여러 번 사용하여 결과를 처리하는 방법에 대해 다른 방식으로 동일하게 요구하는 것이 주요 동기입니다. 의 비 재사용 성격에 대한 SO에 대한 모든 질문 Stream들 지금까지 자동으로 깨진 솔루션을 주도하는 경우 (그렇지 않으면 당신은하지 통지 않습니다, 분명히) 단자 작업을 여러 번 호출하여 문제를 해결하기위한 시도에서 유래 StreamAPI 허용이 각 평가에 대해 다른 결과가 나타납니다. 여기 좋은 예가 있습니다.
Holger

3
실제로, 예제는 프로그래머가 여러 터미널 작업을 적용 할 때의 의미를 이해하지 못하는 경우 어떻게되는지 완벽하게 보여줍니다. 이러한 각 작업이 완전히 다른 요소 집합에 적용될 때 어떤 일이 발생하는지 생각해보십시오. 스트림의 소스가 각 쿼리에서 동일한 요소를 반환 한 경우에만 작동하지만 이것은 우리가 이야기 한 잘못된 가정입니다.
Holger

8

충분히 살펴보면 둘 사이에 차이가 거의 없다고 생각합니다.

얼굴에, IEnumerable재사용 가능한 구조 인 것처럼 보입니다 :

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

그러나 컴파일러는 실제로 우리를 돕기 위해 약간의 작업을 수행하고 있습니다. 다음 코드를 생성합니다.

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

실제로 열거 형을 반복 할 때마다 컴파일러는 열거자를 만듭니다. 열거자는 재사용 할 수 없습니다. 추가 호출 MoveNext은 false를 반환하며 처음으로 재설정 할 수있는 방법이 없습니다. 숫자를 다시 반복하려면 다른 열거 자 인스턴스를 작성해야합니다.


IEnumerable이 Java 스트림과 동일한 '기능'을 가지고 있거나 가질 수 있음을 더 잘 설명하려면 숫자의 소스가 정적 콜렉션이 아닌 열거 가능 항목을 고려하십시오. 예를 들어, 5 개의 난수 시퀀스를 생성하는 열거 가능한 객체를 만들 수 있습니다.

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

이제 우리는 이전 배열 기반 열거 형과 매우 유사한 코드를 가지지 만 두 번째 반복은 numbers다음과 같습니다.

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

두 번째로 반복 할 numbers때 같은 의미에서 재사용 할 수없는 다른 숫자 시퀀스를 얻게됩니다. 또는 RandomNumberStream예외를 여러 번 반복하여 열거 형을 실제로 사용할 수 없게 만드는 경우 (예 : Java 스트림) 예외를 throw하도록을 작성할 수 있습니다 .

또한 열거 형 빠른 정렬은 RandomNumberStream?


결론

따라서 가장 큰 차이점은 .NET을 사용하면 시퀀스의 요소에 액세스해야 할 때마다 백그라운드에서 IEnumerable암시 적으로 새 항목 IEnumerator을 만들어 재사용 할 수 있다는 것입니다.

이 암시 적 동작은 컬렉션을 반복해서 반복 할 수 있기 때문에 종종 유용합니다 (현명한대로 '강력한').

그러나 때때로 이러한 암묵적인 행동으로 인해 실제로 문제가 발생할 수 있습니다. 데이터 소스가 정적 인 것이 아니거나 데이터베이스 나 웹 사이트와 같이 액세스하는 데 비용이 많이 든다면 많은 가정 IEnumerable을 버려야합니다. 재사용은 간단하지 않습니다


2

Stream API에서 일부 "한 번 실행"보호 기능을 무시할 수 있습니다. 예를 들어 java.lang.IllegalStateException, Spliterator( Stream직접적인 것이 아니라 )를 참조하고 재사용함으로써 예외 ( "스트림이 이미 작동되었거나 닫혔다"라는 메시지로)를 피할 수 있습니다 .

예를 들어이 코드는 예외를 발생시키지 않고 실행됩니다.

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

그러나 출력은

prefix-hello
prefix-world

출력을 두 번 반복하지 말고 때문이다 ArraySpliterator사용 된 바와 같이 Stream소스 상태이며, 현재의 위치를 저장한다. 우리가 이것을 재생할 때 우리 Stream는 끝에서 다시 시작합니다.

이 문제를 해결하기위한 여러 가지 옵션이 있습니다.

  1. Stream와 같은 상태 비 저장 생성 방법 을 사용할 수 있습니다 Stream#generate(). 우리는 자체 코드에서 외부 적으로 상태를 관리하고 Stream"재생" 사이에서 재설정해야합니다 .

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
  2. 이것에 대한 또 다른 (약간 더 좋지만 완벽하지 않은) 해결책 은 현재 카운터를 재설정 할 수있는 용량을 포함하는 자체 ArraySpliterator(또는 유사한 Stream소스) 를 작성하는 것 입니다. 우리가 그것을 사용하여 생성한다면 Stream잠재적으로 성공적으로 재생할 수 있습니다.

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
  3. 이 문제에 대한 가장 좋은 해결책은 (내 의견으로는) 새 연산자가에서 호출 될 때 파이프 라인에 Spliterator사용되는 모든 상태 저장 의 새 사본을 만드는 StreamStream입니다. 이것은 더 복잡하고 구현에 관여하지만 타사 라이브러리를 사용하는 것이 마음에 들지 않으면 cyclops-reactStream정확하게이를 구현합니다. (공개 : 저는이 프로젝트의 수석 개발자입니다.)

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);

이것은 인쇄됩니다

prefix-hello
prefix-world
prefix-hello
prefix-world

예상대로.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.