ทบทวน Java Generics ก่อนไป Kotlin

Dew
Black Lens
Published in
7 min readNov 23, 2016

แรกเริ่มเดิมทีตั้งใจอยากจะบันทึกเรื่อง Generics ของ Kotlin เพราะสงสัยเรื่อง in/out ใน Kotlin มาก หลังจากศึกษามาพักหนึ่งก็ค้นพบว่าหากเข้าใจ Generics ใน Java มาก่อนก็สามารถจะเข้าใจของ Kotlin ได้ไม่ยาก จึงขอเริ่มด้วยการทบทวน Generics ใน Java กันก่อนที่จะไป Kotlin

Update 21 December 2017
ตอนที่ 2 Kotlin Generics มาแล้วนะครับ ติดตามกันได้

Disclaimer

บทความนี้เหมาะสำหรับ Java Developerหรือ Kotlin Developer ที่รู้จักและเคยใช้ Generics มาแล้ว รายละเอียดพื้นฐานเช่น Syntax ต่างๆ จะไม่ได้อธิบายไว้โดยละเอียด แต่อย่างไรก็ตามคนที่ไม่เคยใช้ก็สามารถอ่านได้โดยที่ไม่น่าจะมีปัญหาครับ บทความนี้เป็นพื้นฐานปูทางไปยังเรื่อง Kotlin Generics

เราคงเคยใช้ Generics ใน Java กันเป็นปกติอยู่แล้ว โดยพื้นฐานแล้ว Generics ช่วยให้เราสามารถเขียนโค้ดที่สามารถทำงานกับ data type อะไรก็ได้และยังช่วยให้เราไม่จำเป็นต้อง cast type ตอนใช้ด้วย ตัวอย่างเช่นถ้าเราอยากสร้างคลาส Box ที่สามารถใส่ content เข้าไปได้และ content นี้สามารถเป็น type อะไรก็ได้

Problem #1 : สร้างคลาส Box ที่ใส่ content เป็น type อะไรก็ได้

Solution #1–1 : Box of Object

ถ้าไม่ใช้ Generics เราก็คงทำคลาสออกมาประมาณนี้

public class Box {  private Object content;

public Object getContent() {
return content;
}

public void setContent(Object content) {
this.content = content;
}
}

สมมุติเวลาใช้ถ้าเราจะเอาคลาส Box ใส่ String ก็จะได้ว่า

Box myBox = new Box();
myBox.setContent("I am a String in a little box");
String boxString = (String) myBox.getContent();

จากตัวอย่างข้างบนจะเห็นว่าตอน setContent() ก็โอเคดีอยู่ สามารถใส่ String เข้าไปได้ง่ายๆ แต่เวลา getContent() นี่สิที่ต้อง cast เป็น String ด้วยไม่งั้นก็จะเอาตัวแปร String มารับไม่ได้ !

จากตรงนี้พอจะเริ่มเห็นปัญหากันหรือยังครับ เพียงเพราะ type ของ content เป็น Object เราจึงต้อง cast เป็น String ก่อนจึงจะเอาออกมาใช้งานได้ อย่างนี้ถ้าเราเผลอ cast เป็น type อื่นที่ไม่ใช่ String หละ ? ก็เจอ ClassCastException สิครับ

// ClassCastException
Integer boxInteger = (Integer) myBox.getContent();

และถึงแม้ตอน getContent() เราจะ cast type ออกมาถูกแต่วิธีนี้ก็ไม่ได้รับประกันว่าเราจะไม่เผลอ setContent() ด้วย type อื่นที่ไม่ใช่ String

Solution #1–2 : Box of T ใช้ Generics มาช่วยแก้ปัญหา

มาถึงตรงนี้เราคงเห็นแล้วนะครับว่าเพียงแค่เราอยากจะได้คลาส Box ที่ทำงานกับ data type อะไรก็ได้ง่ายๆ แค่นี้ แต่ตอนใช้จริงกับมีปัญหาหลายอย่าง ซึ่งปัญหาเหล่านี้จะหมดไปเมื่อเราใช้ Generics

จากคลาส Box เดิมเราเปลี่ยนมาใช้ Generics ก็จะได้ประมาณนี้

public class Box<T> {

private T content;

public T getContent() {
return content;
}

public void setContent(T content) {
this.content = content;
}

}

ส่วนเวลาใช้งานถ้าจะเก็บ String ใส่กล่องก็จะได้ว่า

Box<String> myBox = new Box<>();
myBox.setContent("I am a String in a generic box");
// no casting required
String boxString = myBox.getContent();
// compile error
//myBox.setContent(1);
// compile error
//Integer boxInteger = myBox.getContent();

