เหตุใด Java Streams จึงปิดทันที


239

ซึ่งแตกต่างจาก C # IEnumerableที่ไพพ์ไลน์การประมวลผลสามารถดำเนินการได้หลายครั้งตามที่เราต้องการใน Java สตรีมสามารถ 'ซ้ำ' เพียงครั้งเดียว

การเรียกใช้งานเทอร์มินัลใด ๆ จะปิดสตรีมทำให้ไม่สามารถใช้งานได้ 'คุณสมบัติ' นี้ใช้พลังงานจำนวนมาก

ฉันคิดว่าเหตุผลนี้ไม่ใช่เทคนิค สิ่งที่ต้องพิจารณาในการออกแบบภายใต้ข้อ จำกัด ที่แปลกประหลาดนี้คืออะไร?

แก้ไข: เพื่อแสดงสิ่งที่ฉันกำลังพูดถึงให้พิจารณาการนำไปใช้งาน Quick-Sort ใน C #:

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
ok แต่ redoing เดียวกันการคำนวณบนเดียวกันกระแสเสียงที่ไม่ถูกต้อง กระแสข้อมูลถูกสร้างขึ้นจากแหล่งข้อมูลที่กำหนดก่อนที่จะทำการคำนวณเช่นเดียวกับตัววนซ้ำที่ถูกสร้างขึ้นสำหรับการทำซ้ำแต่ละครั้ง ฉันยังต้องการเห็นตัวอย่างที่เป็นรูปธรรมที่แท้จริง ในท้ายที่สุดฉันเดิมพันว่ามีวิธีที่สะอาดในการแก้ปัญหาแต่ละครั้งด้วยสตรีมที่ใช้งานครั้งเดียวโดยสมมติว่ามีวิธีที่สอดคล้องกันอยู่กับ C # ของ enumerables
Rogério

2
นี่เป็นสิ่งที่ทำให้ฉันสับสนในตอนแรกเพราะฉันคิดว่าคำถามนี้จะเชื่อมโยง C # IEnumerableกับกระแสข้อมูลของjava.io.*
SpaceTrucker

9
โปรดทราบว่าการใช้ IEnumerable หลายครั้งใน C # เป็นรูปแบบที่เปราะบางดังนั้นคำถามของคำถามอาจมีข้อบกพร่องเล็กน้อย การใช้งาน IEnumerable จำนวนมากยอมให้ทำได้ แต่บางอย่างไม่ได้! เครื่องมือวิเคราะห์รหัสมักจะเตือนคุณไม่ให้ทำสิ่งนี้
Sander

คำตอบ:


368

ฉันมีความทรงจำบางอย่างจากการออกแบบช่วงต้นของ Streams API ที่อาจทำให้เข้าใจถึงเหตุผลในการออกแบบ

ย้อนกลับไปในปี 2012 เราเพิ่ม lambdas เข้ากับภาษาและเราต้องการชุดปฏิบัติการที่เน้นการเก็บรวบรวมหรือ "ข้อมูลจำนวนมาก" โปรแกรมที่ใช้ lambdas ซึ่งจะช่วยให้เกิดความเท่าเทียมกัน ความคิดในการดำเนินงานร่วมกันอย่างเกียจคร้านนั้นได้รับการยอมรับอย่างดีในจุดนี้ นอกจากนี้เรายังไม่ต้องการให้การดำเนินการระดับกลางจัดเก็บผลลัพธ์

ประเด็นหลักที่เราต้องตัดสินใจคือสิ่งที่วัตถุในห่วงโซ่ดูเหมือนใน API และวิธีที่พวกเขาเชื่อมต่อกับแหล่งข้อมูล แหล่งที่มามักจะเก็บ แต่เราต้องการสนับสนุนข้อมูลที่มาจากไฟล์หรือเครือข่ายหรือข้อมูลที่สร้างขึ้นทันทีเช่นจากตัวสร้างตัวเลขสุ่ม

การออกแบบที่มีอยู่มีอิทธิพลมากมาย สิ่งที่มีอิทธิพลมากขึ้นคือห้องสมุดGuavaของ Google และห้องสมุด Scala (หากใครรู้สึกประหลาดใจเกี่ยวกับอิทธิพลจาก Guava โปรดทราบว่าKevin Bourrillionผู้พัฒนานำ Guava อยู่ในกลุ่มผู้เชี่ยวชาญของLambra JSR-335 ) ในคอลเลคชั่นสกาล่าเราพบว่าการพูดคุยของ Martin Odersky น่าสนใจเป็นพิเศษ: อนาคต - ตรวจสอบสกาล่าคอลเลกชัน: จากไม่แน่นอนที่จะต่อเนื่องไปขนาน (Stanford EE380, 2011 1. มิถุนายน)

Iterableการออกแบบต้นแบบของเราในเวลานั้นขึ้นอยู่รอบ ๆ การดำเนินงานที่คุ้นเคยfilter, mapและอื่น ๆ เป็นส่วนขยายวิธีการ (ค่าเริ่มต้น) Iterableบน Iterableหนึ่งโทรเพิ่มการดำเนินการเพื่อโซ่และกลับมาอีก การดำเนินการของเทอร์มินัลcountจะเรียกiterator()ใช้เชนไปยังแหล่งที่มาและการดำเนินการถูกนำไปใช้ภายใน Iterator ของแต่ละขั้นตอน

เนื่องจากสิ่งเหล่านี้เป็น Iterables คุณสามารถเรียกiterator()วิธีการนี้มากกว่าหนึ่งครั้ง แล้วจะเกิดอะไรขึ้น?

หากแหล่งที่มาเป็นคอลเลกชันส่วนใหญ่ใช้งานได้ดี คอลเล็กชันคือ Iterable และการเรียกแต่ละครั้งเพื่อiterator()สร้างอินสแตนซ์ Iterator ที่แตกต่างกันซึ่งเป็นอิสระจากอินสแตนซ์ที่ใช้งานอื่น ๆ ยิ่งใหญ่

