นากการ์ตูนสีมิ้นต์จัดเรียงแท่งไม้ป้ายชื่อบนชั้นวางให้เป็นระเบียบ กำลังหยิบแท่งสีอำพันที่วางผิดที่ย้ายไปไว้ตำแหน่งที่ถูกต้อง

Java เรียงชื่อภาษาไทยให้ตรงพจนานุกรม: ทำไม Collections.sort ส่ง "เกศินี" ไปท้ายลิสต์ และ Collator แก้ยังไง

น้องกายEngineering

ทำหน้ารายชื่อพนักงาน แล้วกดเรียงตามชื่อ ผลออกมาแบบนี้ครับ: กมล, ขวัญใจ, บุญมี, สมชาย, สมหญิง, อรุณ, เกศินี, แก้ว, ไพลิน ชื่อที่ขึ้นต้นด้วย เ แ ไ ไปกองอยู่ท้ายสุด ทั้งที่ในพจนานุกรม “เกศินี” ต้องอยู่หมวด ก ใกล้ๆ “กมล”

นี่ไม่ใช่บั๊กของ 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