Kotlin Generics: in/out มีนอกมีใน
มาถึงภาคต่อจากเรื่อง Java Generics ในครั้งก่อน คราวนี้มาดูกันว่า Kotlin Generics เป็นยังไง
Disclaimer
บทความนี้เหมาะสำหรับผู้ที่มีพื้นฐานการเขียนภาษา Kotlin และสำหรับใครที่ยังไม่ได้อ่านบทความเรื่อง Java Generics ขอแนะนำให้อ่านเรื่องนั้นก่อนครับ เพราะเนื้อหาตอนนี้เป็นส่วนที่ต่อยอดมา บางหัวข้อจะไม่ได้อธิบายในบทความนี้เพราะได้เขียนไว้ในเรื่อง Java Generics แล้วเช่นเรื่อง Variance, เรื่อง Producer/Consumer
Box<T> again …
การทำคลาสให้รองรับ generics ใน Kotlin นั้นก็คล้ายๆ กันกับ Java คือเราต้องประกาศ type parameter ตัวอย่างเช่น
class Box<T> {}
จากนั้นเวลาใช้งานก็แค่ระบุ type argument เข้าไป
val stringBox = Box<String>()
บางกรณี compiler สามารถ infer type argument ได้เราจึงไม่ต้องระบุ type argument
val list1 = listOf<String>()
// compiler can infer type argument
val list2 = listOf("a")
เช่นกันเหมือนกับในบทความก่อน ถ้าเราอยากให้คลาส Box
ของเรารองรับได้เฉพาะคลาสที่กำหนด เช่นถ้าเรามีคลาส Animal
และอยากให้คลาส Box
ของเราเก็บได้เฉพาะ Animal
และ subtype ของมัน ใน Java เราก็ใช้ Bounded Type Parameter ส่วนใน Kotlin นั้นเรียกว่า Generic Constraints
Generic Constraints
ผมขอยกคลาส Animal
, Dog
, Cat
มาจากบทความก่อนเลยนะครับ เพราะเป็นตัวอย่างที่ง่ายดี สมมุติให้เรามีคลาสที่มีความสัมพันธ์กันดังนี้
ถ้าเราอยากให้ Box
ของเรารับได้เฉพาะ Animal
รวมถึง subtype ของ Animal
เราก็สร้าง AnimalBox
ออกมาประมาณนี้
class AnimalBox<T : Animal> {}
ส่วนเวลาใช้งานก็
val catBox = AnimalBox<Cat>()
เท่านี้ก็ได้ AnimalBox
ที่รองรับแค่คลาส Animal
และ subtype ของ Animal
แล้ว
ยังจำเรื่อง Wildcards ของ Java ในบทความก่อนได้ไหมครับ เนื่องจาก Generics ใน Java เป็น invariant เราจึงต้องเอา Wildcards มาช่วยให้การใช้งาน Generics มีความยืดหยุ่นขึ้น ส่วน Kotlin นั้นไม่มี Wildcards แต่มี Declaration-site variance และ Type projections มาช่วยจัดการเรื่องนี้
Declaration-site variance
variance annotation: out
ขอพักจากตัวอย่างกล่องใส่น้องแมวน้องหมา มาเป็น Publisher — Subscriber นะครับ สมมุติเรามีคลาส Publisher
แบบนี้
class Publisher<T>(val content: T) {
fun publish() = content
}
สังเกตุว่าคลาส Publisher
มีฟังก์ชันเดียวคือ publish()
ที่ทำหน้าที่คืนค่า content
ซึ่งเป็น type T
ออกมา และคลาสนี้ไม่มีฟังก์ชันในการรับค่า type T
เข้าไปเลย (ไม่นับการ init ตรง constructor นะ) มองอีกอย่างคลาส Publisher
ทำหน้าที่เป็น Producer นั่นเอง
เวลาใช้งาน Publisher
ก็ประมาณนี้
val publisher = Publisher(Cat())val cat = publisher.publish()
จากตัวอย่างก็จะเห็นว่าการใช้งานก็ตรงไปตรงมา คำถามต่อไปคือในเมื่อ Cat
เป็น subtype ของ Animal
แล้เราจะเอา Publisher<Animal>
มาชี้ Publisher<Cat>
ได้หรือไม่ เพราะ Publisher<Animal>
ก็ควรจะปล่อยของออกมาเป็น Cat
ได้
คำตอบคือไม่ได้
val catPublisher: Publisher<Cat> = Publisher(Cat())
// compile error
val animalPublisher: Publisher<Animal> = catPublisher
ที่ไม่ได้ก็เพราะคลาส Publisher
เป็น invariant ดังนั้น Publisher<Cat>
จึงไม่ได้เป็น subtype ของ Publisher<Animal>
ทีนี้เราจะทำยังไงให้ Publisher
มีความยืดหยุ่นขึ้น สามารถนำ Publisher
ต่าง type มาชี้กันได้ ถ้าใน Java เราก็ใช้ Upper Bounded Wildcard แต่ Kotlin เราจะใส่ variance annotation out
ให้กับ T
class Publisher<out T>(val content: T) {
fun publish() = content
}
สังเกตุว่าเราได้เพิ่มคีย์เวิร์ด out
เข้าไปตรง type parameter T
ซึ่ง out
ตรงนี้เราเรียกว่า variance annotation ความหมายของมันคือการบอกว่าคลาส Publisher
จะมีแค่ฟังก์ชันที่รีเทิร์นค่า T
ออกมาเท่านั้น ไม่มีฟังก์ชันในการรับค่า T
เข้าไป (ไม่นับ constructor ที่ไว้ init ค่า) และเพราะว่ามันถูกประกาศไว้ตรง class declaration เพื่อที่จะใช้กับทั้งคลาส เราจึงเรียกมันว่า Declaration-site variance
เมื่อเราระบุ T
เป็น out
แล้ว compiler จะไม่อนุญาตให้เราประกาศฟังก์ชันที่รับค่า T
เข้าไปเลย
class Publisher<out T>(val content: T) {
fun publish() = content // compile error
fun onNext(content: T) {}}
จากตัวอย่างข้างบนฟังก์ชัน onNext
รับ content: T
เข้ามาซึ่งขัดกับที่เราประกาศ T
เป็น out
ไว้จึงทำให้ compile error
ตอนนี้คลาสของเราก็ทำหน้าที่เป็น Producer เพียงอย่างเดียวแล้ว เราจึงเอา Publisher<Animal>
มาชี้ Publisher<Cat>
ได้โดยไม่มีปัญหา
val catPublisher: Publisher<Cat> = Publisher(Cat())
val animalPublisher: Publisher<Animal> = catPublisher
หรือจะพูดอีกอย่างหนึ่งว่าจากที่คลาส Publisher
เป็น invariant เมื่อเราระบุ T
เป็น out
แล้ว Publisher
จะกลายเป็น covariant นั่นเอง
variance annotation: in
นอกจากคีย์เวิร์ด out
แล้ว Kotlin ก็มี in
อีกตัวหนึ่ง คราวนี้ขอยกตัวอย่างคลาส Subscriber
class Subscriber<T> {
fun onNewContent(content: T) {
// do something with the content.
}
}
สังเกตุว่าคลาส Subscriber
มีฟังก์ชันเดียวคือ onNewContent()
ที่รับ content
ซึ่งเป็น type T
และคลาสนี้ไม่มีฟังก์ชันในการคืนค่า type T
ออกไปเลย มองอีกอย่างคลาส Subscriber
ทำหน้าที่เป็น Consumer นั่นเอง
เวลาใช้งาน Subscriber
ก็ประมาณนี้
val subscriber = Subscriber<Cat>()
subscriber.onNewContent(Cat())
คำถามต่อมาคือถ้าเรามี instance ของ Subscriber<Cat>
อยู่ เราจะสามารถนำตัวแปรของ Subscriber<PersianCat>
มาชี้มันได้หรือไม่ เพื่อที่จะจำกัดให้มันรับได้เฉพาะ PersianCat
เพราะในเมื่อ PersianCat
เป็น subtype ของ Cat
(PersianCat
is a Cat
) ดังนั้นฟังก์ชัน onNewContent()
ของ Subscriber<Cat>
ควรจะรับ PersianCat
ได้
คำตอบคือไม่ได้
val catSubscriber = Subscriber<Cat>()
// compile error
val persianCatSubscriber: Subscriber<PersianCat>
= catSubscriber
ที่ไม่ได้ก็เพราะ Subscriber
เป็น invariant ดังนั้น Subscriber<PersianCat>
จึงไม่ได้เป็น suptertype ของ Subscriber<Cat>
ทีนี้เราจะทำยังไงให้ Subscriber
มีความยืดหยุ่นขึ้น สามารถนำ Subscriber
ต่าง type มาชี้กันได้ ถ้าใน Java เราก็ใช้ Lower Bounded Wildcard แต่ Kotlin เราจะใส่ variance annotation in
ให้กับ T
class Subscriber<in T> {
fun onNewContent(content: T) {
// do something with the content.
}
}
สังเกตุว่าเราได้เพิ่มคีย์เวิร์ด in
ซึ่งเป็น variance annotation อีกตัวหนึ่งเข้าไปตรง type parameter T
ความหมายของมันคือการบอกว่าคลาส Subscriber
จะมีแค่ฟังก์ชันที่รับค่า T
เข้าไปเท่านั้น ไม่มีฟังก์ชันที่คืนค่า T
ออกมา
เมื่อเราระบุ T
เป็น in
แล้ว compiler จะไม่อนุญาตให้เราประกาศฟังก์ชันที่คืนค่า T
ออกมาเลย
class Subscriber<in T> { // compile error
private var content: T? = null
fun getContent() = content fun onNewContent(content: T) {
// do something with the content.
}
}
จากตัวอย่างข้างบนฟังก์ชัน getContent
คืนค่า content: T
ออกมาซึ่งขัดกับที่เราประกาศ T
เป็น in
ไว้จึงทำให้ compile error
ตอนนี้คลาสของเราก็ทำหน้าที่เป็น Consumer เพียงอย่างเดียวแล้ว เราจึงเอา Subscriber<PersianCat>
มาชี้ instance ของ Subscriber<Cat>
ได้โดยไม่มีปัญหาอะไร
val catSubscriber = Subscriber<Cat>()
val persianCatSubscriber: Subscriber<PersianCat>
= catSubscriber
หรือจะพูดอีกอย่างหนึ่งว่าจากที่คลาส Subscriber
เป็น invariant เมื่อเราระบุ T
เป็น in
แล้ว Subscriber
จะกลายเป็น contravariant นั่นเอง
CIPO
ในบทความก่อนเรื่อง Java Generics เราได้รู้จักหลักการจำว่า Producer, Consumer ตัวไหนใช้ Upper Bounded Wildcards ตัวไหนใช้ Lower Bounded Wildcards โดยมีหลักการจำว่า PECS — Producer-Extends, Consumer-Super ซึ่งหมายความว่าใช้ Upper Bounded Wildcards (extends) กับตัวแปรประเภท Producer และใช้ Lower Bounded Wildcards (super) กับตัวแปรประเภท Consumer
มาถึง Kotlin เรามี variance annotation สองตัวคือ in
และ out
โดยที่ in
ใช้กับ Consumer และ out
ใช้กับ Producer ดังนั้นจาก Producer-Extends, Consumer-Super จึงเปลี่ยนเป็น Consumer in, Producer out ในเอกสารของ Kotlin ไม่ได้เสนอตัวย่อไว้ แต่ผมถือวิสาสะย่อให้เลยแระกันว่า CIPO
Type Projection
Use-site variance
กลับมาที่ตัวอย่างกล่องมหัศจรรย์ของเรากัน อย่างที่รู้นะครับว่าคลาส Box
ของเราสามารถเก็บ content เป็น type อะไรก็ได้
class Box<T : Any> { lateinit var content: T}
หมายเหตุ
ใน Kotlin ถ้าเราไม่กำหนด constranint (upper bound)ให้ generic type มันจะมี default upper bound เป็น
Any?
แต่ในกรณีนี้เราต้องการให้คลาสBox
เก็บ non-null content จึงต้องระบุ upper bound เป็นAny
(ไม่มีเครื่องหมาย ?)
คลาส Box
ของเรามี property content
เป็น type T
และเนื่องจาก content
ไม่ได้ถูกระบุให้เป็น private คนที่เอากล่องไปใช้จึงสามารถ get/set ค่า content
ได้ตามใจชอบ
val box = Box<Cat>()
box.content = Cat()
val cat = box.content
จะเห็นว่าคลาส Box
เป็นทั้ง Producer และ Consumer ไม่ได้เป็นอย่างใดอย่างหนึ่ง เราจึงไม่สามารถใส่ variance annotation in
หรือ out
ตอนประกาศ generic type T
ได้ พูดได้อีกอย่างว่าคลาส Box
เป็น invariant จึงทำให้ขาดความยืดหยุ่น เช่นเราจะเอา Box<Animal>
มาชี้ Box<Cat>
ไม่ได้
สมมุติว่าเราอยากจะเพิ่มฟังก์ชันในการ copy ของจากกล่องหนึ่งไปอีกกล่องหนึ่ง
fun <T : Any> copyBox(source: Box<T>, dest: Box<T>) {
dest.content = source.content
}
ฟังก์ชันตรงไปตรงมาแต่เวลาใช้กลับไม่ยืดหยุ่น เพราะสมมุติถ้าเราอยากเอา content
จากกล่อง Cat
ไปใส่ในกล่อง Animal
ก็ควรจะได้แต่ฟังก์ชันนี้กลับทำไม่ได้
val catBox = Box<Cat>()
catBox.content = Cat()
val catBox2 = Box<Cat>()
//(1) This is OK
//Because both catBox and catBox2 are Box<Cat>
copyBox(catBox, catBox2)val animalBox = Box<Animal>()
//(2) compile error
copyBox(catBox, animalBox)
(1) โอน content
ระหว่าง Box<Cat>
ด้วยกันได้โดยไม่มีปัญหาอะไร
(2) แม้ว่า Cat
จะเป็น subtype ของ Animal
ก็ตาม แต่จะเห็นว่าเราไม่สามารถเอา content
จาก Box<Cat>
ไปใส่ให้ Box<Animal>
ได้
ถ้าเราต้องการให้ฟังก์ชัน copyBox
มีความยืดหยุ่นตามที่เราต้องการเราสามารถระบุ variance annotation ให้กับ parameter ของฟังก์ชันได้ซึ่งเรียกว่าการทำ Type Projection
fun <T : Any> copyBox(source: Box<out T>, dest: Box<in T>) {
// source is an out-projected type, dest is an in-projected type
dest.content = source.content
}
เราจะได้ตัวแปร source
เป็น Producer และตัวแปร dest
เป็น Consumer และเนื่องจากเราระบุ variance annotation (in
และ out
) ตอนใช้ตัวแปรซึ่งไม่ได้เป็นการระบุตรงตำแหน่ง type parameter ของ class declaration เหมือน Declaration-site variance เราจึงเรียกมันว่า Use-site variance
คราวนี้เราก็เอาของใน Box<Cat>
ไปใส่ให้ Box<Animal>
ได้แล้ว
val catBox = Box<Cat>()
catBox.content = Cat()
val animalBox = Box<Animal>()
// This is ok now
copyBox(catBox, animalBox)
นอกจากพารามิเตอร์ของฟังก์ชันแล้ว เราสามารถใช้ type projection กับตัวแปรก็ได้
val mutableList = mutableListOf(1, 2, 3)
val outProjectedList: MutableList<out Int> = mutableList//compile error,
//outProjectedList is out-projected type
outProjectedList.add(5)
จากตัวอย่างข้างบนถึงแม้ว่า outProjectedList
จะเป็น MutableList
แต่เราก็ไม่สามารถเรียกฟังกชัน add
ได้เพราะเรากำหนดให้มันเป็น MutableList<out Int>
Star-projections
อีกหนึ่ง projection ที่มีใน Kotlin คือ star-projection ซึ่งไอเดียก็คล้ายกันกับ Java Unbounded Wildcards <?> ที่ไว้ใช้เวลาที่เราไม่ได้สนใจว่า generic type เป็น type อะไรอยู่
เช่นเราแค่อยากจะใช้ฟังก์ชันของคลาส Any
fun displayBoxContent(box: Box<*>) {
println(box.content)
}
จากตัวอย่าง box.content
ถูกมองเป็น type Any
ฟังก์ชัน println
จึงสามารถเรียกฟังก์ชัน toString
ของ box.content
ได้
อีกหนึ่งตัวอย่างที่ใช้ star-projection คือการเช็ค type ของ generic class เช่น
fun checkAny(any: Any) {
//(1) compile error, cannot check for instance of erased type.
if (any is CatBox<PersianCat>) {
// do something
} //(2)
if (any is CatBox<*>) {
//(3)
if (any.content is PersianCat) {
// do something
}
}}
(1) เราไม่สามารถเช็คได้ว่า any
เป็น CatBox
ที่มี generic type เป็นอะไรได้โดยตรงเนื่องจากข้อมูลเกี่ยวกับ type โดนลบไปโดย type erasure (จริงๆ มีบางกรณีที่ทำได้ถ้า compiler มีข้อมูลมากพอ แต่ไม่ได้กล่าวไว้ในบทความนี้)
(2) ถ้าเราต้องการตรวจสอบว่าตัวแปร any
เป็น type CatBox
เราจะสามารถเช็คได้แค่ว่ามันเป็น CatBox
จริงโดยการใช้ star-projection
(3) ถ้าเราอยากจะเช็ค type ของ content
ต้องดึงค่าออกมาเช็คต่ออีกที
Type Erasure
instance ของ
CatBox<Cat>
และCatBox<PersianCat>
จะถูกมองเป็นแค่CatBox
เหมือนกันตอนรันไทม์ ข้อมูลเกี่ยวกับ generic type จะถูกลบไปโดย type erasure ซึ่งก็มีประโยชน์เช่นในเรื่องของการประหยัดหน่วยความจำตอนรันไทม์เพราะไม่ต้องเก็บข้อมูล type ทั้งหมด
สรุป
- ถ้าต้องการสร้าง generic class เราสามารถใส่ type parameter ตรง class declaration ได้เลยเช่น
class Box<T>
- ถ้าอยากกำหนดประเภทของ type ที่ generic class รองรับเราสามารถใช้ generics constraints มาช่วยได้เช่น
class Box<T : Cat>
ซึ่งรองรับได้แค่ type และ subtype ของCat
- ในกรณีที่ generic class ของเรามีลักษณะเป็น Producer หรือ Consumer และเราไม่ต้องการให้ generic class ของเราเป็น invariant เพื่อเพิ่มความยืดหยุ่นในการใช้งาน เราสามารถใช้ declaration-site variance โดยการระบุ variance annotation เป็น
out
สำหรับคลาสที่เป็น Producer หรือin
สำหรับคลาสที่เป็น Consumer - CIPO
- เราสามารถกำหนด (projected) ตัวแปรด้วย variance annotation
out
เพื่อล็อคให้มันถูกใช้เป็น Producer เท่านั้นหรือin
เพื่อล็อคให้มันถูกใช้เป็น Consumer เท่านั้น โดยเราเรียกการใช้งานตัวแปรในลักษณะนี้ว่า use-site variance - Star-projection เป็นอีกหนึ่ง projection ที่ไว้ใช้เมื่อเราไม่ได้สนใจ type ของ
T
เช่นเมื่อเราแค่จะใช้ฟังก์ชันที่อยู่ใน supertype ของT
เท่านั้น หรือเมื่อเราต้องการเช็ค type ของ generic class
หากสนใจศึกษาลงลึกเพิ่มเติมลองอ่านได้จาก document ของ Kotlin ครับ
ส่วน source code ตัวอย่างการทดลองใช้ Generics ในบทความนี้อยู่บน Github ครับ
ขอขอบคุณพี่เอฟ Kittinun Vantasin และ Travis Suban ที่ให้คำปรึกษาและช่วยตรวจสอบความถูกต้องของบทความ
ขอบคุณที่ติดตามอ่านครับ