จากตัวอย่างข้างบนจะเห็นว่าเมื่อเราสร้าง parameterized type Box<String> ขึ้นมาแล้ว ทั้ง setContent() และ getContent() จะใช้ได้กับ String เท่านั้น ไม่ต้องมา cast type เอง และหมดปัญหา ClassCastException ด้วย

หมายเหตุ

- เมื่อ T ของ Box ถูกแทนที่ด้วย concrete type เช่น String เราเรียก Box<String> ว่า parameterized type

- Box<String> myBox = new Box<>()
ใน Java 7 เราสามารถละ type ตรง new Box<>() ได้เพราะ compiler สามารถ infer ได้ว่า T ในตอนนี้คือ String ส่วนสัญลักษณ์ <> นี้เราเรียกว่า the diamond

ตอนนี้เราก็ได้คลาส Box แบบ Generics ที่สามารถเก็บ data type อะไรก็ได้ แถมยังปลอดภัยกว่าการใช้แบบ Object อีกต่างหาก ว่าแต่ถ้าเราอยากระบุลงไปอีกหละว่าเราอยากสร้างคลาส Box เพื่อมาใช้งานกับคลาสบางประเภทเท่านั้น เช่นถ้าเรามีคลาส Animal และมีคลาส Cat คลาส Dog เป็น sub class ของ Animal เราอยากให้ Generics Box ของเราเก็บได้เฉพาะ Animal และ sub type ของมันเท่านั้นเราจะทำยังไง ? คำตอบก็คือใช้ Bounded Type Parameter

Problem #2 : สร้างคลาส AnimalBox ที่ใส่ content เป็น Animal ได้เท่านั้น

สมมุติเรามีคลาส Animal, Dog, Cat ที่มีความสัมพันธ์ประมาณนี้

จาก diagram ข้างบนจะเห็นว่าคลาส Animal มี abstract String say() ที่ sub class แต่ละตัวต้องเอาไป implement

ปัญหาคือถ้าอยากให้คลาส Box ของเราเก็บได้เฉพาะ Animal รวมถึง sub class ของ Animal เท่านั้นต้องทำยังไง ?

Solution 2–1 : ใช้ Bounded Type Parameter

Bounded Type Parameter ก็คือการกำหนด scope ให้กับ generic type คือแทนที่เดิม T จะถูกแทนที่ด้วย type อะไรก็ได้ พอใช้ Bounded Type Parameter เราก็สามารถเจาะจงลงไปได้อีกว่า T ต้องเป็น Type ประเภทไหน

คราวนี้เราก็เอา Bounded Type Parameter มาช่วยสร้างคลาส AnimalBox ที่เก็บได้เฉพาะ Animal ประมาณนี้

public class AnimalBox<T extends Animal> {

private T animal;

public T getAnimal() {
return animal;
}

public void setAnimal(T animal) {
this.animal = animal;
}

public String poke() {
return animal.say();
}


}

ด้วยการประกาศ <T extends Animal> เราก็จะได้คลาส AnimalBox ที่ใช้งานได้เฉพาะกับ Animal ทุกประเภท สังเกตุเมธอด poke() ที่ลองใส่เพิ่มเข้ามามันสามารถเรียก animal.say() ได้เลยเพราะไม่ว่ายังไง T ตอน runtime ก็เป็น Animal แน่นอน

เราสามารถใช้ interface ตอนประกาศ Bounded Type Parameter ก็ได้ เช่นถ้าเรามี interface ชื่อว่า SomeInterface เราก็ประกาศ Bounded Type Parameter ว่า
<T extends SomeInterface> ซึ่ง extends ในที่นี้มันเหมารวมทั้งการ extends ของ class และการ implements ของ interface

ทีนี้เวลาจะใช้ AnimalBox

// compile error
//AnimalBox<String> stringBox = new AnimalBox<>();
AnimalBox<Cat> catBox = new AnimalBox<>();
Cat myCat = new Cat();
catBox.setAnimal(myCat);
System.out.println(catBox.poke());
// compile error
//catBox.setAnimal("Some String");

เท่านี้เราก็ได้ AnimalBox ที่สามารถใช้ใส่สัตว์ประเภทไหนก็ได้แล้ว

Variance

มาลองพิจารณาความสัมพันธ์ของ Animal, Cat, Dog จากไดอะแกรมก่อนหน้านี้ดูนะครับ ในเมื่อ Cat สืบทอดมาจาก Animal (Cat is an Animal) ดังนั้นเราจึงสามารถนำตัวแปรของ Animal ไปชี้ instance ของ Cat ได้

