2017-09-04

קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ו': Collections ו Generics

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


הפעם נדבר על Collections ו Generics - נושאים שעברו כמה התאמות מהגרסה הג'אווה-ית.


Generics - תזכורת


מהם בעצם Generics ("חסרי ייחוד")?
הותיקים-באמת שביננו זוכרים את הימים של Java 1.4 בה כל collection בשפה היה מטיפוס Object. בכל שליפה של איבר מתוך הרשימה - היה צריך לבצע פעולת Down-Casting.


בעזרת Generics יכולנו להגדיר טיפוס למבנה הנתונים, ואז להפסיק לדאוג להם.

בשל שיקולים של תאימות-לאחור, ה generics בג'אווה (וליתר דיוק: ב JVM) הם ברמת הקומפילציה (ולא ה runtime). יש שלב של הקומפיילר בשם Type Erasure בו הוא מוחק את ה generics, ומחליף אותם במבני-נתונים מסוג Object עם down-castings מתאימים + מוסיף בדיקות שפרמטרים שהוזנו למתודות הם מהטיפוס הנכון.

זהו. זה כל מה שמפתח צריך לדעת על Generics, לא?!


יש קצת יותר.
אפשר להשתמש ב generics במחלקות שלנו, ולא רק ב Collection המסופקים ע"י ג'אווה.

למשל, אני רוצה לממש Repository כללי בנוסח DDD - אבל אם אשתמש ב Any בתור טיפוס, לא אוכל להתייחס לתכונות הספציפיות של האובייקטים שבהם אני משתמש.

  1. מי ששולף Entities מה Repository צריך לעשות downcasting - בקוטלין, בעזרת המילה השמורה as.
  2. אין בדיקה ברמת הקומפילציה שאני שולח ערכים רלוונטיים לפונקציות... אאוץ.
  3. אני לא יכול בתוך המחלקה Repository להתייחס לתכונות הספציפיות של האובייקט שאני רוצה להשתמש בו.

כאשר אני משתמש ב Generics - הדברים נראים אחרת:

  1. אני מגדיר ב scope של ה class טיפוס בשם T, ממש לפני הגדרת המחלקה שבסוגריים המסולסלים. 
    1. אני יכול להשתמש עכשיו ב T במקום טיפוס, בכל מקום בקוד של המחלקה.
    2. ברגע שייווצר instance של המחלקה הזו, טיפוס מסוים היה קשור אליה, וכל התייחסות ל T - בעצם "תוחלף" ע"י הקומפיילר בטיפוס שהוגדר.
  2. אין צורך להצהיר על downcasting מתודות שליפה. הקומפיילר דואג לכך.
  3. הקופיילר יאכוף שהערכים שנשלחים הם מהטיפוס הנכון.
  4. עדיין אני לא יכול לגשת בתוך המחלקה לתכונות של הטיפוס הספציפי.
  5. זה נפתר ע"י כך שאגדיר את הטיפוס: T היורש מ Entity.
    1. הקומפיילר יוודא שטיפוסים שנקשרים למחלקה יורשים מ Entity.
    2. כך בתוך קוד המחלקה, אוכל להניח של T יש את כל התכונות / פונקציות הזמינות של Entity.
  6. שימו לב ש T כברירת מחדל הוא מטיפוס ?Any. אם ארצה שהטיפוס יהיה לא-nullable יהיה עלי להגדיר: <T : Any>

אפשר להגביל את הטיפוס הגנרי ("T") אף יותר, ולחייב שירש / יממש יותר ממחלקה - כלומר: גם ממשקים. את האכיפה הזו עושים בתחביר המשתמש במילה where: 

  1. רק טיפוס שגם יורש מ Entity וגם מממש את הממשק Comparable - יוכל להיקשר למופע של המחלקה. 
  2. where הוא המקביל של קוטלין לצורה <T extends ClassA & InterfaceB> של ג'אווה.