ตอนนี้จะเกิดอะไรขึ้นถ้าแหล่งที่มาเป็นภาพเดียวเช่นอ่านบรรทัดจากไฟล์? บางที Iterator แรกควรได้รับค่าทั้งหมด แต่ค่าที่สองและค่าที่ตามมาควรจะว่างเปล่า บางทีค่าควรถูกแทรกใน Iterators หรือบางที Iterator แต่ละคนควรได้รับค่าเดียวกันทั้งหมด ถ้าหากคุณมีตัววนซ้ำสองตัวและอีกตัวหนึ่งจะไกลกว่าตัวอื่น ๆ บางคนจะต้องบัฟเฟอร์ค่าใน Iterator ที่สองจนกว่าพวกเขาจะอ่าน ที่แย่ไปกว่าสิ่งที่ถ้าคุณได้รับหนึ่ง Iterator และอ่านค่าทั้งหมดและมีเพียงแล้วจะได้รับเป็นครั้งที่สอง Iterator คุณค่ามาจากไหนตอนนี้? มีข้อกำหนดสำหรับพวกเขาทั้งหมดไหมที่จะถูกบัฟเฟอร์ในกรณีที่บางคนต้องการ Iterator ตัวที่สอง?

เห็นได้ชัดว่าการอนุญาตให้ Iterator หลาย ๆ คนผ่านแหล่งข้อมูลที่มีหนึ่งนัดทำให้เกิดคำถามมากมาย เราไม่มีคำตอบที่ดีสำหรับพวกเขา เราต้องการพฤติกรรมที่สอดคล้องและคาดการณ์ได้สำหรับสิ่งที่เกิดขึ้นหากคุณโทรหาiterator()สองครั้ง สิ่งนี้ผลักดันให้เราไม่อนุญาตให้มีการแวะผ่านหลายเส้นทางทำให้ท่อส่งภาพเดียว

เรายังสังเกตเห็นคนอื่น ๆ ชนเข้ากับปัญหาเหล่านี้ ใน JDK Iterables ส่วนใหญ่เป็นคอลเลกชันหรือวัตถุที่เหมือนคอลเลกชันซึ่งอนุญาตการแวะผ่านหลายทาง มันไม่ได้ระบุที่ใดก็ได้ แต่ดูเหมือนจะมีความคาดหวังที่ไม่ได้เขียนไว้ว่า Iterables อนุญาตการแวะผ่านหลายทาง ข้อยกเว้นที่น่าสังเกตคืออินเตอร์เฟสNIO DirectoryStream ข้อกำหนดของมันรวมถึงคำเตือนที่น่าสนใจนี้:

ในขณะที่ DirectoryStream ขยาย Iterable มันไม่ได้เป็น Iterable วัตถุประสงค์ทั่วไปเพราะรองรับเพียง Iterator เดียว; เรียกใช้เมธอด iterator เพื่อรับตัววนซ้ำตัวที่สองขึ้นไป IllegalStateException

[กล้าได้กล้าเสียในต้นฉบับ]

นี่ดูเหมือนผิดปกติและไม่เป็นที่พอใจมากพอที่เราไม่ต้องการสร้างกลุ่ม Iterables ใหม่ทั้งหมดซึ่งอาจเป็นเพียงครั้งเดียวเท่านั้น สิ่งนี้ทำให้เราห่างจากการใช้ Iterable

ประมาณเวลานี้บทความของ Bruce Eckelปรากฏว่าอธิบายถึงปัญหาที่เขามีกับ Scala เขาเขียนโค้ดนี้:

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

มันค่อนข้างตรงไปตรงมา แยกวิเคราะห์ข้อความในRegistrantวัตถุและพิมพ์ออกมาสองครั้ง ยกเว้นว่ามันจะพิมพ์ออกมาเพียงครั้งเดียวเท่านั้น ปรากฎว่าเขาคิดว่าregistrantsเป็นคอลเลกชันเมื่อในความเป็นจริงมันเป็นตัววนซ้ำ การเรียกครั้งที่สองเพื่อforeachพบตัววนซ้ำว่างเปล่าซึ่งค่าทั้งหมดหมดแล้วจึงไม่พิมพ์อะไรเลย

ประสบการณ์แบบนี้ทำให้เรามั่นใจว่าเป็นเรื่องสำคัญมากที่จะต้องมีผลลัพธ์ที่สามารถคาดการณ์ได้อย่างชัดเจนหากมีการพยายามสำรวจเส้นทางหลายครั้ง นอกจากนี้ยังเน้นถึงความสำคัญของการแยกแยะความแตกต่างระหว่างโครงสร้างที่เหมือนท่อแบบขี้เกียจจากการรวบรวมจริงที่เก็บข้อมูล สิ่งนี้จะผลักดันการแยกการดำเนินงานไปป์ไลน์ที่ขี้เกียจลงในส่วนต่อประสานกระแสข้อมูลใหม่และเก็บเฉพาะการดำเนินการที่กระตือรือร้นและเปลี่ยนแปลงในคอลเลกชันเท่านั้น Brian Goetz ได้อธิบายเหตุผลสำหรับเรื่องนี้แล้ว

สิ่งที่เกี่ยวกับการอนุญาตให้มีการข้ามเส้นทางหลายครั้งสำหรับท่อตามคอลเลกชัน แต่ไม่อนุญาตสำหรับท่อที่ไม่ได้อยู่ในคอลเลกชัน? มันไม่สอดคล้องกัน แต่มีเหตุผล หากคุณกำลังอ่านค่าจากเครือข่ายแน่นอนว่าคุณไม่สามารถข้ามค่าเหล่านั้นได้อีก หากคุณต้องการสำรวจพวกเขาหลายครั้งคุณต้องดึงพวกเขาเข้าไปในคอลเลกชันอย่างชัดเจน

แต่เรามาสำรวจกันว่าอนุญาตให้มีการแวะผ่านหลายทางจากท่อที่ใช้คอลเลคชั่น สมมติว่าคุณทำสิ่งนี้:

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

(การintoดำเนินการถูกสะกดcollect(toList())แล้ว)

หากแหล่งที่มาเป็นคอลเลกชันการinto()โทรครั้งแรกจะสร้างสายโซ่ของ Iterators กลับไปที่แหล่งที่มาดำเนินการไปป์ไลน์และส่งผลลัพธ์ไปยังปลายทาง สายที่สองเพื่อinto()จะสร้างห่วงโซ่ของ Iterators อื่นและดำเนินการการดำเนินงานท่ออีกครั้ง เห็นได้ชัดว่ามันไม่ได้ผิด แต่มันมีผลต่อการดำเนินการตัวกรองและแผนที่ทั้งหมดในครั้งที่สองสำหรับแต่ละองค์ประกอบ ฉันคิดว่าโปรแกรมเมอร์หลายคนคงจะประหลาดใจกับพฤติกรรมนี้

