บทความ › การเขียนโปรแกรม

Python 3.15: lazy import, frozendict และ UTF-8 ค่าเริ่มต้น แก้ไฟล์ไทยบน Windows

Python 3.15 ออกแล้ว lazy import ทำให้ CLI เปิดเร็วขึ้นหลายเท่า frozendict ใช้เป็น key ได้ และ open() อ่านเขียน UTF-8 เสมอ ไฟล์ภาษาไทยบน Windows ไม่เพี้ยนอีก

กลาง อ่าน 12 นาที 23 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • lazy import json โหลดโมดูลตอนใช้ชื่อครั้งแรก สคริปต์ที่ import ของหนักแต่ไม่ได้ใช้ทุกครั้ง เปิดเร็วขึ้นชัดเจน
  • error ของ lazy import ย้ายไปเกิดตอนใช้ชื่อ ไม่ใช่บรรทัด import ต้องเทสต์ทางที่ใช้โมดูลนั้นจริงด้วย
  • frozendict เป็นชนิดใหม่ใน builtins แก้ไม่ได้และ hash ได้ ใช้เป็น key ของ dict หรือ cache ได้ตรง ๆ
  • 3.15 ใช้ UTF-8 เป็น encoding เริ่มต้นทุกระบบ โค้ดที่เคยพึ่ง cp874 ของ Windows ภาษาไทยต้องใส่ encoding="locale" หรือแปลงไฟล์

Python 3.15 ออกเมื่อ 9 ตุลาคม 2026 และ 3.15.1 ตามมาวันที่ 21 ตุลาคม มีสามเรื่องที่กระทบงานประจำวันตรง ๆ คือ lazy import ที่ทำให้โปรแกรมเปิดเร็วขึ้น frozendict ที่รอกันมานาน และ UTF-8 เป็น encoding เริ่มต้น ซึ่งปิดบั๊กไฟล์ภาษาไทยบน Windows ที่คนไทยเจอกันมาเป็นสิบปี บทความนี้ลองทั้งสามเรื่องด้วยโค้ดจริง พร้อมวิธีย้ายโค้ดเดิมโดยไม่พัง

ทดสอบบน Windows 11, Python 3.15.1 (ติดตั้งด้วย uv 0.13.5) เทียบกับ Python 3.12.10

ติดตั้งมาลองก่อน

uv self update          # uv รุ่นเก่ายังไม่รู้จัก 3.15 ตัวจริง (ติดตั้ง uv ด้วย pip ให้ใช้ pip install -U uv)
uv python install 3.15
uv run --python 3.15 python -VV

กับดักแรกที่เจอ: uv 0.10.9 ที่ค้างในเครื่องเห็นแค่ 3.15.0a6 (รุ่นทดลอง) ต้องอัปเดต uv ก่อนจึงเห็น 3.15.1 ถ้าใช้ pyenv หรือตัวติดตั้งจาก python.org ก็ดูเลขเวอร์ชันให้ดีว่าไม่ใช่ alpha/beta

lazy import คืออะไร

ปกติ import json จะหาไฟล์ โหลด และรันโค้ดของโมดูลทันทีที่ถึงบรรทัดนั้น แม้โปรแกรมจะไม่ได้ใช้ json เลยในรอบนั้น PEP 810 เพิ่มคำว่า lazy นำหน้า import: Python สร้างแค่ตัวแทน (proxy) ไว้ในชื่อนั้น แล้วโหลดจริงตอนใช้ชื่อครั้งแรก

import sys
lazy import json
lazy from decimal import Decimal

print("json loaded?", "json" in sys.modules)   # False
print(json.dumps({"a": 1}))                     # โหลด json ตรงนี้
print("json loaded?", "json" in sys.modules)   # True
print(Decimal("0.1") + Decimal("0.2"))          # 0.3

ผลที่รันจริงบน 3.15.1: บรรทัดแรกพิมพ์ False บรรทัดที่สามพิมพ์ True หลังใช้ json.dumps ไปแล้ว โค้ดส่วนที่ใช้ชื่อไม่ต้องเปลี่ยนอะไรเลย

lazy เป็น soft keyword คือมีความหมายพิเศษเฉพาะหน้า import/from ตัวแปรชื่อ lazy ในโค้ดเดิมยังใช้ได้ตามปกติ

ตัวอย่างตัวเลข: CLI เปิดเร็วขึ้นเท่าไร

โจทย์ที่เห็นผลชัดที่สุดคือ CLI ที่ import ของหนักไว้บนหัวไฟล์ แต่คำสั่งส่วนใหญ่ใช้แค่บางตัว ทดลองด้วยสคริปต์ที่ import asyncio, email.message, http.client, decimal, json, sqlite3, xml.etree.ElementTree แล้วสั่ง --version ซึ่งไม่ได้ใช้โมดูลไหนเลย วัดเวลาตั้งแต่สั่งรันจนจบ ค่า median จาก 30 รอบ

