יום שבת, 16 בספטמבר 2017

קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ז': הספריה הסטנדרטית, וכתיבת קוד אלגנטי

פוסט זה הוא המשך של:
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק א': הבסיס
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ב': פונקציות
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ג': מחלקות
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ד': עוד על מחלקות, אובייקטים, ועוד...
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ה': DSLs
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ו': Collections ו Generics


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



Scope Functions


נתחיל בפונקציות ה"חדשות" ביותר למפתחי ג'אווה - או כך לפחות נדמה לי: ה scope functions.
הדמיון הרב ביניהן - הוא דיי מבלבל!

נפתח בהגדרות:

לנסות לשנן את הטבלה הזו - זה אחד הדברים המטופשים שניתן לעשות! היא נועדה ל reference.

את הפונקציה ()with, אני מניח שכולם מכירים. אני זוכר אותה עוד מימי Object Pascal...

הפונקציה ה"כמעט-תאומה" שלה היא apply:

  1. כפי שאתם רואים הן דיי דומות: משתמשים בהן כאשר רוצים לבצע שורת פעולות על ביטוי מורכב (או סתם משתנה עם שם ארוך), כאשר:
    1. ב with שולחים את הביטוי כפרמטר.
    2. ב apply - כ extension function על הביטוי.
  2. יש גם הבדל בערכי ההחזרה:
    1. הביטוי של with יחזיר את ערך (כלומר: ה evaluation) של הבלוק.
    2. הביטוי של apply יחזיר את האובייקט עליו הופעלה apply.
  3. זה כל ההבדל? בשביל זה יצרו שתי פונקציות כ"כ דומות?
    האם apply היא פשוט עבור עצלנים שלא מסוגלים לעשות extract variable?!
  4. ובכן... דווקא ערך ההחזרה הוא החשוב - המאפשר ב apply לשרשר את הפעולה. זה מתאים לשרשרת פעולות שכבר אין לכם "ביד" את ה reference לאובייקט המדובר - ואז apply מאפשרת את המשך השרשור.


הפונקציה הבאה שנפגוש, ()run - עשויה להישמע קצת מוזרה: היא רק מריצה בלוק.
את הבלוק שתתנו לה - היא תריץ.

מה הטעם בכזו פונקציה? למה היא שימושית?!

טוב... הדוגמה הראשונה באמת מעוררת השתוממות.

הדוגמה השניה - מסבירה את העניין:
כאשר אתם מריצים את run - אתם יוצרים scope חדש/נוסף להרצה.

אם אתם רוצים להימנע מלכלוך ה scope שלכם, למשל במשתנה temp - הפונקציה run תאפשר לכם לעשות זאת בצורה אלגנטית. שימוש ב run מצהיר בצורה מפורשת: "temp קיים רק עבור הפעולה הקצרה הבאה - ואינו רלוונטי להמשך הקוד"

זה עובד עבור הדוגמה הקטנה הזו - אבל יכול לעבוד גם בדוגמאות מורכבות יותר.


קיימת גם פונקציית run שרצה כ extension function, הדומה קצת apply:


run המקבלת למבדה מתאימה, כמו apply, לפעולות שרשור - אבל ערך ההחזרה שלה הוא ה evaluation של הבלוק.
היא שימושית כאשר יש שרשור, ואנו רוצים לבצע חישובים על האובייקט ואז להחזיר ערך - למשל: פונקציית ה ()genrate בדוגמה שסיפקתי.
כמו apply - היא "חיה" ב scope של האובייקט (כי היא extension function), ולכן קיימת גישה לפרמטרים של האובייקט.

אפשר לראות אותה בדוגמה הקאנונית מלמעלה - דיי דומה ל apply.
בכל אופן, הדוגמה הראשונה (Password generator) היא הדוגמה המציאותית יותר - ובמיוחד אם מדובר בשרשור.


שאלה: האם ()x.applyAndReturn היה יכול להיות שם מוצלח יותר ל ()x.run?



הגענו לזוג האחרון: let ו also.

הפונקציה let דומה לפונקציה map, כאשר היא פועלת על איבר יחיד.


במקום להיות extension function, היא מעבירה את האובייקט עליו היא פועלת - כפרמטר (it).
היתרון שבכך?

