2017-08-13

קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ד': עוד על מחלקות, אובייקטים, ועוד...

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

בואו נתחיל!


היררכיית הטיפוסים של קוטלין


בקוטלין, האב הקדמון של כל האובייקטים הוא Any - המקבילה של Object בשפת ג'אווה.

כך נראה אובייקט ה Any (השמטתי את ה comments):


אולי אתם שואלים את עצמכם היכן המימוש של המתודות? מדוע הוא לא הוגדר כ Abstract?
אני מניח שהמימוש נעשה ברמת ה runtime - ויש החרגה ברמת הקומפיילר למחלקה הספציפית הזו.

המחלקה Any מכילה גם כמה extension functions (החשובות שבהן: apply, let, run, also) - המוגדרות בקבצים אחרים. נדון בקונספט ה extension function ובמתודות הללו ספציפית - בהמשך הסדרה.

האם Any הוא באמת האב הקדמון של כל המחלקות בקוטלין?
לקוטלין יש את מערכת ה Nullable, שבעצם אומרת ש:

Any? = Any || null

ולכן באופן לוגי האב הקדמון של כל האובייקטים בקוטלין הוא ?Any. אין קובץ המכיל קוד מקור של ?Any - אך ניתן להחזיר אותו מפונקציה, או לרשת ממנו.

ניתן לשרטט סכמה של היררכיית הטיפוסים בקוטלין בערך כך:


קשר לוגי משמעו שהקומפיילר מכיר בקשר, ואפשר באמת להתייחס לקשר בקוד, אך לא תמצאו קוד כזה בקוד המקור של קוטלין: אין קוד מקור למחלקה ?String היורשת מ ?Any.

הזכרנו כבר שבקוטלין אין פרימיטיביים, ולכן כל ה wrappers (כמו Int) - יורשים בעצם מ Any.

Nothing הוא מקרה קצת מיוחד - אותו גם הזכרנו: Nothing מתאר ביטוי שלעולם לא יסיים evaluation. למחלקים Nothing לעולם גם לא יהיו instances.

מכיוון שכל ערך יכול להיות מוחלף ב Nothing, אבל אף ערך לא יורש ממנו, נהוג לצייר את Nothing בתחתית ההיררכיה - אבל זה תיאור מטאפורי בלבד.

השימוש ב Nothing עוזר לסמן לקופיילר מקרים ש"הקומפיילר לא צריך לדאוג מהם". למשל, המימוש הבא של EmptyList (זהו ה pattern של NullObject - למי שמזהה):

מקור

הורשה בקוטלין


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

  1. עלי להצהיר על המחלקה open, אחרת תהיה לי שגיאת קומפליציה שתצביע על המחלקה SpecialPerson שיורשת אותה. מחלקות הן final בקוטלין, כברירת מחדל. זו גישה הגנתית המספקת פחות הפתעות מהורשה לא צפויה.
  2. כנ"ל לגבי פונקציה: כל הפונקציות הן final כברירת מחדל - ויש "לאפשר" לרשת אותן במפורש.
  3. חובה להגדיר override בפונקציה שדורסת מימוש של מחלקת-האב, בניגוד לאנוטציה Override@ בג'אווה - שהיא רשות. 
  4. התחביר של הורשה הוא : - כמו ב #C. סוגריים נדרשים כדי לדעת אם להפעיל את בנאי-ברירת-המחדל של מחלקת האב - או בנאי אחר.

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

  1. הגדרתי את המתודה a כ final במחלקה SpecialPerson. לולא ההגדרה - הגדרת ה "open" מהמחלקה Person - הייתה ממשיכה הלאה במורד ההיררכיה.
  2. ...ולכן - יש לי שגיאת קומפליציה בבואי לדרוס אותה.


