
Java เรียงชื่อภาษาไทยให้ตรงพจนานุกรม: ทำไม Collections.sort ส่ง "เกศินี" ไปท้ายลิสต์ และ Collator แก้ยังไง
ทำหน้ารายชื่อพนักงาน แล้วกดเรียงตามชื่อ ผลออกมาแบบนี้ครับ: กมล, ขวัญใจ, บุญมี, สมชาย, สมหญิง, อรุณ, เกศินี, แก้ว, ไพลิน ชื่อที่ขึ้นต้นด้วย เ แ ไ ไปกองอยู่ท้ายสุด ทั้งที่ในพจนานุกรม “เกศินี” ต้องอยู่หมวด ก ใกล้ๆ “กมล”
นี่ไม่ใช่บั๊กของ Java แต่เป็นเพราะเราใช้เครื่องมือผิดงาน วันนี้ผมจะใช้ java.text.Collator เรียงชื่อไทยให้ถูก
WHY: ทำไม Collections.sort เรียงไทยผิด
Collections.sortกับStringใช้String.compareToซึ่งเทียบ “รหัสตัวอักษร” ทีละตัว ไม่รู้จักภาษาใดๆ- ใน Unicode สระหน้า เ แ โ ใ ไ มีรหัสอยู่หลังพยัญชนะทั้งหมด
- ภาษาไทยเขียนสระหน้าไว้ก่อนพยัญชนะ แต่พจนานุกรมเรียงด้วยพยัญชนะตัวแรกก่อน ชื่อ “เกศินี” จึงควรถูกจัดในหมวด ก
- ผลคือคำที่ขึ้นต้นด้วยสระหน้าไปอยู่ท้ายลิสต์ทั้งหมด คนใช้หาชื่อไม่เจอ
HOW: Collator ทำงานยังไง
เอกสาร Java อธิบายว่า Collator คือคลาสที่ “เปรียบเทียบข้อความตามภาษา” (locale-sensitive) ใช้สร้างการค้นหาและการเรียงข้อความภาษาธรรมชาติ
- เรียก
Collator.getInstance(Locale.forLanguageTag("th-TH"))ได้ตัวเปรียบเทียบที่รู้กฎภาษาไทย Collatorเป็นComparatorอยู่แล้ว ส่งเข้าlist.sort(collator)ได้เลย- ใน JDK ตัวที่ได้จริงคือ
RuleBasedCollator
ผลจริงตอนผมรันบน JDK 21 กับชื่อชุดเดิม: กมล, เกศินี, แก้ว, ขวัญใจ, บุญมี, ไพลิน, สมชาย, สมหญิง, อรุณ ตรงกับที่คนไทยคาดหวัง
strength: เรื่องเล็กที่มีผลกับการค้นหา
Collator มีระดับความละเอียด 4 ระดับ: PRIMARY, SECONDARY, TERTIARY, IDENTICAL เอกสารบอกว่าการจัดว่าอะไรอยู่ระดับไหน “ขึ้นกับภาษา”
ที่ผมทดสอบกับ locale ไทยบน JDK 21:
- ค่าเริ่มต้นเป็น
TERTIARYแยก “ก้อน” กับ “กอน” และแยก “abc” กับ “ABC” SECONDARYยังแยกวรรณยุกต์ แต่มองตัวพิมพ์ใหญ่/เล็กอังกฤษว่าเท่ากันPRIMARYมองว่า “ก้อน” เท่ากับ “กอน” และ “ไก่” เท่ากับ “ไก” ด้วย
สรุปคือ การเรียงลำดับใช้ค่าเริ่มต้นได้ แต่ถ้าจะใช้ compare(...) == 0 เพื่อ “ค้นหา” หรือ “ตัดชื่อซ้ำ” ต้องระวัง PRIMARY เพราะจะมองว่าคำที่ต่างกันแค่วรรณยุกต์เป็นคำเดียวกัน
เรื่องความเร็วและหลายเธรด
- เรียงลิสต์ใหญ่ ทุกคำถูกเทียบหลายรอบ เอกสารแนะนำ
CollationKeyแปลงข้อความเป็นชุดบิตครั้งเดียว แล้วเทียบบิตกันเร็วกว่า CollationKeyที่มาจากคนละ Collator ห้ามเอามาเทียบกัน- เอกสาร JDK เตือนว่า
RuleBasedCollatorตัวมาตรฐานอาจแย่งกันหนักเมื่อหลายเธรดใช้ตัวเดียวกัน แนะนำให้แต่ละเธรดมี instance ของตัวเอง ผมใช้ThreadLocalในโค้ดด้านล่าง
โค้ดพร้อม assertions
import java.text.CollationKey;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public class Main {
static final Locale TH = Locale.forLanguageTag("th-TH");
// แต่ละเธรดได้ Collator ของตัวเอง ตามคำแนะนำในเอกสาร JDK
static final ThreadLocal<Collator> THAI =
ThreadLocal.withInitial(() -> Collator.getInstance(TH));
static List<String> sortThai(List<String> names) {
List<String> copy = new ArrayList<>(names);
copy.sort(THAI.get());
return copy;
}
static List<String> sortThaiWithKeys(List<String> names) {
Collator c = THAI.get();
List<CollationKey> keys = new ArrayList<>();
for (String n : names) keys.add(c.getCollationKey(n));
keys.sort(Comparator.naturalOrder());
List<String> out = new ArrayList<>();
for (CollationKey k : keys) out.add(k.getSourceString());
return out;
}
public static void main(String[] args) {
List<String> names = List.of("สมหญิง", "เกศินี", "ไพลิน", "อรุณ", "บุญมี",
"กมล", "สมชาย", "แก้ว", "ขวัญใจ");
List<String> byCode = new ArrayList<>(names);
byCode.sort(Comparator.naturalOrder()); // String.compareTo
assert byCode.get(byCode.size() - 1).equals("ไพลิน");
assert byCode.indexOf("เกศินี") > byCode.indexOf("อรุณ"); // ผิดแบบพจนานุกรม
List<String> expected = List.of("กมล", "เกศินี", "แก้ว", "ขวัญใจ", "บุญมี",
"ไพลิน", "สมชาย", "สมหญิง", "อรุณ");
assert sortThai(names).equals(expected);
assert sortThaiWithKeys(names).equals(expected);
// strength มีผลกับการเทียบว่า "เท่ากันไหม"
Collator tertiary = Collator.getInstance(TH);
assert tertiary.getStrength() == Collator.TERTIARY;
assert tertiary.compare("ก้อน", "กอน") != 0;
Collator primary = Collator.getInstance(TH);
primary.setStrength(Collator.PRIMARY);
assert primary.compare("ก้อน", "กอน") == 0; // วรรณยุกต์ถูกมองข้าม
assert primary.compare("abc", "ABC") == 0;
Collator secondary = Collator.getInstance(TH);
secondary.setStrength(Collator.SECONDARY);
assert secondary.compare("ก้อน", "กอน") != 0; // ยังแยกวรรณยุกต์
assert secondary.compare("abc", "ABC") == 0; // แต่ไม่สนตัวพิมพ์
System.out.println("ok");
}
}
ตอนตรวจบทความ โค้ดนี้คอมไพล์ด้วย javac และรันด้วย java -ea (เปิด assertions) บน JDK 21 ของเครื่องนี้
ข้อสังเกต: กฎการเรียงมาจากข้อมูล locale ของ JDK ผลกับคำที่ซับซ้อนมากๆ อาจต่างจากพจนานุกรมฉบับใดฉบับหนึ่งได้ ถ้างานต้องตรงตามมาตรฐานเฉพาะ ให้เขียนเทสต์ด้วยชื่อจริงในระบบของคุณ
ใช้ตรงไหนในงานจริง
| งาน | วิธี |
|---|---|
| รายชื่อพนักงาน/ลูกค้า | list.sort(Collator.getInstance(th-TH)) |
| ลิสต์ยาวหลายหมื่นชื่อ | แปลงเป็น CollationKey ก่อนเรียง |
| เว็บเซิร์ฟเวอร์หลายเธรด | ThreadLocal<Collator> หรือสร้างใหม่ต่อคำขอ |
| ค้นหาแบบไม่สนวรรณยุกต์ | PRIMARY แต่ต้องแน่ใจว่าผลแบบนี้ต้องการจริง |
| เรียงในฐานข้อมูล | ตั้ง collation ของฐานข้อมูลให้รองรับไทย อย่าดึงทั้งหมดมาเรียงใน Java ถ้าข้อมูลใหญ่ |
Recap
- อย่าเรียงชื่อไทยด้วย
Collections.sortเปล่าๆ เพราะเทียบรหัส Unicode - ใช้
Collator.getInstance(Locale.forLanguageTag("th-TH"))เป็น Comparator - ลิสต์ใหญ่ใช้
CollationKeyและอย่าเทียบ key ข้าม Collator - หลายเธรดให้แต่ละเธรดมี Collator ของตัวเอง
- ระวัง
PRIMARYมองคำที่ต่างแค่วรรณยุกต์ว่าเท่ากัน - รันโค้ดด้านบนด้วย
java -eaเพื่อเปิด assertions