שלום לכולם,
השבוע אנחנו ניקח הפסקה קצת מ-design patterns
אבל אני מפתיח לסיים את הסדרה – כולל את הפוסטים החסרים מהפייסבוק.
הפעם אנחנו נלמד על אחד השיפורים המשמעותיים בעיני בג'אווה 8, ה-Java Streams.
קודם כל חשוב מאוד לא להתבלבל בין זה לבין I/O streams בג'אוה (כמו ה-FileOutputStream)
הם לא ממש קשורים אחד לשני.
בפשטות ה-streams עוטפים איזשהו מבנה נתונים, ומאפשרים לנו לבצע פעולות על אותו מבנה נתונים.
ה-stream לא שומר מידע בפני עצמו.
הוא לא מבנה נתונים בפני עצמו.
והוא גם לעולם לא משנה את המבנה נתונים שעליו הוא רץ.
בואו נצלול לקוד כדי להבין איך זה עובד
יצירה של Java Streams
הדרך הראשונה והקלה ביותר לייצר stream היא ממבנה נתונים קיים.
הנה למשל מערך שממנו אנחנו יכולים ליצור stream
אנחנו גם יכולים ליצור stream מרשימה קיימת:
המתודה stream() היא תוספת חדשה ל-collection interface בג'אווה 8.
יש לנו אפשרות גם ליצור stream מאברים ספצייפים
או פשוט להשתמש ב- Stream.builder
אופרטורים של Java Streams
בואו נעבור עכשיו על כמה מהאופרטורים הנפוצים של java stream.
forEach
השימוש באופרטור הזה יחד עם stream יהיה רק אם המבנה נתונים שאנחנו עובדים איתו לא כולל כבר את forEach בתוכו
אחרת, אין סיבה לפתוח stream עליו.
אבל אם יש שילוב של אופרטורים נוספים, אז הוא יבוא אחריהם
אבל בשביל הדוגמה ככה זה יראה
האופרטור foreach הוא סופי, כלומר אחרי שאנחנו קוראים לאופרטור הזה
ה-stream שלנו נסגר, ואי אפשר להשתמש בו עוד.
map
האופרטור map מייצר לנו stream חדש אחרי שמכילים פונקציה על כל אלמנט ב-stream שלנו.
ה-stream החדש שלנו יכול להיות עם סוג גנרי אחר מה-stream המקורי.
בדוגמה הבאה אנחנו נמיר stream של Integers ל-Employees
כאן אנחנו מקבלים stream של Integer, שכל איבר פה הוא ID של עובד.
כל ID מועבר לפונקציה findById של EmployeeRepository, שמחזירה לנו Employee
כלומר היא מבצעת המרה בין Integer לאובייקט Employee ששייך לו.
collect
בדוגמה למעלה ראינו גם שימוש באופרטור נוסף, שנקרא collect.
זו דרך נפוצה לאסוף את כל האלמנטים ב-stream שלנו אחרי שסיימנו לעבוד עליהם
כאשר אנחנו קוראים לאופרטור collect, אנחנו מבצעים אסיפה של האלמנטים למבנה נתונים חדש.
Filter
הדבר הבא הוא ה-filter.
כשנקרא לאופרטור filter, אנחנו ניצור stream חדש שמכיל את האלמטים של ה-stream המקורי
אשר יעברו מבחן לוגי כלשהו
בואו נסתכל על דוגמה
בדוגמה הזאת, אנחנו קודם כל מפלטרים את כל מה שחוזר כ-null מה-map
ולאחן מכן, אנחנו מפעילים עוד filter אשר יוציא את כל מי שהשכר שלו גדול מ-100.
findFirst
האופרטור הבא הוא findFirst.
הוא מחזיר אובייקט מסוג Optional, עבור האלמט הראשון ב-stream.
ה-Optional יכול להיות גם ריק, כמובן.
כאן אנחנו מחפשים את העובד הראשון ב-stream אשר עומד בכל התנאים שלנו.
אם אין כזה, אנחנו נחזיר null
toArray
האופרטור הזה מתנהג בדיוק כמו collect, רק שהוא מחזיר מערך ולא Collection.
הוא מקבל מערך שאותו הוא צריך למלא.
אנחנו מעבירים Employee[]::new שזה בעצם יוצר מערך חדש שאותו toArray ימלא לנו.
flatMap
אמרנו ש-stream יכול לעבד כל סוג של אובייקט
זה אומר שזה יכול להיות גם מבני נתונים בפני עצמם.
למשל יכול להיות לנו stream אשר מחזיק רשימות, או דברים מורכבים יותר
Stream<List<String>>
בשביל זה יש לנו את האופרטור flatMap.
הוא עוזר לנו לשטח את המבני נתונים שאנחנו עובדים עליהם,
כדי שלאופרטורים אחרים יהיה יותר פשוט לעשות את העבודה שלהם
שימו לך איך הצלחנו לרדד את מבנה הנתונים שאנחנו עובדים עליו
מרשימה של רשימות, לרשימה אחד של מחרוזות
וכל זאת באמצעות flatMap.
peek
ראינו מוקדם יותר בפוסט את האופרטור forEach, שהוא אופרטור סופי – כלומר שהוא חייב להיות אחרון.
אבל יש מקרים בהם אנחנו רוצים לעשות כמה פעולות על כל איבר ב-stream שלנו לפני שאנחנו קוראים לאופרטור סופי
האופרטור peek יכול להיות שימושי במקרים כאלו.
בפשטות הוא מבצע את הפעולה על כל איבר ב-stream ואז מחזיר לנו stream חדש שאשפשר להפעיל עליו עוד אופרטורים
הנה דוגמה
כאן ה-peek הראשון מגדיל את השכר של כל העובדים ברשימה,
ולאחר מכן אנחנו מריצים עוד peek כדי להדפיס את כולם.
ובסוף אנחנו משתמשים באופרטור הסופי collect.
סוגי אופרטורים ופייפליין
כמו שכבר דיברנו למעלה, ב-Java stream יש לנו שני סוגים של אופרטורים
אופרטור ביניים (Intermediate) ואופרטור סופי (Terminal).
הסוג הראשון הוא אופרטור ביניים כמו ה-filter מחזיר stream חדש שעליו אפשר להפעיל אופרטורים נוספים.
והשני הוא אופרטור סופי, כמו ה-forEach, מסמן את ה-stream כסגור, ואחרי זה אי אפשר להפעיל עוד אופרטורים עליו.
פייפליין של stream מורכב מ-stream מקור, או ראשוני, כלשהו, שאחריו אפשר להפעיל 0 או יותר אופרטורי ביניים
ובסוף מופעל עליו אופרטור סופי.
למשל בדוגמה הבאה, יש לנו פייפליין על stream שרץ מ empList
על ה-stream המקור אנחנו מפעילים את האופרטור ביניים filter ואז מפעילים את האופרטור הסופי count
אחרי זה אי אפשר יותר להפעיל עוד אופרטורים על ה-stream הזה.
יש לנו עוד סוג של אופרטור ביניים שנקרא אופרטור קצר
הוא סוג של אופרטור ביניים והמטרה שלו היא לאפשר ביצוע של חישובים על streams אינסופיים
לדוגמה:
במקרה הזה יש לנו stream שהוא אינסופי
אבל אנחנו מגבילים אותו על ידי שני אופרטורים שהם מסוג short-circuit
הראשון הוא skip אשר עוזר לנו לדלג על שלושת האיברים הראשונים
ואז אנחנו משתמשים באופרטור limit אשר מגביל את מספר האיברים שאנחנו עובדים איתם ל-5
אנחנו נדבר על אופרטורים אינסופיים בהמשך
הערכה עצלנית – lazy evaluation
אחד היתרונות הגדולים של java streams הוא שהם מאפשרים
אופטימיזציות על ידי lazy evaluation.
כלומר, החישוב על מקור המידע מתבצע רק כאשר אנחנו קוראים לאופרטור סופי.
בעצם כל האופרטורים מסוג ביניים הם עצלנים, הם לא מבצעים את הפעולה שלהם עד אשר החישוב הסופי מגיע.
בדוגה הבאה יש לנו את האופרטור findFirst שראינו מקודם
כמה פעמים אתם חושבים אנחנו קוראים לאופרטור map?
4 פעמים? כי יש לנו 4 איברים נכון?
ובכן, stream מבצע את האופרטור map ואת ה-filters כל איבר בנפרד
בפעם הראשונה הוא מבצע על id = 1.
כיוון שהשכר של עובד 1 הוא לא גדול מ-1000 אנחנו עוברים לאיבר הבא
בפעם השניה אנחנו קוראים לעובד מספר 2
הוא עונה על שני התנאים ב-filter ואז אנחנו עוברים ל findFirst שהוא אופרטור סופי (יחד עם orElse)
אנחנו לא נבצע פעולות על איברים 3 ו-4.
עיבוד האיברים בצורה עצלה מאפשר לנו לא לעשות עבודה מיותרת.
עובדה זאת חשובה עוד יותר כאשר יש לנו infinite streams.
אופרטורי השוואה
sorted
האופרטור הזה מסדר את האיברים ב-stream על ידי שימוש ב-comparator.
למשל:
שימו לב ש-short circuiting לא חל על sorted
למשל בדוגמה למעלה, גם אם היינו משתמשים ב-findFirst, אחרי sorted,
האופרטור sorted היה מתבצע על כל האיברים לפני שנעבור לבצע את findFirst.
הסיבה שזה קורה היא שאנחנו לא נדע איזה איבר יגיע ראשון עד ש-sorted לא יסתיים.
min and max
כמו שהשם מרמז, min ו-max מחזירים את הערך המינימאלי והערך המקסימאלי ב-stream
שני האופרטורים האלו מתבססים על comparator
הם מחזירים אובייקט מסוג Optional כיוון שהתשובה לא בטוח קיימת (למשל אם ביצענו filter לפני).
נשים לב שאנחנו יכולים להשתמש באופרטור orElseThrow כיוון שמה ש-min מחזיר לנו הוא Optional ולא stream
distinct
אנחנו לא מעבירים ארגומנטים לאופרטור distinct
והוא יחזיר לנו איברים יחודיים מה-stream, כלומר אם יש לנו איברים כפולים
האופרטור הזה יסנן לנו אותם.
הוא משתמש ב-equals על מנת להחליט האם אובייקט הוא כפול
התוצאה של ה-stream תכלול רק את 2, 5, 3, 4
allMatch, anyMatch and noneMatch
אנחנו נעביר לאופרטורים הללו תנאי לוגי כלשהו והם מחזירים בוליאני
על האופרטורים הללו short circuiting כן תופס, ולכן הם עוצרים ברגע שיש להם תשובה
אנחנו נשתמש באופרטור allMatch כדי לבדוק האם כל האיברים ב-stream עומדים בתנאי.
בדוגמה למעלה התשובה תהיה false כיוון שאיבר 5 לא עומד בתנאי.
באופרטור anyMatch אנחנו נשתמש כדי לבדוק האם קיים איבר אחד לפחות שעומד בתנאי
בדוגמה התשובה תהיה true כיוון שיש לנו איברים זוגיים ב-stream כמו 2.
ואילו ב-noneMatch אנחנו נשתמש כדי לבדוק אם אף אחד מהאיברים לא עומד בתנאי
כמו למשל בדוגמה, שאנחנו בודקים שאין איברים שמתחלקים בשלוש, והתשובה כמובן היא false כי 6 מתחלק בשלוש.
Streams Specialisations
עד עכשיו דיברנו על Stream שהוא גנרי ויודע לקבל כל אובייקט
אבל לג'אווה יש עוד כמה סוגים של stream והם יודעים לקבל אובייקטים ספציפיים
יש לנו את IntStream, LongStream, DoubleStream.
כל אחד מהם יודע לקבל את האובייקט הספציפי שלו.
חשוב לשים לב שהם לא הרחבה של Stream אלא של BaseStream
ולכן לא כל האופרטורים של Stream שדיברנו עליהם עד עכשיו קיימים.
למשל האופרטורים min ו-max מקבלים comparator כארגומנט בStream רגיל
ואילו ב-streams המיוחדים הם לא מקבלים ארגומנטים.
בנוסף, ה-streams הללו מביאים איתם אופרטורים מיוחדים
כמו למשל sum, average, range וכו
Reduction Operations
באופן כללי, בעולם התוכנה פעולות reduce (נקראות גם fold) הן פעולות אשר יודעות לקבל רצף של אלמנטים כקלט
ולחבר אותן לתוצאה אחת שמסכמת את כולם על ידי פעולה חוזרת של שילוב.
כבר ראינו פעולות כאלה בפוסט, כמו findFirst, min, max.
עכשיו בואו נראה את הפעולה הכללית reduce
reduce
הצורה הכי נפוצה של reduce נראית ככה
T reduce(T identity, BinaryOperator<T> accumulator)
כאשר identity הוא הערך ההתחלתי וה-accumulator הוא הפעולה החוזרת שאנחנו מבצעים.
לדוגמה:
בדוגמה הזאת, יש לנו ערך התחלתי של 0, ואנחנו מבצעים פעולה חוזרת של sum
אנחנו מבצעים את הפעולה הזאת על כל איבר ב-stream שלנו.
בתכלס ממשנו את DoubleStream.sum על ידי שימוש ב-reduce.
פעולות איסוף
כבר ראינו מקודם איך אפשר להשתמש ב Collectors.toList על מנת להפוך את ה-stream שלנו לרשימה.
עכשיו בואו נראה אילו עוד דרכים יש לנו כדי לקבץ את איברי ה-stream
joining
בואו נסתכל על הקוד הבא
הפעם העברנו לאופרטור collect את Collectors.joining.
בעצם, אנחנו מכניסים דלימיטר בין שני מחרוזות המייצגות איברים במערך.
במקרה שלנו הכנסנו פסיק בין כל מחרוזת המייצגת שם של עובד.
מאחורי הקלעים האופרטור הזה משתמש ב java.util.StringJoiner על מנת לבצע את הפעולה.
toSet
אנחנו יכולים להשתמש באופרטור toSet על מנת לקבץ את האיברים לקבוצה.
toCollection
אנחנו יכולים להשתמש ב-Collectors.toCollection כדי לקבץ את האיברים שלנו לכל אוסף שאנחנו רוצים.
כדי לעשות את זה אנחנו נצטרך להעביר כפרמטר את ה-Supplier.
אנחנו יכולים להשתמש בבנאי כ-Supplier.
במקרה הזה, אנחנו יוצרים אוסף ריק מסוג Vector
והאופרטור קורא ל add מאחורי הקלעים על מנת להוסיף את האלמנטים אליו.
summarizingDouble
הנה עוד אופרטור מעניין.
אנחנו נקרא לאופרטור הזה כדי לקבל מידע סטטיסטי על האלמנטים שיש לנו.
בואו נראה דוגמה:
אתם יכולים לראות שאנחנו משתמשים ב-collect על מנת לנתח כל שכר של עובד
ולקבל מידע סטטיסטי על המידע שיש לנו.
הפלט של הריצה הזאת יהיה:
3 3000.0 1000.0 2000.0
partitioningBy
אנחנו יכולים לחלק את ה-stream שלנו לשני חלקים.
החלוקה תהיה על בסיס קריטריון מסוים
בואו נחלק stream של מספרים לפי מספרים זוגיים
במקרה הזה, התוצאה של האופרטור היא מפה,
כאשר הערכים שלנו מחולקים לרשימות לפי האם הם זוגיים או לא
groupingBy
אם אנחנו רוצים חלוקה יותר מתקדמת עלינו להשתמש ב groupingBy.
האופרטור הזה מאפשר לנו לחלק את ה-stream ליותר משתי קבוצות.
אנחנו נעביר לאופרטור פונקציה.
הפונקציה הזאת תחליט לאיזו קבוצה נשים כל איבר ב-stream שלנו.
הערך שחוזר מהפונקציה משמש כמפתח במפה שאנחנו מקבלים מהאופרטור groupingBy.
בדוגמה הזאת, אנחנו מקבצים את העובדים לפי האות הראשונה בשם שלהם.
mapping
בחלוקה לקבוצות שראינו למעלה, כמו groupingBy
אנחנו מקבצים את האלמנטים שלנו באמצעות Map.
כל איבר בקבוצה היא מאותו סוג כמו האיבר ב-stream שלנו.
אבל לפעמים יש לנו צורך לקבץ רק חלק מהמידע.
כדי לבצע את זה, אנחנו יכולים להשתמש באופרטור mapping
והוא יכול לשנות את הקבוצות לסוג אחר, באמצעות mapping function
כאן למשל, אנחנו נרצה לקבץ את העובדים לפי האות הראשונה בשם שלהם,
אבל הפעם אנחנו לא נרצה לשמור את כל האובייקט של העובד, אלא רק את ה ID שלו.
reducing
כמו שהשם מרמז, reducing דומה מאוד לאופרטור reduce.
האופרטור reducing מחזיר Collector אשר מבצע פעולת reduction על איברי הקלט שלו
בדוגה הזאת אנחנו מקבלים את עליית השכר של כל אחד מהעובדים
ומחזירים את הסכום הכולל.
Parallel Streams
אנחנו יכולים לבצע את האופרטורים של stream במקביל על ידי שימוש ב parallel stream.
זה חוסך לנו את הצורך לכתוב שני stream שיעשו את העבודה.
זה נראה ככה:
במקרה הזה, המתודה salaryIncrement תתצבע במקביל על כמה אלמנטים ב-stream.
וכל זה רק על ידי הוספת parallel.
אנחנו כמובן יכולים להגדיר את האופרטור הזה, אם אנחנו צריכים שליטה גדולה יותר על הביצועים של הקוד
כיוון שהאופרטור הזה מבצע פעולות של multi-threaded אנחנו צריכים להיות ערים לכמה נקודות:
- אנחנו צריכים לוודא כמובן שהקוד שלנו הוא thread-safe.
אנחנו צריכים לשים לב במיוחד אם הפעולות שאנחנו מבצעים במקביל מעדכנות את אותו מידע - אנחנו לא נשתמש ב parallel stream אם סדר הפעולות או סדר ההחזרה חשוב לנו.
לדוגה אופרטור כמו findFirst יכול להחזיר תוצאות שונות בריצות שונות אם נשתמש ב-parallel streams
סיכום
במאמר הזה התמקדנו על הפונקציונליות של Java Streams.
חשוב לי לציין שכל זה נכון עבור Java 8. ב- Java 9 התבצעו שיפורים ושינויים ל-streams אשר לא נכללים במאמר הזה.
ראינו אופרטורים שונים, ואיך שימוש בלמדות ובפייפלינים מאפשרים לנו לכתוב קוד קריא יותר, וקצר יותר
בנוסף, למדנו על מאפיינים של streams כמו lazy evaluation.
אתם יכולים למצוא את כל הקוד כאן
אם אתם רוצים לעזור, אז אשמח שתשתפו את המאמר הזה או מאמר אחר שאהבתם על מנת שהבלוג יוכל לגדול
וכמו תמיד, אני אשמח לקבל כל הערה, הארה או שאלה שיש לכם