במידה ואתם רוצים בבלוק להתייחס ל this - האובייקט החיצוני. פונקציות כמו apply עושות shadowing ל this המצביע לאובייקט בו רצים. let לא עושה זאת.

כמו כן, let מחזירה את ה evaluation של הבלוק.

שימוש נפוץ ב let הוא כתיבה קצרה להגנה בפני null:

  1. הדוגמה הזו נכשלת בקומפילציה: מכיוון שמדובר ב property ולא משתנה "אטומי", ייתכן ומאז בדיקת ה null ועד להפעלת הבלוק - ייכנס ל property ערך אחר null-י ש"יפיל" אותנו.
  2. דרך אחת בטוחה היא להעתיק עותק מקומי למשתנה - ולבדוק אותו. הכי טוב val.
  3. דרך יותר קצרה ואלגנטית, היא השימוש ב let: הפונקציה מוערכת ברגע אחד מסוים - כשה evaluation של הביטוי עליה פעלה כבר בזיכרון:
    1. אם ה evaluation הוא null - כל הבלוק לא ירוץ.
    2. אם ה evaluation אינו null - הבלוק ירוץ, וניתן להתייחס ל it בבטחה כ not-null.

שם אפשרי אחר לפונקציה ()let היה יכול להיות ()ApplyItAndReturn.


בקוטלין 1.1 הוסיפו פונקציה בשם also (כלומר: גם כן שם "אינטואטיבי"), הפועלת כמו let - אבל מחזירה את ערך הביטוי עליו פעלה.

היתרון: היכולת לשרשר.

אני אשאיר לדימיון שלכם לתת לה שם יותר משמעותי....


בקרוב תצא קוטלין 1.2 עם פונקציות ה scope החדשות: ()due(), just  ו ()bound.  

סתתתאאאם! 😉








Streams


במשך שנים ג'אווה "חטפה" על היותה שפה verbose. בסה"כ מתכנני-השפה ניסו לתכנן שפה שבה הכל מפורש וקריא - אבל התברר שהם "הגזימו" עם המפורשות של השפה.

אחת הנקודות הכואבות ביותר הייתה היכולות "הפונקציונליות" של שפות כמו פייטון, רובי, או סקאלה: היכולת לעבד נתונים בקלות בעזרת פונקציות כמו filter, map, או max.

בהשוואה בזכוכית מגדלת, כל מימוש "פונקציונלי" היה קצר פי כמה - מהמימוש המקביל בג'אווה.

בג'אווה השתפרו עם הזמן, ובג'אווה 8 הציגו את יכולות ה Stream - יכולות פונקציונליות בשפת ג'אווה ועל גבי ה Collections הסטנדרטיים שלה... עם כמה wrapper שנדרשים.

אי אפשר היה להתעלם מצהלת השמחה בקהילת הג'אווה, שחשה גאווה רבה:


אמנם צריך להוסיף את המילה המעצבנת stream, וגם Collector לפעמים - אבל זה היה בהחלט נסלח, מול היתרונות.


האמת?

קוטלין הוציאה גם פה את ג'אווה באור לא מחמיא. היא מצליחה לפשט משמעותית תחביר ה stream של ג'אווה לתחביר נקי הרבה יותר.

למשל, הדוגמה הלא-מחמיאה הבאה:


יכולה להיכתב בקוטלין כך:


השילוב של ה Collections של קוטלין, ויכולות השפה (פרימיטיביים הם אובייקטים, extension functions, ועוד) - הופכות את היתרון -> למשמעותי מאוד.

הערה של קורא: נכון: הדוגמה של קוטלין עושה רק print ולא println ולכן היא קצרה יותר. טעות שלי.



חזרה על עקרונות הבסיס של Streams

