ครึ่งชั่วโมงที่เกิดขึ้นสองครั้ง
ลูกค้าของออเดอร์ A-1042 คนเดิมใน New York สั่งของอีกครั้ง คราวนี้เลือก slot ส่งของเป็น
2026-11-01 01:30 ตามนาฬิกาของบ้านตัวเอง ในโซน America/New_York
ต่างจาก บทที่ 1 ตรงที่เวลานี้มีอยู่จริง ปัญหาคือมันมีอยู่จริงสองครั้ง และฟอร์มของลูกค้าไม่มีช่องให้บอกว่าหมายถึงครั้งไหน
คำถามของบทนี้คือ เมื่อเวลาบนหน้าปัดค่าหนึ่งตรงกับสองจุดบนเส้นเวลา ภาษาไหนบอกคุณ ในทรงที่ probe วัด มีภาษาเดียวจากห้าภาษาที่คืนทั้งสองจุดให้คุณเห็น
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ
ระหว่างนี้ทุกค่าในบทนี้รันเองได้ด้วย npm run verify:time-semantics
ซึ่งเรียก python3 scripts/time-semantics/probe.py · ไม่ต้องมี Docker แต่การรันครั้งแรก
ต้องต่อเน็ตเพื่อดึง chrono กับ chrono-tz จาก crates.io
นาฬิกาที่ถอยกลับมาเดินช่วงเดิมซ้ำ
หัวข้อที่มีชื่อว่า “นาฬิกาที่ถอยกลับมาเดินช่วงเดิมซ้ำ”คืนที่ DST จบ นาฬิกาของ New York ถอยกลับ และ
01:30ของคืนนั้นเกิดขึ้นสองครั้ง
2026-11-01 คือวันที่ DST ของ America/New_York จบในปีนั้น คืนนั้น offset ของโซนเปลี่ยนจาก
-04:00 กลับเป็น -05:00 นาฬิกาจึงต้องถอยกลับเท่าส่วนต่างนั้น แล้วเดินช่วงเดิมซ้ำอีกรอบ
ช่วงของเวลาบนหน้าปัดที่เกิดขึ้นสองครั้งเรียกว่า DST overlapDST overlapช่วงเวลาบนหน้าปัดที่เกิดขึ้นสองครั้ง เพราะนาฬิกาถอยกลับตอนจบ DST · เวลาเดียวกันบนหน้าปัดจึงตรงกับสอง instant และต้องมีใครสักคนเลือกว่าหมายถึงครั้งไหน (ช่วงเวลาบนหน้าปัดที่เกิดขึ้นสองครั้ง) เป็นคู่ตรงข้ามของ gap ในบทที่ 1 ที่นั่นเวลาไม่มีจุดบนเส้นเวลาเลย ที่นี่เวลามีสองจุด
ครั้งแรกของ 01:30 ยังอยู่ใน DST จึงใช้ -04:00 ส่วนครั้งที่สองเกิดหลังนาฬิกาถอยกลับแล้ว จึงใช้ -05:00
สองค่านี้เป็นเวลาบนหน้าปัดเดียวกันทุกตัวอักษร แต่เป็นคนละ instant
การตัดสินว่าเวลาบนหน้าปัดที่มีสองครั้งจะกลายเป็น instant ไหนเรียกว่า disambiguationdisambiguationการตัดสินว่าเวลาบนหน้าปัดที่ไม่มีอยู่จริง หรือที่มีสองครั้ง จะกลายเป็น instant ไหน · บางภาษาเลือกให้เงียบๆ บางภาษาไม่รับปากว่าจะเลือกฝั่งไหน และบางภาษาคืนทุกทางเลือกให้ผู้เรียกเป็นคนตัดสิน (การตัดสินว่าเวลานั้นหมายถึงจุดไหน) ต้องมีใครสักคนทำมัน ถ้าผู้เรียกไม่ทำ ภาษาจะทำแทน
flowchart LR
A["ฟอร์มของลูกค้า · 2026-11-01 01:30"] --> B{"America/New_York มี 01:30 ของวันนั้นกี่ครั้ง"}
B -->|"ครั้งแรก · ยังอยู่ใน DST"| C["01:30 ที่ -04:00"]
B -->|"ครั้งที่สอง · หลังนาฬิกาถอยกลับ"| D["01:30 ที่ -05:00"]
C --> E["ภาษาเลือกให้หนึ่งครั้งแบบเงียบๆ"]
D --> E
C --> F["ภาษาคืนทั้งสองครั้งให้ผู้เรียกเลือก"]
D --> F
คำบรรยายภาพ: ปัญหาไม่ได้อยู่ที่ไม่มีอะไรให้แปลงไปหาแบบในบทที่ 1 แต่อยู่ที่มีสองอย่างให้แปลงไปหา และสิ่งที่แยกห้าภาษาออกจากกันคือใครเป็นคนเลือก
ห้าภาษา กับเวลาที่มีสองจุด
หัวข้อที่มีชื่อว่า “ห้าภาษา กับเวลาที่มีสองจุด”แต่ละภาษาได้รับ
2026-11-01 01:30ที่America/New_Yorkเหมือนกัน แล้วเลือกให้หรือไม่เลือกให้คนละแบบ
ทุกการเรียกข้างล่างตัดมาจาก runner ของ probe ใน scripts/time-semantics/runners/ และค่าในคอมเมนต์
คือค่าที่ probe วัดได้ ไม่ใช่ค่าที่เขียนจากความจำ
Java เลือกครั้งแรก และมีอีก method ให้ขอครั้งที่สอง
หัวข้อที่มีชื่อว่า “Java เลือกครั้งแรก และมีอีก method ให้ขอครั้งที่สอง”ZonedDateTime.of รับเวลาที่มีสองครั้งโดยไม่โยน exception แล้วคืนครั้งแรก
ZoneId ny = ZoneId.of("America/New_York");ZonedDateTime ov = ZonedDateTime.of(2026, 11, 1, 1, 30, 0, 0, ny);ov.toString(); // 2026-11-01T01:30-04:00[America/New_York]ov.withLaterOffsetAtOverlap().toString(); // 2026-11-01T01:30-05:00[America/New_York]Javadoc ของ ZonedDateTime.of เขียนกฎนี้ไว้ตรงๆ ว่า “In the case of an overlap, when clocks are
set back, there are two valid offsets. This method uses the earlier offset typically corresponding
to “summer”.” ค่าที่ได้จึงเป็น -04:00
ครั้งที่สองขอได้ด้วย withLaterOffsetAtOverlap() ซึ่งคืน -05:00 แต่ผู้เรียกต้องรู้เองว่าเวลานี้อยู่ใน
overlap และต้องรู้เองว่าลูกค้าหมายถึงครั้งที่สอง ZonedDateTime.of ไม่ได้บอกอะไรเลย
Node เลือกครั้งแรก แต่เฉพาะเมื่อ process อยู่ใน New York
หัวข้อที่มีชื่อว่า “Node เลือกครั้งแรก แต่เฉพาะเมื่อ process อยู่ใน New York”Date สร้างเวลาจากวันที่และชั่วโมงได้แค่ในโซนของ process ซึ่งมาจาก TZ ค่าของ Node
ทุกค่าในบทนี้จึงบอก TZ ที่ใช้รันกำกับไว้เสมอ
// เดือนของ Date เริ่มที่ 0 · เลข 10 คือเดือนพฤศจิกายนconst ov = new Date(2026, 10, 1, 1, 30);ov.toString();ov.toISOString();probe รัน file เดียวกันสองครั้ง ต่างกันที่ TZ อย่างเดียว
TZ | toString() | toISOString() |
|---|---|---|
America/New_York | Sun Nov 01 2026 01:30:00 GMT-0400 (Eastern Daylight Time) | 2026-11-01T05:30:00.000Z |
Asia/Bangkok | Sun Nov 01 2026 01:30:00 GMT+0700 (Indochina Time) | 2026-10-31T18:30:00.000Z |
แถวแรกได้ GMT-0400 คือครั้งแรกเหมือน Java แถวที่สองไม่ได้ตอบคำถามของบทนี้ มันสร้าง 01:30
ของ Bangkok ซึ่งมีครั้งเดียว เพราะ Bangkok ไม่มี overlap ในคืนนั้น
สเปก ECMAScript หัวข้อ UTC ( t ) เขียนกฎของแถวแรกไว้ว่าเวลาที่ซ้ำ “t is interpreted using the
time zone offset before the transition” แล้วเขียนขั้นที่ทำมันไว้ว่า “Let disambiguatedInstant be
possibleInstants[0].” คือหยิบตัวแรกของรายการเสมอ
Python เลือกตามช่อง fold ซึ่งค่าตั้งต้นคือครั้งแรก
หัวข้อที่มีชื่อว่า “Python เลือกตามช่อง fold ซึ่งค่าตั้งต้นคือครั้งแรก”datetime ของ Python มีช่องหนึ่งไว้สำหรับคำถามนี้โดยเฉพาะ คือ foldfoldช่องของ datetime ใน Python ที่ระบุว่าเวลาที่มีสองครั้งหมายถึงครั้งไหน · fold=0 คือครั้งแรก fold=1 คือครั้งที่สอง และค่าตั้งต้นคือ 0 ผู้ที่ไม่ได้ตั้งมันจึงได้ครั้งแรกโดยไม่รู้ตัว
(ช่องที่ระบุว่าเวลาที่มีสองครั้งหมายถึงครั้งไหน)
from datetime import datetimefrom zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")datetime(2026, 11, 1, 1, 30, tzinfo=ny).isoformat() # 2026-11-01T01:30:00-04:00datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=ny).isoformat() # 2026-11-01T01:30:00-05:00เอกสาร datetime ของ Python เขียนความหมายของช่องนี้ไว้ว่า “The values 0 and 1 represent,
respectively, the earlier and later of the two moments with the same wall time representation.”
PEP 495 ซึ่งเป็นที่มาของช่องนี้กำหนดให้ constructor รับมันเป็น “a new keyword-only argument called
fold with the default value 0.” บรรทัดแรกที่ไม่ได้ตั้ง fold จึงได้ครั้งแรกโดยไม่รู้ตัว
ราคาของ fold เหมือนกับ withLaterOffsetAtOverlap() ของ Java คือผู้เรียกต้องรู้เองว่าต้องตั้ง
และ datetime(...) ไม่โยน error เมื่อไม่ได้ตั้ง
อ่านประโยคนี้ก่อนอ่านค่าของ Go · go doc time.Date เขียนไว้ว่า
“Date returns a time that is correct in one of the two zones involved in the transition, but it does not guarantee which.”
ค่าที่ Go คืนให้ในเวลาที่มีสองครั้งจึงไม่ใช่คำตอบของ Go ทั้งภาษา มันเป็นค่าของเครื่องที่วัด และเครื่องอื่นตอบอีกครั้งหนึ่งได้โดยไม่ผิดเอกสาร
Go คืนค่าที่เอกสารไม่รับปาก
หัวข้อที่มีชื่อว่า “Go คืนค่าที่เอกสารไม่รับปาก”ny, err := time.LoadLocation("America/New_York")// ...time.Date(2026, 11, 1, 1, 30, 0, 0, ny).Format(time.RFC3339)เครื่องที่ probe รันคือ go1.27.0 บน darwin/arm64 และค่าที่วัดได้บนเครื่องนั้นคือ
2026-11-01T01:30:00-04:00
probe เก็บค่านี้ไว้ในช่อง unguaranteed ของ measured.json แล้วไม่ผูกมันเป็นข้ออ้างสักข้อ
ด่านของ CI รันบนเครื่องอื่น ซึ่งไม่ใช่เครื่องที่วัดค่านี้
ค่านี้บังเอิญตรงกับอีกสามภาษาบนเครื่องที่วัด แต่ความบังเอิญนั้นไม่ใช่สิ่งที่ code ของคุณพึ่งได้
สิ่งที่บทนี้พูดได้เกี่ยวกับ Go คือ time.Date คืน Time ค่าเดียวให้เงียบๆ ไม่มี error และไม่มีค่าที่สองให้เห็นว่าเวลานี้มีสองครั้ง
Rust คืนทั้งสองค่า
หัวข้อที่มีชื่อว่า “Rust คืนทั้งสองค่า”chrono-tz ไม่เลือกให้ มันคืน enum ตัวเดียวกับบทที่ 1 แต่ตกคนละกิ่ง
use chrono::{LocalResult, NaiveDate, TimeZone};use chrono_tz::America::New_York;
let ov = NaiveDate::from_ymd_opt(2026, 11, 1).unwrap().and_hms_opt(1, 30, 0).unwrap();match New_York.from_local_datetime(&ov) { LocalResult::None => "None".to_string(), LocalResult::Single(a) => format!("Single({})", a.to_rfc3339()), LocalResult::Ambiguous(a, b) => format!("Ambiguous({}, {})", a.to_rfc3339(), b.to_rfc3339()),}runner พิมพ์ค่าที่วัดได้เป็น Ambiguous(2026-11-01T01:30:00-04:00, 2026-11-01T01:30:00-05:00)
ทั้งสองครั้งมาพร้อมกัน และ code ที่อยากได้เวลาค่าเดียวต้องเลือกเองว่าจะใช้ตัวไหน
เอกสารของ chrono เขียนความหมายของ variant นี้ว่า “The local time is ambiguous because there is a fold in the local time. This variant contains the two possible results, in the order (earliest, latest).”
สี่ภาษาได้ -04:00 แต่รับปากได้แค่สาม
หัวข้อที่มีชื่อว่า “สี่ภาษาได้ -04:00 แต่รับปากได้แค่สาม”รวมห้าคำตอบไว้ในตารางเดียว พร้อมรุ่นของ tzdata ที่แต่ละภาษาอ่านจริง แล้วแยกค่าที่เอกสารรับปากออกจากค่าที่บังเอิญตรง
| ภาษา | การเรียก | ค่าที่วัดได้ | tzdata ที่ใช้ |
|---|---|---|---|
Java openjdk 25.0.4.1 | ZonedDateTime.of(...) | 2026-11-01T01:30-04:00[America/New_York] | 2026b · tzdb.dat ของ JDK |
Node v24.19.0 ที่ TZ=America/New_York | new Date(2026, 10, 1, 1, 30) | Sun Nov 01 2026 01:30:00 GMT-0400 (Eastern Daylight Time) | 2026b · ICU ที่ฝังใน node |
Python 3.14.6 · ไม่ตั้ง fold | datetime(..., tzinfo=ny) | 2026-11-01T01:30:00-04:00 | 2026c · /usr/share/zoneinfo ของระบบ |
Python 3.14.6 · fold=1 | datetime(..., fold=1, tzinfo=ny) | 2026-11-01T01:30:00-05:00 | 2026c · /usr/share/zoneinfo ของระบบ |
Go go1.27.0 · darwin/arm64 เท่านั้น | time.Date(...) | 2026-11-01T01:30:00-04:00 · เอกสารไม่รับปาก | 2026c · /usr/share/zoneinfo ของระบบ |
Rust chrono-tz 0.10.4 | New_York.from_local_datetime(...) | Ambiguous(2026-11-01T01:30:00-04:00, 2026-11-01T01:30:00-05:00) | 2025b · ฝังใน chrono-tz |
บนเครื่องที่วัด สี่ภาษาได้ -04:00 แต่ตัวเลขสี่นี้นับค่าของ Go ด้วย ถ้าตัดแถวที่เอกสารไม่รับปากออก
สิ่งที่รับปากได้คือ Java, Node ที่ TZ=America/New_York และ Python ที่ไม่ตั้ง fold เลือกครั้งแรก
ตามกฎที่เอกสารของแต่ละภาษาเขียนไว้
ในทรงที่ probe วัด Rust เป็นภาษาเดียวที่ไม่เลือกให้ และคืนทั้งสองค่าให้ผู้เรียกเห็น
ห้าภาษาบนเครื่องเดียวกันอ่าน tzdata สามรุ่น จากสี่แหล่ง ตารางนี้จึงพิมพ์รุ่นของทุกภาษา เพราะคำตอบของคอร์สนี้ขึ้นกับข้อมูลของโซนด้วย ไม่ได้ขึ้นกับ code อย่างเดียว
ถามตรงๆ ว่าเวลานี้มีกี่ค่า
หัวข้อที่มีชื่อว่า “ถามตรงๆ ว่าเวลานี้มีกี่ค่า”Java ตอบได้ว่า
01:30ของวันนั้นมี offset ที่ใช้ได้กี่ตัว ถ้าถามมันอีก method หนึ่ง
ในบทที่ 1 คำถามเดียวกันนี้ตอบ 0 สำหรับเวลาใน gap ลองถามซ้ำกับเวลาใน overlap
ny.getRules().getValidOffsets(LocalDateTime.of(2026, 11, 1, 1, 30)).size(); // 2offset ที่ใช้ได้กับ 01:30 ของวันนั้นมี 2 ตัว Javadoc ของ ZoneRules.getValidOffsets เขียนไว้ว่า
“Thus, for any given local date-time there can be zero, one or two valid offsets.”
ขนาดของรายการนี้คือคำถามที่ code รับเวลาจากผู้ใช้ควรถามก่อนสร้าง ZonedDateTime ทุกครั้ง
แต่ไม่มีอะไรบังคับให้ถาม ZonedDateTime.of ตอบให้ได้โดยไม่ต้องถาม
slot ที่ลูกค้าหมายถึงอีกครั้งหนึ่ง
หัวข้อที่มีชื่อว่า “slot ที่ลูกค้าหมายถึงอีกครั้งหนึ่ง”ในทรงที่ probe วัด สามภาษาที่เอกสารรับปากเลือกครั้งแรกให้เงียบๆ และลูกค้าอาจหมายถึงครั้งที่สอง
กลับไปที่ออเดอร์ A-1042 สมมติว่าลูกค้ากรอก 01:30 หลังจากนาฬิกาที่บ้านถอยกลับแล้ว
ครั้งที่ลูกค้าหมายถึงคือ 2026-11-01T01:30:00-05:00
ระบบที่เขียนด้วย Java, Node ที่ TZ=America/New_York หรือ Python ที่ไม่ตั้ง fold จะบันทึก slot
เป็นครั้งแรก คือ -04:00 รถออกตามครั้งแรก ซึ่งมาก่อนครั้งที่ลูกค้าหมายถึงเท่าส่วนต่างของสอง offset
ไม่มีบรรทัดไหนแดง ใบเสร็จแสดง 01:30 ตรงกับที่ลูกค้ากรอกทุกตัวอักษร
ความผิดอยู่ที่ offset ซึ่งลูกค้าไม่เคยเห็นและไม่เคยถูกถาม
ต่างจาก gap ในบทที่ 1 ตรงที่ครั้งนี้ไม่มีเวลาไหนถูกเลื่อน ทุกภาษาที่เลือกให้คืน 01:30 ตามที่กรอก
จึงไม่มีอะไรบนหน้าจอให้คนตรวจสังเกตได้เลย
ทางออกไม่ใช่การเดาว่าลูกค้าส่วนใหญ่หมายถึงครั้งไหน แต่คือการรู้ก่อนว่าเวลานี้มีสองครั้ง แล้วตัดสินเองว่าจะถามลูกค้า หรือจะปฏิเสธเวลาช่วงนี้ไปเลย
ราคาคือฟอร์มต้องมีคำถามที่ลูกค้าส่วนใหญ่ไม่เคยต้องตอบ และ code ทุกจุดที่รับเวลาจากผู้ใช้ต้องมีกิ่งของเวลาที่มีสองครั้ง
ใน Rust กิ่งนั้นอยู่ในชนิดที่ from_local_datetime คืนมา ในอีกสี่ภาษา การเรียกที่บทนี้วัดคืนเวลาค่าเดียว ไม่มีกิ่งให้เห็น
จุดที่สี่ในหกจุด
หัวข้อที่มีชื่อว่า “จุดที่สี่ในหกจุด”บทนี้ปิดจุดที่สี่ คือเวลาท้องถิ่นที่มีสองครั้ง และในทรงที่ probe วัด มีภาษาเดียวที่คืนทั้งสองครั้งให้เห็น
ธีสิสของคอร์สนี้เป็นประโยคเดียว คือ wall-clock time ค่าหนึ่งไม่ได้ระบุจุดเดียวบนเส้นเวลา มันอาจไม่มีจุดเลย มีสองจุด หรือมีจุดเดียวที่แต่ละภาษาเลือกต่างกัน และในทรงที่ probe วัด มีภาษาเดียวจากห้าภาษาที่บอกคุณ
บทที่ 1 วัดกรณีที่ไม่มีจุดเลย บทนี้วัดกรณีที่มีสองจุด ในทรงที่ probe วัด Java, Node ที่ TZ=America/New_York และ Python ที่ไม่ตั้ง fold เลือกครั้งแรกตามกฎที่เอกสารเขียนไว้
Go คืนค่าที่เอกสารไม่รับปาก และ Rust เป็นภาษาเดียวที่คืนทั้งสองค่าให้ผู้เรียกเลือก
บทที่ 5 ข้ามไปที่การบวกเวลาข้ามคืนที่เปลี่ยน DST แล้วถามว่าบวกหนึ่งวันตามปฏิทินกับบวกระยะเวลาเท่าหนึ่งวันให้คำตอบเดียวกันไหม
-
Go — package time, func Date (เข้าถึง 2026-10-03) — ที่มาของประโยค “Date returns a time that is correct in one of the two zones involved in the transition, but it does not guarantee which.” · ตรงกับผลของ
go doc time.Dateบนgo1.27.0 -
Java SE 25 — ZonedDateTime (เข้าถึง 2026-10-03) — ที่มาของกฎที่
ZonedDateTime.ofใช้ offset ตัวแรกใน overlap และของwithLaterOffsetAtOverlap() -
Java SE 25 — ZoneRules (เข้าถึง 2026-10-03) — ที่มาของ
getValidOffsetsที่คืนรายการขนาดศูนย์ หนึ่ง หรือสอง -
ECMAScript — UTC ( t ) (เข้าถึง 2026-10-03) — ที่มาของกฎที่
Dateหยิบตัวแรกของรายการเมื่อเวลาซ้ำ -
Python — datetime.fold (เข้าถึง 2026-10-03) — ที่มาของความหมายของ
fold=0กับfold=1 -
PEP 495 — Local Time Disambiguation (เข้าถึง 2026-10-03) — ที่มาของค่าตั้งต้นของ
foldใน constructor -
chrono 0.4.45 — LocalResult (เข้าถึง 2026-10-03) — ที่มาของความหมายของ
LocalResult::Ambiguousและลำดับของสองค่าในนั้น -
ค่าทุกค่าในบทนี้มาจาก
scripts/time-semantics/measured.jsonซึ่งscripts/time-semantics/probe.pyเขียนบน darwin/arm64 · probe วัดทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง และ Node รันด้วยTZที่ probe ตั้งเองทุกครั้ง
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ส่ง `2026-11-01 01:30` ที่ `America/New_York` เข้า `New_York.from_local_datetime` ของ `chrono-tz` ได้อะไร