Tôi có một số hồi ức từ thiết kế ban đầu của API Streams có thể làm sáng tỏ cơ sở thiết kế.
Quay trở lại năm 2012, chúng tôi đã thêm lambdas vào ngôn ngữ và chúng tôi muốn có một bộ hoạt động theo định hướng bộ sưu tập hoặc "dữ liệu số lượng lớn", được lập trình bằng lambdas, điều đó sẽ tạo thuận lợi cho việc song song. Ý tưởng về các hoạt động xâu chuỗi lười biếng cùng nhau đã được thiết lập tốt vào thời điểm này. Chúng tôi cũng không muốn các hoạt động trung gian lưu trữ kết quả.
Vấn đề chính mà chúng tôi cần quyết định là các đối tượng trong chuỗi trông như thế nào trong API và cách chúng nối với các nguồn dữ liệu. Các nguồn thường là các bộ sưu tập, nhưng chúng tôi cũng muốn hỗ trợ dữ liệu đến từ tệp hoặc mạng hoặc dữ liệu được tạo khi đang di chuyển, ví dụ: từ trình tạo số ngẫu nhiên.
Có nhiều ảnh hưởng của công việc hiện tại lên thiết kế. Trong số những người có ảnh hưởng lớn hơn có thư viện Guava của Google và thư viện bộ sưu tập Scala. (Nếu có ai ngạc nhiên về ảnh hưởng từ Guava, hãy lưu ý rằng Kevin Bourrillion , nhà phát triển chính của Guava, thuộc nhóm chuyên gia JSR-335 Lambda .) Trên các bộ sưu tập của Scala, chúng tôi thấy bài nói chuyện này của Martin Oderky được đặc biệt quan tâm: Tương lai- Chứng minh Bộ sưu tập Scala: từ Mutable đến Persistent đến Parallel . (Stanford EE380, ngày 1 tháng 6 năm 2011)
Thiết kế nguyên mẫu của chúng tôi tại thời điểm đó được dựa trên xung quanh Iterable
. Các hoạt động quen thuộc filter
, map
v.v. là các phương thức mở rộng (mặc định) trên Iterable
. Gọi một đã thêm một hoạt động cho chuỗi và trả lại một hoạt động khác Iterable
. Một hoạt động đầu cuối như count
sẽ gọi iterator()
chuỗi đến nguồn và các hoạt động được thực hiện trong Iterator của từng giai đoạn.
Vì đây là Iterables, bạn có thể gọi iterator()
phương thức nhiều lần. Điều gì sẽ xảy ra sau đó?
Nếu nguồn là một bộ sưu tập, điều này chủ yếu hoạt động tốt. Các bộ sưu tập là Iterable và mỗi lệnh gọi để iterator()
tạo một cá thể Iterator riêng biệt độc lập với bất kỳ phiên bản hoạt động nào khác và mỗi lần di chuyển qua bộ sưu tập một cách độc lập. Tuyệt quá.
Bây giờ nếu nguồn là một lần, như đọc các dòng từ tệp thì sao? Có thể Iterator đầu tiên sẽ nhận được tất cả các giá trị nhưng các giá trị thứ hai và tiếp theo sẽ trống. Có lẽ các giá trị nên được xen kẽ giữa các Iterators. Hoặc có thể mỗi Iterator sẽ nhận được tất cả các giá trị giống nhau. Sau đó, điều gì sẽ xảy ra nếu bạn có hai vòng lặp và một cái đi xa hơn cái kia? Ai đó sẽ phải đệm các giá trị trong Iterator thứ hai cho đến khi chúng được đọc. Tệ hơn, nếu bạn nhận được một Iterator và đọc tất cả các giá trị, và chỉ sau đó nhận được một Iterator thứ hai. Các giá trị đến từ đâu bây giờ? Có một yêu cầu cho tất cả chúng được đệm lên chỉ trong trường hợp ai đó muốn một Iterator thứ hai?
Rõ ràng, việc cho phép nhiều Iterator trên một nguồn một lần đặt ra rất nhiều câu hỏi. Chúng tôi không có câu trả lời tốt cho họ. Chúng tôi muốn hành vi nhất quán, có thể dự đoán được cho những gì xảy ra nếu bạn gọi iterator()
hai lần. Điều này đẩy chúng tôi về phía không cho phép nhiều đường ngang, làm cho các đường ống một lần.
Chúng tôi cũng quan sát những người khác va vào những vấn đề này. Trong JDK, hầu hết các Iterables là các bộ sưu tập hoặc các đối tượng giống như bộ sưu tập, cho phép truyền tải nhiều lần. Nó không được chỉ định ở bất cứ đâu, nhưng dường như có một kỳ vọng bất thành văn rằng Iterables cho phép nhiều lần truyền tải. Một ngoại lệ đáng chú ý là giao diện NIO DirectoryStream . Đặc điểm kỹ thuật của nó bao gồm cảnh báo thú vị này:
Trong khi DirectoryStream mở rộng Iterable, nó không phải là Iterable có mục đích chung vì nó chỉ hỗ trợ một Iterator duy nhất; gọi phương thức iterator để có được một iterator thứ hai hoặc tiếp theo ném IllegalStateException.
[in đậm trong bản gốc]
Điều này có vẻ bất thường và khó chịu đến mức chúng tôi không muốn tạo ra một loạt các Iterables mới có thể chỉ có một lần. Điều này đã đẩy chúng tôi ra khỏi việc sử dụng Iterable.
Vào thời điểm này, một bài báo của Bruce Eckel đã xuất hiện mô tả một điểm rắc rối mà anh gặp phải với Scala. Ông đã viết mã này:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
Nó khá đơn giản. Nó phân tích các dòng văn bản thành Registrant
các đối tượng và in chúng ra hai lần. Ngoại trừ việc nó thực sự chỉ in chúng ra một lần. Hóa ra anh ta nghĩ rằng đó registrants
là một bộ sưu tập, trong khi thực tế nó là một trình vòng lặp. Cuộc gọi thứ hai để foreach
gặp một trình vòng lặp trống, từ đó tất cả các giá trị đã hết, vì vậy nó không in gì cả.
Loại kinh nghiệm này đã thuyết phục chúng tôi rằng điều rất quan trọng là có kết quả có thể dự đoán rõ ràng nếu cố gắng vượt qua nhiều lần. Nó cũng nhấn mạnh tầm quan trọng của việc phân biệt giữa các cấu trúc giống như đường ống lười biếng với các bộ sưu tập thực tế lưu trữ dữ liệu. Chính điều này đã dẫn đến việc tách các hoạt động đường ống lười biếng sang giao diện Stream mới và chỉ giữ các hoạt động đột biến, háo hức trực tiếp trên Bộ sưu tập. Brian Goetz đã giải thích lý do cho điều đó.
Điều gì về việc cho phép nhiều đường truyền cho các đường ống dựa trên bộ sưu tập nhưng không cho phép các đường ống không dựa trên bộ sưu tập? Nó không nhất quán, nhưng nó hợp lý. Nếu bạn đang đọc các giá trị từ mạng, tất nhiên bạn không thể duyệt lại chúng. Nếu bạn muốn duyệt chúng nhiều lần, bạn phải kéo chúng vào một bộ sưu tập một cách rõ ràng.
Nhưng hãy khám phá cho phép nhiều đường truyền từ các đường ống dựa trên bộ sưu tập. Hãy nói rằng bạn đã làm điều này:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(Các into
hoạt động bây giờ được đánh vần collect(toList())
.)
Nếu nguồn là một bộ sưu tập, thì into()
cuộc gọi đầu tiên sẽ tạo ra một chuỗi các Trình lặp trở lại nguồn, thực hiện các hoạt động đường ống và gửi kết quả vào đích. Cuộc gọi thứ hai để into()
sẽ tạo ra một chuỗi các vòng lặp, và thực hiện các hoạt động đường ống dẫn một lần nữa . Điều này rõ ràng không sai nhưng nó có tác dụng thực hiện tất cả các hoạt động của bộ lọc và bản đồ lần thứ hai cho mỗi phần tử. Tôi nghĩ rằng nhiều lập trình viên sẽ ngạc nhiên về hành vi này.
Như tôi đã đề cập ở trên, chúng tôi đã nói chuyện với các nhà phát triển Guava. Một trong những điều tuyệt vời mà họ có là một Nghĩa địa ý tưởng nơi họ mô tả các tính năng mà họ quyết định không thực hiện cùng với các lý do. Ý tưởng về các bộ sưu tập lười biếng nghe có vẻ khá tuyệt, nhưng đây là những gì họ nói về nó. Hãy xem xét một List.filter()
hoạt động trả về một List
:
Mối quan tâm lớn nhất ở đây là quá nhiều hoạt động trở thành đề xuất đắt tiền, theo thời gian tuyến tính. Nếu bạn muốn lọc một danh sách và lấy lại danh sách, và không chỉ là Bộ sưu tập hoặc Iterable, bạn có thể sử dụng ImmutableList.copyOf(Iterables.filter(list, predicate))
, "nêu lên trước" những gì nó đang làm và mức độ đắt đỏ của nó.
Để lấy một ví dụ cụ thể, chi phí của get(0)
hoặc size()
trong Danh sách là bao nhiêu? Đối với các lớp thường được sử dụng như ArrayList
, chúng là O (1). Nhưng nếu bạn gọi một trong số này trong danh sách được lọc một cách lười biếng, thì nó phải chạy bộ lọc qua danh sách sao lưu và tất cả các hoạt động đột ngột này là O (n). Tệ hơn, nó phải đi qua danh sách sao lưu trên mọi hoạt động.
Điều này dường như chúng ta quá lười biếng. Đó là một điều để thiết lập một số hoạt động và trì hoãn thực hiện thực tế cho đến khi bạn "Đi". Đó là một cách khác để thiết lập mọi thứ theo cách che giấu một lượng tính toán tiềm năng lớn.
Khi đề xuất không cho phép các luồng không tuyến tính hoặc "không sử dụng lại", Paul Sandoz đã mô tả các hậu quả tiềm ẩn của việc cho phép chúng làm phát sinh "kết quả bất ngờ hoặc khó hiểu". Ông cũng đề cập rằng việc thực hiện song song sẽ khiến mọi thứ trở nên phức tạp hơn. Cuối cùng, tôi nói thêm rằng một hoạt động đường ống có tác dụng phụ sẽ dẫn đến các lỗi khó hiểu và tối nghĩa nếu hoạt động đó được thực hiện bất ngờ nhiều lần, hoặc ít nhất là một số lần khác so với dự kiến của lập trình viên. (Nhưng các lập trình viên Java không viết các biểu thức lambda với các tác dụng phụ, phải không? LÀM SAO ??)
Vì vậy, đó là lý do cơ bản cho thiết kế API Luồng Java 8 cho phép truyền tải một lần và yêu cầu một đường ống tuyến tính (không phân nhánh) nghiêm ngặt. Nó cung cấp hành vi nhất quán trên nhiều nguồn luồng khác nhau, nó phân tách rõ ràng sự lười biếng khỏi các hoạt động háo hức và nó cung cấp một mô hình thực thi đơn giản.
Liên quan đến IEnumerable
, tôi không phải là một chuyên gia về C # và .NET, vì vậy tôi sẽ đánh giá cao việc được sửa chữa (một cách nhẹ nhàng) nếu tôi rút ra bất kỳ kết luận không chính xác nào. Tuy nhiên, nó xuất hiện IEnumerable
cho phép nhiều giao dịch hành xử khác nhau với các nguồn khác nhau; và nó cho phép một cấu trúc phân nhánh của các IEnumerable
hoạt động lồng nhau , điều này có thể dẫn đến một số tính toán lại đáng kể. Mặc dù tôi đánh giá cao rằng các hệ thống khác nhau tạo ra sự đánh đổi khác nhau, đây là hai đặc điểm mà chúng tôi tìm cách tránh trong thiết kế API Luồng Java 8.
Ví dụ quicksort do OP đưa ra rất thú vị, khó hiểu và tôi rất tiếc phải nói, hơi kinh khủng. Gọi QuickSort
sẽ mất một IEnumerable
và trả về một IEnumerable
, vì vậy không có sự sắp xếp nào thực sự được thực hiện cho đến khi trận chung kết IEnumerable
được duyệt. Mặc dù vậy, những gì cuộc gọi dường như làm là xây dựng một cấu trúc cây IEnumerables
phản ánh phân vùng mà quicksort sẽ làm, mà không thực sự thực hiện nó. (Rốt cuộc, đây là sự tính toán lười biếng.) Nếu nguồn có N phần tử, cây sẽ là phần tử N rộng nhất, và nó sẽ ở mức lg (N) sâu.
Dường như đối với tôi - và một lần nữa, tôi không phải là chuyên gia về C # hay .NET - rằng điều này sẽ khiến một số cuộc gọi trông vô hại, chẳng hạn như lựa chọn trục qua ints.First()
, đắt hơn so với vẻ ngoài của chúng. Ở cấp độ đầu tiên, dĩ nhiên, đó là O (1). Nhưng hãy xem xét một phân vùng sâu trong cây, ở cạnh bên phải. Để tính toán phần tử đầu tiên của phân vùng này, toàn bộ nguồn phải được duyệt qua, một hoạt động O (N). Nhưng vì các phân vùng ở trên là lười biếng, chúng phải được tính toán lại, yêu cầu so sánh O (lg N). Vì vậy, việc chọn trục sẽ là thao tác O (N lg N), tốn kém như toàn bộ.
Nhưng chúng tôi không thực sự sắp xếp cho đến khi chúng tôi đi qua trả lại IEnumerable
. Trong thuật toán quicksort tiêu chuẩn, mỗi cấp độ phân vùng sẽ nhân đôi số lượng phân vùng. Mỗi phân vùng chỉ có một nửa kích thước, vì vậy mỗi cấp độ vẫn ở độ phức tạp O (N). Cây phân vùng cao O (lg N), vì vậy tổng công việc là O (N lg N).
Với cây IEnumerables lười biếng, ở dưới cùng của cây có N phân vùng. Việc tính toán mỗi phân vùng đòi hỏi phải có một phần tử N, mỗi phần tử yêu cầu so sánh lg (N) trên cây. Để tính toán tất cả các phân vùng ở dưới cùng của cây, sau đó, yêu cầu so sánh O (N ^ 2 lg N).
(Điều này có đúng không? Tôi khó có thể tin điều này. Ai đó làm ơn kiểm tra cái này cho tôi.)
Trong mọi trường hợp, nó thực sự tuyệt vời IEnumerable
có thể được sử dụng theo cách này để xây dựng các cấu trúc tính toán phức tạp. Nhưng nếu nó làm tăng độ phức tạp tính toán nhiều như tôi nghĩ, thì có vẻ như lập trình theo cách này là điều nên tránh trừ khi người ta cực kỳ cẩn thận.