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