מחלקות חתומות (Sealed Classes)


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

  1. אני מגדיר את המחלקה InsuranceResult כ sealed (במקום Open). זה יתיר רק למחלקות שהוגדרו באותו הקובץ (private scope) - לרשת ממנה.
  2. הנה יצרתי 2 מחלקות פשוטות. העימוד בא להדגיש את הקשר.
  3. שימוש נפוץ הוא בכדי להחזיר סט סגור של ערכים. למשל: פעולה של אישור ביטוח שיכולה להסתיים בהצלחה (תשובה מסוג אחד - Approved) או בכישלון (תשובה מסוג אחר - Denied).
    1. המחלקות Denied, Approved, ו InsuranceResult - הן public, וניתן להשתמש בהן מכל מקום.
  4. הנה הקוד שמקבל את התשובה, גם הוא - פשוט ואלגנטי.



מחלקות מופשטות (abstract), וממשקים (interfaces)


בקוטלין יש מחלקות מופשטות (abstract):

  1. עצם ההגדרה של מחלקה כ abstract אומרת ש:
    1. המחלקה יכולה להכיל members מופשטים (abstract).
    2. לא ניתן ליצור instances ממנה.
  2. הנה פונקציה מופשטת. פונקציה מופשטת היא "פתוחה" (open) בהגדרה.
  3. אפשר גם להגדיר פונקציות קונקרטיות - עם מימוש. הם יהיו "סגורות" כברירת מחדל.
  4. כשאני דורס פונקציה מופשטת, עדיין עלי להצהיר על דריסה בעזרת override.
  5. יש לי שגיאת קומפילציה, מכיוון וניסיתי לרשת פונקציה ממחלקה מופשטת, בלי המילה override. מוסיפים override - ויש שגיאה נוספת. מה הפעם?
    1. כברירת מחדל פונקציות (ומחלקות) הן final. עלי לשנות את ה פונקציה collect ב InventoryItem להיות open - ורק אז הכל יסתדר.

