Unix 时间戳和日期的转换
目录
Introduction
本文主要记录了 Unix 时间戳和具体的日期时间互相转换的一些算法,主要参考了 Howard Hinnant 的文章。其实本文有点像对原文章的摘选和翻译,原文章对下述算法有更多的细节和说明,例如原文章用 C++ 实现,我出于个人习惯用 Python 实现,但因此省略了原始文章的类型、静态断言等细节。对这些原理感兴趣的读者请一定要去阅读原文。
首先介绍 Unix 时间戳。Unix 时间戳 (Unix timestamp) 是从 1970 年 1 月 1 日 00:00:00 (UTC) 开始所经过的秒数(不考虑闰秒)。而这个计时起点 1970 年 1 月 1 日 00:00:00 (UTC) 也被称为 Unix epoch (1970 年也是 Unix 诞生的时间)。Unix 时间戳可以说已经成为计算机系统表示时间的事实标准。如果要考虑时区,只需要加上当地时区相比 UTC 偏移的秒数即可。
然后开始介绍 Unix 时间戳和日期时间的转换。首先,时/分/秒的转换无疑是非常简单的。因为每天的小时数,每小时和每分钟的秒数都是固定。每天只有 86400 秒,Unix 时间戳除以 86400 的商就是从 Unix epoch 开始经过的总天数,余数就是当天剩余的秒数。通过简单的计算就可以得出时/分/秒。具体计算如下代码所示。
timestamp = 1791788426 # 2026-10-12 07:00:26 (UTC)
days = timestamp // 86400 # 总天数
remaining_seconds = timestamp % 86400 # 当天剩余秒数
hour = remaining_seconds // 3600 # 小时
minute = (remaining_seconds % 3600) // 60 # 分钟
second = remaining_seconds % 60 # 秒
assert(f"{hour:02}:{minute:02}:{second:02}" == "07:00:27")
同理,在已知时/分/秒后,也可以反向计算出当天剩余秒数。于是剩下的问题就是总天数和年/月/日的转换。
现在介绍 Howard Hinnant 的日期转换算法。这些算法基于一种前推格里历 (Proleptic Gregorian Calendar)的假设,即将 1582 年颁布的现行公历(格里历) 向前和向后推算应用,无论是公元 0 年乃至负数年份,都应用格里历的规则。在这种假设下,除非发生溢出1,算法都是绝对准确的。后文会按照原始文章的称呼,将格里历称为民用历(civil calendar)。
days_from_civil
日期转为总天数
def days_from_civil(year: int, month: int, day: int) -> int:
year -= 1 if month <= 2 else 0
era = year // 400
year_of_era = (year - era * 400) # [0, 399]
month = month - 3 if month > 2 else month + 9
day_of_year = (153 * month + 2) // 5 + day - 1 # [0, 365]
day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year # [0, 146096]
return era * 146097 + day_of_era - 719468
assert(days_from_civil(2026, 10, 12) == 1791788426 // 86400)
首先算法将日期进行了偏移,从而能把 3 月 1 日作为一年的第一天,这样的话闰日 2 月 29 日就会变成了这年的最后一天,或者说是前一年的最后一天。下文的其他算法也会假定 3 月 1 日是一年的第一天。
然后算法定义了 era ,即 400 年的时间段。因为民用历每 400 年会重复一次,所以只需调试一个 era (146097
天)以及 era 之间的转换过程,即可完成对所有时间的调试。
day_of_year 的计算分为三步:
首先将普通月份进行映射,即把 [1-12] (一月-十二月) 映射为 [0-11] (三月-十二月, 一月, 二月)2
计算 3 月 1 日与当前月份的第一天之间的天数。
(153 * month + 2) / 5原理是在以 3 月 1 日为首时,3-7 月,8-12 月这两段连续 5 个月的总天数恰好是 153 天,加 2 则是对误差的修正。从而能够用一次乘法和一次除法实现替代查表的优化。加上本月当前日期与本月第一天之间的数天
day_of_era 的计算主要就是考虑闰年多的一天。并且因为 year_of_era 在 [0, 399] 中,因此不要考虑每 400
年的那一天。
最后的总天数就是 era 的总天数加上 day_of_era ,但这样计算的是到公元 0 年 3 月 1 日的总天数,因此要减去偏移量,从而得出到 Unix epoch 的总天数。
civil_from_days
总天数转为日期
def civil_from_days(days: int) -> Tuple[int, int, int]:
days += 719468
era = days // 146097
day_of_era = days - era * 146097 # [0, 146096]
year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - day_of_era // 146096) // 365 # [0, 399]
year = year_of_era + era * 400
day_of_year = day_of_era - (year_of_era * 365 + year_of_era // 4 - year_of_era // 100) # [0, 365]
month = (5 * day_of_year + 2) // 153 # [0, 11]
day = day_of_year - (153 * month + 2) // 5 + 1 # [1, 31]
month = month + 3 if month < 10 else month - 9 # [1, 12]
year += 1 if month <= 2 else 0 # [1, 12]
return year, month, day
assert(civil_from_days(1791788426 // 86400) == (2026, 10, 12))
本算法中会使用上一章节的不少概念和假设,请先阅读完上一章节,这样能更好的理解。
第一步是把到 Unix epoch 的总天数转换位到公元 0 年 3 月 1 日的总天数。
year_of_era, day_of_year 的计算都需要考虑闰年。
day 和 month 的计算公式来源在上一章节中有所介绍。
最后将 year 和 month 从以 3 月 1 日作为一年的第一天的结果转换以 1 月 1 日作为第一天的结果。
其他算法
上述的两个算法已经将总天数和日期的转换问题解决了,但原文还介绍了一些其他常用算法,这里也一并介绍。原文对这些算法的性能开销等方面有更多的讲述,感兴趣的读者请阅读原文。
is_leap
某一年是否为闰年
def is_leap(y: int) -> bool:
return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)
assert(is_leap(2100) == False)
last_day_of_month
某月的天数
def last_day_of_month_common_year(m: int) -> int:
l = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return l[m-1];
assert(last_day_of_month_common_year(2) == 28)
def last_day_of_month_leap_year(m: int) -> int:
l = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return l[m-1];
assert(last_day_of_month_leap_year(2) == 29)
def last_day_of_month(y: int, m: int) -> int:
return last_day_of_month_common_year(m) if (m != 2 or not is_leap(y)) else 29
assert(last_day_of_month(y = 2100, m = 2) == 28)