Animal animal = new Cat();

อันนี้เป็นเรื่องพื้นฐานเข้าใจกันดีอยู่แล้วใช่ไหมครับ คำถามต่อมาคือในเมื่อเรามีกล่องมหัศจรรย์ที่ใส่ type อะไรเข้าไปก็ได้ จะเป็นไปได้ไหมที่เราจะทำอะไรประมาณนี้ (ขอกลับไปใช้คลาส Box แบบ generics แทน AnimalBox เพื่อความกระชับในการเขียน)

Box<Animal> box = new Box<Cat>();

คำตอบคือไม่ได้ !

ถึงแม้ว่า Cat จะสืบทอดมาจาก Animal แต่ Box<Cat> ไม่ได้สืบทอดมาจาก Box<Animal> หรือจะพูดอีกอย่างหนึ่งก็คือ Box<T> เป็น Invariant สำหรับ type T

VarianceVariance เป็นหัวข้อที่ไม่ได้เกี่ยวข้องแค่เรื่อง Generics เพียงอย่างเดียว แต่ถ้าพูดในแง่ของ Generics นั้น Variance คือการอธิบายความสัมพันธ์ในรูปแบบการสืบทอดกันของ Parameterized Type สองตัวที่ type parameter (T) ของทั้งสองตัวมีความสัมพันธ์ในรูปแบบการสืบทอดกันอยู่ ยกตัวอย่างเช่นถ้า Cat  Animal (Cat สืบทอดมาจาก Animal) เราจะได้ว่าBox<T> เป็น Covariant 
เมื่อ Box<Cat> Box<Animal>
กล่าวคือใช้ลำดับการสืบทอดเหมือน type T เพราะ Cat Animal และ Box<Cat> Box<Animal>
Box<T> เป็น Contravariant
ถ้า Box<Animal> Box<Cat>
กล่าวคือใช้ลำดับการสืบทอดตรงข้ามกับ type T เพราะแม้ว่า Cat Animal แต่ Box<Animal> Box<Cat>
Box<T> เป็น Invariant
กล่าวคือ Box<Animal> ไม่ได้มีความสัมพันธ์ในรูปแบบการสืบทอดกับ Box<Cat> เลย
ซึ่งโดยพื้นฐานแล้ว generic type ใน Java นั้นเป็นแบบ Invariant เพราะอย่างนี้เราจึงเอา Box<Animal> ไปชี้ Box<Cat> ไม่ได้ศึกษาเพิ่มเติมเกี่ยวกับเรื่อง Variance ได้จาก Wikipedia ครับ

แล้วถ้าเราอยากจะทำ Generic Class ของเราให้ยืดหยุ่นขึ้นเราจะทำยังไง ?

คำตอบคือใช้ Wildcards

Problem #3 : เพิ่มเมธอด copyContentFromBox ไว้โอน content มาจาก Box อื่น

สมมุติว่าเราจะเพิ่มเมธอด copyContentFromBox ให้กับคลาส Box เพื่อไว้ใช้ copy content จากอีก Box มาใส่ใน Box ของเรา

Solution #3–1 : รับ Box<T>

สำหรับปัญหานี้ถ้าแก้โดยการใช้ generics ง่ายๆ เราอาจจะได้เมธอดหน้าตาประมาณนี้

public void copyContentFromBox(Box<T> source) {
setContent(source.getContent());
}

ลองใช้เมธอดนี้ copy content จากกล่องสำหรับ PersianCat กล่องแรกไปใส่กล่องสำหรับ PersianCat กล่องที่สอง

Box<PersianCat> persianCatBox1 = new Box<>();
persianCatBox1.setContent(new PersianCat());
Box<PersianCat> persianCatBox2 = new Box<>();
persianCatBox2.copyContentFromBox(persianCatBox1);

ทีนี้ถ้าเรามี Box<Cat> อยู่แล้วอยาก copy content จาก Box<PersianCat> มาหละ?

Box<PersianCat> persianCatBox = new Box<>();
persianCatBox.setContent(new PersianCat());
Box<Cat> catBox = new Box<>();
// compile error
catBox.copyContentFromBox(persianCatBox);

กลายเป็นว่าทำแบบนี้ไม่ได้ ทั้งๆ ที่ความเป็นจริงกล่องใส่แมวก็น่าจะใส่แมวเปอร์เซียได้นี่นา

แมวงง

ที่เป็นอย่างนี้ก็เพราะ Box<T> เป็น Invariant ทำให้ Box<Cat> ไม่สามารถชี้ Box<PersianCat> ได้