מדוע משתמשים ב "T" לתאר את הטיפוס הלא ידוע? מתי יש שמות אחרים?
הקונבנציה אומרת ש:
  • T - אם יש משתנה אחד.
  • S - אם יש משתנה שני, U - אם יש משתנה שלישי, ו V - אם יש משתנה רביעי.
    אפשר לזכור את הסדר כ "SUV" - השם האמריקאי ל"ג'יפון עירוני".
  • K ו V - אם יש צמד key  ו value, למשל ב Map.
  • E - כדי לתאר אלמנט במבנה נתונים.
  • N - לתאר טיפוס שהוא מספר.
  • R - לתאר טיפוס החזרה (return value).



"חורים" ב Generics


בג'אווה קיימת הבעיה הבאה:


אני יכול להגדיר מבנה נתונים מסוג <List<String, בכדי לקבל הגנה של הקומפיילר.

אבל... אם המתודה שלי, במקרה הזה ()unsfaeAdd (שעשויה להימצא במקום אחר ומרוחק בקוד), מצהירה על ממשק כללי List (להלן "raw type") - הקומפיילר יאשר את הקוד: הרי <List<String הוא List - חייבים זאת עבור תמיכה לאחור.

הממשק List מתאר מתודה (add(Object o המקבלת אובייקט מכל סוג - מה שיאפשר לי להכניס גם אובייקטים מסוג אחר לרשימה. הכישלון בזמן ריצה יהיה רק ברגע השליפה, כאשר מנסים לעשות casting (שהוסיף הקומפיילר לקוד):


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



Variance


הפתרון המקורי של ג'אווה היה להוסיף כלי שנקרא wildcard (מסומן כ ?).


במתודה שלי (נניח שהיא במקום מרוחק בקוד) אני מצהיר שאני מקבל רשימה - אבל לא יודע באמת מה הטיפוסים שמאוחסנים בה (<?>List נקרא כ "List of some type"). הקומפיילר לכן יאפשר לי לבצע רק פעולות שליפה - אך לא פעולות השמה. זוהי בעצם הגנה בפני התנהגות לא צפויה.

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

בכלל, כמה אנשים נוחים עם ההגדרה הבאה?


בסה"כ מדובר בספריה הסטנדרטית של ג'אווה: (...)Collections.min. נרצה בוודאי להבין מה שכתוב בתיעוד.

אנחנו מכירים כבר את &, ועד סוף הפוסט, הביטוי (המורכב) הזה - יהיה ברור לחלוטין.


אם נדבר בשפה פורמאלית, אזי String הוא variance של Object - כי הוא יורש ממנו, אבל <List<String הוא invariant של <List<Object - כי הוא לא יורש ממנו (הוא יורש מ <Collection<String).


הפתרון של קוטלין לבעיה הנ"ל הוא מעט אחר, ומוממש כחלק מהספריה הסטנדרטית.
הספריה הסטנדרטית (stdlib) של קוטלין היא קטנה בהגדרה (כ 750KB) - ומכילה התאמות לג'אווה.

בגזרת ה collections, קוטלין לא מציגה collections חדשים מאלו של ג'אווה (Set, Map, Array, List) - אלא רק עוטפת ומרחיבה אותם (פעמים רבות - בעזרת extension functions).

קוטלין מספקת ממשקים למבני-נתונים (Map, List) מ-2 סוגים:
  • Immutable Interfaces - שהם ברירת המחדל, כאלו שניתן רק לשלוף מהם.
    • למשל:<List<E ו <Map<E
  • Mutable Interfaces - כאלו שניתן לבצע בהם גם שינויים.
    • למשל: <MutableList<E  ו <MutableMap<E


התחליף של קוטלין, אם כן, ל wildcard של ג'אווה הם immutable interfaces. 
בהגדרת הפונקציה unsafeAdd קוטלין לא מרשה לי להשתמש ב Raw type כמו List - אלא רק במבנים עם הגדרה גנרית.



הנה אנסה כמה תצורות נוספות:

  1. כאן יש שגיאת קומפילציה: ניסיתי להוסיף איבר למבנה נתונים שהוא immutable - אסור. זוהי ההגנה המקבילה ל wildcard.
  2. כאן הגדרתי שאני רוצה מבנה נתונים שניתן לבצע בו שינויים. אבל מה? מכיוון שהגדרתי את list מטיפוס String - הקומפיילר לא מוכל לקבל any.
  3. הנה התיקון - ביצעתי המרה מסודרת של o למחרוזת - והכל תקין.

הפתרון של קוטלין, להגדיר immutable collections הוא פשוט יותר מהפתרון של ג'אווה, הוא לכאורה "לא מפורש".
הסמנטיקה של immutable collections שימושיים למדי גם ל "functional-like programming" ול concurrency.

corner case שכן הפסדנו בקוטלין, הוא היכולת לעשות ()clear או ()remove ל collection המכיל איברים מסוג לא ידוע. אין סכנה להסיר איברים מסוג "לא ידוע", ולכן ניתן לעשות זאת ב <?>List, אבל לא ניתן לעשות זאת ב immutable list.

Tradeoff הגיוני, לדעתי.



אוקיי. פתרנו מקרה אחד בעייתי של Generics, אבל יש עוד מקרה בעייתי:

נ.ב. - קוד דומה גם לא יתקמפל בג'אווה
הרי: Int הוא מספר (יורש מ Number) - ולכן אני מצפה שהקוד תעבוד.
הבעיה: <Array<Int אינו יורש מ <Array<Number - הם invariants.

Immutable collection לא יעזור כאן. מה עושים?



Covariance & Contravariance


נפתח בהגדרה.

מבנה גנרי כלשהו Something המקיים ש:
  • טיפוס T הוא  subtype  של טיפוס A
  • וגם ניתן להתייחס ל <Something<T כ  subtype  של <Something<A
נקרא covariance.

בג'אווה אפשר להגדיר קשר של covariance בצורה הבאה:

Something<? Extends A>

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

הנה דוגמה:


מה ניתן לעשות במתודה ()foo?

קריאה
  • אפשר להתייחס לכל איבר בשלושת הרשימות כ Number - כולם כאלה.
  • אי אפשר להתייחס לכל איבר בהכרח כ Integer - כי אז "אפול" בטיפול ב list3.
  • אי אפשר להתייחס לכל איבר בהכרח כ Double - כי אז "אפול" בטיפול ב list2.
כתיבה
  • לא ניתן להוסיף לרשימה Integer - כי אז "אפול" ב list3.
  • לא ניתן להוסיף לרשימה Double - כי אז "אפול" ב list2.
  • לא ניתן להוסיף לרשימה גם Number - כי אז "אפול" ב list2 וב list3 המחייבות טיפוסים ספציפיים (אחרת ניפול בשליפה + casting, כמו בדוגמה למעלה).



Generics. הקומפיילר יעזור למנוע טעויות.




Contravariance

הקשר בו מבנה גנרי כלשהו Something מקיים ש:
  • טיפוס T הוא  supertype  של טיפוס A
  • וגם ניתן להתייחס ל <Something<T כ  supertype  של <Something<A
נקרא contravariance.

בג'אווה אפשר להגדיר קשר של covariance בצורה הבאה:

Something<? Super A>

בואו נשתמש בדוגמה:


מה ניתן לעשות במתודה ()goo?

קריאה
  • אי אפשר להתייחס לכל איבר בהכרח כ Integer - כי list2 ו list3 לא מכילים Integers בהכרח.
  • אי אפשר להתייחס לכל איבר בהכרח כ Number- כי list3 מכיל אובייקטים שונים.
  • ניתן רק להתייחס לאיברים כ Object - כי תמיד הם יהיו כאלה.
כתיבה
  • ניתן, מן הסתם, להוסיף לרשימה Integers - כי כל הרשימות יכולות להכיל Integers - בהגדרה.
  • ניתן להוסיף subtypes של Integer לו היו: למשל, אם היה PositiveInteger שהיה subtype של Integer.
  • לא ניתן להוסיף Double או Number, וגם לא Object - כי תהיה לנו את list1 שבה מתבצעת בדיקה שנכנסים רק Integers (או subtypes), כדי להימנע מהבעיה של שליפה + casting שראינו למעלה.


Generics. הקומפיילר יעזור למנוע טעויות [א].



ובחזרה לקוטלין...


הסמנטיקות של ג'אווה,  extends A ? ו super B ? הן מוצלחות בלהזכיר מתי ? יורש מ A, או מתי הוא אב של B - אבל לא כ"כ מוצלחות בלהזכיר לנו את ההתנהגות הצפויה: מה מותר לקרוא ומה מותר לכתוב. זה לא self-explanatory.

בכדי לעזור לזכור, ג'ושוע בלוך הציג את הכלל הבא: "Producer Extends, Consumer Super", או בקיצור PECS.

הווה אומר:
  • אם המבנה הגנרי מספק ערכים (Producer / אנו קוראים ממנו) - השתמשו ב extends, ויהיה לנו אסור להוסיף פריטים לרשימה.
  • אם המבנה הגנרי צורך ערכים (Consumer / אנו כותבים אליו) - השתמשו ב super, אך לא נוכל להסתמך בקריאה על איזה טיפוס יצא.

בקוטלין הטמיעו את הכלל הזה כבר בשפה, בדמות התחבירים:

  • Something<out T>   ==> producer
  • Something<in T>   ==> consumer

במקום לחשוב איזה טיפוס לא ידוע ירחיב או יירש מ T - אנו פשוט מצהירים:

  • האם אנחנו מתכוונים לשלוף ערכי T (או בנים שלהם) - בשימוש ב out.
  • או האם אנחנו הולכים להכניס למבנה ערכי T (או אבות שלהם) - בשימוש ב in.

עכשיו צריכים כלל הפוך שיזכיר לנו: "בחוץ - נמצאים אני והילדים, בפנים - אני והאבות שלי". (דמיינו מסיבת יומולדת).

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

בואו נראה את in ו out בשימוש. הנה למשל ההגדרה של הממשק List:


מכיוון ש List הוא Immutable, הגדירו את המבנה הגנרי <out E> - וכך ניתן לשלוף E או sbutypes של E בצורה בטוחה.


את Mutable List הגדירו בדיוק על הטיפוס E, כלומר: המבנה לא יקבל (הכנסה / הוצאה) - טיפוסים ששונים מ E. זה לכאורה "פחות גמיש" אבל זו הגנה חשובה בפני מקרי-קצה בעייתיים.

אם ג'אווה (ליתר דיוק: ה JVM) היה תומך ב reified generics, כאלו שנושאים metadata ב runtime - ההתעסקות הזו הייתה נחסכת מאיתנו. זה המחיר ששילמו בג'אווה 5 על מנת לספק generics עם תאימות לאחור לקוד ישן יותר.


ה Variance בקוטלין הוא declaration-site variance, כלומר: כזה שנקבע בשלב ההגדרה - כמו ב  < List<out E שראינו למעלה. הקומפיילר "קשר" את הטיפוס E (או בנים שלו) למופע הרשימה - ואין צורך להצהיר על זה יותר.

בג'אווה ה variance הוא use-site variance, כלומר יש מגדירים את ה variance על השימוש - על המתודה. למשל.
הנה המתודה ()addall של המחלקה Collection:


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

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

מה עושים בקוטלין?



Type Projections


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


ל SomeStructure קשור טיפוס T כלשהו - אבל אני יכול להחליט שבפונקציה copy אני מצפה למבנה של T או supertypes שלו - לקריאה בלבד.  הדוגמה הייתה עובדת גם אם SomeStructure היה קשור ל <in T>.

אמנם MutableList קשור לערך מדויק (כפי שראינו למעלה), אבל כאן מדובר בסוגי ה MutableList שיכולים להישלח לפונקציה, כיחס ל T הקשור למחלקה. אין כאן קשר להגדרה של MutableList עצמו.

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


אם אני רוצה לוודא בפונקציה אם טיפוס מסוים הוא מבנה גנרי מסוג מסוים אני יכול לשאול:


if (x is Collection<*>) ...

הכלי הזה נקרא star projection והוא מקביל להגדרה ?out Any וגם in Nothing.




Reified Generics in Kotlin



היינו רוצים reified generics - אך החלטות design של ה JVM לא מאפשרות זאת.
בקוטלין, בכל זאת, התירו שימוש ב reified generics בפינה קטנה, שעשויה להיות שימושית לפעמים.

כאשר יש פונקציה שהורינו לקומפיילר לעשות לה inline - הקומפיילר יכול לאפשר בה שימוש ב reified generics - כאלו שיהיו זמינים ב runtime. למשל:

  1. כאשר הערך T קשור לפונקציה, מה יותר טבעי מלבדוק אם משתנה מסוים הוא מאותו הסוג?
    1. אופס! ... T קשור רק בזמן קומפילציה ואז הוא נמחק. הוא לא זמין ב runtime ולכן לא ניתן לבצע reflection: הקומפיילר פשוט לא יכול לנתח איזה ערך יישלח בזמן הרצת התוכנה.
  2. כאשר אני מגדיר את T כ reified - הקומפיילר יודע לבצע את האנליזה המתאימה כאילו יש לי את המידע ב runtime.
    1. זה יכול לעבוד רק על פונקציה שהיא inline.

לא ניתן לקרוא מקוד ג'אווה לפונקציה שהוגדרה כ reified: בכל מקרה הפונקציה היא inline והקומפיילר של ג'אווה לא ימצא הגדרה של פונקציות inline ב class files.




ולקינוח...


זוכרים את הביטוי המורכב של ()Collection.min בספריה הסטנדרטית בג'אווה? - בואו נוודא שאנחנו מבינים אותו, עד הפרט האחרון.


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

  1. ראשית קושרית בסוגריים משולשים טיפוס או טיפוסים ל scope של הפונקציה.
    1. האם יש טעם להצהיר T extends Object? זה לא מיותר?
      1. לכאורה כן: ההגדרה <T> שקולה ל <T extends Objects> כשהיא מופיעה לבדה.
      2. כאשר יש הגבלות (&), אם לא נצהיר על Object, ה erasure יתבצע להגבלה (Comparable) - שחסרה כמה מהתמודות של Object. בקוד הזה החליטו לקבוע erasure ל Object (שהוא גם Comparable שאליו קשור טיפוס לא ידוע שהוא supertype של T).
  2. ערך ההחזרה של המתודה (...)min הוא T. פשוט מאוד.
  3. שם הפונקציה.
  4. רשימת הפרמטרים. במקרה שלנו אנו מקבלים Collection אחר, של איברים לקריאה בלבד - שהם T או subtypes של T.

נראה פשוט, לא?



סיכום


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

אני מקווה שהצלחתי להתיר את הסיבוכיות מהנושא הזה - ולהציג אותו "פרוש על השולחן" בצורה קלה וברורה.


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



---


[א] שווה לציין:


5 תגובות:

  1. אנונימי5/9/17 15:02

    "מקווה שהצלחתי להתיר את הסיבוכיות מהנושא הזה - ולהציג אותו "פרוש על השולחן" בצורה קלה וברורה."

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

    השבמחק
  2. הסיבה ש refied עובד רק עם inline קשורה לעובדה שזה לא באמת עובר ל runtime אלא מבוצע כאנליזה של הקומפיילר בזמן קומפילציה. תוצאת האנליזה מוטמעת לתוך ה call site ששם ה type כבר ידוע וכמו שכתבת לא באמת מיוצרת פונקציה ב bytecode

    השבמחק