程式中的時間議題

簡介

如果要做一個國際化的應用程式,就需要對應各個國家/文化的時間格式。

議題

  1. 時間的表示法
    1. 格式、排序
    2. 年份
      1. 紀元
      2. 年號
    3. 月份
    4. 星期
      1. 每個星期的開始日
    5. 小時
    6. 時區
    7. 曆法
    8. 一段時間
  2. 時區
    1. 夏令時間
    2. 閏年、閏月、閏秒
  3. 實作
    1. 資料儲存
    2. 精度
    3. 時間同步
    4. 生日
    5. 時間段和時間點

時間表示法

參照ISO8601標準,下列以⭐代表標準表示法。

格式

年由4位數字組成YYYY,月、日用兩位數字表示:MM、DD。
只使用數字為基本格式。使用短橫線"-"間隔開年、月、日為擴展格式。

  1. 日曆日期表示法
  • 2022-05-21 (⭐)
  • 20220521 (⭐)
  • 2022/05/21
  • 2022,05,21
  • 2022.05.21
  1. 順序日期表示法:將一年內的天的序數用3位數字表示
  • 2022-141 (⭐)
  • 2022141 (⭐)
  1. 星期日曆表示法:用2位數表示年內第幾個日曆星期,再加上一位數表示日曆星期內第幾天,但日曆星期前要加上一個大寫字母W。每個日曆星期從星期一開始,星期日為第7天
    • 2022-W21-6 (⭐)
    • 2022W216 (⭐)
    • 2022-W01-1是從2022年1月3日開始的,前幾天屬於上年的第53個日曆星期

年月日排序

  • 年 月 日 (⭐)
  • 月 日 年
  • 日 月 年

年份

紀元

  1. 西元/公元
    • BC(Before Christ)/AD(Anno Domini)
    • BCE(Before Common Era)/CE(Common Era)
    • 正負號表示 (⭐)
      • 公元1年為0001年,公元前1年為0000年,公元前2年為-0001年
  2. 年號
    • 日本、(中華)民國
    • 年號會增加大量轉換成本

❓某民國什麼時候要廢年號

月份

  • 01, 02, 03, ... (⭐)
  • January, Febuary, March...
  • Jan, Feb, Mar, ...
  • 1, 2, 3, ...

星期

通常會用當地語言表示

  • 1, 2, 3, 4, 5, 6, 7 (⭐)
  • Sun, Mon, Tue, ...
  • 日、月、火、水、木、金、土 (日本)
  • 一、二、三、四、五、六、日

星期的開始日:會影響日曆的顯示

  1. 星期日(西方國家、日本、港澳)
  2. 星期一(大多亞州國家,⭐)
  3. 星期六(埃及)

小時

  • 24小時制 (⭐)
  • 12小時制
  • 12:00是pm還是am
    • 我很久以前就有的疑惑
    • 一般來說,12:00pm是中午,12:00am是午夜

時區

  • 如果時間在零時區,那麼(不加空格地)在時間最後加一個大寫字母Z
  • 其他時區用實際時間加時差表示
    • 22:30:05+08:00 (⭐)
    • 223005+0800 (⭐)
    • 223005+08

日期+時間

在時間前面加一大寫字母T

  1. 2004-05-03T17:30:08+08:00 (⭐)
  2. 20040503T173008+08 (⭐)

曆法

公曆(格勒哥里曆)之外,目前廣泛使用的曆法:

  • 希伯來曆(猶太曆)
  • 伊斯蘭曆(回回曆)
  • 農曆
    • 陰陽合曆:其日期採朔望月以指示月球的相位,年則與太陽相關

一段時間

時間段:前面加一大寫字母P

  • P1Y3M5DT6H7M30S(一年三個月五天六小時七分三十秒內) (⭐)
  • 19850412/19860101(起始/結束) (⭐)
  • 19850412/P6M(起始/一段時間) (⭐)
  • P6M/19860101(一段時間/結束) (⭐)

循環時間:前面加上一大寫字母R

  • R{循環次數}/{開始時間}/{時間間隔}/{結束時間}
    • R3/20040506T130000+08/P0Y6M5DT3H0M0S (⭐)
    • R/P1Y2M/20250101(無限次循環) (⭐)

時區

  • 時區和經度沒有絕對關係
    • 夏令時間
    • 換日線不是直線
    • UTC+8的地區有台北、北京、新加坡、印尼等,但新加坡實際的地理位置在UTC+7
    • 中國全境使用相同時區(UTC+8)

所以在選擇時區時,實際上是在選擇國家或地區

夏令時間(Daylight Saving Time)

以實作上來看,這是最噁心的規則

  1. 有些地區在接近春季開始的時候,會將時間調快一小時,並在秋季調回來
  2. 這代表春季的某一天會少一個小時,秋季的某一天會多一個小時
    1. 如2018年的夏令時間就直接跳過了2018年3月25日3:00:00到3:59:59

以上述夏令時間為例,假設用戶設定3:30要執行備份任務,有以下實作方法

  1. 檢查經過某個時間點
    • if (nowTime == 3:30) { runTask(); }
    • 任務不會執行
    • 如果這是一年執行一次的任務,問題就大了
  2. 檢查超過某個時間點
    • if (nowTime > 3:30 and !isRunTask) { isRunTask=true; runTask(); }
    • 這種情況,任務會在3:00過後馬上執行(也就是顯示為4:00的時候)
    • 和用戶想像中不同,並不是3:00再經過30分鐘的時候執行
      • 如果這個任務有前提任務,而且在3:10分執行,此時會同時觸發執行
        • 可能會因為執行順序不同而出錯
  • 同理,離開夏令時間時,同一個任務可能會被執行兩次
  • 用戶如果設定「12小時後」執行任務,也會發生類似的問題
    • 用倒數的方法:實際的12小時後
    • 用時鐘時間:時鐘上的12小時後