Solution #3–2 : ใช้ Upper Bounded Wildcards

วิธีแก้ไขก็คือใช้ Upper Bounded Wildcards (คนละอย่างกับเรื่อง Bounded Type Parameter ก่อนหน้านี้นะ) เราจะได้เมธอดใหม่ประมาณนี้

public void copyContentFromBox(Box<? extends T> source) {
setContent(source.getContent());
}

คราวนี้ตัวแปร source จะเป็นคลาส Box ของ T หรือ sub class ของ T ก็ได้หมด งั้นลองเอาแมวจากกล่อง PersianCat มาใส่กล่อง Cat ดู

Box<PersianCat> persianCatBox = new Box<>();
persianCatBox.setContent(new PersianCat());
Box<Cat> catBox = new Box<>();
catBox.copyContentFromBox(persianCatBox);

เท่านี้ก็เรียบร้อย เราใช้ Box<? extends T> ทำให้เมธอด copyContentFromBox สามารถรับ Box ของ T หรือ sub class ของ T ก็ได้

การใช้ Upper Bounded Wildcards <? extends T> นอกจากจะการันตีว่าของที่ออกมาจากตัวแปร source จะเป็น instance ของ T หรือ sub class ของ T แล้ว compiler ยังห้ามไม่ให้เราใส่ content ใหม่กลับเข้าไปใน source อีกด้วย เพราะว่า compiler ไม่สามารถบอกได้ว่าตอน run time นั้น type T ของ source เป็น type อะไร ทำให้เมธอด copyContentFromBox มีสิทธิ์ทำงานกับตัวแปร source แค่แบบ read-only เท่านั้น หรือจะมองว่าตอนนี้ตัวแปร source ทำหน้าที่เป็น Producer นั่นเอง

สิ่งที่ Producer สามารถส่งออกมาได้มีแค่ Cat, PersianCat หรือ ScottishFoldCat

หมายเหตุ
Producer ทำหน้าที่ผลิตของออกมา ส่วน Consumer ทำหน้าที่รับของเข้าไป

นอกจาก Upper Bounded Wildcards แล้วยังมี Lower Bounded Wildcards ด้วย คราวนี้เรามาลองเพิ่มเมธอด copyContentToBox บ้าง ซึ่งเอาไว้ copy ของไปให้กล่องอื่น

Problem #4 : เพิ่มเมธอด copyContentToBox ไว้โอน content ให้กับ Box อื่น

สมมุติว่าเราจะเพิ่มเมธอด copyContentToBox ให้กับคลาส Box เพื่อไว้ใช้ copy content จาก box ของเราไปให้อีก box

Solution #4–1 : รับ Box<T>

ถ้ายังไม่ใช้ wildcards ก็จะได้เมธอดหน้าตาประมาณนี้

public void copyContentToBox(Box<T> target) {
target.setContent(getContent());
}

เวลาใช้งานก็ตรงไปตรงมา

Box<ScottishFoldCat> scottishFoldCatBox1 = new Box<>();
scottishFoldCatBox1.setContent(new ScottishFoldCat());
Box<ScottishFoldCat> scottishFoldCatBox2 = new Box<>();
scottishFoldCatBox1.copyContentToBox(scottishFoldCatBox2);

แต่มันก็ยังมีปัญหาความไม่ยืดหยุ่นเหมือนเดิม ตรงที่ว่าถ้าเรามี Box<ScottishFoldCat> อยู่ ทำไมเราจะเอา Box<Cat> มาใช้กับเมธอด copyContentToBox ไม่ได้

Box<ScottishFoldCat> scottishFoldCatBox = new Box<>();
scottishFoldCatBox.setContent(new ScottishFoldCat());
Box<Cat> catBox = new Box<>();
// compile error
scottishFoldCatBox.copyContentToBox(catBox);

ก็เหมือนเดิมครับมันเป็น Invariant เลยใช้แบบนี้ไม่ได้

Solution #4–2 : ใช้ Lower Bounded Wildcards

คราวนี้เราจะแก้ด้วย Lower Bounded Wildcards เพราะเราจะเขียนข้อมูลลงไปยัง Box ที่รับเข้ามา ดังนั้นหน้าตาเมธอดใหม่จะออกมาประมาณนี้

public void copyContentToBox(Box<? super T> target) {
target.setContent(getContent());
}

คราวนี้ตัวแปร target จะเป็นคลาส Box ของ T หรือ super class ของ T ก็ได้หมด งั้นลองเอาแมวจากกล่อง ScottishFoldCat ใส่ให้กล่อง Cat ดู

