קצת על תכנות פונקציונלי בג’אווה

על מה הפוסט?

הפוסט מסביר על תכנות פונקציונלי בשפת ג’אווה, מי שמכיר מוזמן לפתוח את תוכן העניינים ולקפוץ ישירות לחלקים שמעניינים אותו.

תוכן עניינים

 

מה זה?

יכולת חדשה יחסית שהתווספה לג’אווה בגרסה 8 והורחבה מעט בגרסה 9, שמאפשרת לנו להתייחס לפונקציות כישויות עצמאיות בשפה ובכך לכתוב קוד יותר תמציתי ותיאורי (קוד שמתאר ‘מה’ אנחנו רוצים לעשות ומסתיר חלק מה’איך’) ולכן יותר קל להבין מה מטרת הקוד (כמובן אחרי שמתרגלים…).

למה צריך את זה?

שפת ג’אווה היא שפה ‘זקנה’ ומאוד מפורשת (צריך לכתוב הרבה קוד כדי להשיג את אותו הדבר, יחסית לשפות אחרות). אחרי שעובדים קצת בשפות יותר פונקציונלית ו’קלילות’ הפער מאוד בולט. בגרסה 8 אורקל בעצם עשו ניסיון (לא רע) לסגור את הפער ולהגדיל את הפרודוקטיביות של מפתחי ג’אווה.

השינוי כולל הפיכה של פונקציות לישויות עצמאיות בשפה ע”י הכנסת Interface פונקציונלי וLambdas והרחבה של הCollection Framework עם Stream שמאפשר להשתמש בדפוסים פונקציונליים עם כל אחד ממבני הנתונים הקיימים בשפה.

איך משתמשים בזה?

נתחיל בדוגמת הדפסה של אברי רשימה (כל הדוגמאות זמינות בGithub):

List l = Arrays.asList("one", "two", "tree");
l.forEach(s -> System.out.println(s));

השתמשנו במתודה חדשה forEach שנוספה לIterable  בג’אווה 8. המתודה מקבלת למבדה (Lambda) בתור קלט. למבדה היא קטע קוד שניתן להעביר כפרמטר למתודה (דומה למצביע לפונקציה ב++C). בדוגמה שלנו הלמבדה מורצת ע”י המתודה forEach על כל אחד מהאברים וזה שקול כמובן לקוד הבא:

List<String> l = Arrays.asList("one", "two", "tree");
for (String s : l) {
    System.out.println(s);
}
 זה אולי לא נראה כשינוי מדהים, אבל די הרבה קרה פה. כדי להבין איך זה עובד נוציא את הלמבדה משורה 2 בדוגמה הראשונה למשתנה:
Consumer<String> consumer = s -> System.out.println(s);
l.forEach(consumer);

קיבלנו משתנה מטיפוס Consumer, שזוהי ההגדרה שלו:

@FunctionalInterface
public interface Consumer {

/**
* Performs this operation on the given argument.
*
* @param t the input argument
*/
void accept(T t);

זהו בעצם ממשק (Interface) פונקציונלי, שמאפשר להחליף מימוש של מחלקה בעלת מתודה אחת בלמבדה. כלומר, ללא תמיכה בלמבדה הקוד היה נראה ככה:

Consumer<String> verboseConsumer = new Consumer<String>() {
    @Override
    public void accept(String s) {
        System.out.println(s);
    }
};
l.forEach(verboseConsumer);

שימו לב שכל מחלקה/ממשק בעלת מתודה אחת יכולה לשמש בתור למבדה. כלומר, נוכל להשתמש בכל מחלקה שכוללת מתודה יחידה (שמקבלת מחרוזת ומחזירה void) ולהשתמש בה בforEach (אין צורך להרחיב את Consumer).

בכתיבת למבדה נשאף להשתמש אך ורק בפרמטרים המועברים ללמבדה. בג’וואה אנחנו לא יכולים לשנות ערכים של משתנים מקומיים מתוך הלמבדה, אבל כן יכולים לשנות שדות של המחלקה. מאוד רצוי להימנע מכל שימוש ושינוי של state חיצוני בלמבדה שיוצר side-effect, מקשה על ההבנה של הקוד ומגביל שימוש חוזר בקוד (ואם הגענו לזה, פשוט צריך למצוא דרך אחרת לביצוע הלוגיקה).

פרט אחרון לפני שנעבור לדברים יותר מעניינים. ניתן לקצר את הלמבדה מהדוגמה הראשונה ע”י שימוש ב’מצביע’ למתודה:

List<String> l = Arrays.asList("one", "two", "tree");
l.forEach(System.out::println);
public class Sea {
    private String region;
    private Integer area;
    private String name;