แบบเวลา (median)
python -c pass (Python เปล่า)27.6 ms
import ปกติทั้ง 7 ตัว234-245 ms
ใส่ lazy หน้า import ทั้ง 7 ตัว28-29 ms
import ปกติ แต่รันด้วย -X lazy_imports=all27-29 ms

คิดทีละขั้น:

  1. เวลาที่ import กินจริง = 240 - 27.6 ≈ 212 ms ต่อการเรียกหนึ่งครั้ง
  2. ใส่ lazy แล้วเหลือส่วนเกินไม่ถึง 2 ms (29 - 27.6)
  3. ถ้า CLI นี้ถูกเรียกจาก git hook หรือ script วันละ 500 ครั้ง ประหยัด 212 ms × 500 = 106 วินาทีต่อวัน

ตัวเลขสัมบูรณ์ขึ้นกับเครื่องและระบบไฟล์ เครื่องอื่นจะได้ไม่เท่านี้ ให้วัดของตัวเอง ดูว่าโมดูลไหนกินเวลาด้วย python -X importtime app.py ในการทดลองนี้ บรรทัดรายงานลดจาก 194 เหลือ 30 บรรทัด

lazy import ไม่ได้ทำให้โมดูลโหลดเร็วขึ้น มันแค่เลื่อนการโหลดไปตอนใช้ ถ้าทุกทางในโปรแกรมใช้ทุกโมดูล เวลารวมเท่าเดิม

Python เปล่า27.6 msimport ปกติ~240 mslazy import~29 msmedian 30 รอบ Python 3.15.1
CLI ที่ไม่ได้ใช้โมดูลไหนเลย เปิดเร็วเกือบเท่า Python เปล่า

ข้อจำกัด และ error ที่ย้ายที่

ใช้ lazy ได้เฉพาะระดับโมดูล ลองแล้วได้ error ตามนี้:

lazy import ในฟังก์ชัน     → SyntaxError: lazy import not allowed inside functions
lazy import ใน try/except  → SyntaxError: lazy import not allowed inside try/except blocks

lazy from x import * และ lazy from __future__ import ... ก็ไม่ได้เช่นกัน

เรื่องที่ต้องระวังที่สุดคือ error เปลี่ยนจุดเกิด ถ้าโมดูลไม่มีในเครื่อง โปรแกรมจะเปิดขึ้นได้ปกติ แล้วไปพังตอนใช้ชื่อ

lazy import not_installed_pkg
print("startup ok")              # พิมพ์ออกมาได้
def use():
    return not_installed_pkg.run()
use()
startup ok
ImportError: lazy import of 'not_installed_pkg' raised an exception during resolution
...
ModuleNotFoundError: No module named 'not_installed_pkg'

traceback บอกทั้งบรรทัด import และบรรทัดที่ใช้ ช่วยไล่ได้เร็ว แต่ถ้าโมดูลนั้นใช้แค่ในคำสั่งที่ไม่ค่อยมีคนเรียก บั๊กจะโผล่ใน production แทนตอน deploy ทางแก้คือ test ต้องผ่านทางที่ใช้โมดูลนั้นจริง หรือมี smoke test ที่ import ตรง ๆ ทุกโมดูลหนึ่งรอบ

ใช้กับโค้ดที่ต้องรองรับหลายเวอร์ชัน

ไฟล์ที่มี lazy import จะ SyntaxError ทั้งไฟล์บน 3.14 ถ้า library ต้องรองรับหลายเวอร์ชัน ใช้ตัวแปร __lazy_modules__ แทน

__lazy_modules__ = ["json", "decimal"]
import sys
import json      # 3.15: lazy · 3.12: โหลดทันทีตามเดิม
import decimal
print("json" in sys.modules)

รันจริงได้ False บน 3.15.1 และ True บน 3.12.10 โค้ดชุดเดียวใช้ได้ทั้งสองเวอร์ชัน

คุมทั้งโปรแกรมได้อีกสองทาง: -X lazy_imports=all หรือ env PYTHON_LAZY_IMPORTS=all ทำให้ทุก import เป็น lazy และ sys.set_lazy_imports_filter() เลือกเฉพาะโมดูลที่ต้องการ แบบ all สะดวกตอนวัดผล แต่เสี่ยงกับโมดูลที่พึ่ง side effect ตอน import เช่น plugin ที่ลงทะเบียนตัวเองตอนโหลด ใช้ใน production ควรเลือกทีละโมดูล

frozendict

frozendict เป็นชนิดใหม่ใน builtins (PEP 814) แก้ไขไม่ได้หลังสร้าง hash ได้ถ้า key และ value ทุกตัว hash ได้ จึงใช้เป็น key ของ dict หรือใส่ใน set ได้

