ในที่สุดก็ได้ใช้ in (Generic ใน Kotlin) ซะที

Akexorcist
4 min readJul 6, 2024

--

เขียน 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 ซึ่งผมยังไม่มีโอกาสได้ใช้งานจริงซักที ก็เลยยังไม่ค่อยเข้าใจว่ามันเอาไว้ทำอะไร 😂

--

--

Akexorcist
Akexorcist

Written by Akexorcist

Lovely android developer who enjoys learning in android technology, habitual article writer about Android development for Android community in Thailand.

No responses yet