    public Sea(String name, String region, Integer area) {
        this.name = name;
        this.area = area;
        this.region = region;
    }

    //Class is a bean and includes the standard getters,setters,equals,hashcode which are omitted here to reduce noise
}

 

 ונגדיר קצת נתונים:

private List<Sea> seas = Arrays.asList(
        new Sea("Baltic Sea", "Europe, Africa, and Asia",1641650 ),
        new Sea("Caribbean Sea", "Americas", 2754000),
        new Sea("Mediterranean Sea", "Europe, Africa, and Asia", 2500000)
);

נרוץ על הנתונים שהגדרנו ונמצא את השטח הגדול ביותר:

Optional<Integer> largestArea = seas.stream().
        map(Sea::getArea).
        max(Comparator.naturalOrder());

assertTrue(largestArea.isPresent());
assertEquals(2754000, largestArea.get().intValue());

קרו פה מספר דברים, אז נעבור שורה שורה… שימו לב לשימוש במתודה stream בשורה הראשונה. בדוגמה הקודמת השתמשנו בforEach שנוסף לIterable ומאפשר הפעלת פונקציה על כל איבריו. לצורך פעולות יותר מורכבות אנחנו צריכים ליצר זרם (Stream), שזה בפשטות אוסף סופי או אינסופי של איברים. בדוגמה שלנו הזרם נוצר מרשימה ולכן ‘מכיל’ את כל אברי הרשימה.

פעולות על הזרם מתחלקות לפעולות ביניים (intermediate) ופעולות מסיימות (terminal). העבודה עם הזרם היא lazy, כלומר החישובים מתבצעים רק כאשר הפעולה המסיימת נקראת, מה שמאפשר ביצוע של אופטימיזציות ועיבוד מקבילי של האיברים (ועל כך בהמשך הפוסט).

בדוגמה שלנו הפעלנו שתי פעולות על הזרם. הראשונה בשורה 2, פעולת מיפוי (map) המאפשרת להפוך זרם המכיל טיפוס מסוים לזרם חדש המכיל טיפוס אחר ע”י מעבר על כל אחד מהאיברים והפעלת פונקציית המרה על האיבר. בעזרתה, אנחנו עוברים מזרם שמכיל Seas לזרם שמכיל Integers (השטחים של הימים). הזרם החדש שומר על הסדר של הזרם המקורי. זוהי פעולת ביניים, כלומר היא תבוצע בפועל כאשר יהיה צורך בתוצר שלה לצורך השלמת פעולה מסיימת. הפעולה השניה max בשורה 3, היא הפעולה המסיימת שלנו, היא רצה על הזרם ומחזירה את השטח המקסימלי תוך שהיא גוררת את ביצוע פעולת הביניים. הפעולה max מקבלת את הComparator המוכר של ג’אווה, המימוש של naturalOrder עובד על משתנים שממשים Comparable ופשוט מפעיל compareTo על האיברים.

 

אם נרצה למצוא ישירות את הים הגדול ביותר (ללא מיפוי לשטח הים כפי שעשינו בדוגמה הקודמת) נוכל לעשות זאת בקלות:

Optional<Sea> largestSea = seas.stream().
        max(Comparator.comparing(Sea::getArea));
assertSeaName(largestSea, "Caribbean Sea");

בשורה 2 אנחנו משתמשים בפונקציה max מהדוגמה הקודמת, שכאמור מקבלת את הComparator המוכר של ג’אווה. שימו לב לשימוש בComparator.comparing, אנחנו מעבירים למתודה comparing למבדה שמחלצת את השדה שאיתו אנחנו רוצים להשוות. המתודה תפעיל את הלמבדה שלנו על כל האברים. שקול לקוד הבא, שבו הגדרנו את הComparator בצורה מפורשת:

Optional<Sea> largestSea = seas.stream().
        max((s1, s2) -> s1.getArea() > s2.getArea() ? 1 : -1);
assertSeaName(largestSea, "Caribbean Sea");

 

