หนึ่งวันไม่ใช่ 24 ชั่วโมง
ลูกค้าของออเดอร์ A-1042 อยู่ใน New York และได้ slot ส่งของเป็น 2026-03-07 12:00
ตามนาฬิกาของบ้านตัวเอง ซึ่งอยู่ในโซน America/New_York
เช้าวันนั้นลูกค้ากดปุ่ม “เลื่อนไปพรุ่งนี้ เวลาเดิม” ระบบต้องคำนวณ slot ใหม่จาก slot เดิม และคนที่เขียน code ส่วนนั้นมีสองทางให้เลือก คือบวกหนึ่งวัน หรือบวก 24 ชั่วโมง
คำถามของบทนี้คือ สองทางนี้ให้ slot เดียวกันหรือเปล่า คืนที่อยู่ระหว่าง slot เดิมกับ slot ใหม่ คือคืนที่ DST ของ New York เริ่ม และในทรงที่ probe วัดจากห้าภาษา คำตอบคือไม่ใช่ ยกเว้นในภาษาเดียว
orderctl คือเครื่องมือประมวลผลออเดอร์ที่คอร์สภาษาในเสานี้สร้างซ้ำทีละภาษา repo ตัวอย่าง
กำลังจัดทำ
ระหว่างนี้ทุกค่าในบทนี้รันเองได้ด้วย npm run verify:time-semantics
ซึ่งเรียก python3 scripts/time-semantics/probe.py · ไม่ต้องมี Docker แต่การรันครั้งแรก
ต้องต่อเน็ตเพื่อดึง chrono กับ chrono-tz จาก crates.io
สองความหมายของคำว่าพรุ่งนี้
หัวข้อที่มีชื่อว่า “สองความหมายของคำว่าพรุ่งนี้”“บวกหนึ่งวัน” กับ “บวก 24 ชั่วโมง” เป็นสองคำสั่งที่ต่างกัน และคืนที่ DST เริ่มคือคืนที่ความต่างนั้นโผล่ออกมา
slot เดิมคือ 2026-03-07T12:00:00-05:00 ใน บทที่ 1
เราเห็นแล้วว่าคืนถัดมาเป็นคืนที่ DST ของ America/New_York เริ่ม และ offset เปลี่ยนจาก -05:00 เป็น -04:00
การบวกแบบแรกคือ calendar arithmeticcalendar arithmeticการบวกลบตามปฏิทิน เช่นบวกหนึ่งวันแล้วได้เวลาเดิมบนหน้าปัดของวันถัดไป · ถ้าข้ามคืนที่เปลี่ยน DST ระยะที่ผ่านไปจริงจะไม่ใช่ 24 ชั่วโมง (การบวกลบตามปฏิทิน) มันเดินบนหน้าปัด คือเลื่อนวันที่ไปหนึ่งวันแล้วคงชั่วโมงกับนาทีไว้ตามเดิม จากนั้นค่อยถามโซนว่าวันใหม่ใช้ offset อะไร ลูกค้าที่บอกว่า “พรุ่งนี้ เวลาเดิม” หมายถึงการบวกแบบนี้
การบวกแบบที่สองคือ duration arithmeticduration arithmeticการบวกลบระยะที่ผ่านไปจริงบนเส้นเวลา เช่นบวก 24 ชั่วโมง · ถ้าข้ามคืนที่เปลี่ยน DST เวลาบนหน้าปัดที่ได้จะไม่ตรงกับการบวกหนึ่งวัน และไม่ใช่ทุกภาษาที่ทำแบบนี้เมื่อคุณสั่งบวกเป็นชั่วโมง (การบวกระยะที่ผ่านไปจริง) มันเดินบนเส้นเวลา คือเลื่อน instant ไปตามระยะที่สั่ง แล้วค่อยแปลงกลับมาเป็นเวลาบนหน้าปัด งานที่ต้องการระยะจริง เช่นกำหนดว่ารถต้องออกจากคลังภายในระยะหนึ่ง ต้องการการบวกแบบนี้
flowchart LR
A["slot เดิม · 2026-03-07 12:00 ที่ America/New_York · offset -05:00"] --> B{"บวกแบบไหน"}
B -->|"หนึ่งวันตามปฏิทิน"| C["เดินบนหน้าปัด · ได้ 12:00-04:00"]
B -->|"24 ชั่วโมงบนเส้นเวลา"| D["เดินบนเส้นเวลา · ได้ 13:00-04:00"]
B -->|"timedelta ของ Python ไม่ว่าจะสั่งเป็นวันหรือชั่วโมง"| E["เดินบนหน้าปัด · ได้ 12:00-04:00"]
E --> F["ระยะที่ผ่านไปจริง 23:00:00"]
คำบรรยายภาพ: ระหว่างสองค่านี้มีคืนที่ offset เปลี่ยน การบวกบนหน้าปัดกับการบวกบนเส้นเวลาจึงไปจบคนละจุด และ Python เดินบนหน้าปัดทั้งสองกิ่ง แม้ผู้เรียกจะสั่งเป็นชั่วโมงก็ตาม
สี่ภาษาแยกสองคำถามออกจากกัน
หัวข้อที่มีชื่อว่า “สี่ภาษาแยกสองคำถามออกจากกัน”Java Go Rust และ Node ที่
TZ=America/New_Yorkมี API คนละตัวสำหรับสองคำถามนี้ และได้สองคำตอบที่ต่างกัน
ทุกการเรียกข้างล่างตัดมาจาก runner ของ probe ใน scripts/time-semantics/runners/
และค่าในคอมเมนต์คือค่าที่ probe วัดได้ ไม่ใช่ค่าที่เขียนจากความจำ
ZoneId ny = ZoneId.of("America/New_York");ZonedDateTime s = ZonedDateTime.of(2026, 3, 7, 12, 0, 0, 0, ny);s.plusDays(1).toString(); // 2026-03-08T12:00-04:00[America/New_York]s.plus(Duration.ofHours(24)).toString(); // 2026-03-08T13:00-04:00[America/New_York]Javadoc ของ ZonedDateTime.plus(long, TemporalUnit) แบ่งหน่วยไว้สองพวก “Date units operate on the
local time-line.” และ “Time units operate on the instant time-line.” วันเป็นหน่วยของวันที่
ส่วนชั่วโมงเป็นหน่วยของเวลา
plus(Duration.ofHours(24)) ส่งงานต่อให้ Duration และ Javadoc ของ plus(TemporalAmount) เขียนว่าตัว amount
“typically calls back to plus(long, TemporalUnit)”
Javadoc ของ plusHours เขียนผลของการแบ่งนั้นไว้ตรงๆ ว่า “thus adding one day is not the same as
adding 24 hours.” ค่าที่ probe วัดได้ตรงกับประโยคนั้น plusDays(1) ได้ 12:00 และ
plus(Duration.ofHours(24)) ได้ 13:00 ทั้งคู่อยู่ที่ offset -04:00
ny, err := time.LoadLocation("America/New_York")// ...s := time.Date(2026, 3, 7, 12, 0, 0, 0, ny)s.AddDate(0, 0, 1).Format(time.RFC3339) // 2026-03-08T12:00:00-04:00s.Add(24 * time.Hour).Format(time.RFC3339) // 2026-03-08T13:00:00-04:00เอกสารของ AddDate เขียนว่า “calendrical periods like days don’t have fixed durations” และ
“AddDate uses the Location of the Time value to determine these durations.” ส่วน Add บวก
time.Duration ซึ่งเป็นระยะคงที่ จึงเดินบนเส้นเวลา
time.Date ในบทที่ 1 คืนค่าที่เอกสารไม่รับปาก เพราะ 02:30 อยู่ใน gap ส่วน 12:00 ของทั้งสองวันในบทนี้
ไม่อยู่ใน gap และไม่อยู่ในช่วงที่มีสองครั้ง ค่าของ AddDate กับ Add ข้างบนจึงเป็นข้ออ้างที่ probe ผูกไว้
ไม่ใช่ค่าของเครื่องเดียว
แต่เอกสารของ AddDate เขียนไว้ด้วยว่า “AddDate normalizes its result in the same way that Date does”
ถ้าการบวกวันไปตกใน gap ก็จะกลับไปเจอคำถามเดียวกับบทที่ 1 ซึ่ง probe ไม่ได้วัดในทรงนี้
use chrono::{Days, Duration, TimeZone};use chrono_tz::America::New_York;
let s = New_York.with_ymd_and_hms(2026, 3, 7, 12, 0, 0).unwrap();s.checked_add_days(Days::new(1)).unwrap().to_rfc3339(); // 2026-03-08T12:00:00-04:00(s + Duration::hours(24)).to_rfc3339(); // 2026-03-08T13:00:00-04:00Duration ในบรรทัดนี้เป็นของ chrono ไม่ใช่ของ std ชื่อชนิดสองตัวบอกความต่างไว้เอง
Days คือจำนวนวันบนปฏิทิน ส่วน Duration คือระยะบนเส้นเวลา
เอกสารของ checked_add_days เขียนว่ามัน “Add a duration in Days to the date part of the DateTime.”
และคืน None ถ้า “The local time at the resulting date does not exist or is ambiguous”
runner ของ probe เรียก unwrap() ตรงนั้น เพราะ 12:00 ของวันใหม่มีอยู่จริงและมีครั้งเดียว
Node ที่ TZ=America/New_York
หัวข้อที่มีชื่อว่า “Node ที่ TZ=America/New_York”// เดือนของ Date เริ่มที่ 0 · เลข 2 คือเดือนมีนาคมconst s = new Date(2026, 2, 7, 12, 0);const d = new Date(s);d.setDate(d.getDate() + 1);const h = new Date(s.getTime() + 864e5);Date ไม่มี API สำหรับสองคำถามนี้ตรงๆ runner จึงใช้สองท่าที่ code จริงมักเขียนกัน
setDate เปลี่ยนวันที่ในโซนของ process ส่วน 864e5 คือจำนวนมิลลิวินาทีของ 24 ชั่วโมง
ที่บวกเข้าไปบนค่าที่ getTime() คืน
สเปก ECMAScript หัวข้อ Date.prototype.setDate เขียนขั้นตอนไว้ว่า “Set tv to LocalTime(tv).”
ก่อนเปลี่ยนวัน แล้วจึงแปลงกลับด้วย UTC(newDate) การบวกวันของ Date จึงเดินบนหน้าปัดของโซนของ process
TZ | การเรียก | toString() | toISOString() |
|---|---|---|---|
America/New_York | setDate(getDate() + 1) | Sun Mar 08 2026 12:00:00 GMT-0400 (Eastern Daylight Time) | 2026-03-08T16:00:00.000Z |
America/New_York | getTime() + 864e5 | Sun Mar 08 2026 13:00:00 GMT-0400 (Eastern Daylight Time) | 2026-03-08T17:00:00.000Z |
สองแถวนี้ได้ instant คนละค่า แถวแรกตรงกับการบวกหนึ่งวันของ Java Go และ Rust ทั้ง wall-clock time และ offset ส่วนแถวที่สองตรงกับการบวก 24 ชั่วโมงของสามภาษานั้น
probe รัน file เดียวกันซ้ำที่ TZ=Asia/Bangkok และได้ค่านี้ทั้งสองแถว
toString()ได้Sun Mar 08 2026 12:00:00 GMT+0700 (Indochina Time)toISOString()ได้2026-03-08T05:00:00.000Z
สองการบวกให้ instant เดียวกัน เพราะ Date สร้าง 12:00 ของ Bangkok ไม่ใช่ของ New York และ Bangkok
ไม่มี DST ในคืนนั้น code บรรทัดเดียวกันจึงแสดงความต่างของบทนี้บนเครื่องหนึ่ง และไม่แสดงบนอีกเครื่องหนึ่ง
Python ตอบสองคำถามด้วยค่าเดียวกัน
หัวข้อที่มีชื่อว่า “Python ตอบสองคำถามด้วยค่าเดียวกัน”ในทรงที่ probe วัด การบวก
timedeltaเข้ากับ aware datetime ของ Python เดินบนหน้าปัด ต่อให้สั่งเป็นชั่วโมง
ค่าที่มี tzinfo ติดมาเรียกว่า aware datetimeaware datetimedatetime ที่มี tzinfo ติดมา จึงแปลงเป็น instant ได้ · aware ไม่ได้แปลว่ามีชื่อโซนเสมอ เพราะ tzinfo อาจเป็นแค่ offset คงที่ที่ไม่รู้เรื่อง DST เลย (datetime ที่แปลงเป็น instant ได้)
slot เดิมของลูกค้าเป็นค่าแบบนี้ เพราะมันมี ZoneInfo("America/New_York") ติดมา
from datetime import datetime, timedelta, timezonefrom zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")s = datetime(2026, 3, 7, 12, 0, tzinfo=ny)(s + timedelta(days=1)).isoformat() # 2026-03-08T12:00:00-04:00(s + timedelta(hours=24)).isoformat() # 2026-03-08T12:00:00-04:00สองบรรทัดได้สตริงเดียวกันทุกตัวอักษร และสตริงนั้นตรงกับ wall-clock time และ offset ที่อีกสี่ภาษาได้จากการบวกหนึ่งวัน
ผู้ที่สั่ง timedelta(hours=24) เพราะต้องการระยะจริงจึงได้การบวกตามปฏิทินโดยไม่มีอะไรเตือน
เหตุผลมีสองชั้น ชั้นแรกอยู่ที่ timedelta เอง เอกสารของมันเขียนว่า “Only days, seconds and
microseconds are stored internally.” และ “An hour is converted to 3600 seconds.” แล้วค่าทั้งหมด
“are then normalized so that the representation is unique” ชั่วโมงที่ส่งเข้าไปจึงไม่ได้ถูกเก็บเป็นชั่วโมง
ชั้นที่สองอยู่ที่การบวก เอกสารของ datetime เขียนว่าผลของ datetime + timedelta มี tzinfo
เดิม และ “Note that no time zone adjustments are done even if the input is an aware object.”
Python จึงเลื่อนค่าบนหน้าปัด แล้วให้โซนเลือก offset ของวันใหม่
ระยะที่ผ่านไปจริง
หัวข้อที่มีชื่อว่า “ระยะที่ผ่านไปจริง”probe วัดระยะของการบวก 24 ชั่วโมงใน Python ด้วยการแปลงทั้งสองค่าไป UTC ก่อนลบ และได้
23:00:00
str((s + timedelta(hours=24)).astimezone(timezone.utc) - s.astimezone(timezone.utc)) # 23:00:00runner แปลงไป UTC ก่อนลบ เพราะเอกสารเขียนกฎของการลบสองค่าที่มี tzinfo เดียวกันไว้ว่า
“the tzinfo attributes are ignored” และ “No time zone adjustments are done in this case.”
ถ้าลบตรงๆ ผลจะเป็นระยะบนหน้าปัด ไม่ใช่ระยะบนเส้นเวลา
ค่าที่ได้คือ 23:00:00 ผู้เรียกสั่งบวก 24 ชั่วโมง แต่ระยะที่ผ่านไปจริงตามที่ API คืนสั้นกว่านั้น
เพราะคืนนั้นนาฬิกาของ New York กระโดดข้ามช่วงหนึ่งไป และการบวกบนหน้าปัดไม่ได้นับช่วงนั้น
ห้าภาษาในตารางเดียว บนเครื่องที่วัด
หัวข้อที่มีชื่อว่า “ห้าภาษาในตารางเดียว บนเครื่องที่วัด”รวมค่าของสองการบวกจากห้าภาษา พร้อมรุ่นของ tzdata ที่แต่ละภาษาอ่านจริง
| ภาษา | บวกหนึ่งวันตามปฏิทิน | บวก 24 ชั่วโมง | tzdata ที่ใช้ |
|---|---|---|---|
Java openjdk 25.0.4.1 | plusDays(1) → 2026-03-08T12:00-04:00[America/New_York] | plus(Duration.ofHours(24)) → 2026-03-08T13:00-04:00[America/New_York] | 2026b · tzdb.dat ของ JDK |
Go go1.27.0 | AddDate(0, 0, 1) → 2026-03-08T12:00:00-04:00 | Add(24 * time.Hour) → 2026-03-08T13:00:00-04:00 | 2026c · /usr/share/zoneinfo ของระบบ |
Node v24.19.0 ที่ TZ=America/New_York | setDate(getDate() + 1) → 2026-03-08T16:00:00.000Z | getTime() + 864e5 → 2026-03-08T17:00:00.000Z | 2026b · ICU ที่ฝังใน node |
Rust chrono-tz 0.10.4 | checked_add_days(Days::new(1)) → 2026-03-08T12:00:00-04:00 | + Duration::hours(24) → 2026-03-08T13:00:00-04:00 | 2025b · ฝังใน chrono-tz |
Python 3.14.6 | + timedelta(days=1) → 2026-03-08T12:00:00-04:00 | + timedelta(hours=24) → 2026-03-08T12:00:00-04:00 | 2026c · /usr/share/zoneinfo ของระบบ |
ทุกแถววัดบน darwin/arm64 ในทรงที่ probe วัด สี่แถวแรกให้สองการบวกเป็นคนละจุดบนเส้นเวลา และแถวของ Python เป็นแถวเดียวที่สองช่องเท่ากัน
ห้าภาษาบนเครื่องเดียวกันอ่าน tzdata สามรุ่น และในทรงนี้ทั้งห้าได้ offset -04:00 ของวันใหม่ตรงกัน
แต่ตารางที่รวมหลายภาษายังต้องพิมพ์รุ่นของทุกภาษา เพราะคำตอบของคอร์สนี้ขึ้นกับข้อมูลของโซนด้วย
slot ที่ลูกค้าไม่ได้ขอ
หัวข้อที่มีชื่อว่า “slot ที่ลูกค้าไม่ได้ขอ”ภาษาที่แยกสองคำถามผิดทางหนึ่ง และ Python ผิดอีกทางหนึ่ง ทั้งสองทางไม่มีบรรทัดไหนแดง
กลับไปที่ออเดอร์ A-1042 ถ้าคนเขียนระบบเลื่อน slot ด้วยการบวก 24 ชั่วโมงใน Java Go Rust หรือ Node
ที่ TZ=America/New_York slot ใหม่จะเป็น 13:00 ตามนาฬิกาของลูกค้า ลูกค้าขอ 12:00
และรถจะมาถึงช้ากว่าที่ลูกค้ารอ
ถ้าคนเขียนรู้เรื่องนี้แล้วเปลี่ยนไปบวกหนึ่งวัน slot ใหม่จะเป็น 12:00-04:00 ตรงกับที่ลูกค้าขอ
แต่ทีมเดียวกันอาจมีอีกงานหนึ่งที่ต้องการระยะจริง และงานนั้นต้องใช้ API อีกตัว
ทีมที่เขียนด้วย Python เจอปัญหากลับด้าน การเลื่อน slot ได้ 12:00-04:00 ถูกต้อง ไม่ว่าจะสั่งเป็นวันหรือชั่วโมง
แต่งานที่สั่ง timedelta(hours=24) เพื่อนับระยะจริงจะได้ระยะ 23:00:00 แทน
ทางออกไม่ใช่การจำว่าภาษาไหนบวกแบบไหน แต่คือการตัดสินก่อนเขียนว่าแต่ละจุดต้องการ “เวลาเดิมบนหน้าปัด” หรือ “ระยะที่ผ่านไปจริง” แล้วเลือก API ให้ตรงกับคำถามนั้น
ราคาคือทุกจุดที่บวกลบเวลาต้องตอบคำถามนี้เอง ใน Java Go และ Rust ชื่อ API แยกสองคำถามไว้ให้
ส่วนใน Python คำว่า hours=24 ที่บวกเข้ากับ aware datetime บอกสิ่งที่ API ไม่ได้ทำ และไม่มีใครเตือนจนกว่าจะมีคืนที่ offset เปลี่ยน
จุดที่ห้า การบวกข้ามคืนที่เปลี่ยน DST
หัวข้อที่มีชื่อว่า “จุดที่ห้า การบวกข้ามคืนที่เปลี่ยน DST”บทนี้ปิดจุดที่ห้า การบวกเวลาค่าเดียวกันด้วยสองความหมายข้ามคืนที่ offset เปลี่ยน
ธีสิสของคอร์สนี้เป็นประโยคเดียว คือ wall-clock time ค่าหนึ่งไม่ได้ระบุจุดเดียวบนเส้นเวลา มันอาจไม่มีจุดเลย มีสองจุด หรือมีจุดเดียวที่แต่ละภาษาเลือกต่างกัน และในทรงที่ probe วัด มีภาษาเดียวจากห้าภาษาที่บอกคุณ
จุดที่ห้าคือจุดที่บทนี้เพิ่งวัด slot เดิมเป็นจุดเดียวบนเส้นเวลา แต่คำว่า “พรุ่งนี้” พาไปได้สองจุด
ในทรงที่ probe วัด สี่ภาษาแยกสองจุดนั้นด้วย API คนละตัว (Node ที่ TZ=America/New_York)
และ Python พาไปจุดเดียวทั้งสองคำสั่ง
บทที่ 6 ถามคำถามที่ต่อจากนี้ ถ้า slot ใหม่ต้องเดินทางผ่าน JSON ไปที่ระบบอื่น ชื่อโซนที่ทำให้การบวกหนึ่งวันได้ offset ที่ถูกต้องจะไปถึงปลายทางหรือเปล่า
-
Python — datetime objects (เข้าถึง 2026-10-03) — ที่มาของประโยค “Note that no time zone adjustments are done even if the input is an aware object.” และของกฎการลบสองค่าที่มี
tzinfoเดียวกัน -
Python — timedelta objects (เข้าถึง 2026-10-03) — ที่มาของข้อที่
timedeltaเก็บแค่ days, seconds และ microseconds และแปลงชั่วโมงเป็นวินาที -
Java SE 25 — ZonedDateTime (เข้าถึง 2026-10-03) — ที่มาของประโยค “Date units operate on the local time-line.” กับ “Time units operate on the instant time-line.” ของ
plus(long, TemporalUnit)และประโยค “adding one day is not the same as adding 24 hours.” ของplusHours -
Go — package time, func (Time) AddDate (เข้าถึง 2026-10-03) — ที่มาของข้อที่
AddDateใช้ Location ของค่าเวลากำหนดความยาวของวัน และ normalize ผลแบบเดียวกับDate -
ECMAScript — Date.prototype.setDate (เข้าถึง 2026-10-03) — ที่มาของขั้นตอนที่
setDateแปลงค่าเป็นเวลาท้องถิ่นก่อนเปลี่ยนวัน -
chrono 0.4.45 — DateTime::checked_add_days (เข้าถึง 2026-10-03) — ที่มาของข้อที่
checked_add_daysบวกที่ส่วนวันที่ และคืนNoneเมื่อเวลาท้องถิ่นของวันใหม่ไม่มีอยู่จริงหรือมีสองครั้ง -
ค่าทุกค่าในบทนี้มาจาก
scripts/time-semantics/measured.jsonซึ่งscripts/time-semantics/probe.pyเขียนเมื่อ 2026-10-02 บน darwin/arm64 · probe วัดทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง และ Node รันด้วยTZที่ probe ตั้งเองทุกครั้ง
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3`s = datetime(2026, 3, 7, 12, 0, tzinfo=ny)` โดย `ny` คือ `ZoneInfo` ของ `America/New_York` แล้วสั่ง `(s + timedelta(hours=24)).isoformat()` ได้อะไร