PEP 686: เปิดใช้โหมด UTF-8 เป็นค่าเริ่มต้นใน Python 3.15
(peps.python.org)- Python 3.15 จะเปิด โหมด UTF-8 เป็นค่าเริ่มต้น เพื่อให้การเข้ารหัสเริ่มต้นของไฟล์, standard input/output และ pipe เป็น UTF-8
- UTF-8 ถูกใช้เป็น มาตรฐานโดยพฤตินัยของการเข้ารหัส ใน source file, JSON·TOML·YAML, ตัวแก้ไขข้อความหลัก, ข้อมูลบนเว็บ และภาษาอย่าง Node.js·Go·Rust·Java ทำให้การทำงานร่วมกันดีขึ้น
- การเข้ารหัสเริ่มต้นเดิมแตกต่างกันไปตามแพลตฟอร์ม ดังนั้นหากนักพัฒนาบน Unix ละ
encoding="utf-8"ออกไป ก็อาจเกิด บั๊กจากความไม่สอดคล้องกัน บน Windows และระบบอื่นได้ - หากจำเป็นสามารถปิดได้ด้วย
PYTHONUTF8=0หรือ-X utf8=0และสามารถใช้EncodingWarning,encoding="utf-8",encoding="locale",locale.getencoding()เพื่อตรวจสอบความเข้ากันได้ - โปรแกรมที่พึ่งพาการเข้ารหัสเริ่มต้นอาจเจอ
UnicodeError, mojibake หรือความเสียหายของข้อมูลแบบเงียบ ๆ โดยเฉพาะบน Windows จึงควรตรวจสอบล่วงหน้า
การเข้ารหัสเริ่มต้นที่จะเปลี่ยนใน Python 3.15
- PEP 686 คือการเปลี่ยนแปลงที่เปิดใช้ โหมด UTF-8 จาก PEP 540 เป็นค่าเริ่มต้น
- สำหรับไฟล์, stdio และ pipe ที่ต้องใช้การเข้ารหัสเริ่มต้น Python จะใช้ UTF-8 อย่างสม่ำเสมอ
- จะเปิดใช้เป็นค่าเริ่มต้นตั้งแต่ Python 3.15 เป็นต้นไป และผู้ใช้สามารถปิดได้ด้วยวิธีต่อไปนี้
PYTHONUTF8=0-X utf8=0
เหตุผลที่ใช้ UTF-8 เป็นค่าเริ่มต้น
- UTF-8 ได้กลายเป็นเสมือนมาตรฐานการเข้ารหัสข้อความในหลายสภาพแวดล้อม
- การเข้ารหัสเริ่มต้นของไฟล์ source ของ Python คือ UTF-8
- JSON, TOML, YAML ใช้ UTF-8
- ตัวแก้ไขข้อความส่วนใหญ่ รวมถึง Visual Studio Code และ Windows Notepad ใช้ UTF-8 เป็นค่าเริ่มต้น
- เว็บไซต์และข้อมูลข้อความบนอินเทอร์เน็ตส่วนใหญ่ใช้ UTF-8
- ภาษาโปรแกรมยอดนิยมหลายภาษา เช่น Node.js, Go, Rust, Java ใช้ UTF-8 เป็นค่าเริ่มต้น
- เมื่อการเข้ารหัสเริ่มต้นของ Python เปลี่ยนเป็น UTF-8 จะช่วยให้ การทำงานร่วมกัน กับเครื่องมือ ภาษา และรูปแบบข้อมูลอื่นดีขึ้น
- นักพัฒนา Python บน Unix จำนวนมากมักลืมไปว่าการเข้ารหัสเริ่มต้นขึ้นกับแพลตฟอร์ม และละ
encoding="utf-8"เมื่ออ่านข้อความ UTF-8 เช่น JSON·TOML·Markdown·ไฟล์ source ของ Python - ความแตกต่างของการเข้ารหัสเริ่มต้นในแต่ละแพลตฟอร์มจึงกลายเป็น สาเหตุของบั๊ก ที่ทำให้โค้ดเหล่านี้พังในสภาพแวดล้อมอื่น
การปรับ locale API และ encoding="locale"
- โหมด UTF-8 มีผลต่อ
locale.getpreferredencoding(False)ดังนั้นจึงจำเป็นต้องมี API ที่ใช้ดึงการเข้ารหัส locale โดยไม่ขึ้นกับโหมด UTF-8 locale.getencoding()ถูกเพิ่มเข้ามาเพื่อจุดประสงค์นี้ โดยจะคืนค่าการเข้ารหัส locale และไม่สนใจโหมด UTF-8- API นี้ถูกเพิ่มใน Python 3.11
- หากกำหนดตัวเลือก
warn_default_encodingไว้locale.getpreferredencoding()จะส่ง PEP 597EncodingWarningเช่นเดียวกับopen() - PEP 597 ได้เพิ่มตัวเลือก
encoding="locale"ให้กับTextIOWrapperเพื่อให้สามารถระบุการเข้ารหัส locale อย่างชัดเจนได้ - ก่อนหน้านี้ แม้จะระบุ
encoding="locale"ในโหมด UTF-8 แต่TextIOWrapperก็ยังใช้"UTF-8"- สิ่งนี้ไม่สอดคล้องกับแรงจูงใจของ PEP 597
- เพราะไม่ได้คาดการณ์สถานการณ์ที่โหมด UTF-8 จะกลายเป็นค่าเริ่มต้นเมื่อการเข้ารหัสข้อความเริ่มต้นของ Python เปลี่ยนไป
- ความไม่สอดคล้องนี้ถูกแก้ใน Python 3.11 และเมื่อส่ง
encoding="locale"ในโหมด UTF-8 ก็จะใช้ การเข้ารหัส locale
ความเข้ากันได้ย้อนหลังและขั้นตอนการย้ายระบบ
- ระบบ Unix ส่วนใหญ่ใช้ UTF-8 locale และ Python จะเปิดโหมด UTF-8 เมื่อ locale เป็น C หรือ POSIX ดังนั้นผลกระทบของการเปลี่ยนแปลงนี้จะไปกระจุกอยู่ที่ ผู้ใช้ Windows เป็นหลัก
- โปรแกรม Python ที่พึ่งพาการเข้ารหัสเริ่มต้นอาจพบปัญหาดังต่อไปนี้
UnicodeError- mojibake
- ความเสียหายของข้อมูลแบบเงียบ ๆ
- ขั้นตอนที่แนะนำในการแก้ปัญหาความเข้ากันได้ย้อนหลังมีดังนี้
- ปิดโหมด UTF-8
- ใช้
EncodingWarningจาก PEP 597 เพื่อหาจุดที่โหมด UTF-8 ส่งผลกระทบ- หากละตัวเลือก
encodingไว้ ให้พิจารณาใช้encoding="utf-8"หรือencoding="locale" - หากใช้
locale.getpreferredencoding()ให้พิจารณาใช้"utf-8"หรือlocale.getencoding()
- หากละตัวเลือก
- ทดสอบแอปพลิเคชันในโหมด UTF-8
กรณีนำมาก่อนของ Ruby·Java และทางเลือกที่ถูกปฏิเสธ
- Ruby ได้เปลี่ยน
external_encodingเริ่มต้นบน Windows เป็น UTF-8 ใน Ruby 3.0 เมื่อปี 2020 - Java ได้เปลี่ยนการเข้ารหัสข้อความเริ่มต้นเป็น UTF-8 ใน JDK 18 เมื่อปี 2022
- ทั้ง Ruby และ Java มีตัวเลือกเพื่อความเข้ากันได้ย้อนหลัง แต่ไม่มีคำเตือนการใช้การเข้ารหัสเริ่มต้นแบบ
EncodingWarningของ Python - แนวทางที่จะเลิกใช้การพึ่งพาการเข้ารหัสเริ่มต้นโดยสิ้นเชิงถูกปฏิเสธ
- มีหลายกรณีที่ใช้การเข้ารหัสเริ่มต้นเพื่ออ่านและเขียนเฉพาะข้อความ ASCII
- สำหรับแอปพลิเคชันที่รันบน Unix เท่านั้นและไม่ข้ามแพลตฟอร์ม คำเตือนลักษณะนี้ไม่ได้มีประโยชน์
- หากบังคับให้ระบุ
encodingทุกที่ จะเพิ่มภาระให้ผู้ใช้มาก และDeprecationWarningจำนวนมากอาจทำให้ผู้ใช้เมินคำเตือนไป - PEP 387 กำหนดให้เพิ่มคำเตือนเมื่อมีการเปลี่ยนแปลงที่กระทบความเข้ากันได้ย้อนหลัง แต่ไม่ได้บังคับว่าจะต้องเป็น
DeprecationWarning
- แนวทางที่จะใช้
PYTHONIOENCODINGเป็นการเข้ารหัสเริ่มต้นของ pipe ในโมดูลsubprocessก็ถูกปฏิเสธเช่นกัน- วิธีนี้จะทำให้ยังใช้การเข้ารหัสแบบ legacy กับ
subprocess.Popen(text=True)ได้ แม้อยู่ในโหมด UTF-8 - แต่จะทำให้แนวคิดเรื่อง “การเข้ารหัสเริ่มต้น” ซับซ้อนขึ้น และตัววิธีเองก็เป็นการเปลี่ยนแปลงที่กระทบความเข้ากันได้ย้อนหลัง
- ผู้ใช้สามารถปิดโหมด UTF-8 ไปก่อนจนกว่าจะเปลี่ยน
text=Trueเป็นencoding="utf-8"หรือencoding="locale"
- วิธีนี้จะทำให้ยังใช้การเข้ารหัสแบบ legacy กับ
มุมมองด้านการให้ความรู้ผู้ใช้
- ผู้ใช้ใหม่จะมีความจำเป็นต้องเรียนรู้เรื่องการเข้ารหัสข้อความน้อยลงในช่วงปีแรก
- ค่อยเรียนรู้เรื่องการเข้ารหัสเมื่อจำเป็นต้องจัดการกับไฟล์ข้อความที่ไม่ใช่ UTF-8
- ผู้ใช้เดิมควรตรวจสอบจุดที่ได้รับผลกระทบตามขั้นตอนด้านความเข้ากันได้ย้อนหลัง
1 ความคิดเห็น
ความเห็นจาก Hacker News
การที่การเข้ารหัสไฟล์ข้อความแบบค่าปริยาย เปลี่ยนไปตามแพลตฟอร์ม เป็นเรื่องน่าหงุดหงิดมาโดยตลอด การเปลี่ยนครั้งนี้เลยน่ายินดี
และก็ดีด้วยที่ไม่ได้พยายามไปยุ่งกับการเข้ารหัสของระบบไฟล์ เพราะนั่นเป็นอีกปัญหาหนึ่งและปวดหัวในแบบของมันเอง
การที่ Windows ไม่เปิดทางง่าย ๆ มานานให้เลือกใช้ code page แบบ UTF-8 กับฟังก์ชัน ANSI อย่าง
TextOutAถือเป็นความผิดพลาดครั้งใหญ่ กว่าจะทำได้ผ่านไฟล์ manifest ก็ช่วงกลาง ๆ ของการพัฒนา Windows 10 แล้ว ทั้งที่ฟีเจอร์แบบนี้ควรมีมาตั้งแต่ยุค NT4 หรือ Windows 98มันไม่ได้ขึ้นกับแค่แพลตฟอร์ม แต่ยังขึ้นกับ locale ที่ผู้ใช้ตั้งไว้ด้วย และ C standard library ก็ทำงานแบบเดียวกัน ตัวอย่างเช่นบน Unix/Linux นั้น
iso-8859-1เคยพบได้บ่อยในภาษายุโรปตะวันตก และหลังมีการใช้เงินยูโร ก็มักเปลี่ยนไปเป็นiso-8859-15ที่มีสัญลักษณ์€ด้วย UTF-8 เริ่มใช้งานได้แบบไม่มีปัญหาจริง ๆ ราวปลายทศวรรษ 2000 และ Debian ก็เปลี่ยนค่าปริยายเป็น UTF-8 ในรุ่น Etchทดสอบในเครื่องที่ทำงานทุกอย่างผ่านหมด แต่พอ deploy ไปยังโฮสต์ Linux กลับใช้ไม่ได้ เพราะแอปพลิเคชันย่อยต้องการ CRLF จึงอ่านต่อไม่ได้ เป็นหนึ่งในปัญหาเล็ก ๆ งี่เง่าที่ต้องคอยจำไว้เป็นครั้งคราว แต่ก็เป็นคำถามที่สมเหตุสมผลเหมือนกันว่าทำไมซอฟต์แวร์ที่เขียนขึ้นใหม่ยังต้องบังคับใช้ตัวจบบรรทัดแบบใดแบบหนึ่ง
การไม่พึ่งค่าปริยายของระบบที่ไว้ใจไม่ได้เป็นเรื่องที่ดี
ค่าพวกนี้มักกลับมาเป็นอย่างอื่นจากที่เราคิดไว้ในสักช่วงหนึ่ง เมื่อหลายปีก่อนตอนจัดการ Ubuntu กับสคริปต์
init.dมีสคริปต์ที่รัน Java ในฐานะ root และยิ่งเป็นก่อนยุค Docker ก็ยิ่งเจอบ่อย มันถูกรันจากเชลล์ที่ไม่ได้ตั้งค่า UTF-8 ปกติให้ผู้ใช้ทั่วไป ผลก็คือไปเผยให้เห็นการใช้ API แย่ ๆ ของ Java ที่อาศัยค่าปริยายของ OSทุกวันนี้ API ส่วนใหญ่มีรุ่นที่ระบุ encoding ได้ชัดเจน และเครื่องมือ static analysis ก็จะเตือนถ้าใช้ตัวที่ผิด แต่ถ้าพลาดไปแค่จุดเดียว เนื้อหาก็เริ่มเสียหายได้แล้ว ตอนนี้การใช้ encoding ที่ไม่ใช่ UTF-8 ส่วนมากมีโอกาสสูงมากว่าจะไม่ได้ตั้งใจ และถ้าตั้งใจจริงก็ไม่ควรไปพึ่งการตั้งค่าทางอ้อมประหลาด ๆ ของ OS แต่ควรระบุให้ชัดเจน ดังนั้นนี่จึงเป็น การเปลี่ยนแปลงที่ดี และถ้ามีโค้ดพังเพราะเรื่องนี้ การแก้แบบตรงไปตรงมาก็ดีกว่า
.gitignoreที่สร้างจากฟังก์ชันtouchซึ่งทำเป็น alias ใน PowerShell แต่ทำอย่างไรก็ Git ไม่ยอมสนใจพอตรวจดูถึงพบว่าไฟล์ข้อความที่สร้างขึ้นเป็น UTF-16 เลยแทบจะถูกเมินไปโดยสิ้นเชิง จากนั้นก็ได้บทเรียนและเปลี่ยนค่าปริยายของระบบเป็น UTF-8 แต่ตอนนี้พึ่ง text editor ไปเลยง่ายกว่า
ถ้า
printf("%f", 4.2)แสดงผลเป็นสตริงคนละแบบแบบมีมนตร์ตาม environment มันก็สร้างปัญหามากกว่าช่วยแก้ปัญหา ถ้าต้องการพฤติกรรมที่อิง locale ก็ควรส่งข้อมูล locale หรือส่วนที่เกี่ยวข้องเข้าไปในฟังก์ชันอย่างชัดเจนมี heuristic อย่างหนึ่งที่ยิ่งนานยิ่งแม่นขึ้นในช่วงหลายสิบปีที่ผ่านมา: ถ้ามีการตั้งค่า
charsetอยู่ที่ไหนสักแห่ง แล้ว ไม่ใช่ UTF-8 ก็แปลว่าผิดPython 2 ไม่ผูกกับชุดอักขระจึงดูเหมือนทำงานได้เสมอ แต่การปรับปรุงใน Python 3 ก็ไม่ได้เป็นแค่การปรับปรุงล้วน ๆ วิธีแยกสคริปต์ Python 3 ออกจาก Python 2 คือ ถ้ามีสตริง
utf-8อยู่ มันคือ Python 3 และถ้ารันได้เฉพาะใน localeC.UTF-8มันก็คือ Python 3 การเปลี่ยนครั้งนี้จึงน่ายินดีเพราะเหมือนเป็นการ “ซ่อม” Python 3นึกว่าตั้งแต่ Python 3 เป็นต้นมามันเป็นค่าปริยายอยู่แล้ว
u""แล้วเมื่อกี้ลองพิมพ์
"éķů"ใน Python 2.7 ก็ได้ไบต์ UTF-8 ของตัวอักษรนั้นออกมา เลยไม่แน่ใจนักว่าคำนำหน้าuทำอะไรแน่ แต่หนึ่งในการเปลี่ยนแปลงใหญ่ตอนย้ายจาก Python 2 ไป Python 3 คือสตริงมี encoding ส่วน byte string กลายเป็นลำดับไบต์ที่ไม่มี encoding การเปลี่ยนครั้งนี้ดูจะเกี่ยวหลัก ๆ กับปัญหาที่ในสภาพแวดล้อมอย่าง Windows ซึ่ง encoding ค่าปริยายไม่ใช่ UTF-8 นั้น เวลาจะใช้open('filename', mode='r')ต้องระบุopen('filename', mode='r', encoding='UTF-8')เองอย่าง
Path("filenames use their own encoding").write_text("file content encoding uses yet another encoding")นั้น encoding ของ string literal, ชื่อไฟล์ และเนื้อหาไฟล์เป็นคนละส่วนกันทั้งหมด โดย encoding ที่สอดคล้องกันคือ UTF-8 ของtokenize.open,sys.getfilesystemencoding()ของos.fsencodeและlocale.getpreferredencoding()ของopenที่บอกว่า “ภาษาโปรแกรมยอดนิยมอื่น ๆ รวมถึง Node.js, Go, Rust, Java ก็ใช้ UTF-8 เป็นค่าปริยายเช่นกัน” นี่ทำเหมือนกับว่าผมพลาดตอนที่ Java ย้ายจาก UTF-16 ไป UTF-8
ภายในคลาส
Stringยังใช้การเข้ารหัสแบบ UTF-16 และ latin-1 อยู่ และ JVM ก็ยังใช้การเข้ารหัสแบบ modified UTF-8 เหมือนเดิม เดิมทีคลาสStringใช้แต่ UTF-16 อย่างเดียว แต่ตั้งแต่ Java 9 เป็นต้นมา ถ้าทำได้ก็จะใช้ latin-1 แบบ 1 ไบต์ต่ออักขระด้วยJava ไม่เคยใช้ UTF-16 เป็นค่าปริยายสำหรับ encoding ตอนอ่าน/เขียน
การเข้ารหัสภายในของ CPython ตอนนี้เป็น UTF-8 แล้วหรือ?
สตริงของ Python สามารถเข้าถึงด้วยดัชนีได้ แต่การเข้าถึงแบบสุ่มเกิดขึ้นไม่บ่อยพอสมควร จึงน่าจะพอรับได้หากทำดัชนีแบบหน่วงเวลาเมื่อจำเป็น แค่เลื่อนไปข้างหน้าหรือถอยหลังทีละตำแหน่งก็ไม่ต้องใช้ดัชนี ดังนั้นการใช้ UTF-8 เป็นรูปแบบภายในก็เป็นไปได้เพียงพอ
strคืออ็อบเจ็กต์PyUnicodeเมื่อมีการขอไบต์ UTF-8 ระบบจะสร้างอ็อบเจ็กต์
bytesขึ้นเมื่อจำเป็น แล้วแคชไว้เป็นส่วนหนึ่งของPyUnicodeและจะถูกปล่อยไปพร้อมกันเมื่อPyUnicodeถูกคืนหน่วยความจำ ส่วน code point ที่ประกอบเป็นสตริงจะถูกเก็บแยกไว้ในอาร์เรย์ธรรมดาเพื่อให้เข้าถึงแบบสุ่มได้ แต่ละ code point อาจใช้ขนาด 1, 2 หรือ 4 ไบต์ และตอนสร้างPyUnicodeถ้าระบุค่า code point สูงสุดไว้ ระบบจะปัดขึ้นเป็นหนึ่งใน 127, 255, 65535, 1,114,111 เพื่อกำหนดว่าจะใช้ 1/2/4 ไบต์ถ้าค่า code point สูงสุดเป็น 127 ก็สามารถใช้อาร์เรย์นั้นเป็น UTF-8 ได้โดยตรง ดังนั้นคำตอบของคำถามนี้คือ สตริงจำนวนมากที่มีทุก code point ไม่เกิน 127 จะถูกเก็บเป็น UTF-8 อย่างไรก็ตาม เวลาวนผ่านสตริงไม่ควรทำในระดับ code point เพราะอักขระที่ผู้ใช้รับรู้ หรือ grapheme cluster อาจประกอบด้วย code point หนึ่งตัวหรือมากกว่า ตัวอย่างเช่น e ที่มีเครื่องหมายกำกับเสียงอาจเป็น code point
eตามด้วย code point ของเครื่องหมายกำกับเสียงแบบผสม และอีโมจินกฟีนิกซ์ประกอบด้วยอีโมจินก ตัวเชื่อมแบบไม่มีความกว้าง และอีโมจิไฟ ระบบอักษรบางแบบที่มีผู้ใช้นับร้อยล้านคนก็คล้ายกัน คือมีเครื่องหมายผสมเพื่อบอกสระติดกับพยัญชนะ ข้อความ- -นี้มี 5 code point และมีบทความที่ดีว่าภาษาแต่ละภาษารายงาน “ความยาว” ของมันอย่างไร: https://hsivonen.fi/string-length/. เนื้อหาส่วนนี้มาจากประสบการณ์ที่เพิ่งลองทำ Unicode TR29 เป็น Python C extensionสงสัยว่าทำไมไม่ใช้
utf-8-sigมันรองรับ BOM แบบเลือกได้ด้วย และเมื่อสัปดาห์ก่อนก็ยังต้องแก้สคริปต์เพราะเรื่องนี้อยู่เลยไม่ใช่แนวทางที่แนะนำ และทุกวันนี้ก็ถือว่าสมเหตุสมผลด้วยซ้ำหากการทำงานบางอย่างล้มเหลวเพราะ BOM
ถ้าพูดถึง UTF-8, Linux framebuffer ก็ควรมีการรองรับ UTF-8 ที่ถูกต้องมาตั้งนานแล้ว
ไม่ใช่แบบ 256/512 glyph แต่เป็นการรองรับจริง GNU Hurd เองยังมีเทอร์มินัลคอนโซลที่ดีกว่าซึ่งรองรับ UTF-8 มาตั้งแต่ราวปี 2007 แล้ว แต่นี่ปี 2024 แล้วนะ
ดีเลย ตอนนี้ก็เหลือแค่ JS เปลี่ยนมาใช้ UTF-8 เท่านั้น
แน่นอนว่า JS ปรับปรุงไม่ได้ เพราะต่างจากภาษาโปรแกรมอื่น ๆ มันต้องเข้ากันได้กับโค้ดที่เขียนไว้ตั้งแต่ปี 1995
รูปแบบภายในของสตริงเป็นอีกเรื่องหนึ่ง และเหมือนกับ JavaScript ที่ Python ก็ไม่ได้ใช้ “UTF-8 ล้วน ๆ” ภายใน
จากประโยคที่ว่า “นักพัฒนา Python จำนวนมากที่ใช้ Unix ลืมไปว่าการเข้ารหัสปริยายขึ้นอยู่กับแพลตฟอร์ม จึงละ
encoding=\"utf-8\"ออกเวลาอ่านไฟล์ข้อความที่เข้ารหัสเป็น UTF-8” อาจไม่ใช่ว่าลืม แต่อาจเป็นเพราะเรื่องนี้ไม่เป็นที่รู้กันมากพอพูดตามตรง ฉันคิดมาตลอดว่า Python ใช้ UTF-8 ทุกที่อยู่แล้ว เว้นแต่จะขออย่างชัดเจนให้เป็นอย่างอื่น
bytes.decodeและstr.encodeใช้ UTF-8 เป็นค่าปริยายมาตั้งแต่ Python 3 เป็นอย่างน้อย ในทางกลับกัน encoding ปริยายที่ใช้ถอดรหัสชื่อไฟล์จะใช้sys.getfilesystemencoding()ซึ่งบน Windows และ macOS ก็เป็น UTF-8 เช่นกัน แต่บน Linux จะขึ้นอยู่กับ locale โดยเฉพาะCODESETสุดท้ายopenจะใช้locale.getencoding()โดยตรง