ดังที่ฉันได้กล่าวไว้ข้างต้นเราได้พูดคุยกับผู้พัฒนา Guava หนึ่งในสิ่งที่ยอดเยี่ยมที่พวกเขามีคือIdea Graveyardที่พวกเขาอธิบายคุณสมบัติที่พวกเขาตัดสินใจที่จะไม่ดำเนินการพร้อมกับเหตุผล แนวคิดของคอลเล็กชั่นขี้เกียจฟังดูสวย แต่นี่คือสิ่งที่พวกเขาพูดถึง พิจารณาการList.filter()ดำเนินการที่คืนค่า a List:

ความกังวลที่ใหญ่ที่สุดที่นี่คือการดำเนินการจำนวนมากเกินไปกลายเป็นข้อเสนอที่มีราคาแพงและเป็นเส้นตรง หากคุณต้องการกรองรายการและรับรายการกลับไม่ใช่แค่คอลเล็กชันหรือ Iterable คุณสามารถใช้ImmutableList.copyOf(Iterables.filter(list, predicate))ซึ่ง "แจ้งล่วงหน้า" สิ่งที่ทำและราคาแพง

เพื่อยกตัวอย่างเฉพาะค่าใช้จ่ายของget(0)หรือsize()ในรายการคืออะไร? สำหรับคลาสที่ใช้กันทั่วไปเช่นArrayListพวกมัน O (1) แต่ถ้าคุณเรียกหนึ่งในรายการเหล่านี้ในรายการที่กรองอย่างเกียจคร้านก็จะต้องเรียกใช้ตัวกรองเหนือรายการสำรองข้อมูลและการดำเนินการเหล่านี้ทั้งหมดในทันทีคือ O (n) ยิ่งไปกว่านั้นมันจะต้องสำรวจรายการสำรองในทุกการดำเนินการ

ดูเหมือนว่าเราจะขี้เกียจมากเกินไป เป็นสิ่งหนึ่งที่ต้องตั้งค่าการดำเนินการบางอย่างและเลื่อนการทำงานจริงออกไปจนกว่าคุณจะ "ลงมือ" อีกวิธีหนึ่งในการกำหนดสิ่งต่าง ๆ ในลักษณะที่ซ่อนการคำนวณซ้ำจำนวนมาก

ในการเสนอที่จะไม่อนุญาตให้ใช้แบบไม่เป็นเชิงเส้นหรือ "ไม่มีการใช้ซ้ำ" พอล Sandozอธิบายถึงผลกระทบที่อาจเกิดขึ้นจากการอนุญาตให้พวกเขาก่อให้เกิด "ผลลัพธ์ที่ไม่คาดคิดหรือสับสน" นอกจากนี้เขายังกล่าวว่าการดำเนินการแบบขนานจะทำให้สิ่งต่าง ๆ ยิ่งยุ่งยาก ในที่สุดฉันจะเพิ่มว่าการดำเนินการไปป์ไลน์ที่มีผลข้างเคียงจะนำไปสู่ข้อผิดพลาดที่ยากและคลุมเครือหากการดำเนินการถูกดำเนินการโดยไม่คาดคิดหลายครั้งหรืออย่างน้อยก็หลายครั้งกว่าที่โปรแกรมเมอร์คาดไว้ (แต่โปรแกรมเมอร์ Java ไม่ได้เขียนแลมบ์ดานิพจน์ที่มีผลข้างเคียงใช่มั้ยพวกเขาทำได้ไหม?)

นั่นเป็นเหตุผลพื้นฐานสำหรับการออกแบบ Java 8 Streams API ที่อนุญาตให้มีการส่งผ่านครั้งเดียวและต้องมีขั้นตอนเชิงเส้นอย่างเคร่งครัด (ไม่มีการแยกสาขา) มันมีพฤติกรรมที่สอดคล้องกันในแหล่งที่มาของสตรีมที่แตกต่างกันหลายอย่างมันแยกความขี้เกียจออกจากการดำเนินการกระตือรือร้นและให้รูปแบบการดำเนินการที่ตรงไปตรงมา


เกี่ยวกับIEnumerableฉันอยู่ไกลจากผู้เชี่ยวชาญใน C # และ. NET ดังนั้นฉันจะขอบคุณที่ถูกแก้ไข (เบา ๆ ) ถ้าฉันวาดข้อสรุปที่ไม่ถูกต้อง อย่างไรก็ตามมันปรากฏขึ้นซึ่งIEnumerableอนุญาตให้การสำรวจเส้นทางหลายครั้งมีพฤติกรรมแตกต่างกันไปตามแหล่งที่มาที่แตกต่างกัน และอนุญาตให้โครงสร้างการแยกย่อยของIEnumerableการดำเนินการซ้อนกันซึ่งอาจส่งผลให้มีการคำนวณใหม่อย่างมีนัยสำคัญ ในขณะที่ฉันชื่นชมว่าระบบที่แตกต่างกันสร้างการแลกเปลี่ยนที่แตกต่างกัน แต่นี่คือลักษณะสองอย่างที่เราพยายามหลีกเลี่ยงในการออกแบบ Java 8 Streams API

ตัวอย่างสั้น ๆ ที่ให้โดย OP น่าสนใจงงงวยและขอโทษที่พูดค่อนข้างน่ากลัว โทรQuickSortใช้เวลาIEnumerableและผลตอบแทนIEnumerableจึงไม่มีการเรียงลำดับจะทำจริงจนสุดท้ายIEnumerableจะเหี่ยวแห้ง อะไรโทรดูเหมือนว่าจะทำ แต่เป็นสร้างขึ้นโครงสร้างของIEnumerablesที่สะท้อนถึงการแบ่งพาร์ทิชันที่ quicksort จะทำโดยไม่ต้องทำจริงมัน (นี่คือการคำนวณขี้เกียจหลังจากทั้งหมด) หากแหล่งที่มามีองค์ประกอบ N ต้นไม้จะเป็นองค์ประกอบ N ที่กว้างที่สุดและจะเป็นระดับ lg (N) ลึก

