ในที่สุดก็ได้ใช้ in (Generic ใน Kotlin) ซะที
เขียน Kotlin มาก็หลายปี แต่เพิ่งจะได้ใช้ in
ของ Generic ในการทำงาน
ที่มาของเรื่องนี้
in
ที่อยู่ในเรื่อง Generic ของภาษา Kotlin เป็นอะไรที่ผมกับ Arut Jinadit คุยกันมาพักใหญ่แล้วว่าต้องเป็นโจทย์หรือเป็นกรณีแบบไหนถึงต้องใช้ in
เพราะกรณีส่วนใหญ่เราใช้แต่ out
กันมาตลอด
เข้าเรื่องกันเถอะ
สำหรับใครที่เคยเขียน Java หรือ Kotlin ก็จะคุ้นเคยกันดีกับเรื่อง Generic กันอยู่แล้ว เพราะเป็นหนึ่งในเทคนิคที่ช่วยให้เราเขียนโค้ดนำไปใช้งานได้
ยกตัวอย่างเช่น ผมสร้าง Interface ใน Kotlin ที่ใช้กับข้อมูลอะไรก็ได้ โดยใช้ Generic เข้ามาช่วยแบบนี้
interface Response<T> {
val data: T
}
จะเห็นว่า data
สามารถเป็นอะไรก็ได้ตามที่กำหนดไว้ใน T
val stringResponse: Response<String> = /* ... */
class Person(/* ... */) { /* .. */ }
val personResponse: Response<Person> = /* ... */
และสมมติว่าผมอยากให้ Response ที่ว่าใช้ได้กับแค่บางคลาสเท่านั้น ก็จะต้องกำหนดเป็น T : Person
แบบนี้แทน
open class Person
interface Response<T : Person> {
val data: T
}
ทำให้การใช้งาน Response บังคับว่าจะต้องเป็นคลาส Person หรือเป็นคลาสลูกของ Person เท่านั้น
และสมมติว่าเราสร้างคลาสจาก Person และ Response ขึ้นมาใหม่ และสร้างฟังก์ชันเพื่อทำอะไรบางอย่างกับคลาสนั้นขึ้นมาแบบนี้
open class Person
interface Response<T : Person> {
val data: T
}
+ class Student : Person()
+ class StudentResponse(override val data: Student) : Response<Student>
+ fun processResponse(response: Response<Person>) { /* ... */ }
มาถึงตรงนี้อาจจะดูไม่มีปัญหาอะไร จนกระทั่งผมสร้าง Response และเรียกใช้งานฟังก์ชัน processResponse
แบบนี้
fun createAndProcess() {
val studentResponse: Response<Student> = StudentResponse(Student())
processResponse(studentResponse) // ❌ Type mismatch
}
และนั่นจึงเป็นที่มาของการใส่ out
ใน Generic เพื่อทำให้ฟังก์ชันดังกล่าวสามารถรับค่าเป็นคลาสลูกของ Person ได้ด้วย
open class Person
- interface Response<T : Person> {
+ interface Response<out T : Person> {
val data: T
}
class Student : Person()
class StudentResponse(override val data: Student) : Response<Student>
fun processResponse(response: Response<Person>) { /* ... */ }
fun createAndProcess() {
val studentResponse: Response<Student> = StudentResponse(Student())
processResponse(studentResponse) // ✅ No error
}
แล้วใช้ in
ตอนไหนล่ะ?
นี่คือคำถามที่ผมสงสัยมาตลอด เพราะที่ผ่านมาใช้แต่ out
เพียงอย่างเดียว ไม่เคยมีโอกาสได้ใช้ in
เลยซักครั้ง
พอได้ลองคิดดูก็ไม่แปลกใจ เพราะโค้ดส่วนใหญ่ที่ต้องใช้ Generic มักจะต้องการให้ T เป็น Covariant Type Parameter เพื่อให้ใช้กับคลาสลูกได้อยู่แล้ว แต่การใช้ in
เพื่อทำให้ T เป็น Contravariant Type Parameter นั้นมีน้อยมากถึงมากที่สุด และรูปแบบในการใช้งานก็ต่างกันด้วย
จนกระทั่งได้มาเขียน Extension Function ให้กับ Bundle บนแอนดรอยด์
Bundle เป็นคลาสที่ทำหน้าที่เก็บข้อมูลในลักษณะของ Key-value ที่จะถูกแปลงเป็นคลาสที่ชื่อว่า Parcel เพื่อส่งระหว่าง Component หนึ่งไปยังอีก Component หนึ่ง
@Parcelable
data class Address(/* ... */): Parcelable { /* ... */ }
// Source
val bundle = Bundle().apply {
pubString("name", "Akexorcist")
putBoolean("verified", true)
putInt("coin", 300)
putParcelable("address", Address(/* ... */)
}
// Destination
val bundle: Bundle = /* ... */
val name: String? = bundle.getStringExtra("name")
val verified: Boolean = bundle.getBooleanExtra("verified")
val coin: Int = bundle.getIntExtra("coin")
val address: Address? = bundle.getParcelableExtra<Address>("address")
ซึ่งวิธีดังกล่าวเป็นรูปแบบมาตรฐานในการสื่อสารข้อมูลผ่าน IPC (Interprocess Communication) ของแอนดรอยด์ และบน Android 13 (API Level 33) มีการเปลี่ยนให้ใช้อีกคำสั่งแทนทำให้ต้องใช้คำสั่งจากคลาส BundleCompat และได้ออกมาเป็นแบบนี้แทน
// Destination
val bundle: Bundle = /* ... */
val name: String? = bundle.getStringExtra("name")
val verified: Boolean = bundle.getBooleanExtra("verified")
val coin: Int = bundle.getIntExtra("coin")
- val address: Address? = bundle.getParcelableExtra<Address>("address")
+ val address: Address? = BundleCompat.getParcelable(bundle, "address", Address::class.java)
จะเห็นว่าคำสั่งใหม่นั้นยาวกว่าเดิมพอสมควร ผมจึงตัดสินใจสร้าง Extension Function เพื่อลดโค้ดตรงนี้ให้น้อยลง
inline fun <reified T : Parcelable> Bundle.getParcelableCompat(
key: String?
): T? {
return BundleCompat.getParcelable(this, key, T::class.java)
}
inline fun <reified T : Parcelable> Bundle.getParcelableArrayCompat(
key: String?
): Array<T>? {
// ❌ Type mismatch
return BundleCompat.getParcelableArray(this, key, T::class.java)
}
inline fun <reified T : Parcelable> Bundle.getParcelableArrayListCompat(
key: String?
): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}
แต่ปัญหาที่ตามมาก็คือตอนที่สร้าง Extension Function สำหรับ getParcelableArray
จะไม่สวยหรูแบบตอนที่สร้างให้กับ getParcelable
และ getParcelableArrayList
เพราะจะได้ Error แบบนี้แทน
Type mismatch.
Required: Array<T>?
Found: Array<(out) Parcelable!>?
ทำให้ใน getParcelableArray
จะต้องกำหนด in
เพิ่มเข้าไปใน Return Type ด้วยในขณะที่ getParcelableArrayList
จะกำหนดหรือไม่ก็ได้
inline fun <reified T : Parcelable> Bundle.getParcelableArrayCompat(
key: String?
- ): Array<T>? {
+ ): Array<in T>? {
// ✅ No error
return BundleCompat.getParcelableArray(this, key, T::class.java)
}
อ้าวทำไมล่ะ? ทำไมคำสั่งของ Parcelable แบบ Array ถึงมีปัญหา แต่ ArrayList ใช้ได้ปกติล่ะ?
ถ้าลองกดเข้าไปดูความแตกต่างระหว่าง 2 คำสั่งนี้ก็จะพบว่าทั้งคู่มี Return Type ที่ไม่เหมือนกันตั้งแต่แรกอยู่แล้ว
public static Parcelable[] getParcelableArray(/* ... */) { /* ... */ }
public static <T> ArrayList<T> getParcelableArrayList(/* ... */) { /* ... */ }
จากโค้ดดังกล่าวจะเห็นความต่างได้ชัดเจนเลยว่า
getParcelableArray
มี Return Type เป็นParcelable[]
getParcelableArrayList
มี Return Type เป็นArrayList<T>
ดังนั้นเพื่อให้เข้าใจมากกว่านี้อีก แทนที่จะเรียกคำสั่งจาก BundleCompat ก็เปลี่ยนมาเขียนคำสั่งแยกเวอร์ชันของแอนดรอยด์เองแบบนี้
inline fun <reified T : Parcelable> Bundle.getParcelableArrayCompat(key: String?): Array<in T>? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.getParcelableArray(key, T::class.java)
} else {
this.getParcelableArray(key)
}
}
และเมื่อเข้าไปดูรูปแบบของคำสั่งทั้ง 2 ที่ผมเรียกใช้งาน ก็จะพบว่าคำสั่ง getParcelableArray
ของเวอร์ชันเก่าดันมี Return Type เป็น Parcelable[]
นั่นเอง
// For Android 13 or higher
public <T> T[] getParcelableArray(
@Nullable String key,
@NonNull Class<T> clazz
) { /* ... */ }
// For Android 12L or lower
public Parcelable[] getParcelableArray(
@Nullable String key
) { /* ... */ }
ทำให้ใน Android 13 ปรับปรุงคำสั่งนี้เสียใหม่ ให้มีความเป็น Type-safer มากขึ้น และมี Return Type เป็น T[]
เพื่อให้สะดวกต่อการนำไปใช้งาน
ส่วน ArrayList ไม่มีปัญหานี้เพราะใช้
ArrayList<T>
เป็น Return Type ตั้งแต่แรกอยู่แล้ว
นั่นก็หมายความว่าคำสั่งที่ผมเขียนขึ้นมาจะมีโอกาสที่ Return Type เป็น T หรือ Parcelable ก็ได้ ขึ้นอยู่กับว่าคำสั่งนี้ทำงานบนแอนดรอยด์เวอร์ชันไหน
inline fun <reified T : Parcelable> Bundle.getParcelableArrayCompat(
key: String?
): Array<in T>? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.getParcelableArray(key, T::class.java)
} else {
this.getParcelableArray(key)
}
}
และเมื่อ Return Type สามารถเป็นได้ทั้ง T (ที่ต้องสืบทอดมาจาก Parcelable) และ Parcelable จึงต้องใช้ in
เพื่อทำเป็น Contravariant Type Parameter นั่นเอง
อธิบายเผื่อ
- Covariant Type Parameter: เป็น Type ที่กำหนดได้ทั้งคลาสตัวเองและคลาสลูก (Subtype)
- Contravariant Type Parameter: เป็น Type ที่กำหนดได้ทั้งคลาสตัวเองและคลาสแม่ (Supertype)
- Invariant Type Parameter: เป็น Type ที่กำหนดได้แค่คลาสตัวเองเท่านั้น
สรุป
Generic ใน Kotlin เป็นหนึ่งในเรื่องที่ผมอ่านแล้วไม่ค่อยเข้าใจ ต้องไปลองเขียนเองเพื่อทำความเข้าใจ ซึ่งแน่นอนว่ากรณีของ out
นั้นทำความเข้าใจได้ไม่ยาก เพราะใช้กันบ่อยในทุกวันนี้ แต่สำหรับ in
นั้นเป็นอะไรที่ผมสงสัยมาตลอดว่าจะมีกรณีแบบไหนที่จำเป็นต้องใช้ และในที่สุดก็ได้ใช้และได้ทำความเข้าใจแบบจริงจังเสียที
แต่เรื่องที่เศร้าไปกว่านั้นคือ นอกจาก out
และ in
แล้ว Generic ใน Kotlin จะมี Keyword อีกตัวที่ชื่อว่า where
ซึ่งผมยังไม่มีโอกาสได้ใช้งานจริงซักที ก็เลยยังไม่ค่อยเข้าใจว่ามันเอาไว้ทำอะไร 😂