ממשקים בקוטלין הם דיי דומים לג'אווה - עם כמה הבדלים קטנים.

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

  1. הנה הגדרה של ממשק פשוט: יש בו פונקציה אחת מופשטת (ללא מימוש), ואחת קונקרטית - עם מימוש.
  2. בממשקים של קוטלין ניתן להגדיר תכונות, אבל ללא state (חייבים להגדיר getter, ולא יהיה  backing field מאחורי התכונה). 
    1. שימוש אפשרי לתכונות על ממשק - היא כחלופה לקבועים על הממשק - שאין בקוטלין.
  3. הנה אני יוצר אובייקט שיורש מ Person ומממש את הממשקים Happy ו Shiny. סדר ההגדרה של הממשקים / מחלקת אב - לא משנה.
  4. אני מחויב לדרוס את המתודות המופשטות של הממשקים, כמובן - ולספק להן מימוש קונקרטי.
  5. מה קורה כאשר לכמה ממשקים שירשתי מהם, יש פונקציה קונקרטית עם חתימה זהה (בדוגמה שלנו: reflect)? כיצד לא נגררים לבעיות של הורשה מרובה?
    הפתרון של קוטלין אלגנטי: הקומפיילר מחייב אותי לממש מחדש את הפונקציה.
    1. שימו לב שאם החתימה היא שונה (פרמטרים שונים) - אז אין בעיה, ואני לא אחויב לממש מחדש.
  6. אם אני רוצה לפנות למימוש קונקרטי - אני פשוט קורא ל <super.<function name.
    שנייה! יש פה בעיה: הקומפיילר לא יודע איזה מימוש של reflect אני רוצה לקבל: של Shiny או של Happy?
    1. הפתרון הוא לבאר לקומפיילר מה אני רוצה, בעזרת התחביר הבא: (super<Happy>.reflect(s 
      כשאני משתמש בתחביר הזה - הקומפליציה תקינה, וקריאה ל ()reflect של S.H.Person - תפעיל את המימוש הקונקרטי של Happy.

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

אפשר לאפיין ארבעה הבדלים בין מחלקות מופשטות לממשקים:
  1. במחלקה מופשטת כל ה members הם final כברירת מחדל (כמו כל מחלקה) - בממשק הם open, ולא ניתן להגדיר אותם כ final.
  2. בממשק לא ניתן להגדיר members כ internal. ורק פונקציות קונקרטיות ניתן להגדיר כ private. בקטנה.
  3. מחלקה יכולה לרשת ממשקים רבים - אך רק מחלקה מופשטת אחת - זה כנראה ההבדל הכי חשוב.
  4. זה מעט מורכב: ממשק לא יכול להחזיק state, אחרת עלולים להיווצר קונפליקטים במימוש-מרובה.
    לכן, השפה מציבה כמה מגבלות על תכונות (properties) שהוגדרו בממשקים:
  1. בממשק אני יכול להגדיר val או var:
    1. val - ניתן להגדיר לו getter בלבד, או להשאיר את התכונה מופשטת (abstract).
    2. var - התכונה תמיד תהיה מופשטת. ניסיון להוסיף getter יגרור לשגיאת קומפילציה.
  2. מכיוון שהתכונות של הממשק Koo הן מופשטות - המחלקה המממשת חייבת לממש את התכונות הללו: x ו y (להלן override), ואז עלי לנקוט באחת מהגישות הבאות:
      1. אני יכול להציב בתכונות ערך התחלתי (מה שאי אפשר לעשות בממשק)
      2. אני יכול לממש getter/setter בעצמי.
        1. הערה: val הוא readonly - ולכן אין צורך ב setter.
      3. אני יכול לדרוס את התכונות ב primary constructor. הערכים שהוגדרו שם "יחביאו" את אלו שהוגדרו על הממשק.

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


מחלקות מקוננות (Nested Classes)


בקוטלין יש 2 סוגים של מחלקות מקוננות:

  1. Minion היא מחלקה מקוננת של Father, סתם כך.
    כברירת מחדל מחלקה מקוננת היא "סטאטית" כלמר - מחלקה עצמאית לכל דבר שרק חיה ב namespace של המחלקה שעוטפת אותה.
    1. יוצרים אותה בנפרד.
    2. היא לא יכולה לגשת לתכונות / פונקציות של מחלקת האב.
  2. בצורה השנייה, משתמשים במילה השמורה inner, בכדי לחבר את המחלקות ברמת המופעים (instances)
    1. לא ניתן ליצור מחלקה פנימית ללא החיצונית. ביצירת מופע של המחלקה החיצונית - נוצר גם מופע, מקושר, של המחלקה הפנימית.
    2. אם יש התנגשות בשמות, ניתן להשתמש ב qualified this" expression" - להגיע למחלקת האב. דומה מאוד ל OuterClass.this בג'אווה.
      1. ניתן גם להשתמש ב qualified super, בתחביר ()super@Father.foo - בכדי לגשת לפונקציה של המחלקה ממנה האובייקט החיצוני יורש.


מה קרה ל static ובן-אל?


בקוטלין ביטלו את המילה השמורה static. אין קונספט של מתודות סטטיות במחלקה.
מצד שני, יש קונספט של singleton מובנה בשפה - בעזרת המילה השמורה object:

  1. בעזרת המילה השמורה object, אנחנו בעצם מגדירים מחלקה - ומייד יוצרים לה מופע.
    1. אובייקט הוא מופע יחיד (singleton). השימוש הנפוץ בו הוא ליצירת Factory או אובייקט שמכיל איברים סטטיים - המשותפים לכולם.
    2. ניתן להגדיר על האובייקט תכונות, ופונקציות - אבל לא בנאים. הרי: לא יוצרים אותו. המופע נוצר בעצם ההגדרה.
    3. ניתן לרשת ממחלקה אחרת / לממש ממשקים.
  2. אם היינו רוצים להגדיר קבוע בג'אווה היינו משתמשים ב static final. בקוטלין קיימת במילה השמורה const שמצביעה לקומפיילר שמדובר בערך שידוע בזמן קומפילציה. השימוש ב const היא הצהרה שזהו ערך סטטי שלא הולך להשתנות. המשמעות:
    1. אי אפשר להציב תשובה מפונקציה, קרי ()const val x = foo - זו שגיאת קומפילציה.
    2. אי אפשר להוסיף getter - הרי getter יוכל "לשנות את הערך, ע"י החזרה של ערך אחר", ולעולם בעתיד לא יהיה ניתן להוסיף getter. זו אולי התכונה בעלת הסמנטיקה העמוקה ביותר.
    3. אפשר לאתחל את התכונה רק ערכים פרימיטיביים (מספרים, מחרוזות), ולא אובייקטים - אחרת, ייתכן אי אפשר להבטיח שכלום לא ישתנה.
    4. בפועל: מאחורי const לא יהיה backing field.

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

בקוטלין, ניתן להגדיר קבועים או פונקציות במרחב הגלובאלי, אולי בתוך אובייקט - בכדי לנהל את ה scope בצורה מסודרת יותר. 
מה עושים כאשר אנו רוצים את ההתנהגות של ה static methods בג'אווה? לקרוא לפונקציה בלי לייצר מופע של האובייקט? לכך יש כלי בשם Companion Object:

  1. לצורך הדוגמה יצרתי אובייקט מקונן בתוך המחלקה. 
    1. זו צורת עבודה לגיטימית. תחביר-ההפעלה הוא קצת יותר מסורבל, כי יש לציין את שם האובייקט.
  2. כאשר אני מגדיר companion object, אני יכול לפנות לפונקציות שלו, ישירות תחת ה namespace של המחלקה. ל companion object:
    1. אין צורך להגדיר לאובייקט שם.
    2. ניתן להגדיר רק companion object יחיד בכל מחלקה.
    3. ה companion object ייווצר בזמן שטוענים את קוד המחלקה - מה שמקביל את הפונקציות שלו ל static methods בג'אווה.
  3. ה annotation של JvmStatic@ היא רשות, והיא תגרום ל bytecode שנוצר לגרום לג'אווה להתנהג עם ה companion object בצורה דומה לאופן הטיפול בקוטלין.
    1. ללא ה annotation יש לקרוא בג'אווה: ()Log.Companion.createLogger
    2. עם ה annotation יש לקרוא בג'אווה: ()Log.createLogger
  4. אם המחלקה עושה shadowing למשתנה / פונקציה של ה companion object, אני יכול בצורה מפורשת לגשת ל member של ה companion object בעזרת השם Companion.



שונות


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

late init

לפעמים יש לי תכונה מסוג var שאני עומד להציב בה ערך בתהליך האתחול של המערכת - אך לא מהרגע הראשון.
הקומפיילר, המגן עלי בפני nulls - יצעק, ולא יוותר. "מה פתאום ערך לא מאותחל?!"
בואו נראה דוגמה קלאסית:

  1. הפתרון ה"פשוט" הוא להציב ערך null בשדה - עד האתחול ה"אמיתי".
    1. בקוטלין זה דיי מעצבן, כי אז עלי להפוך את כל הטיפול באותו משתנה ל nullable - התעסקות מיותרת.
  2. הפתרון: להוסיף modifier בשם lateinit, המורה לקומפיילר: "אחי, עלי! המשתנה הזה יאותחל לפני שיקראו לו". השימוש ב lateinit מאפשר קוד נקי יותר, ללא טיפול ב nullability.
    1. מצד שני: אם טעינו (והטענו את הקומפיילר) - תיזרק שגיאת ...UninitilizedProperty.
      שם השגיאה, מכוון למהות הבעיה, יותר טוב מאשר לקבל סתם Kotlin)NullPointerException).
  3. כאשר מגדירים lateinit, השדה של התכונה (במקרה שלנו: repository2) ייחשף לקוד הג'אווה. זוהי החלטה תכנונית של מקסום התאימות לג'אווה. 
    1. אפשר למנוע את חשיפת השדה ע"י ה annotation האופציונלי: field:JvmSynthetic@

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