 ניתן להשיג את אותו הדבר (חישוב המקסימום) ע”י שימוש בreduce:

Optional<Sea> largestSea =
        seas.stream().reduce((s1, s2) -> s1.getArea() > s2.getArea() ? s1 : s2);
assertSeaName(largestSea, "Caribbean Sea");

פעולת reduce כשמה כן היא, מאפשרת לצמצם סט של אברים לאבר אחד. הפונקציה עוברת על כל זוג אברים, ש’מצומצם’ לאיבר אחד ומשמש בתורו כקלט הראשון לפונקציה כאשר הקלט השני יהיה האיבר הבא ברשימה:

Stream elements: 4,2,5,3,8

Reduce:
1. 4,2->4
2. 4,5->5
3. 5,3->5
4. 5,8->8

דפוס מאוד נפוץ בזרמים הוא ביצוע פעולה על הנתונים לפי אחד השדות:

List<Sea> result  = seas.stream().
        filter(sea -> sea.getArea() > 2000000).
        sorted(Comparator.comparing(Sea::getArea)).
        collect(Collectors.toList());
assertThat(result, contains(seas.get(2), seas.get(1)));

בדוגמה למעלה אנחנו מפלטרים החוצה את כל הימים הקטנים משני מיליון קילומטר מרובע, וממיינים את הימים לפי השטח. הפעולה collect הופכת את הזרם לרשימה (והיא הפעולה המסיימת שלנו).

השימוש בCollectors נראה קצת מסורבל, אבל הם מאפשרים יכולות מאוד חזקות כפי שנראה בדוגמה הבאה:

Map<String, List<Sea>> result = seas.stream().collect(Collectors.groupingBy(Sea::getRegion));
assertEquals(2, result.size());
assertThat(result.get("Europe, Africa, and Asia"), contains(seas.get(0), seas.get(2)));
assertThat(result.get("Americas"), contains(seas.get(1)));

כאן אנחנו משתמשים שוב בcollect אבל הפעם כדי לבצע groupBy. בעצם, חילקנו את הרשימה המקורית לשתי כניסות במפה כשכל כניסה מכילה את רשימת הימים באזור. תחשבו לרגע על הקוד הפרוצדורלי המייגע שזה מחליף…

 

עוד נקודה מעניינת – יש חשיבות לסדר הפעולות:

Predicate<Sea> seaPredicate = sea -> {
 System.out.println("filter: " + sea);
 return sea.getArea() > 2000000;
};
Comparator<Sea> seaComparator = (s1, s2) -> {
 System.out.println("sort: " + s1 + " ? " + s2);
 return s1.getArea() - s2.getArea();
};

seas.stream().
 filter(seaPredicate).
 sorted(seaComparator).
 collect(Collectors.toList());

System.out.println("-------------------------");

seas.stream().
 sorted(seaComparator).
 filter(seaPredicate).
 collect(Collectors.toList());

הוספנו הדפסות לפעולת הsort והfilter והתקבל הפלט הבא:

filter: Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: Sea{region='Americas', area=2754000, name='Caribbean Sea'}
filter: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Americas', area=2754000, name='Caribbean Sea'}
-------------------------
sort: Sea{region='Americas', area=2754000, name='Caribbean Sea'} ? Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Americas', area=2754000, name='Caribbean Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Americas', area=2754000, name='Caribbean Sea'}
sort: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'} ? Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'}
filter: Sea{region='Americas', area=2754000, name='Caribbean Sea'}

כלומר אם נקפיד לבצע את הפילטור לפני המיון נחסוך ביצוע מיונים מיותרים. עוד נקודה מעניינת מהדוגמה למעלה היא שכל פעולה בוצעה על כל הזרם במלואו לפני המעבר לפעולה הבאה. זה נגרם עקב השימוש בsorted שהיא פעולה ‘מיוחדת’, בעוד שדרך הפעולה ה’סטנדרטית’ היא דווקא להעביר כל איבר בתורו דרך כל הפעולות. נמחיש זאת ע”י דוגמה נוספת עם הדפסות:

Predicate<Integer> predicate = i -> {
     System.out.println("filter: " + i);
     return i > 2000000;
 };

 Function<Sea, Integer> map = s -> {
     System.out.println("map: " + s);
     return s.getArea();
 };
 seas.stream().
         map(map).
         filter(predicate).
         forEach(i -> System.out.println("forEach: " + i));

עיון קצר בהדפסה יאשר שאכן הפעולות מתבצעות בצורה סדרתית (האיבר הראשון מפולטר ולכן אין forEach עבורו):

map: Sea{region='Europe, Africa, and Asia', area=1641650, name='Baltic Sea'}
filter: 1641650
map: Sea{region='Americas', area=2754000, name='Caribbean Sea'}
filter: 2754000
forEach: 2754000
map: Sea{region='Europe, Africa, and Asia', area=2500000, name='Mediterranean Sea'}
filter: 2500000
forEach: 2500000

 

יתרון נוסף של שימוש בזרמים הוא קבלת מקביליות די ב’חינם’:

recordTime("Regular sum", () -> LongStream.range(1, 10000000).sum());
recordTime("Parallel sum", () -> LongStream.range(1, 10000000).parallel().sum());