用TimeStamp也無法避免這個問題,會拿到錯的時間
(有點懷疑是不是我寫錯)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import arrow
from datetime import datetime

timezone = "Europe/Athens" # 有夏令時間的時區

now = arrow.get(datetime(2018, 3, 25, 2, 30, 0), timezone) # 夏令時間前
print(now)
print(now.int_timestamp)
now = arrow.get(datetime(2018, 3, 25, 3, 0, 0), timezone) # 進入夏令時間(實質UTC改變)
print(now)
print(now.int_timestamp)
now = arrow.get(datetime(2018, 3, 25, 3, 30, 0), timezone) # 夏令時間後
print(now)
print(now.int_timestamp)
1
2
3
4
5
6
2018-03-25T02:30:00+02:00
1521937800
2018-03-25T03:00:00+03:00
1521936000
2018-03-25T03:30:00+03:00
1521937800

所以如果要處理上述問題,我目前想到的方法只有在每一次檢查時間時,額外檢查是否進入/離開夏令時間

閏年、閏月、閏秒

會造成和夏令時間類似的問題

  • 閏秒
    • 消彌精確的時間(使用原子鐘測量)和不精確的觀測太陽時(UT1)之間的差異
    • 因為地球的旋轉速度會隨著氣候和地質事件變化,因此UTC的閏秒間隔不規則且不可預知
    • Unix實作:重複23:59:59一次,或新增23:59:60
    • 🆕 2022年11月,在第27屆國際計量大會決定於2035年取消閏秒
  • 閏日
    • 彌補因人為曆法規定的年度天數365日和平均回歸年的大約365.24219日的差距
    • 雖然只多一天,不過普遍被叫做閏年,即閏日出現的年份
    • 公曆(典型程式題)
      • 公元年分非4的倍數,為平年
      • 公元年分為4的倍數但非100的倍數,為閏年
      • 公元年分為100的倍數但非400的倍數,為平年
      • 公元年分為400的倍數為閏年
  • 閏月
    • 農曆
      • 平年比一回歸年少約11天
      • 3年一閏,5年二閏,19年七閏
      • 閏月加到哪個月,以農曆曆法規則推斷,主要依照與農曆的二十四節氣相符合來確定
      • 基本上19年為一周期對應於公曆同一時間,但亦有部分例外
      • 每年天數差距過大,不利以年計算的會計 (梁啟超《改用太陽曆法議》)
  • 閏年
    • 真正的閏年,即於原本的曆法中插入一年
    • 古埃及人在1460個地球公轉週期中加入一整年

實作

資料儲存

上下限

  • 2038年問題
    • 因為儲存時間的格式為int32,只能儲存從1970-01-01到2038-01-19T03:14:07Z的時間
    • 受影響的資料
      • 使用POSIX時間表示時間的程式
      • 使用int儲存時間的程式語言,如C語言
      • 使用int儲存時間的檔案格式,如.zip

精度

  • 一個system tick會更新一次時間
    • 10MHz的情況下,可得100ns的精度
    • 預設的tick rate沒有這麼高
  • Linux: tickless kernel
    • 沒有詳細研究,看起來是自訂tick rate的工具

時間同步

NTP(Network Time Protocol)

  • 校正本地的UTC時間
    • 定期輪詢不同網路上的三個或更多伺服器,計算其時間偏移量和來回通訊延遲

Bug

  1. 千禧蟲問題
    • 工程師的技術債
    • 自訂年號的國家會更容易遇到,如民國百年蟲問題、日本更換年號問題
  2. 用戶手動(或惡意)調整時間/時區
    • 舊資料的修改時間可能會大於新資料的修改時間
    • 解法
      • 不要相信任何本機檔案的時間
        • 在資料庫中記錄時間

生日

雖然上述的時間大多需要考慮時區,但是生日似乎是個例外

不管人在何處,都是在當地的日期過生日

時間段和時間點

通常用Instant代表時間點,Duration代表時間段

套件

  • Python
    • 標準Library(datetime, time, zoneinfo, calendar, dateutil)
    • arrow
      • 光陰似箭?
  • Javascript
    • Moment.js
    • Day.js
  • Java
    • java.time
  • C#
    • Noda Time
  • C++
    • <chrono>

總結

時間相關的問題能夠有效的產生一堆bug。

建議在儲存資料時使用UTC+0時間或TimeStamp,顯示時才切換,以避免許多可能的bug,但是會付出額外的轉換成本。

實際上,使用者也沒在管這些標準;預設格式如果不符合使用者的習慣格式就會被嫌棄。

當然,貨幣、度量衡、標點符號等習慣用法也有和時間一樣的問題,這些被統稱為本地化(Localization)問題。

參考資料

  • Wiki:各國家時間表示法
  • Wiki:農曆
  • Wiki:置閏
  • Wiki:夏令時間
  • 三分鐘科普夏令時,並用Javascript判斷當前時間是否屬於夏令時
  • Wiki:2038年問題
  • How precise is the internal clock of a modern PC?
  • Precision and accuracy of DateTime
  • Wiki: 網路時間協定