a = frozendict(x=1, y=2)
a == frozendict(y=2, x=1)                     # True (ลำดับไม่มีผลกับการเทียบ)
hash(a) == hash(frozendict(y=2, x=1))         # True
a["z"] = 3       # TypeError: 'frozendict' object does not support item assignment
a | {"z": 3}     # frozendict({'x': 1, 'y': 2, 'z': 3}) ได้ตัวใหม่ ตัวเดิมไม่เปลี่ยน

cache = {frozendict(city="BKK", unit="C"): 33}
cache[frozendict(unit="C", city="BKK")]       # 33

งานที่เหมาะ: key ของ cache ที่มาจากพารามิเตอร์หลายตัว ค่า config ที่ไม่อยากให้ใครแก้ระหว่างรัน และค่า default ของฟังก์ชันที่เป็น mapping (ไม่โดนกับดัก mutable default)

ข้อควรรู้: frozendict ไม่ใช่ subclass ของ dict โค้ดที่เช็ก isinstance(x, dict) จะได้ False ให้เช็ก collections.abc.Mapping หรือ (dict, frozendict) แทน ส่วน json และ pickle รองรับแล้ว

UTF-8 เป็นค่าเริ่มต้น: บั๊กไฟล์ไทยบน Windows

ก่อน 3.15 ถ้าเรียก open("data.txt") โดยไม่ระบุ encoding Python ใช้ encoding ของ locale เครื่อง บน Linux และ macOS เป็น UTF-8 อยู่แล้วจึงไม่มีใครสังเกต แต่ Windows ที่ตั้งภาษาไทยใช้ code page 874 (cp874) ผลคือไฟล์เดียวกันอ่านได้บนเครื่องหนึ่ง แต่พังอีกเครื่อง

ดูทีละไบต์กับคำว่า “สวัสดี” (6 ตัวอักษร):

encodingขนาดไบต์
cp8746 ไบต์ca c7 d1 ca b4 d5
UTF-818 ไบต์ตัวละ 3 ไบต์ ขึ้นต้น e0 b8 ...
  • เขียนบน Windows ภาษาไทย (cp874) แล้วเอาไปอ่านเป็น UTF-8 บนเซิร์ฟเวอร์: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xca in position 0
  • กลับกัน ไฟล์ UTF-8 ที่อ่านด้วย cp874 ไม่ error แต่ได้ เธชเธงเธฑเธชเธ”เธต ตัวอักษร “เธ” ซ้ำ ๆ แบบนี้คือสัญญาณว่าไฟล์ UTF-8 ถูกอ่านเป็น cp874

เครื่องที่ใช้ทดสอบตั้ง locale เป็น cp1252 (อังกฤษ) เลยพังหนักกว่า: Python 3.12 เขียน “สวัสดี” ลงไฟล์ไม่ได้เลย ได้ UnicodeEncodeError: 'charmap' codec can't encode characters ส่วน 3.15.1 เขียนออกมาเป็น UTF-8 ถูกต้องโดยไม่ต้องแก้โค้ด และ sys.flags.utf8_mode เป็น 1

PEP 686 ทำให้ UTF-8 mode เปิดเป็นค่าเริ่มต้น ครอบคลุม open(), stdin/stdout/stderr และ pipe ของ subprocess ที่ใช้ text=True ลองแล้ว print("สวัสดี") ที่ส่งผ่าน pipe บน 3.12 พังด้วย UnicodeEncodeError แต่ 3.15.1 ส่งออกเป็นไบต์ UTF-8 ถูกต้อง เรื่องนี้แยกจาก การจัดการวันเวลาบน Windows ที่ต้องลง tzdata ซึ่งยังต้องทำเหมือนเดิม

ย้ายไป 3.15 ให้ไม่พัง

กลุ่มที่โดนคือโค้ดที่ ตั้งใจ อ่านไฟล์ cp874 เช่น CSV ที่ export จากโปรแกรมบัญชีเก่าบน Windows พอขึ้น 3.15 จะได้ UnicodeDecodeError ทันที วิธีหาและแก้:

# บน 3.10 ขึ้นไป หาจุดที่เรียก open() โดยไม่ระบุ encoding
python -X warn_default_encoding app.py
# <string>:1: EncodingWarning: 'encoding' argument not specified
open("export.csv", encoding="cp874")    # ไฟล์ที่รู้ว่ามาจากระบบเก่า
open("notes.txt", encoding="locale")    # ตั้งใจใช้ encoding ของเครื่องจริง ๆ
open("data.json", encoding="utf-8")     # ทุกอย่างที่เหลือ

