บทนำ: ปัญหาของการเขียนโปรแกรมที่ยืดหยุ่น
ในการพัฒนาโปรแกรมขนาดใหญ่ นักพัฒนามักต้องเผชิญกับสถานการณ์ที่ class หรือ method ควรทำงานได้กับหลายประเภท data type ตัวอย่างเช่น เราอาจต้องสร้าง class สำหรับเก็บข้อมูล (container) ที่สามารถเก็บ String, Integer, Double หรือ object ชนิดอื่นๆ ได้ก็ตาม
ในยุคแรกของ Java ก่อนมี Generics มีวิธีแก้ปัญหาคือการใช้ Object class ซึ่งเป็น parent class ของทุก class ใน Java แต่วิธีนี้สร้างปัญหาหลายอย่าง เมื่อใช้ Object เราต้อง cast (เปลี่ยนประเภท) ข้อมูลทุกครั้ง และถ้า cast ผิดประเภท program อาจ crash ได้ตั้งแต่ runtime ซึ่งเป็นเรื่องที่อันตราย เพราะ error อาจไม่ถูกจับได้ตั้งแต่ compile time
Generics เป็นวิธีแก้ปัญหานี้อย่างมีประสิทธิภาพ มันช่วยให้เราสามารถเขียน code ที่ทำงานกับหลายประเภท data type ขณะเดียวกันก็รักษาความปลอดภัยด้าน type (type safety) ทั้งนี้ compiler จะ check ประเภท data ให้เรา ถ้า type ไม่ตรงกัน error จะเกิดขึ้นทันทีที่ compile code ไม่ใช่เมื่อ run program
ความแตกต่างระหว่าง Object Approach กับ Generics
ปัญหาของการใช้ Object Class
เพื่อให้เห็นภาพชัดเจน ลองดูตัวอย่างการสร้าง “box” (ภาชนะ) ที่เก็บสิ่งของได้
ในแบบเก่า โปรแกรมเมอร์ต้องใช้ Object ซึ่งเป็นประเภททั่วไปที่สุด:
java// ❌ วิธีเก่า: ใช้ Object
public class SimpleBox {
private Object item;
public void put(Object item) {
this.item = item;
}
public Object get() {
return item;
}
}
// เมื่อใช้งาน
SimpleBox box = new SimpleBox();
box.put("Hello");
String text = (String) box.get(); // ← ต้อง cast
ปัญหาของวิธีนี้คือ:
- ต้อง Cast บ่อย – ทุกครั้งที่เอาข้อมูลออกจาก box ต้องระบุประเภท
- ไม่ปลอดภัยด้าน Type – ถ้า cast ผิดประเภท program จะ crash ที่ runtime เช่น ถ้าเก็บ Integer แต่ cast เป็น String
- ไม่ชัดเจน – เมื่ออ่านโค้ด ไม่รู้ว่า box นี้เก็บข้อมูลประเภทอะไร
วิธีใหม่: ใช้ Generics
Generics แก้ปัญหาทั้งหมดข้างต้นด้วยการให้เราระบุประเภท data ตั้งแต่ตอนสร้าง:
java// ✓ วิธีใหม่: ใช้ Generics
public class Box<T> { // T คือ type parameter
private T item;
public void put(T item) {
this.item = item;
}
public T get() {
return item;
}
}
// เมื่อใช้งาน
Box<String> stringBox = new Box<>();
stringBox.put("Hello");
String text = stringBox.get(); // ← ไม่ต้อง cast!
Box<Integer> intBox = new Box<>();
intBox.put(123);
Integer number = intBox.get(); // ← ไม่ต้อง cast!
เมื่อใช้ Generics ประโยชน์ที่ได้คือ:
- ไม่ต้อง Cast – Compiler รู้ประเภทแล้ว จึงคืนประเภทที่ถูกต้องโดยอัตโนมัติ
- Type-safe – Compiler จะ check ประเภท ถ้าพยายาม put ข้อมูลประเภทผิด จะเกิด compile error
- ชัดเจน –
Box<String>บ่งชี้ว่ากล่องนี้เก็บ String เท่านั้น
ทำความเข้าใจ Type Parameter
T คืออะไร?
สัญลักษณ์ <T> ที่เห็นในคำนิยาม class เรียกว่า type parameter ซึ่งเป็นเหมือน placeholder หรือตัวแทน ของประเภท data ที่จะถูกระบุภายหลัง
ลองเปรียบเทียบกับ parameter ปกติ:
int add(int x, int y)– x และ y เป็น value parameters ที่จะรับค่าจริงpublic class Box<T>– T เป็น type parameter ที่จะรับประเภทจริง
ชื่อ T เพียงแค่ convention (ความเห็นพ้องกัน) เราสามารถใช้ชื่ออื่นได้ เช่น <E> หรือ <ItemType> แต่ T เป็นมาตรฐานที่นักพัฒนามักใช้ และเมื่อเราสร้าง instance ของ class ที่เป็น generic เราต้องระบุ concrete type:
javaBox<String> box1 = new Box<>(); // T = String
Box<Integer> box2 = new Box<>(); // T = Integer
Box<Double> box3 = new Box<>(); // T = Double
ที่นี้ compiler จะสร้าง “specialized version” ของ Box สำหรับแต่ละประเภท และ ensure ว่า type ตรงกัน
ตัวอย่างที่ 1: Generic Class – Pair Container
สถานการณ์จริง: เก็บข้อมูลคู่
นิยม class ที่ต้องเก็บข้อมูล 2 ตัวที่อาจเป็นประเภทต่างกัน เช่น:
- (ชื่อคน, อายุ) – String กับ Integer
- (ชื่อเมือง, ระยะทาง) – String กับ Double
- (ID ลำดับ, ราคาสินค้า) – Integer กับ Double
แทนที่จะสร้าง class ต่างๆ สำหรับแต่ละ combination เราสามารถใช้ generic class ที่มี 2 type parameters:
javapublic class Pair<T, U> { // T และ U เป็นสอง type parameters
private T first;
private U second;
public Pair(T first, U second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public U getSecond() {
return second;
}
public void setFirst(T first) {
this.first = first;
}
public void setSecond(U second) {
this.second = second;
}
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
}
ตอนนี้ class Pair สามารถใช้ได้กับ type combination ใดก็ได้:
java// เก็บชื่อกับอายุ
Pair<String, Integer> person = new Pair<>("Alice", 25);
System.out.println(person); // (Alice, 25)
// เก็บพิกัด X, Y
Pair<Double, Double> point = new Pair<>(10.5, 20.3);
System.out.println(point); // (10.5, 20.3)
// เก็บรายละเอียดสินค้า
Pair<Integer, String> product = new Pair<>(101, "Laptop");
System.out.println(product); // (101, Laptop)
ข้อดีของวิธีนี้คือ:
- Reusable – ใช้ class Pair เดียวสำหรับ type combination ต่างๆ
- Type-safe – Compiler check ว่า type ตรงกัน
- Clean – ไม่ต้องสร้าง class ใหม่ซ้ำๆ
ตัวอย่างที่ 2: Generic Methods
เมื่อ Generic ต้องการ
นอกจาก generic class ยังมี generic method ที่เป็นวิธีการเขียน method ที่ยืดหยุ่นขึ้น
มักใช้ generic method เมื่อ method ต้องทำงานกับประเภท data ต่างๆ แต่ class เองไม่ใช่ generic ตัวอย่างเช่น utility method สำหรับหาค่าสูงสุดจากอาร์เรย์:
javapublic class ArrayUtils {
// Generic method: หาค่าสูงสุด
public static <T extends Comparable<T>> T findMax(T[] array) {
if (array == null || array.length == 0) {
return null;
}
T max = array[0];
for (T item : array) {
if (item.compareTo(max) > 0) {
max = item;
}
}
return max;
}
}
ในนี้ <T extends Comparable<T>> หมายความว่า:
<T>– Method นี้เป็น generic ยอมรับประเภท T ใดก็ได้extends Comparable<T>– T ต้องเป็น class ที่ implement Comparable interface (เพื่อให้เปรียบเทียบได้)
เมื่อใช้:
javaInteger[] numbers = {5, 2, 9, 1, 7};
Integer max = ArrayUtils.findMax(numbers); // 9
String[] words = {"apple", "banana", "cherry"};
String maxWord = ArrayUtils.findMax(words); // cherry
Method เดียวกันสามารถทำงานกับ Integer, String, หรือประเภทอื่นที่สามารถเปรียบเทียบได้ ข้อดีคือเราไม่ต้องเขียน findMaxInt() และ findMaxString() แยก
Bounded Type Parameters: จำกัดประเภท
ทำไมต้องจำกัด?
บางครั้ง generic ต้องการให้ type parameter เป็น class ที่มี method ใหม่เท่านั้น เราสามารถใช้ bounded type parameter เพื่อจำกัดว่า type ใดที่ยอมรับได้
ตัวอย่างคือการสร้าง Repository (ที่เก็บข้อมูล) ที่ต้องการให้สามารถ save, find, delete ได้ สิ่งต่างๆ ที่ save ต้องมี getId() method เพื่อ identify ซึ่งเป็น interface ชื่อ Entity:
java// Entity interface ที่มี getId()
public abstract class Entity {
private int id;
public Entity(int id) {
this.id = id;
}
public int getId() {
return id;
}
}
// Repository ที่ยอมรับ Entity หรือ subclass เท่านั้น
public class Repository<T extends Entity> {
private java.util.List<T> items = new java.util.ArrayList<>();
public void add(T item) {
items.add(item);
}
public T findById(int id) {
for (T item : items) {
if (item.getId() == id) { // ← สามารถใช้ getId() ได้
return item;
}
}
return null;
}
}
<T extends Entity> หมายว่า T ต้องเป็น Entity หรือ class ที่ extend Entity ตัวอย่างการใช้:
java// User class extend Entity
public class User extends Entity {
private String name;
public User(int id, String name) {
super(id);
this.name = name;
}
}
// Product class extend Entity
public class Product extends Entity {
private String title;
public Product(int id, String title) {
super(id);
this.title = title;
}
}
// ใช้ Repository กับ User
Repository<User> userRepo = new Repository<>();
userRepo.add(new User(1, "Alice"));
// ใช้ Repository กับ Product
Repository<Product> productRepo = new Repository<>();
productRepo.add(new Product(101, "Laptop"));
// ❌ ไม่สามารถใช้กับ String เพราะ String ไม่ extend Entity
// Repository<String> stringRepo = new Repository<>(); // Error!
ข้อดีของ bounded type parameters:
- Flexible – ยังคงสามารถใช้ได้กับหลาย type
- Safe – Compiler ensure ว่า type ที่ใช้มี method ที่ต้องการ
- Reusable – ไม่ต้องเขียน repository ใหม่สำหรับแต่ละ type
Wildcards: ยืดหยุ่นกว่าเดิม
ปัญหา: Type ต้องตรงกันพอดี
ลองนึกภาพว่าเรามี method ที่ print list ของสิ่งต่างๆ:
javapublic static void printList(List<Object> list) {
for (Object item : list) {
System.out.println(item);
}
}
ดูเหมือนว่า method นี้ยอมรับ List<Object> ได้ สิ่งต่างๆ ที่เป็น Object หรือ subclass ของ Object ก็ได้ แต่ java generics ไม่ทำงานแบบนั้น
javaList<String> strings = Arrays.asList("a", "b");
printList(strings); // ❌ Error! List<String> ไม่ match List<Object>
error เกิดขึ้นเพราะว่า compiler ไม่ยอมให้ List<String> ผ่านเป็น List<Object> แม้ว่า String เป็น subclass ของ Object
วิธีแก้คือใช้ wildcard ซึ่งแทน concrete type:
javapublic static void printList(List<?> list) { // ← ? = ประเภทใดก็ได้
for (Object item : list) {
System.out.println(item);
}
}
// ตอนนี้สามารถใช้ได้
List<String> strings = Arrays.asList("a", "b");
printList(strings); // ✓ OK
List<Integer> integers = Arrays.asList(1, 2, 3);
printList(integers); // ✓ OK
Bounded Wildcards
นอกจากแค่ ? ที่รับประเภทใดก็ได้ ยังมี bounded wildcard ที่จำกัด:
- Upper Bounded:
? extends T– ยอมรับ T หรือ subclass ของ T
javapublic static double sumNumbers(List<? extends Number> numbers) {
double sum = 0;
for (Number num : numbers) {
sum += num.doubleValue();
}
return sum;
}
// สามารถใช้ได้
List<Integer> ints = Arrays.asList(1, 2, 3);
double sum1 = sumNumbers(ints); // ✓ OK
List<Double> doubles = Arrays.asList(1.5, 2.5);
double sum2 = sumNumbers(doubles); // ✓ OK
- Lower Bounded:
? super T– ยอมรับ T หรือ superclass ของ T
javapublic static void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
list.add(3);
}
List<Number> numbers = new ArrayList<>();
addIntegers(numbers); // ✓ OK
List<Integer> integers = new ArrayList<>();
addIntegers(integers); // ✓ OK
Wildcards ให้ flexibility มากขึ้น ช่วยให้ generic code ยืดหยุ่นได้มากยิ่งขึ้น
Type Erasure: ข้อจำกัดสำคัญ
Generics ไม่มี Runtime Information
สิ่งสำคัญที่ต้องเข้าใจคือ Generics เป็น feature ของ compile time เท่านั้น ที่ runtime ข้อมูล type จะ “erase” (ลบ) ไป:
javaList<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
System.out.println(strings.getClass()); // class java.util.ArrayList
System.out.println(integers.getClass()); // class java.util.ArrayList
// ทั้ง 2 เป็น ArrayList เหมือนกันที่ runtime
// ข้อมูล <String> และ <Integer> หายไปแล้ว
สิ่งนี้เรียกว่า Type Erasure – Generics ถูก “erase” ที่ runtime เหตุนี้เองที่เรา ไม่สามารถ:
- ใช้ generic type ใน
instanceof:
javaif (list instanceof List<String>) {} // ❌ Error
- สร้าง generic array:
javaList<String>[] array = new List<String>[10]; // ❌ Error
- สร้าง instance ของ generic type:
javaT obj = new T(); // ❌ Error
การเข้าใจ Type Erasure ช่วยให้เรารู้ว่า Generics มีข้อจำกัดบางอย่าง และรู้วิธีแก้ปัญหา
สรุป
Generics เป็น feature ที่มีคุณค่าสูงในการเขียน Java code ที่ยืดหยุ่น ปลอดภัย และ reusable:
หลักการหลัก:
- Generics ช่วยให้เขียน code ที่ทำงานกับหลายประเภท data โดยรักษา type safety
- Type parameter (เช่น T) คือ placeholder สำหรับประเภทที่จะระบุต่อมา
- Bounded types จำกัด type ที่ยอมรับ เพื่อให้แน่ใจว่ามี methods ที่ต้องการ
- Wildcards (
?) เพิ่ม flexibility ในการยอมรับ type ต่างๆ
ข้อดี:
- Type-safe – Compiler check type แต่ compile time
- Reusable – Code ใช้ได้กับหลาย types
- No casting – ไม่ต้องแปลงประเภท
- Self-documenting – Code บ่งชี้ประเภท data ที่คาดหวัง
Generics อาจดูซับซ้อนในตอนแรก แต่เมื่อเข้าใจแนวคิดแล้ว มันกลายเป็น tool ที่ทรงพลัง ช่วยให้เขียน library code ที่ generic, collection ที่ type-safe และ algorithms ที่ reusable สำหรับ multiple data types ทั้งยังคงความชัดเจนและป้องกัน errors