ดูเหมือนว่าฉัน - และอีกครั้งฉันไม่ใช่ผู้เชี่ยวชาญ C # หรือ. NET - ซึ่งจะทำให้เกิดการโทรที่ดูไม่น่ากลัวเช่นการเลือก pivot ผ่านทางints.First()จะมีราคาแพงกว่าที่พวกเขาดู แน่นอนในระดับแรกมันคือ O (1) แต่ให้พิจารณาพาร์ทิชันลึกลงไปในต้นไม้ที่ขอบด้านขวา ในการคำนวณองค์ประกอบแรกของพาร์ติชั่นนี้จะต้องทำการสำรวจแหล่งข้อมูลทั้งหมดการดำเนินการ O (N) แต่เนื่องจากพาร์ติชันข้างต้นขี้เกียจจึงต้องทำการคำนวณใหม่จึงต้องมีการเปรียบเทียบ O (lg N) ดังนั้นการเลือกเดือยจะเป็นการดำเนินการ O (N lg N) ซึ่งมีราคาแพงเท่ากับการจัดเรียงทั้งหมด

IEnumerableแต่เราทำไม่ได้จริงเรียงลำดับจนกว่าเราจะสำรวจกลับ ในอัลกอริทึม quicksort มาตรฐานแต่ละระดับของการแบ่งพาร์ติชันจะเพิ่มจำนวนพาร์ติชันเป็นสองเท่า แต่ละพาร์ติชั่นมีขนาดเพียงครึ่งเดียวดังนั้นแต่ละระดับจะยังคงอยู่ที่ความซับซ้อน O (N) แผนผังของพาร์ติชันคือ O (lg N) สูงดังนั้นงานทั้งหมดคือ O (N lg N)

ด้วยต้นไม้ของขี้เกียจ IEnumerables ที่ด้านล่างของต้นไม้มีพาร์ติชัน N การคำนวณแต่ละพาร์ติชั่นจำเป็นต้องมีการข้ามผ่านขององค์ประกอบ N ซึ่งแต่ละส่วนต้องการ LG (N) เปรียบเทียบต้นไม้ ในการคำนวณพาร์ติชันทั้งหมดที่ด้านล่างของทรีต้องใช้การเปรียบเทียบ O (N ^ 2 lg N)

(ถูกต้องหรือไม่ฉันแทบจะไม่เชื่อสิ่งนี้ใครก็ได้โปรดตรวจสอบเรื่องนี้ให้ฉัน)

ไม่ว่าในกรณีใดมันยอดเยี่ยมมากที่IEnumerableสามารถใช้วิธีนี้เพื่อสร้างโครงสร้างการคำนวณที่ซับซ้อน แต่ถ้ามันเพิ่มความซับซ้อนในการคำนวณให้มากที่สุดเท่าที่ฉันคิดมันก็ดูเหมือนว่าการเขียนโปรแกรมด้วยวิธีนี้เป็นสิ่งที่ควรหลีกเลี่ยงเว้นแต่จะมีความระมัดระวังอย่างมาก


35
ก่อนอื่นขอขอบคุณสำหรับคำตอบที่ยอดเยี่ยมและไม่ย่อท้อ! นี่คือคำอธิบายที่ถูกต้องที่สุดและตรงประเด็นที่สุดที่ฉันได้รับ เท่าที่ตัวอย่าง QuickSort ดำเนินไปดูเหมือนว่าคุณพูดถูกเกี่ยวกับ ints.First bloating เมื่อระดับการเรียกซ้ำเพิ่มขึ้น ฉันเชื่อว่าสิ่งนี้สามารถแก้ไขได้อย่างง่ายดายโดยการคำนวณ 'gt' และ 'lt' อย่างกระตือรือร้น (โดยรวบรวมผลลัพธ์ด้วย ToArray) ดังที่กล่าวไว้แน่นอนว่ามันสนับสนุนจุดประสงค์ของคุณที่ว่ารูปแบบการเขียนโปรแกรมนี้อาจทำให้เกิดประสิทธิภาพที่ไม่คาดคิด (ดำเนินการต่อในความคิดเห็นที่สอง)
Vitaliy

18
ในทางกลับกันจากประสบการณ์ของฉันกับ C # (มากกว่า 5 ปี) ฉันสามารถบอกได้ว่าการรูทการคำนวณ 'ซ้ำซ้อน' นั้นไม่ใช่เรื่องยากเมื่อคุณประสบปัญหาเกี่ยวกับประสิทธิภาพ (หรือถูกห้ามหากมีคนคิดไม่ถึง มีผลกระทบข้างเคียงนั่น) สำหรับผมแล้วดูเหมือนว่าการประนีประนอมมากเกินไปทำให้มั่นใจในความบริสุทธิ์ของ API ด้วยค่าใช้จ่ายของ C # ที่เป็นไปได้ คุณช่วยฉันปรับมุมมองของฉันอย่างแน่นอน
Vitaliy

7
@Vitaliy ขอบคุณสำหรับการแลกเปลี่ยนความคิดที่เป็นธรรม ฉันได้เรียนรู้เล็กน้อยเกี่ยวกับ C # และ. NET จากการตรวจสอบและเขียนคำตอบนี้
Stuart Marks

10
ความคิดเห็นเล็ก: ReSharper เป็นส่วนขยายของ Visual Studio ที่ช่วยด้วย C # ด้วย QuickSort code ข้างต้น ReSharper เพิ่มคำเตือนสำหรับการใช้งานแต่ละครั้งints : "เป็นไปได้หลายการนับ IEnumerable" การใช้งานIEenumerableมากกว่าหนึ่งครั้งเป็นสิ่งที่น่าสงสัยและควรหลีกเลี่ยง ฉันยังชี้ไปที่คำถามนี้ (ซึ่งฉันตอบแล้ว) ซึ่งแสดงคำเตือนบางส่วนด้วยวิธี. Net (นอกเหนือจากประสิทธิภาพที่ไม่ดี): รายการ <T> และความแตกต่าง
IEnumer

4
@Kobi น่าสนใจมากที่มีคำเตือนใน ReSharper ขอบคุณสำหรับตัวชี้ไปยังคำตอบของคุณ ฉันไม่ทราบว่า C # /. NET ดังนั้นฉันจะต้องเลือกอย่างระมัดระวัง แต่ดูเหมือนว่าจะมีปัญหาคล้ายกับข้อกังวลด้านการออกแบบที่ฉันกล่าวถึงข้างต้น
Stuart Marks

122

พื้นหลัง

ในขณะที่คำถามปรากฏง่ายคำตอบที่แท้จริงต้องใช้พื้นหลังบางอย่างให้เหมาะสม หากคุณต้องการข้ามไปยังข้อสรุปให้เลื่อนลง ...

เลือกจุดเปรียบเทียบของคุณ - ฟังก์ชั่นพื้นฐาน

