קריאה לקוד Kotlin מ-Java

ב-codelab הזה תלמדו איך לכתוב או להתאים את קוד Kotlin כדי שיהיה קל יותר לקרוא לו מקוד Java.

מה תלמדו

  • איך משתמשים בהערות @JvmField, @JvmStatic ואחרות.
  • מגבלות בגישה לתכונות מסוימות של שפת Kotlin מקוד Java.

מה צריך לדעת לפני שמתחילים

ה-codelab הזה מיועד למתכנתים, והוא מניח שיש לכם ידע בסיסי ב-Java וב-Kotlin.

ב-codelab הזה נדגים איך להעביר חלק מפרויקט גדול יותר שנכתב בשפת התכנות Java, כדי לשלב קוד חדש של Kotlin.

כדי לפשט את הדברים, נשתמש בקובץ .java יחיד בשם UseCase.java, שייצג את בסיס הקוד הקיים.

נניח שהחלפנו פונקציונליות שנכתבה במקור ב-Java בגרסה חדשה שנכתבה ב-Kotlin, ואנחנו צריכים לסיים את השילוב שלה.

ייבוא הפרויקט

אפשר לשכפל את הקוד של הפרויקט מ-GitHub כאן: GitHub

אפשרות נוספת היא להוריד ולחלץ את הפרויקט מארכיון ZIP שנמצא כאן:

הורדת קובץ Zip

אם אתם משתמשים ב-IntelliJ IDEA, בוחרים באפשרות Import Project (ייבוא פרויקט).

אם אתם משתמשים ב-Android Studio, בוחרים באפשרות'ייבוא פרויקט (Gradle,‏ Eclipse ADT וכו')'.

בואו נפתח את UseCase.java ונתחיל לטפל בשגיאות שמופיעות.

הפונקציה הראשונה עם בעיה היא registerGuest:

public static User registerGuest(String name) {
   User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);
   Repository.addUser(guest);
   return guest;
}

השגיאות שמופיעות גם ב-Repository.getNextGuestId() וגם ב-Repository.addUser(...) הן זהות: "לא ניתן לגשת ללא סטטי מתוך הקשר סטטי".

עכשיו נסתכל על אחד מקובצי Kotlin. פותחים את הקובץ Repository.kt.

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

לדוגמה, אפשר להפנות אל Repository.getNextGuestId() באמצעות Repository.INSTANCE.getNextGuestId(), אבל יש דרך טובה יותר.

אפשר להגדיר ש-Kotlin תיצור מאפיינים ושיטות סטטיים על ידי הוספת ההערה @JvmStatic למאפיינים ולשיטות הציבוריים של Repository:

object Repository {
   val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

מוסיפים את ההערה ‎ @JvmStatic לקוד באמצעות ה-IDE.

אם נחזור ל-UseCase.java, המאפיינים והשיטות ב-Repository לא יגרמו יותר לשגיאות, למעט Repository.BACKUP_PATH. נחזור לזה בהמשך.

ננסה לתקן את השגיאה הבאה בשיטה registerGuest().

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

לצערנו, אם נסתכל על השיטה registerGuest() בתוך UseCase.java, נראה שמשהו לא בסדר:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

הסיבה לכך היא שב-Kotlin, הפונקציות האלה ברמה העליונה או ברמת החבילה ממוקמות בתוך מחלקה שהשם שלה מבוסס על שם הקובץ. במקרה הזה, מכיוון שהקובץ נקרא StringUtils.kt, השם של המחלקה המתאימה הוא StringUtilsKt.

אנחנו יכולים לשנות את כל ההפניות שלנו ל-StringUtils ל-StringUtilsKt ולתקן את השגיאה הזו, אבל זה לא אידיאלי כי:

  • יכול להיות שיהיה צורך לעדכן הרבה מקומות בקוד שלנו.
  • השם עצמו מוזר.

לכן, במקום לשנות את קוד Java, נעדכן את קוד Kotlin כדי להשתמש בשם אחר לפונקציות האלה.

פותחים את StringUtils.Kt ומחפשים את הצהרת החבילה הבאה:

package com.google.example.javafriendlykotlin

אפשר להגדיר ב-Kotlin שם אחר לשיטות ברמת החבילה באמצעות ההערה @file:JvmName. נשתמש בהערה הזו כדי לתת שם למחלקה StringUtils.

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

אם נחזור עכשיו אל UseCase.java, נראה שהשגיאה שקשורה ל-StringUtils.nameToLogin() נפתרה.

לצערי, השגיאה הזו הוחלפה בשגיאה חדשה לגבי הפרמטרים שמועברים לבונה של User. נעבור לשלב הבא ונפתור את השגיאה האחרונה ב-UseCase.registerGuest().

‫Kotlin תומכת בערכי ברירת מחדל לפרמטרים. כדי לראות איך משתמשים בהם, אפשר לבדוק את הבלוק init של Repository.kt.

Repository.kt:

_users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
_users.add(User(103, "warlow", groups = listOf("staff", "inactive")))

אפשר לראות שכשמדובר במשתמש warlow, אפשר לדלג על הזנת ערך עבור displayName כי ב-User.kt צוין ערך ברירת מחדל עבורו.

User.kt:

data class User(
   val id: Int,
   val username: String,
   val displayName: String = username.toTitleCase(),
   val groups: List<String> = listOf("guest")
)

לצערנו, אי אפשר להשתמש באותה שיטה כשקוראים לה מ-Java.

UseCase.java:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

ערכי ברירת מחדל לא נתמכים בשפת התכנות Java. כדי לפתור את הבעיה, נשתמש בהערה ‎@JvmOverloads כדי להנחות את Kotlin ליצור עומסים יתרים עבור הקונסטרוקטור שלנו.

קודם צריך לעדכן קצת את User.kt.

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

data class User constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

אם מילת המפתח constructor מופיעה, אפשר להוסיף את ההערה @JvmOverloads:

data class User @JvmOverloads constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

אם נחזור אל UseCase.java, נראה שאין יותר שגיאות בפונקציה registerGuest.

השלב הבא הוא לתקן את הקריאה השגויה אל user.hasSystemAccess() ב-UseCase.getSystemUsers(). אפשר להמשיך לשלב הבא או לקרוא את ההמשך כדי להבין לעומק מה @JvmOverloads עשה כדי לתקן את השגיאה.

@JvmOverloads

כדי להבין טוב יותר מה עושה @JvmOverloads, ניצור שיטת בדיקה ב-UseCase.java:

private void testJvmOverloads() {
   User syrinx = new User(1001, "syrinx");
   User ione = new User(1002, "ione", "Ione Saldana");

   List<String> groups = new ArrayList<>();
   groups.add("staff");
   User beaulieu = new User(1002, "beaulieu", groups);
}

אפשר ליצור User עם שני פרמטרים בלבד, id ו-username:

User syrinx = new User(1001, "syrinx");

אפשר גם ליצור User על ידי הוספת פרמטר שלישי ל-displayName, תוך שימוש בערך ברירת המחדל של groups:

User ione = new User(1002, "ione", "Ione Saldana");

אבל אי אפשר לדלג על displayName ולספק רק ערך ל-groups בלי לכתוב קוד נוסף:

לכן נמחק את השורה הזו או נוסיף לה את התו'//' כדי להפוך אותה לתגובה.

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

// This doesn't work...
User(104, "warlow", listOf("staff", "inactive"))
// But using named parameters, it does...
User(104, "warlow", groups = listOf("staff", "inactive"))

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

נחזור אל UseCase.java ונטפל בבעיה הבאה: הקריאה אל user.hasSystemAccess() בשיטה UseCase.getSystemUsers():

public static List<User> getSystemUsers() {
   ArrayList<User> systemUsers = new ArrayList<>();
   for (User user : Repository.getUsers()) {
       if (user.hasSystemAccess()) {     // Now has an error!
           systemUsers.add(user);
       }
   }
   return systemUsers;
}

זו שגיאה מעניינת! אם משתמשים בתכונת ההשלמה האוטומטית של סביבת הפיתוח המשולבת (IDE) בכיתה User, אפשר לראות ששם הכיתה hasSystemAccess() שונה ל-getHasSystemAccess().

כדי לפתור את הבעיה, אנחנו רוצים ש-Kotlin ייצור שם אחר למאפיין valhasSystemAccess. כדי לעשות את זה, אפשר להשתמש בהערה @JvmName. נחזור ל-User.kt ונראה איפה צריך להחיל אותו.

יש שתי דרכים להוסיף את ההערה. האפשרות הראשונה היא להחיל את ההגדרה ישירות על שיטת get(), כך:

val hasSystemAccess
   @JvmName("hasSystemAccess")
   get() = "sys" in groups

הסימון הזה מאותת ל-Kotlin לשנות את החתימה של ה-getter שהוגדר באופן מפורש לשם שסופק.

אפשרות אחרת היא להחיל את התכונה על הנכס באמצעות קידומת get: באופן הבא:

@get:JvmName("hasSystemAccess")
val hasSystemAccess
   get() = "sys" in groups

השיטה החלופית שימושית במיוחד למאפיינים שמשתמשים בשיטת getter שמוגדרת כברירת מחדל באופן מרומז. לדוגמה:

@get:JvmName("isActive")
val active: Boolean

כך אפשר לשנות את השם של ה-getter בלי להגדיר אותו באופן מפורש.

למרות ההבדל הזה, אתם יכולים להשתמש בשיטה שנוחה לכם יותר. בשני המקרים, Kotlin תיצור getter בשם hasSystemAccess().

אם נחזור ל-UseCase.java, נוכל לוודא ש-getSystemUsers() לא מכיל שגיאות.

השגיאה הבאה היא ב-formatUser(), אבל אם רוצים לקרוא עוד על מוסכמות למתן שמות לפונקציות getter ב-Kotlin, אפשר להמשיך לקרוא כאן לפני שעוברים לשלב הבא.

שמות של Getter ו-Setter

כשכותבים ב-Kotlin, קל לשכוח שכשכותבים קוד כמו:

val myString = "Logged in as ${user.displayName}")

