Kotlin Generics: in/out มีนอกมีใน

Dew
Black Lens
Published in
7 min readDec 19, 2016
ภาพประกอบบทความนี้มาจากภาพยนต์เรื่อง Skyfall

มาถึงภาคต่อจากเรื่อง 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>

add ไม่ได้นะ

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 ที่ให้คำปรึกษาและช่วยตรวจสอบความถูกต้องของบทความ

ขอบคุณที่ติดตามอ่านครับ

--

--