การใช้แนวคิดพื้นฐานแนวคิดของ C # IEnumerableเกี่ยวข้องกับJavaIterableอย่างใกล้ชิดมากขึ้นซึ่งสามารถสร้างIterators ได้มากเท่าที่คุณต้องการ สร้างIEnumerables สร้างIEnumeratorsของ JavaIterableIterators

ประวัติความเป็นมาของแต่ละแนวคิดมีความคล้ายคลึงกันซึ่งทั้งคู่IEnumerableและIterableมีแรงจูงใจขั้นพื้นฐานเพื่อให้สไตล์ 'สำหรับแต่ละคน' วนรอบสมาชิกของการรวบรวมข้อมูล นั่นเป็นสิ่งที่เกินความจริงเพราะทั้งคู่อนุญาตมากกว่านั้นและพวกเขาก็มาถึงขั้นนั้นผ่านความก้าวหน้าที่แตกต่างกัน แต่มันก็เป็นคุณสมบัติทั่วไปที่สำคัญโดยไม่คำนึงถึง

ลองเปรียบเทียบคุณลักษณะนั้น: ในทั้งสองภาษาถ้าคลาสใช้IEnumerable/ Iterableจากนั้นคลาสนั้นจะต้องใช้วิธีการอย่างน้อยหนึ่งวิธี (สำหรับ C #, มันGetEnumeratorและสำหรับ Java iterator()) ในแต่ละกรณีอินสแตนซ์ที่ส่งคืนจากนั้น ( IEnumerator/ Iterator) อนุญาตให้คุณเข้าถึงสมาชิกปัจจุบันและสมาชิกที่ตามมาของข้อมูล คุณลักษณะนี้ใช้ในไวยากรณ์แต่ละภาษา

เลือกจุดเปรียบเทียบของคุณ - ฟังก์ชันการทำงานที่ได้รับการปรับปรุง

IEnumerableใน C # ได้รับการขยายเพื่อให้มีคุณสมบัติภาษาอื่น ๆ จำนวนมาก ( ส่วนใหญ่เกี่ยวข้องกับ Linq ) คุณสมบัติที่เพิ่มเข้ามา ได้แก่ การเลือกการคาดการณ์การรวมตัว ฯลฯ ส่วนขยายเหล่านี้มีแรงจูงใจที่แข็งแกร่งจากการใช้งานในทฤษฎีเซตคล้ายกับแนวคิด SQL และฐานข้อมูลเชิงสัมพันธ์

Java 8 ยังมีการเพิ่มฟังก์ชันการทำงานเพื่อเปิดใช้งานระดับการเขียนโปรแกรมการทำงานโดยใช้ Streams และ Lambdas โปรดทราบว่า Java 8 สตรีมไม่ได้รับแรงบันดาลใจหลักจากทฤษฎีเซต ไม่ว่าจะมีอะไรมากมาย

ดังนั้นนี่คือจุดที่สอง การปรับปรุงที่ทำกับ C # ถูกนำไปใช้เป็นการปรับปรุงกับIEnumerableแนวคิด อย่างไรก็ตามใน Java การปรับปรุงที่เกิดขึ้นนั้นถูกนำไปใช้โดยการสร้างแนวคิดพื้นฐานใหม่ของ Lambdas และ Streams และจากนั้นก็สร้างวิธีที่ไม่สำคัญสำหรับการแปลงจากIteratorsและIterablesเป็น Streams และวีซ่าในทางกลับกัน

ดังนั้นการเปรียบเทียบ IEnumerable กับแนวคิด Stream ของ Java จึงไม่สมบูรณ์ คุณต้องเปรียบเทียบกับ API ของสตรีมและสตรีมแบบรวมใน Java

ใน Java Streams นั้นไม่เหมือนกับ Iterables หรือ Iterators

สตรีมไม่ได้ออกแบบมาเพื่อแก้ปัญหาเช่นเดียวกับตัววนซ้ำ:

  • การวนซ้ำเป็นวิธีการอธิบายลำดับของข้อมูล
  • สตรีมเป็นวิธีการอธิบายลำดับของการแปลงข้อมูล

ด้วยIteratorคุณจะได้รับค่าข้อมูลประมวลผลแล้วรับค่าข้อมูลอื่น

ด้วยสตรีมคุณเชื่อมโยงลำดับของฟังก์ชันเข้าด้วยกันจากนั้นคุณป้อนค่าอินพุตไปยังสตรีมและรับค่าเอาต์พุตจากลำดับรวมกัน หมายเหตุในแง่ Java แต่ละฟังก์ชั่นถูกห่อหุ้มในStreamอินสแตนซ์เดียว Streams API ช่วยให้คุณสามารถเชื่อมโยงลำดับของStreamอินสแตนซ์ในแบบที่เชื่อมโยงกับลำดับของการแปลงนิพจน์

เพื่อให้Streamแนวคิดสมบูรณ์คุณต้องมีแหล่งข้อมูลเพื่อป้อนกระแสข้อมูลและฟังก์ชันเทอร์มินัลที่ใช้กระแสข้อมูล

วิธีที่คุณป้อนค่าลงในสตรีมอาจมาจากอันที่จริงIterableแต่Streamลำดับตัวเองไม่ได้เป็นIterableมันคือฟังก์ชันผสม

A Streamมีจุดมุ่งหมายที่จะขี้เกียจด้วยในแง่ที่ว่าจะใช้งานได้เฉพาะเมื่อคุณขอค่าจากมัน

หมายเหตุข้อสมมติฐานและคุณสมบัติที่สำคัญของสตรีม:

  • A Streamใน Java เป็นเอ็นจิ้นการแปลงมันจะแปลงไอเท็มข้อมูลในสถานะหนึ่งให้อยู่ในสถานะอื่น
  • สตรีมไม่มีแนวคิดของลำดับข้อมูลหรือตำแหน่งเพียงแปลงสิ่งที่ต้องการ
  • สตรีมสามารถจัดหาให้กับข้อมูลจากหลายแหล่งรวมถึงสตรีมอื่น, Iterators, Iterables, Collections,
  • คุณไม่สามารถ "รีเซ็ต" สตรีมได้ซึ่งจะเป็นเช่น "reprogramming การแปลง" การรีเซ็ตแหล่งข้อมูลอาจเป็นสิ่งที่คุณต้องการ
  • มีเพียงหนึ่งรายการข้อมูลที่มีเหตุผล 'กำลังบิน' ในสตรีมได้ตลอดเวลา (เว้นแต่สตรีมเป็นสตรีมแบบขนานซึ่ง ณ จุดนั้นจะมี 1 รายการต่อเธรด) สิ่งนี้เป็นอิสระจากแหล่งข้อมูลซึ่งอาจมีมากกว่ารายการปัจจุบัน 'พร้อม' ที่จะส่งไปยังกระแสข้อมูลหรือตัวรวบรวมกระแสซึ่งอาจต้องรวมและลดค่าหลายค่า
  • สตรีมสามารถ unbound (ไม่มีที่สิ้นสุด) จำกัด โดยแหล่งข้อมูลหรือตัวรวบรวมเท่านั้น (ซึ่งอาจไม่มีที่สิ้นสุดเช่นกัน)
  • สตรีมคือ 'chainable' เอาต์พุตของการกรองหนึ่งสตรีมเป็นสตรีมอื่น ค่าที่ป้อนเข้าและแปลงโดยสตรีมสามารถถูกส่งไปยังสตรีมอื่นซึ่งเป็นการแปลงที่แตกต่างกัน ข้อมูลที่อยู่ในสถานะที่ถูกแปลงจะไหลจากกระแสหนึ่งไปยังอีกกระแสหนึ่ง คุณไม่จำเป็นต้องแทรกแซงและดึงข้อมูลจากสตรีมหนึ่งและเสียบเข้ากับสตรีมถัดไป

เปรียบเทียบ C #

เมื่อคุณพิจารณาว่า Java Stream เป็นเพียงส่วนหนึ่งของการจัดหาสตรีมและการรวบรวมระบบและ Streams and Iterators มักใช้ร่วมกับ Collections ดังนั้นจึงไม่น่าแปลกใจที่จะเกี่ยวข้องกับแนวคิดเดียวกันซึ่งเป็น เกือบทั้งหมดฝังอยู่ในIEnumerableแนวคิดเดียวใน C #

บางส่วนของ IEnumerable (และแนวคิดที่เกี่ยวข้องอย่างใกล้ชิด) ปรากฏชัดเจนในแนวคิด Java Iterator, Iterable, Lambda และ Stream ทั้งหมด

มีสิ่งเล็ก ๆ ที่แนวคิดของ Java สามารถทำได้ยากขึ้นใน IEnumerable และในทางกลับกัน


ข้อสรุป

  • ไม่มีปัญหาการออกแบบที่นี่เพียงปัญหาในแนวคิดการจับคู่ระหว่างภาษา
  • ลำธารแก้ปัญหาต่าง ๆ
  • สตรีมเพิ่มฟังก์ชันการทำงานให้กับ Java (พวกเขาเพิ่มวิธีการทำสิ่งต่าง ๆ พวกเขาไม่ได้ตัดการทำงาน)

การเพิ่มสตรีมจะช่วยให้คุณมีทางเลือกมากขึ้นเมื่อแก้ปัญหาซึ่งยุติธรรมที่จะจัดว่าเป็น 'การเสริมพลัง' ไม่ใช่ 'ลด', 'กำลังออกไป' หรือ 'จำกัด '

เหตุใด Java Streams จึงปิดทันที

คำถามนี้เข้าใจผิดเพราะสตรีมเป็นลำดับของฟังก์ชันไม่ใช่ข้อมูล ขึ้นอยู่กับแหล่งข้อมูลที่ฟีดสตรีมคุณสามารถรีเซ็ตแหล่งข้อมูลและฟีดเดียวกันหรือสตรีมที่แตกต่างกัน

แตกต่างจาก C # ของ IEnumerable ที่ไพพ์ไลน์การประมวลผลสามารถเรียกใช้งานได้หลายครั้งตามที่เราต้องการใน Java สตรีมสามารถ 'วนซ้ำ' ได้เพียงครั้งเดียว

การเปรียบเทียบIEnumerablea ถึง a Streamนั้นถูกเข้าใจผิด บริบทที่คุณใช้ในการพูดIEnumerableสามารถเรียกใช้งานได้หลายครั้งตามที่คุณต้องการเปรียบเทียบกับ Java ได้ดีที่สุดIterablesซึ่งสามารถทำซ้ำได้บ่อยเท่าที่คุณต้องการ Java Streamแสดงชุดย่อยของIEnumerableแนวคิดไม่ใช่ชุดย่อยที่ให้ข้อมูลดังนั้นจึงไม่สามารถ 'เรียกใช้ซ้ำ' ได้

การเรียกใช้งานเทอร์มินัลใด ๆ จะปิดสตรีมทำให้ไม่สามารถใช้งานได้ 'คุณสมบัติ' นี้ใช้พลังงานจำนวนมาก

ประโยคแรกนั้นเป็นจริงในแง่หนึ่ง คำสั่ง 'take away power' ไม่ได้เป็น คุณยังคงเปรียบเทียบ Streams กับ IEnumerables การดำเนินการของเทอร์มินัลในสตรีมเป็นเหมือนประโยค 'break' ใน for for loop คุณมีอิสระที่จะมีสตรีมอื่นหากคุณต้องการและถ้าคุณสามารถจัดหาข้อมูลที่คุณต้องการได้อีกครั้ง อีกครั้งถ้าคุณพิจารณาที่IEnumerableจะเป็นเหมือนIterableสำหรับคำสั่งนี้ Java ทำมันได้ดี

ฉันคิดว่าเหตุผลนี้ไม่ใช่เทคนิค สิ่งที่ต้องพิจารณาในการออกแบบภายใต้ข้อ จำกัด ที่แปลก

เหตุผลนั้นเป็นเรื่องทางเทคนิคและด้วยเหตุผลง่ายๆที่สตรีมเป็นส่วนย่อยของสิ่งที่คิดว่าเป็น ชุดย่อยสตรีมไม่ได้ควบคุมการส่งข้อมูลดังนั้นคุณควรรีเซ็ตการจัดหาไม่ใช่สตรีม ในบริบทนั้นมันไม่แปลกเลย

ตัวอย่าง QuickSort

ตัวอย่าง quicksort ของคุณมีลายเซ็นต์:

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

คุณปฏิบัติต่ออินพุตIEnumerableเป็นแหล่งข้อมูล:

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

นอกจากนี้ค่าส่งคืนก็IEnumerableเช่นกันซึ่งเป็นแหล่งข้อมูลและเนื่องจากเป็นการดำเนินการเรียงลำดับลำดับของอุปทานนั้นจึงมีความสำคัญ หากคุณพิจารณาว่าIterableคลาสJava เป็นคู่ที่เหมาะสมสำหรับสิ่งนี้โดยเฉพาะอย่างยิ่งListความเชี่ยวชาญของIterableเนื่องจากรายการเป็นแหล่งข้อมูลที่มีคำสั่งรับประกันหรือการวนซ้ำดังนั้นโค้ด 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)รหัสจะสามารถอ่านได้เกือบ ...
Holger