גישה לאובייקט ה Class

בג'אווה ניתן לגשת למחלקה של אובייקט ע"י הקריאה object.class - זהו בעצם אובייקט מטיפוס <Class<T, הנוצר ב מנגנון ה reflection ומתאר את תכונות (metadata) המחלקה: שם ה package, שדות, וכו'.

ספריות שונות משתמשות ביכולת הזו, למשל: JUnit:

@RunWith(RobolectricTestRunner.class)

בקוטלין, ניתן לגשת לאובייקט ה reflection המתאר את המחלקה בעזרת הקריאה object::class - זהו אובייקט של ספריית ה runtime של קוטלין מסוג <*>KClass (על הכוכבית - בהמשך הסדרה).
הוא מכיל מתודות ותכונות reflection של קוטלין, למשל companionObjectInstance - המחזירה מצביע ל companion object, אם קיים.

רוצים לקבל את אובייקט הג'אווה המתאר את המחלקה שלנו (אובייקט ה Class, שהוא בעל מבנה שונה מאובייקט ה KClass)? - קראו ב object::class.java.

ברוב הפעמים דווקא נדרש לאובייקט-המטא של קוטלין. למשל:

@RunWith(RobolectricTestRunner::class)

זה תלוי מה הספריה עושה איתו. זה עניין של ניסוי וטעייה.