ถ้าต้องขึ้น 3.15 ก่อนแก้เสร็จ ตั้ง PYTHONUTF8=0 ไว้ชั่วคราว และถ้าต้องการรู้ encoding ของเครื่องจริง ใช้ locale.getencoding() (ไม่สนใจ UTF-8 mode) ไม่ใช่ locale.getpreferredencoding()

ใส่ encoding= ทุกครั้งที่เปิดไฟล์ข้อความ แล้วโค้ดจะให้ผลเหมือนกันทุกเครื่องและทุกเวอร์ชัน เหมือนที่บทความ works on my machine เล่าไว้

ข้อผิดพลาดที่เจอบ่อย

  • ใส่ lazy ในไฟล์ที่ต้องรันบน 3.14 ด้วย ทั้งไฟล์จะ SyntaxError ใช้ __lazy_modules__ แทน
  • ไม่มี test ผ่านทางที่ใช้โมดูล lazy ModuleNotFoundError จะโผล่ตอนผู้ใช้กดเมนูนั้น ไม่ใช่ตอน deploy
  • เปิด -X lazy_imports=all ทั้งโปรเจกต์ โมดูลที่ลงทะเบียนตัวเองตอน import จะหายเงียบ
  • เช็ก isinstance(x, dict) กับ frozendict ได้ False ใช้ Mapping แทน
  • คิดว่า UTF-8 default แก้ทุกอย่าง ไฟล์ cp874 เก่ายังต้องระบุ encoding เอง ส่วนโค้ดที่รันบน 3.14 ลงไปยังได้ encoding ของ locale เหมือนเดิม
  • library ยังไม่มี wheel สำหรับ 3.15 เพิ่งออกได้ไม่กี่สัปดาห์ ลอง pip install ใน venv แยกก่อนเปลี่ยนเวอร์ชันบนเซิร์ฟเวอร์

แหล่งอ้างอิง

คำถามที่พบบ่อย

โค้ดที่มี lazy import รันบน Python 3.14 ได้ไหม

ไม่ได้ เพราะ lazy เป็น syntax ใหม่ 3.14 จะขึ้น SyntaxError ทั้งไฟล์ ถ้าต้องรองรับหลายเวอร์ชันให้ใช้ตัวแปร __lazy_modules__ แทน 3.15 จะทำให้ import ที่ระบุเป็น lazy ส่วนเวอร์ชันเก่าเห็นเป็นตัวแปรธรรมดาและ import แบบเดิม

UTF-8 เป็นค่าเริ่มต้นแล้ว ยังต้องเขียน encoding="utf-8" อยู่ไหม

ถ้าโค้ดต้องรันบน 3.14 หรือเก่ากว่าด้วย ยังต้องเขียน เอกสาร Python เองแนะนำให้ระบุ encoding ตรง ๆ เพื่อให้ผลเหมือนกันทุกเวอร์ชัน ถ้ารันแต่ 3.15 ขึ้นไปจะละได้

ปิด UTF-8 mode กลับไปแบบเดิมได้ไหม

ได้ ตั้ง PYTHONUTF8=0 หรือรันด้วย python -X utf8=0 ใช้เป็นทางหนีชั่วคราวระหว่างแก้โค้ด ไม่ควรใช้ถาวร

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#โค้ดเริ่มต้น

เก็บวันเวลาให้ถูก: เก็บเป็น UTC เก็บ timezone ผู้ใช้ แล้วค่อยแปลงตอนแสดง

เวลาเพี้ยน 7 ชั่วโมง วันเกิดเลื่อน นัดหมายเคลื่อนตอน DST แก้ด้วยกฎสามข้อ ใช้ timestamptz ใน Postgres, zoneinfo ใน Python, Temporal ใน JS และแปลงเป็น พ.ศ. ตอนแสดงเท่านั้น

อ่าน 13 นาที
#โค้ดกลาง

ผ่านในเครื่อง พังบน production: สามเรื่องจริง และวิธีให้เครื่องเรา = เครื่องจริง

library ที่มีแค่ในเครื่องเรา library ที่ติดมาเองแล้วเปลี่ยนผลเงียบ ๆ ค่าอ้างอิงที่บันทึกผิดเครื่อง สามบั๊กจริง และวิธีกันด้วย image เดียว ตรึงเวอร์ชัน และเทสต์สภาพแวดล้อม

อ่าน 6 นาที
#โค้ดกลาง

Go ใน Docker: GOMAXPROCS ตาม CPU limit (Go 1.25) และ Green Tea GC (Go 1.26)

Go 1.25 ตั้ง GOMAXPROCS ตาม CPU limit ของ container เอง Go 1.26 เปิด Green Tea GC เป็นค่าเริ่มต้น สิ่งที่ต้องเช็กใน Dockerfile และ go.mod พร้อมผลวัดจริง

อ่าน 9 นาที