offset ไม่ใช่โซน
ออเดอร์ A-1042 มี slot ส่งของที่คลังสินค้าใน New York ได้รับมาเป็นสตริง "2026-03-07T12:00-05:00"
สตริงนี้มีวันที่ มีเวลา และมี offset ครบ ดูเหมือนไม่มีอะไรขาด
แล้วลูกค้าโทรมาขอเลื่อน slot ไปวันถัดไป เวลาเดิม คลังจึง parse สตริงนั้น บวกหนึ่งวัน แล้วส่งให้ระบบจัดรถ ไม่มีบรรทัดไหนแดง และไม่มี exception
คำถามของบทนี้คือ “เวลาเดิม” ของวันถัดไปคือเวลาไหน เมื่อสิ่งที่อยู่ในมือมีแค่ offset คำตอบขึ้นกับว่าคุณบวกบน offset หรือบวกบนโซน และในทรงที่ probe วัด สองทางนี้ได้คนละจุดบนเส้นเวลา
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ
ระหว่างนี้ทุกค่าในบทนี้รันเองได้ด้วย npm run verify:time-semantics
ซึ่งเรียก python3 scripts/time-semantics/probe.py · ไม่ต้องมี Docker แต่การรันครั้งแรก
ต้องต่อเน็ตเพื่อดึง chrono กับ chrono-tz จาก crates.io
offset ถูกต้องแค่จุดเดียว
หัวข้อที่มีชื่อว่า “offset ถูกต้องแค่จุดเดียว”
-05:00บอกว่าจุดนี้ห่างจาก UTC เท่าไร แต่ไม่ได้บอกว่าวันถัดไปที่เดียวกันจะห่างเท่าไร
-05:00 ท้ายสตริงคือ UTC offsetUTC offsetระยะห่างจาก UTC ณ จุดหนึ่งบนเส้นเวลา · offset ถูกต้องเฉพาะจุดนั้นจุดเดียว มันไม่รู้ว่าวันถัดไปโซนเดียวกันจะใช้ offset อะไร การบวกเวลาบน offset จึงไม่เดินตาม DST (ระยะห่างจาก UTC ณ จุดหนึ่งบนเส้นเวลา)
มันถูกต้องสำหรับ 2026-03-07 12:00 ที่ New York และถูกต้องแค่จุดนั้น
สิ่งที่รู้ว่าวันถัดไปจะใช้ offset อะไรคือ time zonetime zoneชุดกฎที่บอกว่าแต่ละช่วงของเส้นเวลาใช้ offset อะไร ระบุด้วยชื่ออย่าง America/New_York หรือ Asia/Bangkok · ต่างจาก offset ตรงที่มันรู้ว่า DST เริ่มและจบวันไหน และรู้ว่าในอดีตโซนนั้นเคยใช้ offset อะไร (ชุดกฎที่บอกว่าแต่ละช่วงของเส้นเวลาใช้ offset อะไร)
ระบุด้วยชื่ออย่าง America/New_York ไม่ได้ระบุด้วยตัวเลข
ชื่อโซนกับกฎของมันมาจาก IANA tz databaseIANA tz databaseฐานข้อมูลกฎของโซนเวลาที่ IANA ดูแล และเป็นที่มาของชื่อโซนอย่าง Asia/Bangkok · แต่ละภาษาได้มันมาคนละทาง บางภาษาฝังสำเนาไว้ในตัว บางภาษาอ่านจากระบบปฏิบัติการ (ฐานข้อมูลกฎของโซนเวลาที่ IANA ดูแล) หน้าของ IANA เขียนว่าฐานข้อมูลนี้ “is updated periodically to reflect changes made by political bodies to time zone boundaries, UTC offsets, and daylight-saving rules.”
Javadoc ของ ZoneId แยก ID ของโซนไว้สองชนิด:
- “Fixed offsets - a fully resolved offset from UTC/Greenwich, that uses the same offset for all local date-times”
- “Geographical regions - an area where a specific set of rules for finding the offset from UTC/Greenwich apply”
บทที่ 1 วัดไว้แล้วว่าวันที่ 2026-03-08 คือวันที่ DST ของ America/New_York เริ่ม
slot ใบนี้อยู่ก่อนคืนนั้นหนึ่งวัน การบวกหนึ่งวันจึงข้ามคืนที่โซนเปลี่ยน offset พอดี
flowchart TB A["สตริงจากสาย · 2026-03-07T12:00-05:00"] --> B["parse ได้ offset -05:00 ติดมา"] B --> C["บวกหนึ่งวันบน offset"] B --> D["แปลงเป็นโซน America/New_York ก่อน แล้วบวกหนึ่งวัน"] C --> E["2026-03-08 12:00-05:00 · offset เดิม เพราะ offset ไม่มีกฎ"] D --> F["2026-03-08 12:00-04:00 · กฎของโซนรู้ว่า DST เริ่มคืนนั้น"] E --> G["คนละจุดบนเส้นเวลา"] F --> G
คำบรรยายภาพ: สตริงเดียวกันแยกเป็นสองกิ่งตั้งแต่ตอนที่เลือกว่าจะบวกบนอะไร กิ่งซ้ายไม่มีข้อมูลพอจะรู้ว่า DST เริ่มคืนนั้น ส่วนกิ่งขวาต้องถามกฎของโซนก่อนจะตอบ
สี่ภาษา กับสตริงเดียว
หัวข้อที่มีชื่อว่า “สี่ภาษา กับสตริงเดียว”สี่ภาษา parse slot ใบเดียวกัน แล้วบวกหนึ่งวันสองทาง คือบนค่าที่ parse มา และบนโซนที่แปลงไป
ทุกการเรียกข้างล่างตัดมาจาก runner ของ probe ใน scripts/time-semantics/runners/ และค่าในคอมเมนต์
คือค่าที่ probe วัดได้บน darwin/arm64
runner ของ Go กับ Rust parse "2026-03-07T12:00:00-05:00" ซึ่งมีช่องวินาที ส่วน runner ของอีกสามภาษา
parse "2026-03-07T12:00-05:00" snippet ข้างล่างใช้สตริงตามที่ runner ของแต่ละภาษาเขียนไว้
Java แยกชนิดให้เห็นตั้งแต่ชื่อ
หัวข้อที่มีชื่อว่า “Java แยกชนิดให้เห็นตั้งแต่ชื่อ”ZoneId ny = ZoneId.of("America/New_York");OffsetDateTime off = OffsetDateTime.parse("2026-03-07T12:00-05:00");ZonedDateTime z = off.atZoneSameInstant(ny);off.plusDays(1).toString(); // 2026-03-08T12:00-05:00z.plusDays(1).toString(); // 2026-03-08T12:00-04:00[America/New_York]off.plusDays(1).toInstant().equals(z.plusDays(1).toInstant()); // falseJavadoc ของ OffsetDateTime เขียนความต่างนี้ไว้ในสองประโยคติดกัน:
“OffsetDateTime adds to the instant the offset from UTC/Greenwich, which allows the local date-time to be obtained.
ZonedDateTime adds full time-zone rules.”
OffsetDateTime จึงบวกหนึ่งวันแล้วคง -05:00 ไว้ ส่วน ZonedDateTime ถามกฎของ America/New_York
แล้วได้ -04:00 และ equals ของ instant ทั้งสองตอบ false
Go ได้ location ที่ไม่มีชื่อ
หัวข้อที่มีชื่อว่า “Go ได้ location ที่ไม่มีชื่อ”o, err := time.Parse(time.RFC3339, "2026-03-07T12:00:00-05:00")// ...z := o.In(ny)o.Location().String() // ""o.AddDate(0, 0, 1).Format(time.RFC3339) // 2026-03-08T12:00:00-05:00z.AddDate(0, 0, 1).Format(time.RFC3339) // 2026-03-08T12:00:00-04:00o.AddDate(0, 0, 1).Equal(z.AddDate(0, 0, 1)) // falseชื่อ location ของค่าที่ parse มาเป็นสตริงว่าง เอกสารของ time.Parse อธิบายที่มาไว้ว่า
“Otherwise it records the time as being in a fabricated location with time fixed at the given zone offset.”
คำว่า “Otherwise” ในประโยคนั้นมีเงื่อนไขนำหน้า เอกสารเขียนว่า “if the offset corresponds to a time zone used by the current location (Local), then Parse uses that location and zone in the returned time.”
ผลของ time.Parse จึงขึ้นกับ Local ของเครื่องที่รันด้วย probe วัดได้สตริงว่างบนเครื่องที่วัด
และไม่ได้วัดเครื่องที่ Local เป็น America/New_York บทนี้จึงไม่บอกว่าเครื่องแบบนั้นจะได้ค่าอะไร
Python ได้ tzinfo ที่เป็น offset คงที่
หัวข้อที่มีชื่อว่า “Python ได้ tzinfo ที่เป็น offset คงที่”p = datetime.fromisoformat("2026-03-07T12:00-05:00")z = p.astimezone(ny)repr(p.tzinfo) # datetime.timezone(datetime.timedelta(days=-1, seconds=68400))(p + timedelta(days=1)).isoformat() # 2026-03-08T12:00:00-05:00(z + timedelta(days=1)).isoformat() # 2026-03-08T12:00:00-04:00(p + timedelta(days=1)) == (z + timedelta(days=1)) # Falsefromisoformat คืน tzinfo เป็น datetime.timezone ไม่ใช่ ZoneInfo
เอกสารของ datetime เขียนข้อจำกัดของ timezone ไว้ตรงๆ ว่า “Objects of this class cannot be used to represent time zone information in the
locations where different offsets are used in different days of the year or where historical changes
have been made to civil time.”
ny ในบรรทัดที่สองคือ ZoneInfo("America/New_York") ตัวเดียวกับที่บทที่ 1 ใช้
Rust ได้ FixedOffset
หัวข้อที่มีชื่อว่า “Rust ได้ FixedOffset”let o = DateTime::parse_from_rfc3339("2026-03-07T12:00:00-05:00").unwrap();let z = o.with_timezone(&New_York);let a1 = o.checked_add_days(Days::new(1)).unwrap();let b1 = z.checked_add_days(Days::new(1)).unwrap();a1.to_rfc3339(); // 2026-03-08T12:00:00-05:00b1.to_rfc3339(); // 2026-03-08T12:00:00-04:00a1 == b1; // falseเอกสารของ chrono เขียนชนิดที่ได้ไว้ในประโยคแรกของ method ว่า “Parses an RFC 3339 date-and-time string
into a DateTime<FixedOffset> value.” ชนิดของ o จึงบอกตั้งแต่ตอนคอมไพล์ว่ามันมีแค่ offset
ต่างจากบทที่ 1 ตรงที่ Rust ไม่ได้คืน enum ให้เลือก เวลา 12:00 ของวันที่ 2026-03-08
มีอยู่จริงในทั้งสองกิ่ง คำถามของบทนี้ไม่ใช่ว่าเวลามีอยู่ไหม แต่คือบวกบนอะไร
สี่ภาษา คำตอบเดียวกัน บนเครื่องที่วัด
หัวข้อที่มีชื่อว่า “สี่ภาษา คำตอบเดียวกัน บนเครื่องที่วัด”รวมสี่ภาษาไว้ในตารางเดียว พร้อมรุ่นของ tzdata ที่แต่ละภาษาอ่านจริง
| ภาษา | บวกหนึ่งวันบนค่าที่ parse มา | บวกหนึ่งวันบนโซน America/New_York | จุดเดียวกันไหม | tzdata ที่ใช้ |
|---|---|---|---|---|
Java openjdk 25.0.4.1 | 2026-03-08T12:00-05:00 | 2026-03-08T12:00-04:00[America/New_York] | false | 2026b · tzdb.dat ของ JDK |
Go go1.27.0 | 2026-03-08T12:00:00-05:00 | 2026-03-08T12:00:00-04:00 | false | 2026c · /usr/share/zoneinfo ของระบบ |
Python 3.14.6 | 2026-03-08T12:00:00-05:00 | 2026-03-08T12:00:00-04:00 | False | 2026c · /usr/share/zoneinfo ของระบบ |
Rust chrono-tz 0.10.4 | 2026-03-08T12:00:00-05:00 | 2026-03-08T12:00:00-04:00 | false | 2025b · ฝังใน chrono-tz |
ในทรงที่ probe วัด สี่ภาษาตอบตรงกันทั้งสาม column บวกบน offset ได้ -05:00 บวกบนโซนได้ -04:00
และสองค่านี้เป็นคนละจุดบนเส้นเวลา
column ที่สองใช้แค่ offset ที่มากับสตริง ส่วน column ที่สามต้องถามกฎของโซน ซึ่งสี่ภาษานี้อ่านจาก tzdata สามรุ่น จากสามแหล่ง ตารางที่รวมหลายภาษาจึงต้องพิมพ์รุ่นของทุกภาษา
ตารางนี้ไม่มีแถวของ Node เพราะ Node ไม่มีค่าให้ใส่ใน column ที่สอง หัวข้อถัดไปบอกเหตุผล
Node ไม่เก็บ offset ไว้เลย
หัวข้อที่มีชื่อว่า “Node ไม่เก็บ offset ไว้เลย”
Dateใช้-05:00แค่ตอน parse แล้วบวกวันในโซนของ process จึงได้คำตอบตามTZของเครื่อง
const p = new Date("2026-03-07T12:00-05:00");const p1 = new Date(p);p1.setDate(p1.getDate() + 1);p.toISOString();p1.toISOString();probe รัน file เดียวกันสองครั้ง ต่างกันที่ TZ อย่างเดียว
TZ | p.toISOString() | p1.toISOString() หลังบวกหนึ่งวัน |
|---|---|---|
America/New_York | 2026-03-07T17:00:00.000Z | 2026-03-08T16:00:00.000Z |
Asia/Bangkok | 2026-03-07T17:00:00.000Z | 2026-03-08T17:00:00.000Z |
column แรกตรงกันทั้งสอง TZ ที่ probe วัด แต่หลังบรรทัดแรก -05:00 ไม่ได้อยู่ใน p อีกแล้ว
สเปก ECMAScript เขียนว่า “The DateValue internal slot is the time value represented by this Date.”
time value ในสเปกเดียวกันคือ “an integral Number representing an instant in time to millisecond precision”
Date จึงเก็บแค่ instant ไม่มีช่องให้ offset หรือชื่อโซน
พอสั่ง setDate ขั้นตอนในสเปกเขียนว่า “Set tv to LocalTime(tv).” ก่อนจะบวกวัน คือแปลงเป็นเวลาท้องถิ่น
ในโซนของ process แล้วค่อยบวก ใน probe โซนนั้นคือ TZ ที่ probe ตั้ง runner รายงาน resolvedTz
ตรงกับ TZ ทั้งสองครั้ง โซนนั้นไม่ได้มาจากสตริงที่ parse มา
ผลคือ code เดียวกัน อินพุตเดียวกัน ได้สอง instant ตามเครื่องที่รัน ทั้งสองแถวบวกวันในโซนของ process
ตามขั้นตอน LocalTime ของสเปก ไม่ใช่บน -05:00 เพราะ offset นั้นหายไปตั้งแต่บรรทัดแรก
ZonedDateTime ที่ข้างในเป็นแค่ offset
หัวข้อที่มีชื่อว่า “ZonedDateTime ที่ข้างในเป็นแค่ offset”ชื่อชนิดมีคำว่า Zoned แต่
ZonedDateTime.parseของสตริงที่มีแค่ offset ได้ offset ไม่ได้โซน
ทางลัดที่ดูปลอดภัยคือ parse สตริงด้วย ZonedDateTime ตรงๆ แทน OffsetDateTime เพราะชื่อชนิดสัญญาว่ามีโซน
ZonedDateTime pz = ZonedDateTime.parse("2026-03-07T12:00-05:00");pz.getZone().toString(); // -05:00String.valueOf(pz.getZone() instanceof ZoneOffset); // truepz.plusDays(1).toString(); // 2026-03-08T12:00-05:00getZone() คืน ZoneOffset ซึ่งเป็น ZoneId ชนิด fixed offset และ plusDays(1) ได้ 2026-03-08T12:00-05:00
ตรงกับ OffsetDateTime ทุกตัวอักษร ไม่ใช่ 12:00-04:00 ของโซน
Javadoc ของ ZonedDateTime.parse ยกตัวอย่างอินพุตเป็น “2007-12-03T10:15:30+01:00[Europe/Paris]”
ชื่อโซนอยู่ในวงเล็บเหลี่ยมท้ายสตริง สตริงจากสายของบทนี้ไม่มีวงเล็บนั้น ZonedDateTime จึงไม่มีชื่อโซนให้ใช้
ZonedDateTime ไม่ได้มีโซนเสมอ มันถือ ZoneId ซึ่งเป็นได้ทั้งชื่อโซนและ offset คงที่
ถ้าจะรู้ว่าตัวที่ถืออยู่เป็นแบบไหน ต้องถาม getZone() เหมือนที่ probe ถาม
slot ที่เลื่อนแล้วมาผิดเวลา
หัวข้อที่มีชื่อว่า “slot ที่เลื่อนแล้วมาผิดเวลา”คลังที่บวกบน offset ส่งรถไปถึงตอนที่นาฬิกาของลูกค้าไม่ได้แสดงเวลาที่ลูกค้าขอ
กลับไปที่ออเดอร์ A-1042 คลังที่เขียนตามสัญชาตญาณจะ parse สตริงจากสาย บวกหนึ่งวัน แล้วได้
2026-03-08T12:00:00-05:00 ทุกภาษาในตารางข้างบนเห็นตรงกันว่านี่ไม่ใช่จุดเดียวกับ 12:00 ของโซน
probe วัดท่อนี้ซ้ำในท่อของบทที่ 8 ซึ่งเขียนด้วย Python แล้วอ่านค่าที่ได้ด้วยชื่อโซนของลูกค้า
นาฬิกาของลูกค้าที่ instant นั้นแสดง 2026-03-08T13:00:00-04:00
ลูกค้าขอ 12:00 ของวันถัดไป และรถมาถึงตอน 13:00 ตามนาฬิกาของลูกค้าเอง ไม่มีระบบไหนในท่อแดง
เพราะทุกค่าที่ผ่านมือเป็นเวลาที่ถูกต้องตาม offset ที่มันถือ
ทางออกไม่ใช่การเลือกภาษา ในทรงที่ probe วัด สี่ภาษาบวกบน offset ได้ค่าเดียวกันหมด ทางออกคือบวกบนโซน ซึ่งแปลว่าปลายทางต้องรู้ชื่อโซน และสตริงที่มากับสายในบทนี้ไม่มีชื่อนั้น
ราคามีสองข้อ:
- ชื่อโซนต้องเดินทางไปกับเวลาเป็นอีกช่องหนึ่ง บทที่ 6 วัดว่าเวลาของออเดอร์ข้าม JSON แล้วเหลืออะไร
- ทุกระบบที่บวกบนโซนต้องมีกฎของโซนอยู่ในมือ และบนเครื่องที่วัด สี่ภาษาในตารางอ่านกฎนั้นจาก tzdata สามรุ่น
จุดที่สาม · offset ที่มากับสายแล้วถูกบวกต่อ
หัวข้อที่มีชื่อว่า “จุดที่สาม · offset ที่มากับสายแล้วถูกบวกต่อ”บทนี้ปิดครึ่งแรกของจุดที่สาม คือ offset ที่ถูกใช้คำนวณต่อราวกับเป็นโซน
ธีสิสของคอร์สนี้คือ wall-clock time ค่าหนึ่งไม่ได้ระบุจุดเดียวบนเส้นเวลา มันอาจไม่มีจุดเลย มีสองจุด หรือมีจุดเดียวที่แต่ละภาษาเลือกต่างกัน และในทรงที่ probe วัด มีภาษาเดียวจากห้าภาษาที่บอกคุณ
บทนี้เติมอีกด้านหนึ่ง สตริงที่มี offset ระบุจุดเดียวได้จริง แต่จุดนั้นจุดเดียว พอบวกวันบน offset ในทรงที่ probe วัด Java Go Python และ Rust ได้คนละจุดกับการบวกบนโซน และไม่มีภาษาไหนแดง
Node ไม่ได้เข้าตารางเดียวกับอีกสี่ภาษา เพราะมันไม่เก็บ offset ไว้เลย และให้สองคำตอบตาม TZ ของเครื่อง
บทที่ 4 กลับไปที่ wall-clock time ค่าเดียว คือ 01:30 ของคืนที่ DST จบ แล้วถามว่าภาษาไหนบอกคุณว่ามันมีสองครั้ง
-
Java SE 25 — ZoneId (เข้าถึง 2026-10-03) — ที่มาของการแยก ID สองชนิด คือ fixed offset กับ geographical region
-
Java SE 25 — OffsetDateTime (เข้าถึง 2026-10-03) — ที่มาของประโยคที่ว่า
OffsetDateTimeเพิ่ม offset ส่วนZonedDateTimeเพิ่มกฎของโซนทั้งชุด -
Java SE 25 — ZonedDateTime.parse (เข้าถึง 2026-10-03) — ที่มาของตัวอย่างอินพุตที่มีชื่อโซนในวงเล็บเหลี่ยม
-
Go — package time, func Parse (เข้าถึง 2026-10-03) — ที่มาของ fabricated location และของเงื่อนไขที่
Parseใช้Localเมื่อ offset ตรงกับโซนของเครื่อง -
Python — datetime, timezone objects (เข้าถึง 2026-10-03) — ที่มาของข้อจำกัดที่
timezoneแทนโซนที่ใช้ offset ต่างกันในแต่ละวันของปีไม่ได้ -
chrono 0.4.45 — DateTime::parse_from_rfc3339 (เข้าถึง 2026-10-03) — ที่มาของชนิด
DateTime<FixedOffset>ที่ parser คืนให้ -
ECMAScript — Properties of Date Instances, Time Values and Time Range และ Date.prototype.setDate (เข้าถึง 2026-10-03) — ที่มาของข้อที่
Dateเก็บแค่ time value และของขั้นตอนLocalTimeในsetDate -
IANA — Time Zones (เข้าถึง 2026-10-03) — ที่มาของประโยคที่ว่า tz database ถูกปรับตามการเปลี่ยนแปลงของ UTC offset และกฎ DST
-
ค่าทุกค่าในบทนี้มาจาก
scripts/time-semantics/measured.jsonซึ่งscripts/time-semantics/probe.pyเขียนเมื่อ 2026-10-02 บน darwin/arm64 · probe วัดทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง และ Node รันด้วยTZที่ probe ตั้งเองทุกครั้ง
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3parse `"2026-03-07T12:00-05:00"` ด้วย `OffsetDateTime` แล้วสั่ง `plusDays(1)` ได้อะไร