בעצם קוראת לפונקציה כדי לקבל את הערך של displayName. כדי לוודא את זה, עוברים לתפריט כלים > Kotlin > הצגת קוד בייט של Kotlin ולוחצים על הלחצן פירוק:

String myString = "Logged in as " + user.getDisplayName();

כדי לגשת למאפיינים האלה מ-Java, צריך לכתוב במפורש את השם של שיטת ה-getter.

ברוב המקרים, שם ה-getter ב-Java של מאפייני Kotlin הוא פשוט get + שם המאפיין, כמו שראינו בדוגמאות User.getHasSystemAccess() ו-User.getDisplayName(). יוצא מן הכלל הוא מאפיינים שהשם שלהם מתחיל ב-'is'. במקרה כזה, השם של ה-getter ב-Java הוא השם של המאפיין ב-Kotlin.

לדוגמה, נכס ב-User כמו:

val isAdmin get() = //...

הגישה אליו מ-Java תהיה באמצעות:

boolean userIsAnAdmin = user.isAdmin();

כשמשתמשים בהערה @JvmName, שפת Kotlin יוצרת קוד בייט עם השם שצוין, ולא עם שם ברירת המחדל, עבור הפריט שמוער.

הדבר נכון גם לגבי פונקציות setter, שהשמות שנוצרים שלהן הם תמיד set + שם המאפיין. לדוגמה, נניח שיש לכם את הכיתה הבאה:

class Color {
   var red = 0f
   var green = 0f
   var blue = 0f
}

נניח שאנחנו רוצים לשנות את שם הפונקציה setter מ-setRed() ל-updateRed(), בלי לשנות את הפונקציות getter. אפשר להשתמש בגרסה @set:JvmName כדי לעשות את זה:

class Color {
   @set:JvmName("updateRed")
   var red = 0f
   @set:JvmName("updateGreen")
   var green = 0f
   @set:JvmName("updateBlue")
   var blue = 0f
}

מ-Java, נוכל לכתוב:

color.updateRed(0.8f);

UseCase.formatUser() משתמש בגישה ישירה לשדה כדי לקבל את ערכי המאפיינים של אובייקט User.

ב-Kotlin, מאפיינים נחשפים בדרך כלל באמצעות getters ו-setters. למשל, נכסי val.

אפשר לשנות את ההתנהגות הזו באמצעות ההערה @JvmField. כשמחילים את ההגדרה הזו על נכס במחלקה, Kotlin מדלג על יצירת שיטות getter (ו-setter לנכסי var), ואפשר לגשת ישירות לשדה הגיבוי.

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

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   @get:JvmName("hasSystemAccess")
   val hasSystemAccess
       get() = "sys" in groups
}

אם נחזור ל-UseCase.formatUser(), נראה שהשגיאות תוקנו.

@JvmField או const

בנוסף, יש שגיאה דומה בקובץ UseCase.java:

Repository.saveAs(Repository.BACKUP_PATH);

אם נשתמש כאן בהשלמה אוטומטית, נראה שיש Repository.getBACKUP_PATH(), ולכן יכול להיות שנרצה לשנות את ההערה ב-BACKUP_PATH מ-@JvmStatic ל-@JvmField.

בוא ננסה את זה. חוזרים אל Repository.kt ומעדכנים את ההערה:

