ภาษาไทยทำให้โค้ดพังคนละชั้นกันสี่ชั้น
โค้ดที่จัดการข้อความมักถูกเขียนและทดสอบด้วยภาษาอังกฤษ พอมันทำงานถูก คนเขียนก็เชื่อว่า มันจัดการ “ข้อความ” ได้ ไม่ใช่แค่ “ข้อความภาษาอังกฤษ”
หน้านี้ไปวัดว่าความเชื่อนั้นพังตรงไหนบ้าง คำตอบคือมันพัง คนละชั้นกันสี่ชั้น และ แต่ละชั้นพังด้วยกลไกคนละอย่าง วิธีแก้ของชั้นหนึ่งจึงช่วยอีกชั้นไม่ได้เลย
ตัวอย่างทุกตัวมาจากรีโปของเว็บนี้เอง ไม่ใช่ตัวอย่างที่แต่งขึ้น และสามในสี่ชั้น เป็นความผิดพลาดที่เกิดขึ้นจริงแล้วที่นี่
สี่ชั้นนั้นคืออะไร
หัวข้อที่มีชื่อว่า “สี่ชั้นนั้นคืออะไร”ไล่จากล่างขึ้นบน แต่ละชั้นเป็นหน่วยคนละหน่วยของสิ่งที่เราเรียกรวม ๆ ว่า “ตัวอักษร”
| ชั้น | หน่วย | สิ่งที่พัง |
|---|---|---|
| 1 | byte | ลบอักขระด้วยการลบ byte แล้วอักษรไทยข้างเคียงเสียไปด้วย |
| 2 | code point | \b ของ regex มองไม่เห็นขอบเขตคำไทย ด่านตรวจจึงเงียบ |
| 3 | คำ | ไม่มีช่องว่างคั่นคำ ตัว index จึงตัดคำผิดและค้นไม่เจอ |
| 4 | culture | th-TH ผูกกับปฏิทินพุทธ ปีเดียวกันจึงต่างกัน 543 |
หน่วยของหลักฐานในหน้านี้คือจำนวนนับกับค่าจริง-เท็จ ไม่มีวินาที ตัวเลขทุกตัวมาจาก
scripts/thai-breaks-code/probe.mjs ที่ commit ไว้แล้ว รันซ้ำได้ด้วย npm run verify:thai-text
และ CI รันให้ทุกครั้งที่ push
ชั้นที่ 1 · byte — ลบอักขระตัวหนึ่ง แล้วอีกสามตัวหายไปด้วย
หัวข้อที่มีชื่อว่า “ชั้นที่ 1 · byte — ลบอักขระตัวหนึ่ง แล้วอีกสามตัวหายไปด้วย”
U+200Bเขียนเป็น UTF-8 ได้E2 80 8Bและอักษรไทยบางตัวใช้ byte ชุดเดียวกันนี้เป็นส่วนประกอบ
เว็บนี้แทรกอักขระ zero-width space (U+200B) ลงระหว่างคำไทยทุกคำตอน build
เพื่อให้ตัว index ค้นหาเห็นขอบเขตคำ อักขระตัวนี้ไม่มีความกว้าง ผู้อ่านจึงไม่เห็นมัน
แต่สคริปต์ที่ไปอ่านหน้าเว็บจะเจอ และต้องลบทิ้งก่อนเทียบข้อความ
คำถามคือลบอย่างไร ถ้าเลือกเครื่องมือที่ทำงาน ระดับ byte อย่าง tr -d ผลจะเป็นแบบนี้
| อักษร | code point | UTF-8 |
|---|---|---|
ซ | U+0E0B | E0 B8 8B |
เ | U+0E40 | E0 B9 80 |
๋ | U+0E4B | E0 B9 8B |
สาม byte ของ U+200B คือ E2 80 8B และอักษรสามตัวข้างบนต่างก็มี 80 หรือ 8B
เป็น byte สุดท้ายของตัวเอง เครื่องมือที่สั่งว่า “ลบ byte เหล่านี้ทิ้ง” จึงลบมันออกจาก
อักษรไทยพวกนั้นด้วย แล้ว byte ที่เหลือก็ประกอบกลับเป็นอักษรไม่ได้อีก
ก่อนลบ เราเลือกซ่อนความซับซ้อนไว้เบื้องหลังลบถูกวิธี เราเลือกซ่อนความซับซ้อนไว้เบื้องหลังลบทีละ byte �รา�ลือก�่อนความ�ับ�้อนไว้�บื้องหลังประโยคเดียวเสียไป 6 ตัวอักษร และตัวที่โดนคือ เ
กับ ซ ซึ่งเป็นสองตัวที่พบบ่อยที่สุดในภาษาไทย ผลจึงไม่ใช่
“เสียหายเล็กน้อยตรงมุมหนึ่ง” แต่คือ ข้อความเสียกระจายทั้งไฟล์
สคริปต์ไม่ error ไฟล์ยังเปิดได้ และถ้าคนตรวจอ่านผ่าน ๆ ก็ยังพอเดาความออก ความเสียหายจึงเดินทางต่อไปได้ไกลมากก่อนจะมีคนสังเกต
วิธีที่ถูกคือใช้เครื่องมือที่รู้จักการเข้ารหัส แล้วลบ ตัวอักษร ไม่ใช่ลบ byte
# ผิด — ลบทีละ bytetr -d '\342\200\213' < page.html
# ถูก — บอกให้ perl อ่านและเขียนเป็น UTF-8 แล้วลบทีละ code pointperl -CSD -pe 's/\x{200B}//g' < page.htmlชั้นที่ 2 · code point — ด่านตรวจที่ไม่เคยทำงานเลยสักครั้ง
หัวข้อที่มีชื่อว่า “ชั้นที่ 2 · code point — ด่านตรวจที่ไม่เคยทำงานเลยสักครั้ง”
\bของ JavaScript นิยามขอบเขตคำบน[A-Za-z0-9_]เท่านั้น หลังอักษรไทยจึงไม่มีขอบเขตให้เจอ
คอร์สที่ 28 ของเว็บนี้มีด่านตรวจข้อหนึ่งที่ห้ามบทเรียนอ้างความเร็วเป็นวินาที เพราะตัวเลขเวลาเปลี่ยนตามเครื่อง ด่านนั้นเขียนไว้แบบนี้
/\d+\s*(ms|มิลลิวินาที|วินาที|นาที)\b/อ่านแล้วดูครบ มีทั้งหน่วยอังกฤษและหน่วยไทยสามแบบ แต่พอเอาไปรันจริงกับสี่ประโยค
| ประโยค | regex เดิมจับได้ | หลังแก้จับได้ |
|---|---|---|
ใช้เวลา 12 ms | จับได้ | จับได้ |
ใช้เวลา 12 วินาที | ไม่จับ | จับได้ |
ใช้เวลา 12 นาที | ไม่จับ | จับได้ |
ใช้เวลา 12 มิลลิวินาที | ไม่จับ | จับได้ |
regex เดิมจับได้ 1 จาก 4 ประโยค และประโยคเดียว ที่มันจับได้คือประโยคที่ลงท้ายด้วยหน่วยภาษาอังกฤษ ตัวเลือกภาษาไทยทั้งสามตัวไม่มีวันแมตช์
สาเหตุอยู่ที่นิยามของ \b มันคือรอยต่อระหว่างอักขระที่อยู่ในชุด [A-Za-z0-9_]
กับอักขระที่ไม่อยู่ในชุดนั้น อักษรไทยไม่อยู่ในชุด ท้ายคำว่า วินาที จึงไม่ใช่รอยต่อ
และเมื่อไม่มีรอยต่อ ก็ไม่มีอะไรให้ \b แมตช์
/\d+\s*(ms|มิลลิวินาที|วินาที|นาที)(?![\w])/เปลี่ยนเป็น (?![\w]) แล้วจับได้ 4 จาก 4 ประโยค
เงื่อนไขกลับด้านจาก “ต้องมีรอยต่อ” เป็น “ห้ามตามด้วยอักขระคำ” ซึ่งเป็นจริงเมื่อจบสตริงด้วย
ด่านที่ไม่มีอยู่ ทุกคนรู้ว่าไม่มี ส่วนด่านนี้ขึ้นเขียวทุกครั้ง คนเขียนบทเรียนจึงเชื่อว่า มีคนเฝ้าให้อยู่ ประโยคอย่าง “ใช้เวลา 12 วินาที” ผ่านฉลุยมาตลอด
กฎนี้ใช้ได้กับทุก regex ที่จับคำไทย ไม่ใช่แค่ตัวนี้ และไม่ใช่แค่ JavaScript
ทุกภาษาที่นิยาม \b บนชุดอักขระแบบ ASCII มีปัญหาเดียวกัน
หน่วยของ “ความยาว” ก็คนละเรื่องกัน
หัวข้อที่มีชื่อว่า “หน่วยของ “ความยาว” ก็คนละเรื่องกัน”ชั้นนี้ยังมีอีกด้านที่เงียบกว่า คือการนับความยาว สามวิธีที่ดูสมเหตุสมผลพอกัน ให้คำตอบคนละค่าบนคำเดียวกัน
| คำ | .length | code point | สิ่งที่ผู้อ่านเห็น |
|---|---|---|---|
เพื่อน | 6 | 6 | 4 |
กิน | 3 | 3 | 2 |
น้ำ | 3 | 3 | 1 |
เกี่ยวข้อง | 10 | 10 | 7 |
คำว่า น้ำ ชัดที่สุด .length ตอบ 3
ส่วนคนอ่านเห็น 1 ตัว เพราะสระกับวรรณยุกต์เป็น code point แยก
ที่ไปเกาะบนพยัญชนะ ไม่ได้กินที่ของตัวเอง
ผลตกกับทุกที่ที่นับความยาว ทั้ง maxLength ของฟอร์ม การตัดข้อความด้วย slice
และกฎ “ย่อหน้าห้ามยาวเกินกี่ตัวอักษร” ที่เว็บนี้ใช้เอง ถ้าอยากได้จำนวนที่ตรงกับสายตา
ต้องใช้ Intl.Segmenter ที่ granularity เป็น grapheme
ชั้นที่ 3 · คำ — ไม่มีช่องว่าง ตัว index จึงต้องเดา
หัวข้อที่มีชื่อว่า “ชั้นที่ 3 · คำ — ไม่มีช่องว่าง ตัว index จึงต้องเดา”ภาษาอังกฤษบอกขอบเขตคำด้วยช่องว่าง ภาษาไทยไม่บอก ใครก็ตามที่ต้องการ “คำ” จึงต้องเดาเอง
เว็บนี้ใช้ Pagefind เป็นตัวค้นหา และมี build step ที่แทรก U+200B ระหว่างคำไทยทุกคำ
ก่อนส่งให้ Pagefind ทำ index คอมเมนต์ในโค้ดเขียนเหตุผลไว้ว่า ถ้าไม่ทำ การค้นภาษาไทย
จะพัง — ประโยคนี้อยู่ในรีโปมาตั้งแต่เดือนมิถุนายน และไม่เคยมีใครวัดว่าจริงไหม
ครึ่งแรก — วัดว่าการแทรกช่วยจริงหรือเปล่า
หัวข้อที่มีชื่อว่า “ครึ่งแรก — วัดว่าการแทรกช่วยจริงหรือเปล่า”วิธีวัดคือทำ index สองชุดจากหน้าเดียวกัน ชุดหนึ่งแทรก U+200B อีกชุดไม่แทรก แล้วยิง
คำเดียวกัน 200 คำใส่ทั้งคู่ ความจริงที่ใช้เทียบคือ หน้าไหนมีคำนั้นอยู่จริง
ในข้อความที่ผู้อ่านเห็น คลังที่ใช้มี 60 หน้า และถูก commit ไว้พร้อม hash
93d7258c0b635f12 ผลจึงไม่เลื่อนตามเนื้อหาที่เพิ่มเข้ามาทีหลัง
| index | recall | จำนวนคำที่เข้า index |
|---|---|---|
แทรก U+200B (ของจริงที่ deploy อยู่) | 91.1 % | 3363 |
| ไม่แทรกเลย | 84.4 % | 4043 |
การแทรกช่วยจริง แต่ช่วยประมาณเจ็ดจุด ไม่ใช่ช่วยจากศูนย์ คอมเมนต์ในโค้ดจึงถูกในทิศ และเกินจริงในระดับ Pagefind รุ่นที่เว็บนี้ใช้ตัดคำไทยเองได้ระดับหนึ่งอยู่แล้ว และ มันเป็นรุ่นเดียวกันนี้มาตั้งแต่วันที่เขียน build step นั้น สิ่งที่ขาดไปคือไม่มีใคร วัดกรณีตรงข้าม เทสต์ที่มีอยู่ยิงคำไทยแล้วดูว่าเจอผลลัพธ์ไหม ซึ่งเป็นจริงทั้งสองทาง
ที่สำคัญกว่าคือ ไม่มีฝั่งไหนชนะทุกคำ มีคำที่การตัดคำทำให้ค้นเจอ น้อยลง
| คำ | แทรก U+200B | ไม่แทรก |
|---|---|---|
ฐาน | 15 | 19 |
นิยาม | 15 | 16 |
เครื่องมือ | 11 | 12 |
เหตุผลคือตัวตัดคำเลือกขอบเขตแบบหนึ่ง ผู้อ่านพิมพ์อีกแบบหนึ่ง พอสองแบบไม่ตรงกัน คำนั้นก็หายไปจากผลลัพธ์ การตัดคำไม่ได้แก้ปัญหา มันแค่ย้ายที่ผิด
ครึ่งหลัง — ผมวัดผิดชั้น และมันคือบทเรียนของบทความนี้เอง
หัวข้อที่มีชื่อว่า “ครึ่งหลัง — ผมวัดผิดชั้น และมันคือบทเรียนของบทความนี้เอง”ครึ่งนี้เคยเขียนไว้ว่า ช่องค้นหาของเว็บนี้คืนศูนย์ผลลัพธ์ให้คำว่า ไม่ได้
ทั้งที่คำนั้นอยู่ในหน้าส่วนใหญ่ของไซต์ · ข้ออ้างนั้นผิด และถูกแก้เมื่อ 2026-08-19
ที่มาของความผิดพลาดคือวิธีวัด ผมเรียก pagefind.search() จาก Node ตรง ๆ แล้วได้ศูนย์จริง
แต่ ผู้อ่านไม่ได้เรียกฟังก์ชันนั้น เขาพิมพ์ลงช่องค้นหา พอไปวัดในเบราว์เซอร์จริง
คำว่า ไม่ได้ คืน 475 ผลลัพธ์ ตัวเลขศูนย์เป็นของ API ที่ถูกเรียกเย็น ๆ ครั้งเดียว
ยิงคำอื่นก่อนหนึ่งครั้งแล้วยิงซ้ำ ก็ได้ผลลัพธ์ตามปกติ
เครื่องมือวัดทำงานถูกต้องทุกบรรทัด ตัวเลขที่ได้ก็ทำซ้ำได้ สิ่งที่ผิดคือ มันไม่ใช่ชั้น ที่ผู้อ่านอยู่ · ข้อผิดพลาดชนิดนี้ไม่มีทางโผล่มาเป็นเลขแปลก ๆ เพราะทุกอย่างดูสมเหตุสมผล จนกว่าจะมีคนไปยืนที่ชั้นของผู้ใช้แล้ววัดใหม่
สิ่งที่ยังจริงหลังวัดใหม่ คำว่า ค้นหา มีอยู่ 64 หน้า แต่ช่องค้นหาคืน 7 หน้า
· นั่นคือการค้นไม่เจอจริง ๆ และมันเป็นคนละอาการกับที่เคยรายงานไว้ ส่วนคำอื่นที่วัดคู่กัน
ทำงานได้ดี — ธุรกิจ 197 หน้าคืน 189 และ ขอบเขต 242 หน้าคืน 231
ตอนนี้มีด่านคุมของจริงแล้วที่ e2e/search-thai-compound.spec.ts ซึ่งขับเบราว์เซอร์
แล้วพิมพ์ลงช่องค้นหาเหมือนผู้อ่าน ไม่ใช่เรียก API · ด่านเดิมที่มีอยู่ตรวจแค่คำเดี่ยว
มันจึงเขียวทั้งตอนที่ข้ออ้างนี้ถูกและตอนที่มันผิด
ชั้นที่ 4 · culture — ปีเดียวกัน ต่างกัน 543
หัวข้อที่มีชื่อว่า “ชั้นที่ 4 · culture — ปีเดียวกัน ต่างกัน ”
th-THผูกกับปฏิทินพุทธในข้อมูล CLDR ทุกรันไทม์ที่ใช้ ICU จึงได้ผลเหมือนกันหมด
สามชั้นแรกเป็นเรื่องของข้อความ ชั้นนี้ไม่ใช่ มันคือเรื่องที่ตัวเลขชุดเดียวกัน ถูกแสดงออกมาคนละค่าเพราะ locale ของผู้ใช้
var day = new DateTime(2026, 8, 14);day.ToString("d", new CultureInfo("th-TH")); // "14/8/2569"day.Year; // 2026| รันไทม์ | culture | ผลลัพธ์ | ปฏิทินที่ถูกเลือก |
|---|---|---|---|
| .NET | en-US | 8/14/2026 | GregorianCalendar |
| .NET | th-TH | 14/8/2569 | ThaiBuddhistCalendar |
| Node | en-US | 8/14/26 | gregory |
| Node | th-TH | 14/8/69 | buddhist |
ทั้งสองรันไทม์เลือกปฏิทินพุทธให้เองโดยไม่มีใครสั่ง เพราะทั้งคู่อ่านจากฐานข้อมูล CLDR ชุดเดียวกัน นี่จึงไม่ใช่นิสัยของ .NET และไม่ใช่บั๊ก มันคือค่าตั้งต้นที่ถูกต้องตามที่ คนไทยเขียนวันที่จริง ๆ
สิ่งที่ทำให้มันอันตรายคือ ค่าที่โปรแกรมถืออยู่ไม่เปลี่ยน day.Year ยังเป็น
2026 อยู่เหมือนเดิม สิ่งที่เปลี่ยนคือตอนแปลงเป็นข้อความ ปัญหาจึงโผล่
ตอนข้อความนั้นเดินทางกลับเข้าระบบ เช่น เขียนลงไฟล์ ส่งขึ้น API หรือ parse กลับเป็นวันที่
ผูก culture ให้ชัดเจนทุกครั้งที่แปลงระหว่างข้อความกับค่า ใช้ค่าคงที่ของระบบ
(InvariantCulture ใน .NET) สำหรับข้อมูลที่เครื่องอ่าน และใช้ culture ของผู้ใช้
เฉพาะตอนแสดงผลให้คนอ่านเท่านั้น
หนึ่งจุดที่พลาดกันบ่อยเป็นพิเศษคือ InvariantGlobalization ถ้าตั้งเป็น true
(ค่าตั้งต้นของ container image หลายตัว) th-TH จะกลายเป็นปฏิทินเกรกอเรียนเงียบ ๆ
โค้ดชุดเดียวกันจึงให้ปีต่างกัน 543 ระหว่างเครื่องนักพัฒนากับ production
เส้นที่ร้อยทั้งสี่ชั้นเข้าด้วยกัน
หัวข้อที่มีชื่อว่า “เส้นที่ร้อยทั้งสี่ชั้นเข้าด้วยกัน”ทุกชั้นมีคนแก้ไว้แล้ว และทุกชั้นยังพังอยู่ดี
เรื่องที่ซ้ำกันทั้งสี่ชั้นไม่ใช่ “ภาษาไทยยาก” แต่เป็นสามข้อนี้
หนึ่ง — ความพังไม่เคยส่งเสียง ไม่มีชั้นไหน throw exception ไม่มีชั้นไหนทำให้เทสต์แดง
tr -d คืน exit code 0 regex ที่ไม่แมตช์คือผลลัพธ์ปกติของ regex ช่องค้นหาที่คืนศูนย์
ผลลัพธ์ก็หน้าตาเหมือนคำที่ไม่มีในเว็บจริง ๆ
สอง — วิธีแก้อยู่คนละชั้นกับที่มันพัง build step ที่แทรก U+200B แก้ฝั่ง index
แต่คำที่ค้นไม่เจอพังที่ฝั่ง query การเพิ่มหน่วยภาษาไทยเข้าไปใน regex ไม่ช่วย
ถ้า \b ยังอยู่ การรู้ว่าตัวเองอยู่ชั้นไหนสำคัญกว่าการรู้วิธีแก้
สาม — ด่านที่ตรวจครึ่งเดียวคือด่านที่เขียวตลอด เทสต์ค้นหาของเว็บนี้ยิงคำไทยแล้ว ยืนยันว่าเจอผลลัพธ์ ซึ่งเป็นจริงทั้งตอนที่ build step ทำงานและตอนที่ไม่ทำงาน มันจึงยืนยันได้แค่ว่า “ค้นหาไม่ตาย” ไม่ได้ยืนยันสิ่งที่มันถูกเขียนขึ้นมาเพื่อยืนยัน
ข้อสามคือข้อที่แพงที่สุด และมันไม่ใช่เรื่องของภาษาไทยเลย การวัดกรณีตรงข้ามคือสิ่งที่ แยกด่านออกจากพิธีกรรม ถ้าเทสต์ผ่านทั้งตอนที่ของทำงานและตอนที่ของไม่ทำงาน มันไม่ได้ตรวจอะไรอยู่
เอาไปใช้ได้ทันที
หัวข้อที่มีชื่อว่า “เอาไปใช้ได้ทันที”- ลบอักขระ อย่าลบ byte ใช้เครื่องมือที่รู้จักการเข้ารหัส
perl -CSDไม่ใช่tr -d - อย่าใช้
\bกับคำไทย ใช้(?![\w])หรือ(?<![\w])แทน แล้วเทสต์ด้วยประโยคไทยจริง .lengthไม่ใช่จำนวนตัวอักษรที่คนเห็น ใช้Intl.Segmenterเมื่อความยาวมีความหมายกับผู้ใช้- ผูก culture ให้ชัดทุกครั้งที่แปลงข้อความเป็นค่า และเช็ก
InvariantGlobalizationใน container - ตรวจกรณีตรงข้ามเสมอ ปิดสิ่งที่คุณเพิ่งเพิ่ม แล้วดูว่าเทสต์แดงไหม ถ้าไม่แดง มันไม่ได้ตรวจสิ่งนั้น
ข้อสุดท้ายเป็นข้อเดียวที่ไม่เกี่ยวกับภาษาไทย และเป็นข้อที่ทำให้อีกสี่ข้อข้างบน ถูกค้นพบตั้งแต่แรก