22

Streams ถูกสร้างขึ้นรอบ ๆSpliterators ซึ่งเป็นวัตถุ stateful และไม่แน่นอน พวกเขาไม่มีการ "รีเซ็ต" การกระทำและในความเป็นจริงการสนับสนุนการกระทำแบบย้อนกลับดังกล่าวจะ "ใช้กำลังมาก" วิธีจะRandom.ints()จะควรที่จะจัดการกับคำขอดังกล่าวหรือไม่

ในทางกลับกันสำหรับStreams ที่มีแหล่งกำเนิดแบบย้อนกลับได้ง่ายต่อการสร้างเทียบเท่าStreamที่จะใช้อีกครั้ง เพียงแค่ใส่ขั้นตอนในการสร้างStreamเป็นวิธีการที่นำกลับมาใช้ใหม่ได้ โปรดทราบว่าการทำซ้ำขั้นตอนเหล่านี้ไม่ใช่การดำเนินการที่มีราคาแพงเนื่องจากทุกขั้นตอนเหล่านี้เป็นการดำเนินการที่ไม่ได้ใช้งาน งานจริงเริ่มต้นด้วยการทำงานของเครื่องและขึ้นอยู่กับการทำงานของเครื่องที่แท้จริงรหัสที่แตกต่างกันอย่างสิ้นเชิงอาจได้รับการดำเนินการ