Box<ScottishFoldCat> scottishFoldCatBox = new Box<>();
scottishFoldCatBox.setContent(new ScottishFoldCat());
Box<Cat> catBox = new Box<>();
scottishFoldCatBox.copyContentToBox(catBox);

เท่านี้ก็เรียบร้อย เราได้ Box<? super T> ทำให้เมธอด copyContentToBox สามารถรับ Box ของ T หรือ super class ของ T ก็ได้

การใช้ Lower Bounded Wildcards <? super T> จะช่วยการันตีว่าตัวแปร target ที่มารับของจะสามารถอ้างอิง instance ของ T (หรือ sub class ของ T) ได้แน่นอน แล้วยังทำให้ตัวแปร target มีลักษณะเป็น consumer เพราะสามารถรับของเข้าไปได้ แต่การที่จะเอาของออกมาใช้อาจจะทำให้ใช้ผิด type ได้ เพราะสมมุติถ้า target ที่เข้ามาเป็น Box<Animal> ที่มีน้องหมา Dog อยู่ข้างในก่อนแล้ว พอเราเอา Dog ออกมาแล้วเรียกเมธอดของ Scottish Fold ก็พังแน่นอน

Consumer สามารถใช้ตัวแปรประเภท Cat, Animal หรือ super type ตัวอื่นๆ ของ Animal มาใช้ในการรับของเข้าไปได้เท่านั้น เพราะทั้งหมดสามารถชี้ (reference) ไปยัง instance ของ Cat ได้

PECS

คุณ Joshua Bloch เสนอหลักการจำง่ายๆ ว่า Producer และ Consumer จะใช้ Upper Bounded Wildcards หรือ Lower Bounded Wildcards ว่า Producer-Extends, Consumer-Super ย่อว่า PECS

ดังนั้นเวลาเราจะใช้ตัวแปรที่ทำหน้าที่เป็น Producer เราก็ระบุด้วย extends (Upper Bounded Wildcards) <? extends T> ส่วนเวลาจะใช้ตัวแปรที่ทำหน้าที่เป็น Consumer เราก็ระบุด้วย super (Lower Bounded Wildcards) <? super T>

อีกตัวอย่างที่ดีของเรื่อง wildcards กับแนวคิด PECS คือ Collection ของ Java

อย่างเมธอด AddAll ของ Collection

เมธอด addAll มีพารามิเตอร์ c ที่ทำหน้าที่เป็น Producer ให้กับ Collection เพราะ c ส่ง element ทั้งหมดออกมา (produce) ให้กับ Collection

หรือเมธอด removeIf

เมธอด removeIf มีพารามิเตอร์ filter ที่ทำหน้าที่เป็น Consumer ให้กับ Collection เพราะ filter รับ element ใน Collection เข้าไปใช้งาน (consume)

สรุป

สรุปเนื้อหาจากบทความโดยย่อ

Problem #1 : สร้างคลาส Box ที่ใส่ content เป็น type อะไรก็ได้

  • Solution : ใช้ Generics Box<T> มาช่วยแก้ปัญหา

Problem #2 : สร้างคลาส AnimalBox ที่ใส่ content เป็น Animal ได้เท่านั้น

  • Solution : ใช้ Bounded Type Parameter <T extends Animal>

Variance

  • Invariant
  • Covariant
  • Contravariant

Problem #3 : เพิ่มเมธอด copyContentFromBox ไว้โอน content มาจาก Box อื่น

  • Solution : ใช้ Upper Bounded Wildcards <? extends T>

Problem #4 : เพิ่มเมธอด copyContentToBox ไว้โอน content ให้กับ Box อื่น

  • Solution : ใช้ Lower Bounded Wildcards <? super T>

PECS

  • Producer-Extends, Consumer-Super

ยังมีเนื้อหาอีกหลายส่วนที่ไม่ได้ลงรายละเอียดไว้ในบทความนี้นะครับ อาทิเช่น

  • Unbounded Wildcards <?>
    ที่เอาไว้ใช้ในกรณีที่เราจะใช้เมธอดของคลาส Object ในตัวแปรประเภท Producer
  • Wildcard Capture
  • Type Erasure
  • Type Inference
  • Raw Type

หากสนใจศึกษาลงลึกเพิ่มเติมลองอ่านได้จาก document ของ Java ครับ

ส่วน source code ตัวอย่างการทดลองใช้ Generics ในบทความนี้อยู่บน Github ครับ

ขอขอบคุณพี่เอฟ Kittinun Vantasin และทราวิสที่ช่วยตรวจสอบความถูกต้องของบทความ

--

--