10 sai lầm ‘huyền ảo’ khi sử dụng Stream API trong Java 8

Bài gốc: http://blog.jooq.org/2014/06/13/java-8-friday-10-subtle-mistakes-when-using-the-streams-api/

Lambda Expression và Java Stream API là tính năng lớn trong Java 8 (Ngoài ra còn New Date Time API và vài thay đổi khác). Bài viết mình dịch là 10 sai lầm khi sử dụng Stream API, vì vậy các bạn nên biết trước Stream API là gì? Nó hoạt động thế nào? Mình thấy có rất nhiều các bài hướng dẫn sử dụng Stream API trên mạng.

http://winterbe.com/posts/2014/07/31/java8-stream-tutorial-examples/
http://blog.hartveld.com/2013/03/jdk-8-33-stream-api.html

Nguồn chính mình học Java Stream API là từ cuốn sách Java SE 8 for the Really Impatient. Cuốn sách khá hay, bao quát và chi tiết hầu hết tính năng mới của Java 8. (Lambda Expression, Stream API, New Date Time API …)

1. Sử dụng lại Stream

Cá là điều này xảy ra với tất cả mọi người ít nhất một lần. Giống như những stream trước (InputStream, OutputStream) bạn có thể sử dụng stream một lần duy nhất. Đoạn code sau sẽ không chạy được:

Bạn sẽ nhận được một exception:
java.lang.IllegalStateException:  stream has already been operated upon or closed
Vì vậy hãy cẩn thận khi sử dụng stream. Nó chỉ có thể sử dụng một lần duy nhất.

2. Tạo ra stream vô hạn

Bạn có thể tạo ra những stream vô hạn khá dễ dàng mà không có thông báo nào. Thử đoạn mã ví dụ sau:

Thực thế những stream có thể vô hạn, nếu bạn muốn thiết kế như vậy. Chỉ có một vấn đề là nếu bạn không muốn tạo ra điều đó. Vì vậy đảm bảo là bạn luôn luôn sử dụng tính năng limit:

Mình giải thích thêm 2 đoạn mã trên, đoạn mã đầu tiên là cách để tạo ra một stream bằng cách lặp (iterate). iterate(0, i -> i + 1), vòng lặp sẽ bắt đầu từ 0 và mỗi lần lặp sẽ tăng giá trị lên một. Như vậy ta có một stream vô hạn từ 0 đến vô cùng (iterate là hàm trung gian (intermediate) nên nó sẽ không thực thi đến khi bạn gọi một hàm terminal)

Ở đoạn mã thứ 2 thì có thêm hàm limit, vòng lặp sẽ chỉ lặp 10 lần nên stream sẽ từ 0 đến 9.

3. Tạo ra stream vô hạn một cách huyền ảo 

Chúng ta không thể nói đủ mọi trường hợp cho vấn đề này. Cuối cùng rồi bạn cũng tạo ra stream vô tận một cách ngẫu nhiên thôi hehe :D. Thử đoạn mã ví dụ sau:

  • Chúng ta tạo ra stream với các giá trị 0 và 1 xen kẽ (iterate(0, i -> ( i + 1 ) % 2))
  • Sau đó chúng ta chỉ giữ lại một giá trị trùng, ví dụ 0 và 1
  • Sau đó chúng ta giới hạn lại kích thứơc của stream là 10
  • Sau đó chúng ta sử dụng nó

À ha, hàm distinct() không hề biết hàm cung cấp cho iterate() chỉ tạo ra hai giá trị. Nó có thể mong đợi nhiều hơn thế. Vì vậy, nó sẽ mãi mãi sử dụng những giá trị mới từ stream, và hàm limit(10) sẽ không bao giờ được gọi.

Mình giải thích thêm do đoạn mã trên luôn generate ra 0 và 1 nên hàm sẽ gộp giá trị 0 và 1 vào, nên stream luôn luôn chỉ có 2 giá trị nên không thể đạt đến limit 10. Nếu đoạn mã trên thay đổi lại thành iterate(0, i -> i + 1 ) thì sẽ in ra từ 1 tới 9.

4. Vô hình tạo ra một stream song song vô hạn

Chúng ta muốn nhấn mạnh bạn vô tình có thể sử dụng một stream vô hạn. Chúng ta cùng giả sử bạn tin rằng hàm distinct() nên được thực thi trong stream song song(parallel). Bạn có thể viết:

Bây giờ, sai lầm trước chúng ta vừa thấy nó chạy muôn đời. Nhưng ít nhất nó chỉ sử dụng một CPU. Bây giờ, nó chắc chắn sử dụng cả 4 CPU, khả năng tìm tàng là chiếm dụng hầu hết hệ thống khi sử dụng stream vô hạn. Điều này cực kỳ tệ hại. Bạn chắc chắn có thể khởi động (hard-reboot) lại server sau đó. Bạn nhìn chuyện gì xảy ra với laptop của tôi giống như trước một vụ nổ.

stream-api-1

5. Thứ tự gọi các hàm trong stream