בשורה 1, אנחנו מייצרים זרם שמכיל מספר גדול של אברים וסוכמים את אברי הזרם, ללא מקביליות. בעוד שבשורה 2 הסכימה מתבצעת בצורה מקבילית. כל הנדרש לצורך סכימה מקבילית הוא שימוש במתודה parallel ע”מ ליצר זרם מקבילי. המתודה recordTime היא מתודת עזר פשוטה שמדפיסה את זמני ההרצה (ניתן למצוא אותה בGitHub). להלן הפלט של ההרצה:

Regular sum took 42,002 micro seconds
Parallel sum took 15,000 micro seconds

ההבדל בזמני הריצה הוא בהחלט משמעותי (כמובן שזו לא בדיקה מדויקת, יותר לקבלת תחושה של ההבדל)! מה בעצם קרה פה? הפעולה sum למעשה מבצעת reduce על כל אברי הרשימה. כלומר הקוד שקול ל:

LongStream.range(1, 10000000).parallel().reduce(0L, Long::sum);

הקוד המקבילי בעצם מחלק את האברים למספר קבוצות ומריץ את הreduce על קבוצה במקביל בthreads שונים. כלומר בפועל יתבצע משהו כזה:

thread1: LongStream.range(1, 3000000).reduce(0L, Long::sum);
thread2: LongStream.range(1, 3000000).reduce(0L, Long::sum);
thread3: LongStream.range(1, 4000000).reduce(0L, Long::sum);

כאשר התוצאה של כל ההרצות תאוחד לתוצאה סופית אחת. זה אמנם מאוד קל ליצור מקבול, אך יש להשתמש בזהירות ורצוי למדוד את הביצועים. ברירת המחדל בג’אווה היא שימוש בThread Pool יחיד לכל הזרמים מה שעלול לפגוע בביצועים במקום לשפר (ניתן להגדיר Thread Pool נפרד לזרם מסוים, אך נשאיר זאת לפוסט אחר).

 

עוד בעיה עלולה לנבוע מכך שכאשר יוצרים זרם ממבנה נתונים, הזרם מתבסס עליו ומשפיע על מהירות העיבוד:

largeList = LongStream.range(1, 10000000).boxed().collect(Collectors.toList());
recordTime("Regular sum", () -> largeList.stream().reduce(0L, Long::sum));
recordTime("Parallel ArrayList sum",
        () -> new ArrayList<>(largeList).parallelStream().reduce(0L, Long::sum));
recordTime("Parallel LinkedList sum",
        () -> new LinkedList<>(largeList).parallelStream().reduce(0L, Long::sum));

זמני הרצה:

Regular sum took 317,018 micro seconds
Parallel ArrayList sum took 159,009 micro seconds
Parallel LinkedList sum took 881,050 micro seconds

ביצוע מקבול על LinkedList בעצם פוגע בביצועים לעומת הרצה ללא מקבול כלל, בעוד שביצוע על ArrayList משפר ביצועים. ההבדל נובע מכך שמאוד מהיר לחלק מערך (שמאוכסן באזור רציף בזכרון) לתחומים ויותר איטי לעשות זאת ברשימה מקושרת.

 

לסיום, דפוס שמאוד חביב עלי משפות אחרות ועכשיו ניתן לביצוע פשוט בג’וואה:

Map<String, Function<Sea, String>> printFunctionByRegion = Map.of(
 "Americas",
 s -> String.format("Size is %.2f million square miles", s.getArea() * 0.386),
 "Europe, Africa, and Asia",
 s -> String.format("Size is %s million square kilometers", s.getArea()));

seas.stream().
 map(s -> printFunctionByRegion.get(s.getRegion()).apply(s)).
 forEach(System.out::println);

הקוד מבצע הדפסה שונה לפי האזור שבו נמצא הים. בתכנות פרוצדורלי היינו רושמים if או case. בתכנות פונקציונלי אלו הן פקודות מהעבר:) בדוגמה, אנחנו מאתחלים מפה שממפה פונקציה לכל אזור ומפעילים את הפונקציה המתאימה על כל איבר (Map.of נוספה בג’וואה 9 והיא בונה מפה מרשימת הפרמטרים. שאר הדוגמאות בפוסט רצות בג’וואה 8).

מתי נשתמש בזה?

ג’אווה בניגוד לשפות פונקציונליות ‘טהורות’ נטועה חזק בעולם התכנות מונחה העצמים (OOP). היכולות החדשות בשפה מאפשרות לנו לשלב בין דפוסים מונחי עצמים ותכנות פונקציונלי.

נרצה להשתמש כמה שיותר בתכנות פונקציונלי כדי לעבד קבוצה של נתונים, כדי להחליף הסתעפויות בקוד בדפוסים פונקציונליים ואף לצורך שימוש חוזר בקוד (ע”י הוצאת פונקציה) במקומות שהיו מאוד מסורבלים קודם.