มันจะขึ้นอยู่กับคุณนักเขียนของวิธีการดังกล่าวเพื่อระบุสิ่งที่เรียกว่าวิธีการสองครั้งหมายถึง: มันทำซ้ำลำดับเดียวกันเป็นกระแสที่สร้างขึ้นสำหรับอาร์เรย์หรือคอลเลกชันไม่แปรเปลี่ยนหรือทำกระแสที่มี ซีแมนทิกส์ที่คล้ายคลึงกัน แต่มีองค์ประกอบต่างกันเช่นสตรีมของ ints แบบสุ่มหรือสตรีมของบรรทัดอินพุตคอนโซล ฯลฯ


โดยวิธีการเพื่อหลีกเลี่ยงความสับสนการดำเนินงานของเครื่องจะใช้สิ่งStreamที่แตกต่างจากการปิดการStreamเรียกใช้close()บนสตรีม (ซึ่งจำเป็นสำหรับสตรีมที่มีทรัพยากรที่เกี่ยวข้องเช่นผลิตโดยFiles.lines())


มันดูเหมือนว่าจำนวนมากสับสนเกิดจากการเปรียบเทียบ misguiding ของด้วยIEnumerable Streaman IEnumerableแทนค่าความสามารถในการจัดเตรียมจริงIEnumeratorดังนั้นจึงเหมือนกับIterableใน Java ในทางตรงกันข้าม a Streamเป็นตัววนซ้ำและเปรียบได้กับIEnumeratorมันดังนั้นจึงผิดที่จะอ้างว่าชนิดข้อมูลชนิดนี้สามารถใช้ได้หลายครั้งใน. NET การสนับสนุนสำหรับIEnumerator.Resetเป็นทางเลือก ตัวอย่างที่กล่าวถึงที่นี่ค่อนข้างใช้ข้อเท็จจริงที่ว่าIEnumerableสามารถนำมาใช้เพื่อดึงข้อมูลใหม่ IEnumeratorและใช้งานได้กับ Java Collectionเช่นกัน Streamคุณจะได้รับใหม่ หากผู้พัฒนา Java ตัดสินใจที่จะเพิ่มการStreamดำเนินการIterableโดยตรงกับการดำเนินงานกลางส่งกลับอีกIterableมันเปรียบได้จริงและสามารถทำงานในลักษณะเดียวกันได้

อย่างไรก็ตามผู้พัฒนาตัดสินใจต่อและตัดสินใจพูดคุยในคำถามนี้ จุดที่ใหญ่ที่สุดคือความสับสนเกี่ยวกับการดำเนินงานคอลเลกชันกระตือรือร้นและการดำเนินการสตรีมขี้เกียจ โดยการดูที่. NET API ฉัน (ใช่เป็นการส่วนตัว) พบว่าถูกต้อง ในขณะที่มันดูสมเหตุสมผลอยู่IEnumerableคนเดียวคอลเลกชันพิเศษจะมีวิธีการมากมายที่จัดการคอลเลกชันโดยตรงและวิธีการมากมายที่กลับมาขี้เกียจIEnumerableในขณะที่ลักษณะของวิธีการบางอย่างไม่เป็นที่รู้จักอย่างสังหรณ์ใจเสมอไป ตัวอย่างที่เลวร้ายที่สุดที่ฉันพบ (ภายในไม่กี่นาทีที่ผมมองมัน) เป็นList.Reverse()ที่มีชื่อการแข่งขันว่าชื่อของสืบทอด (นี้เป็นปลายทางที่เหมาะสมสำหรับวิธีการขยาย?) Enumerable.Reverse()ในขณะที่มีพฤติกรรมที่ขัดแย้งอย่างสิ้นเชิง


แน่นอนว่านี่เป็นการตัดสินใจที่แตกต่างกันสองอย่าง คนแรกที่ทำให้Streamประเภทที่แตกต่างจากIterable/ Collectionและคนที่สองที่จะทำให้Streamเป็นชนิดหนึ่งครั้ง iterator มากกว่า iterable ชนิดอื่น แต่การตัดสินใจเหล่านี้ถูกทำร่วมกันและอาจเป็นกรณีที่การแยกการตัดสินใจทั้งสองนี้ไม่เคยถูกพิจารณา มันไม่ได้ถูกสร้างขึ้นโดยเทียบเคียงกับ. NET ในใจ