ביטוי סטרימי (Stream-י) יהיה בנוי מ:
  • מקור: מקור נתונים. בדר"כ מבנה נתונים מאולס של Java שהופעלה עליו הפונקציה ()stream או Stream שנוצר במיוחד.
    • ה Stream עשוי להיות "אינסופי" (למשל: רצף מספרים אקראיים) ולכן ניתן להגביל את מספר האלמנטים בהם רוצים לטפל בעזרת הפונקציה ()limit.
    • רצפים אינסופיים ניתן לייצר בעזרת פונקציית (Stream.generate(lambda - כאשר lambda מספקת את הערך הבא, או (Stream.iterate(lambda - כאשר lambda מספקת את הערך הבא, תוך כדי שהיא מקבלת את התוצאה הקודמת כפרמטר.
  • פעולות ביניים (Intermediate Operations)
    • אלו פעולות שמקבלות Stream ומחזירות Stream - כך שניתן לשרשר אותן, ולהרכיב אותן זו על זו - בכל הרכב שנבחר. למשל: (...)filter(...), map, או (...)limit
    • באופן מעשי, הפעולות לא מחויבות לפעול ברגע (או סדר) ה evaluation שלהן - כך שמתכנני מגנון ה Streams יכולים להוסיף אופטימיזציות שונות. 
    • מה שיגרום לשרשרת הפעולות להתחיל ולפעול - הוא המצאות פעולת הסיום.
  • פעולת סיום (Terminal Operation) היא התוצאה המצופה מן כלל ביטוי ה Stream.
    • זוהי פונקציה שמקבלת Stream אבל לא מחזירה Stream (בהכרח). למשל: ()sum(), findFirst, או ()findAny.
      • השם findFirst הוא קצת מבלבל: למה צריך "לחפש" את האיבר הראשון?
        • בפועל: לא מחפשים אותו (זמן הריצה יהיה (O(1) - אבל זהו אילוץ שמחייב את ה Stream לשמור על סדר האיברים.
        • כאשר מפעילים את ()findAny - אין אילוץ כזה. בד"כ יחזור האיבר הראשון, אבל לפעמים יחזור איבר אחר מהרשימה (אם הופעלה איזו אופטימיזציה).
    • פעולות סיום נפוצות אחרות הן:
      • (forEach(lambda - שיכולה לבצע פעולה שרירותית כמו הדפסה של האיברים, אבל אחד אחרי השני ולפי הסדר.
      • (reduce(lambda - שיכולה לבצע "סיכום של תשובה" כאשר מגיעים אליה 2 פרמטרים: תשובה חלקית, והאיבר הבא (נניח: חישוב ממוצע מסוג מסוים). בשימוש בה - ניתן לבצע אופטימיזציות על ה Stream.

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

קוטלין מימשה מנגנון Steam משלה, שנראה מאוד דומה - אך בנוי באופן מובנה על ה collections של השפה.
המימוש ה default-י של streams בקוטלין אינו כולל lazy evaluation ואופטימיזציות או יכולות מקבול כמו בג'אווה. אפרט עוד על ההבדלים בהמשך.
 את תחביר ה Streams בקוטלין מפעילים ללא פעולת ה ()stream - בכדי להתחיל stream, ולא צריך את פעולות ה ()collect על מנת להמיר אותו חזרה ל collection ולטפל בטיפוסים שונים:

בקוטלין אפשר פשוט לסיים את פעולת ה Stream ב ()toList בכדי לקבל רשימה.
רוצים מערך? השתמשו ב: ()toList().toTypedArray.

מפה? השתמשבו ב ()associate  או ()associateBy:


גם נושא האינדקסים סודר בקוטלין, ויש פונקציות (למשל: ()mapIndexed) המאפשרות לגשת לאינדקס האיברים ב stream.

האמת: עברתי על 10 השאלות הנפוצות של התג "java-stream" באתר stackoverflow כדי לראות במה כדאי לעסוק בפוסט - ובקוטלין כיסו בצורה אלגנטית את כל הבעיות שהופיעו ב 10 השאלות הללו. נראה לי שגם הם הסתכלו - על אותה הרשימה בדיוק.


הפעולות בקוטלין בעלות שמות זהים ברוב המקרים. הנה כמה הבדלים:
  • findFirst ו findAny נקראות first ו any - בהתאמה.
  • limit נקראת בקוטלין take.
  • peek (כמו forEach, רק שמחזירה Stream) נקראת בקוטלין onEach (שם יותר ברור- לטעמי).


מעבר לכך - הטיפול ב Streams הוא ממש דומה.

בואו נראה קצת דוגמאות:

  • flatmap היא שימושית כמובן כאשר אנו מפעילים פונקציה שמייצרת רשימה - אף אנחנו רוצים את האיברים שבה, או כאשר אנחנו רוצים להפוך איבר אחד ב stream - למספר איברים.
  • חשוב לזכור ש filter משאיר (ולא מסיר) - את מי שעומד בתנאי.
    • filterNot - מסיר.

  • ()takeLast הוא ההופכי ל take, ו ()drop - הוא המשלים.
  • ()takeWhile ימשיך לקחת איברים כל עוד הפרדיקט נכון. ברגע שנתקל בתנאי שלילי - הוא יעצור.

הנה כמה פעולות סיום נפוצות:

  • כמה פעולות סיום, כמו ()last ו ()first מופיעות ב 2 צורות: כפונקציה ללא פרמטרים, או כפילטר עם הפעולה מובנה. 
    • הצורה האידיומטית היא צורת הפילטר - כאשר זה אפשרי.
  • ()single תזרוק Exception אם לא נמצאו איברים, או שנמצא יותר מאיבר אחד. 
    • יש גם גרסאת ()singleOrNull - שפשוט מחזירה null.
  • ()fold היא ()כמו reduce, רק שהיא מקבל כפרמטר ערך התחלתי לעבוד עליו. במקרה שלנו - אפס.
    • יש גם ()foldRight שפשוט תפעיל את הפעולה בסדר הפוך: מהאיבר האחרון - לראשון. במקרה של חיבור התוצאה תהיה זהה.


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

חשוב לזכור שהסיבה להשתמש בביטויי למבדה היא להפוך את הקוד לקריא יותר - ולא להפגין יכולות אישיות גבוהות של תפיסת לוגיקה מורכבת.
כאשר הביטוי מתחיל להיות מאתגר לקריאה, בחייאת דינאק: פרקו אותו לכמה ביטויים פשוטים יותר.
החלוקה הזו, וגם פעולות כמו ()onEach, יכולות לעזור לכם ב debugging - כאשר נדרש.



Late Evaluation


בכל זאת, יכולים להיות מצבים בהם יש לנו רשימה ארוכה של איברים, ואז הפעלה של ביטויים סטרימיים מורכבים מהווים overhead רציני. למשל: stream של עשרות אלפי איברים, כאשר לא כל פונקציה צריכה לפעול - כי לוקחים בסוף רק את האיבר הראשון.

לצורך כך בקוטלין יש מנגנון דומה לזה של ג'אווה של lazy evaluation הנקרא Sequences (שם שונה על מנת למנוע התנגשות בשם מחלקות).


בקוד הקוטלין, כל מה שצריך להוסיף הוא ()asSequence בתחילת הביטוי.
הפונקציה asSequence  ממירה את ה collection ל lazily evaluated sequence, בדומה ל Steam של ג'אווה.

לאובייקט ה Sequence יש מימושים מתאימים ל filter, map, first ועוד - כל הפונקציות שיכולות לאפשר מצב של אופטימיזציה.

למשל בדוגמה: במקום לרוץ על 5 מיליון איברים ולסנן מי גדול מאפס, ואז לקחת חמש מיליון איברים ולבדוק מי ראשון, ב sequence עובדים ב batches של יחידים: לוקחים איבר, בודקים אם הוא גדול מ 0 - ואז ממשיכים הלאה.
כאשר ה terminator מסופק - מפסיקים, ומכאן שיפור הביצועים.

המחיר של השימוש ב sequence הוא שלא יהיו לנו זמינות סט הפעולות שלא יכולות לעבוד במוד של lazy eval כמו ()takeLast או ()foldRight. במקרים מעטים, בהם יש עבודה אינטנסיבית שנהנית מ memory / resource locality - ה Sequence עלול להיות פחות יעיל.


בקוטלין יש עוד כלים ל lazy evaluation כמו הפונקציה הסטנדרטית lazy או המילה השמורה by - אבל זה נושא לפוסט אחר.

במידה ואתם כותבים תשתית חישובית ל big data, רוצים parallel streams - עליכם להשתמש בתשתית ה Streams של ג'אווה (עדיין אפשר לכתוב את הקוד בקוטלין).



סיכום



הפונקציות שהצגתי בפוסט הן פשוטות ברובן - אך יכולות להציג שיפור מיידי ומשמעותי על קריאות הקוד שלכם.
חבל לעבוד בקוטלין - ולא להשתמש בהן!


שיהיה בהצלחה!



2 comments: