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. 首先将普通月份进行映射,即把 [1-12] (一月-十二月) 映射为 [0-11] (三月-十二月, 一月, 二月)2

  2. 计算 3 月 1 日与当前月份的第一天之间的天数。

    (153 * month + 2) / 5 原理是在以 3 月 1 日为首时,3-7 月,8-12 月这两段连续 5 个月的总天数恰好是 153 天,加 2 则是对误差的修正。从而能够用一次乘法和一次除法实现替代查表的优化。

  3. 加上本月当前日期与本月第一天之间的数天

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)

  1. 事实上,使用 32 位算术运算时,溢出大约发生在 ±580 万年左右;而使用 64 位算术运算时,溢出则会在远远超出宇宙年龄的范围内发生。因此在可预见的未来都不用担心溢出的问题。 ↩︎

  2. 原文详细讲述了如何计算出映射公式,写得很好。最后的公式为 month = (month + 9) % 12 ,但原文作者在一些性能测试后(C++ 中)发现分支结构比取模运算更快,这确实是我不知道的。 ↩︎