การตัดสินใจการออกแบบ API ที่เกิดขึ้นจริงคือการเพิ่มการปรับปรุงประเภทของ iterator Spliteratorที่ Spliterators สามารถให้บริการโดยIterables เก่า(ซึ่งเป็นวิธีการดัดแปลงเหล่านี้) หรือการใช้งานใหม่ทั้งหมด จากนั้นStreamได้รับการเพิ่มเป็นระดับสูง front-end ให้อยู่ในระดับที่ค่อนข้างต่ำSpliterators แค่นั้นแหละ. คุณอาจพูดคุยเกี่ยวกับว่าการออกแบบที่แตกต่างกันจะดีกว่าหรือไม่ แต่ก็ไม่ได้ผลหรือไม่เปลี่ยนไปตามวิธีที่พวกเขาออกแบบมา

มีแง่มุมอื่นของการนำไปใช้ที่คุณต้องพิจารณา Streams ไม่ใช่โครงสร้างข้อมูลที่ไม่เปลี่ยนรูป การดำเนินการระดับกลางแต่ละรายการอาจส่งคืนStreamอินสแตนซ์ใหม่ที่ห่อหุ้มสิ่งเก่า แต่อาจจัดการอินสแตนซ์ของตัวเองแทนและส่งคืนตัวเองได้ ตัวอย่างที่รู้จักกันทั่วไปคือการดำเนินการเช่นparallelหรือunorderedที่ไม่ได้เพิ่มขั้นตอนอื่น แต่จัดการกับไปป์ไลน์ทั้งหมด) การมีโครงสร้างข้อมูลที่ไม่แน่นอนและพยายามนำมาใช้ซ้ำ (หรือแย่กว่านั้นการใช้มันหลายครั้งในเวลาเดียวกัน) เล่นได้ไม่ดี ...


เพื่อความสมบูรณ์นี่คือตัวอย่าง quicksort ของคุณที่แปลเป็น 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นั้น และฉันค่อนข้างแน่ใจว่าเป็นไปได้มีคำถามเกี่ยวกับ SO เช่น "ทำไมการโทรcount()สองครั้งในการStreamให้ผลลัพธ์ที่แตกต่างกันในแต่ละครั้ง" ฯลฯ ...
Holger

1
มันถูกต้องอย่างแน่นอนสำหรับการนับ () เพื่อให้ผลลัพธ์ที่แตกต่าง count () เป็นแบบสอบถามในสตรีมและหากสตรีมไม่แน่นอน (หรือเป็นข้อมูลที่แน่นอนมากขึ้นสตรีมจะแสดงผลลัพธ์ของการสืบค้นในคอลเลกชันที่ไม่แน่นอน) ซึ่งคาดว่าจะเกิดขึ้น ดู API ของ C # พวกเขาจัดการกับปัญหาเหล่านี้อย่างสง่างาม
Vitaliy

4
สิ่งที่คุณเรียกว่า "ถูกต้องแน่นอน" เป็นพฤติกรรมต่อต้าน ท้ายที่สุดมันเป็นแรงจูงใจหลักในการถามเกี่ยวกับการใช้สตรีมหลายครั้งเพื่อประมวลผลผลลัพธ์ซึ่งคาดว่าจะเหมือนกันในรูปแบบที่แตกต่างกัน ทุกคำถามในดังนั้นเกี่ยวกับธรรมชาติที่ไม่ได้นำมาใช้ใหม่ของStreams เพื่อให้ห่างไกลจะเกิดขึ้นจากความพยายามที่จะแก้ปัญหาโดยการเรียกการดำเนินงานสถานีหลายครั้ง (ชัดมิฉะนั้นคุณจะไม่แจ้งให้ทราบล่วงหน้า) ซึ่งนำไปสู่การแก้ปัญหาเสียเงียบถ้าStreamAPI ได้รับอนุญาต ด้วยผลลัพธ์ที่แตกต่างกันในการประเมินแต่ละครั้ง นี่คือตัวอย่างที่ดี
Holger

3
อันที่จริงตัวอย่างของคุณแสดงให้เห็นอย่างสมบูรณ์แบบว่าจะเกิดอะไรขึ้นหากโปรแกรมเมอร์ไม่เข้าใจความหมายของการใช้การดำเนินการหลายอย่าง แค่คิดว่าจะเกิดอะไรขึ้นเมื่อการดำเนินการแต่ละอย่างจะถูกนำไปใช้กับองค์ประกอบที่แตกต่างกันโดยสิ้นเชิง จะใช้งานได้หากแหล่งที่มาของสตรีมกลับองค์ประกอบเดียวกันในแต่ละข้อความค้นหา แต่นี่เป็นข้อสมมติฐานที่ผิดที่เรากำลังพูดถึง
Holger

8

ฉันคิดว่ามีความแตกต่างน้อยมากระหว่างสองสิ่งนี้เมื่อคุณมองอย่างใกล้ชิดมากพอ

ที่ใบหน้า, an 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จะเพิ่งกลับมาเป็นเท็จและไม่มีวิธีรีเซ็ตเป็นจุดเริ่มต้น หากคุณต้องการวนซ้ำอีกครั้งคุณจะต้องสร้างอินสแตนซ์ตัวระบุอื่น


เพื่อแสดงให้เห็นได้ดีขึ้นว่า IEnumerable มี 'คุณสมบัติ' เหมือนกับ Java Stream ให้พิจารณาจำนวนที่มีแหล่งที่มาของตัวเลขไม่ใช่คอลเลกชันคงที่ ตัวอย่างเช่นเราสามารถสร้างออบเจ็กต์ที่นับได้ซึ่งสร้างลำดับของตัวเลขสุ่ม 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 Stream)

นอกจากนี้สิ่งที่ไม่นับตามอย่างรวดเร็วเฉลี่ยเรียงลำดับเมื่อนำไปใช้ของคุณ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แหล่งที่มาเป็น stateful และเก็บตำแหน่งปัจจุบัน เมื่อเราเล่นซ้ำ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. ทางออกที่ดีที่สุดในการแก้ไขปัญหานี้ (ในความคิดของฉัน) คือการทำให้สำเนาใหม่ของ stateful ใด ๆSpliteratorของใช้ในท่อเมื่อผู้ประกอบการใหม่ที่จะเรียกในStream Streamสิ่งนี้ซับซ้อนกว่าและเกี่ยวข้องกับการนำไปใช้ แต่ถ้าคุณไม่สนใจการใช้ไลบรารี่ของบุคคลที่สามไซคลอปตอบสนองก็จะมีStreamการนำไปปฏิบัติ (การเปิดเผยข้อมูล: ฉันเป็นผู้พัฒนาหลักสำหรับโครงการนี้)

    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.