Tại sao chúng ta lại nhấn mạnh vào stream vô hạn mà các bạn vô tình tạo ra? Đơn giản tôi, là vì bạn vô tình tạo ra nó. Đoạn mã trên sẽ trở nên hoàn hảo nếu bạn thay đổi thứ tự của hàm limit() và distinct():

Giờ kết quả là :

0
1
Tại sao? Bởi vì đầu tiền chúng ta đã giới hạn stream vô hạn còn 10 giá trị (0 1 0 1 0 1 0 1 0 1 0 1), trước khi chúng ta gọi hàm distinct() kết quả sẽ chỉ còn (0,1)

Đương nhiên, điều này không có nhiều ý nghĩa, bởi vì bạn muốn 10 giá trị duy nhất đầu tiên từ một tập hợp dữ liệu. Không ai muốn 10 giá trị ngẫu nhiên, và chỉ reduce chúng trở thành duy nhất.

Nếu bạn quen thuộc với kiến thức nền SQL, bạn có thể không mong đợi những sự khác biệt. Các đoạn mã SQL sau là như nhau:

Vì vậy, nếu bạn là một lập trình viên SQL, bạn có thể không ý thức được tầm quan trọng thứ tự các hàm trong stream.

6. Thứ tự gọi các hạm trong stream (tiếp theo)

Nếu bạn là ‘người của’ MySQL hay PostgreSQL, bạn có thể quen với việc sử dụng LIMIT .. OFFSET. SQL đầy các thứ không minh bạch, mà đây là một trong chúng. OFFSET được áp dụng như cú pháp FIRST (trong SQL Server 2012)

Nếu bạn chuyển đổi MySQL/PostgreSQL trực tiếp sang stream, bạn chắc chắn sẽ gặp vấn đề:

Kết quả là :

5
6
7
8
9

Nó không tiếp tục giá trị 9, bởi vì hàm limit() bây giờ áp dụng đầu tiên, cho ra (0 1 2 3 4 5 6 7 8 9). skip() được áp dụng sau, cho ra stream (5 6 7 8 9). Không như những gì bạn dự định làm.

CẨN TRỌNG với bẩy LIMIT .. OFFSET với “OFFSET .. LIMIT” .

7. Dạo (walking) qua hệ thống tập tin với filters

Ý tưởng hay nào xuất hiện khi dạo (walking) hệ thống tập tin với filters:

Stream trên chỉ walking qua những thư mục không ẩn (non-hidden) như là những thư mục không bắt đầu với kứ tự .(dot). Không may bạn lại vừa tạo ra sai lầm nhưng #5 và #6. Hàm walk() đã tạo ra tất cả những thư mục con của thư mục hiện hành. Tư duy trên chứa tất cả các sub-path (các tập tin hay thư mục con). Bây giờ, hàm filter sẽ loại ra tất cả các Path mà tên của chúng bắt đầu là .(dot) ví dụ .git or .idea sẽ không có trong kết qủa lọc. Nhưng những Path này sẽ được: .\.git\refs or .\.idea\libraries. Nó không phải là điều chúng ta mong đợi.

Bây giờ, đừng sửa bằng cách có một đoạn mã khác:

Tuy đoạn mã này sẽ tạo kết quả đúng, nó vẫn dạo (traversaling) tất cả các thư mục con, đệ quy tất cả các thư mục con của các thư mục “ẩn (hidden)”.

Tôi đoán bạn sẽ phải dùng lại hàm File.list(). Tin tốt là FilenameFilter và FileFilter đều là functional interfaces.

Mình giải thích rõ vấn đề. Ở đoạn mã 1. Hàm walk sẽ giúp bạn đi qua tất cả các thư mục con và tập tin con (mỗi thư mục và tập là một Path). Khi qua tất cả các tập tin hàm filter sẽ bắt đầu thực thi là loại bỏ các thư mục có dấu dot(.). Hàm nãy bị lỗi ở chỗ nó vẫn vào bên trong của các thư mục bắt đầu dấu dot(.) để tiếp tục kiểm tra. Nên tất cả sửa lại đoạn mã 2 để nếu trong path của thư mục có dấu chấm thì loại bỏ. Kết quả sẽ ra như bạn mong muốn nhưng hiệu xuất vẫn cùi bắp do nó vẫn chạy chạy qua tất cả các thư mục con của các thư mục này để kiểm tra trong khi không cần thiết. Mình đưa ra một cách để hạn chế vấn đề này là các bạn có thể tìm hiểu và sử dụng FileVisitor https://docs.oracle.com/javase/tutorial/essential/io/walk.html hoặc như tác giả đề cập sử dụng FilterFileName hoặc FileFilter.

(cont)

 

Như các bạn thấy mình dịch khá tệ, dịch từng từ nên khó truyền đạt hết hàm ý của các giả. Mình chỉ cố gắng cung cấp nhiều nhất từ khóa có thể để mọi người tìm kiếm. Mình khuyên các bạn nên đọc bài gốc tiếng Anh.

Got something to say? Go for it!