object Repository {
   @JvmField
   val BACKUP_PATH = "/backup/user.repo"

אם נסתכל עכשיו על UseCase.java, נראה שהשגיאה נעלמה, אבל יש גם הערה לגבי BACKUP_PATH:

ב-Kotlin, הסוגים היחידים שיכולים להיות const הם פרימיטיבים, כמו int,‏ float ו-String. במקרה הזה, מכיוון ש-BACKUP_PATH הוא מחרוזת, אפשר לשפר את הביצועים באמצעות const val במקום val עם ההערה @JvmField, ועדיין תהיה אפשרות לגשת לערך כשדה.

נשנה את זה עכשיו בקובץ Repository.kt:

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

אם נחזור אל UseCase.java, נראה שנותרה רק שגיאה אחת.

השגיאה הסופית היא Exception: 'java.io.IOException' is never thrown in the corresponding try block.

אם נסתכל על הקוד של Repository.saveAs ב-Repository.kt, נראה שהוא אכן יוצר חריגה. מה הבעיה?

ב-Java יש מושג שנקרא "חריגה שנבדקה". אלה חריגים שאפשר להתאושש מהם, כמו משתמש שהקליד שם קובץ לא נכון, או שהרשת לא זמינה באופן זמני. אחרי שתפיסת חריגה מסומנת, המפתח יכול לספק למשתמש משוב על אופן התיקון של הבעיה.

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

public void openFile(File file) throws FileNotFoundException {
   // ...
}

לעומת זאת, ב-Kotlin אין חריגים מסוג checked, וזו הסיבה לבעיה כאן.

הפתרון הוא לבקש מ-Kotlin להוסיף את IOException שעשוי להיות מושפע מהחתימה של Repository.saveAs(), כדי שקוד הבייט של JVM יכלול אותו כחריגה שנבדקה.

אנחנו עושים את זה באמצעות ההערה @Throws של Kotlin, שעוזרת בתאימות בין Java ל-Kotlin. ב-Kotlin, חריגים מתנהגים באופן דומה ל-Java, אבל בניגוד ל-Java, ב-Kotlin יש רק חריגים לא מסומנים. לכן, אם רוצים להודיע לקוד Java שפונקציית Kotlin זורקת חריג, צריך להשתמש בהערה ‎ @Throws בחתימת הפונקציה של Kotlin. צריך לעבור אל Repository.kt file ולעדכן את saveAs() כך שיכלול את ההערה החדשה:

@JvmStatic
@Throws(IOException::class)
fun saveAs(path: String?) {
   val outputFile = File(path)
   if (!outputFile.canWrite()) {
       throw FileNotFoundException("Could not write to file: $path")
   }
   // Write data...
}

אחרי שמוסיפים את ההערה @Throws, אפשר לראות שכל שגיאות הקומפיילר ב-UseCase.java תוקנו. מעולה!

יכול להיות שתהיתם אם תצטרכו להשתמש בחסימות try ו-catch כשמתקשרים ל-saveAs() מ-Kotlin.

לא! חשוב לזכור שב-Kotlin אין חריגים מסוג checked, והוספת @Throws ל-method לא משנה את זה:

fun saveFromKotlin(path: String) {
   Repository.saveAs(path)
}

עדיין כדאי לטפל בחריגים כשאפשר, אבל ב-Kotlin לא חייבים לטפל בהם.

ב-codelab הזה הסברנו את העקרונות הבסיסיים של כתיבת קוד Kotlin שתומך גם בכתיבת קוד Java אידיומטי.

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

  • @JvmStatic כדי ליצור חברים ושיטות סטטיים.
  • @JvmOverloads כדי ליצור שיטות עמוסות מדי לפונקציות שיש להן ערכי ברירת מחדל.
  • @JvmName כדי לשנות את השם של getters ו-setters.
  • @JvmField כדי לחשוף מאפיין ישירות כשדה, ולא באמצעות getters ו-setters.
  • @Throws כדי להצהיר על חריגים שנבדקו.

התוכן הסופי של הקבצים הוא:

User.kt

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   val hasSystemAccess
       @JvmName("hasSystemAccess")
       get() = "sys" in groups
}

Repository.kt

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   @Throws(IOException::class)
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

StringUtils.kt

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

fun String.toTitleCase(): String {
   if (isNullOrBlank()) {
       return this
   }

   return split(" ").map { word ->
       word.foldIndexed("") { index, working, char ->
           val nextChar = if (index == 0) char.toUpperCase() else char.toLowerCase()
           "$working$nextChar"
       }
   }.reduceIndexed { index, working, word ->
       if (index > 0) "$working $word" else word
   }
}

fun String.nameToLogin(): String {
   if (isNullOrBlank()) {
       return this
   }
   var working = ""
   toCharArray().forEach { char ->
       if (char.isLetterOrDigit()) {
           working += char.toLowerCase()
       } else if (char.isWhitespace() and !working.endsWith(".")) {
           working += "."
       }
   }
   return working
}