סיכום


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

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


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



3 תגובות:

  1. תודה ראובן!

    האם אתה מתכוון ל design pattern הקלאסי של build?
    אם כן: בקוטלין השימוש פחות נפוץ בו בגלל יכולות של השפה.
    הרבה פעמים default values בבנאי - חוסכים את הצורך ב builder, כפי שהצגתי בפסקה מעל הקישור הבא: http://www.softwarearchiblog.com/2017/07/kotlin-3-classes.html#2

    אם המבנים הם מאוד נפוצים לשימוש - לפעמים שווה להשתמש ב lambda extension functions על מנת לייצר DSL - כפי שהצגתי כאן: http://www.softwarearchiblog.com/2017/08/kotlin-dsls.html#5

    ליאור

    השבמחק
  2. הי ליאור. תודה רב על סדרת הפוסטים המעולה. לראשונה מזה הרבה שנים אני רואה חלופה טובה באמת לג'אווה שנשארה מאחור בהמון אספקטים. אני רואה ומתפעל מאוסף הרעיונות שהביאו לשפה משפות כמו C# ואפילו TypeScript - וכולי תקווה שקוטלין תוכל גם להתחרות ראש בראש באחרונה כדי לייצר שפה אחידה גם ל Client וגם ל Server. זה חלום של כל מתבעת FullStack... ישנם כמה נושאים חשובים שאמשח אם תוכל לגעת גם בהם - בעיקר מקביליות ו Generics. למשל, האם יש מנגנון דומה ל async await או continuableFuture? האם יש תמיכה טובה באובייקטי סנכרון? בנוסף, אשמח להרחבה על Generics בשפה - האם תוקן העוול ההיסטורי של Java שנקרא Type Erasure?
    מצפה לפוסטים הבאים!
    ולדימיר

    השבמחק
    תשובות
    1. היי ולדימיר, תודה - שמח לשמוע!

      על גנריות - באמת חשוב לכתוב, וגם על מקיביליות. אשים את זה ב todo שלי.

      העוול ההיסטורי של ג'אווה לא תוקן, כי הוא בעצם עוול של ה JVM ולא רק של שפת ג'אווה.
      שפות כמו סקאלה, סיילון, וגם Gosu (קישור: https://gosu-lang.github.io) בעצם הוסיפו אמולציה ל reified generics (כלומר: כאלו ללא erasure, שזמינים גם ב runtime), מה שדורש מהן לגרר metadata עם הקוד - ומסבך כמה דברים. למשלב: פגיעה משמעותית בביצועים.

      בקוטלין בחרו גישה אחרת: תואמים לג'אווה מבחינת ה generics, אבל כן מאפשרים reified generics בפינה קטונה: פונקציות שהן inline - וכך לא מסתבכים.

      אני אכתוב על זה פוסט (כבר התחלתי, מקווה לסיים).

      שווה אולי לציין שמימוש ה generics בג'אווה, שדרישת בסיס שלו הייתה תאימות לאחור - סיבך עוד כמה דברים: auto-boxing (כדי ליצור התאמה בין פרמיטיביים ל Wrappers), חוסר היכולת לעשות overload לפונקציה עם שם זהה המקבלת פרמטר עם generics שונה (מה שיצר קונבנציית שמות מייגעת: consumer XXtoLongYY וכו'), ומגבלות של ה reflection לטפל ב generics...

      מחק