Python中的时间问题

410 阅读11分钟
原文链接: zhuanlan.zhihu.com

时间是个永恒的谜,没有人能洞穿它的本质。当我们谈到时间时,它可能是一个纯粹的物理量,也可能是一个社会学概念--比如时区、日历、夏令时等。通过这篇文章,让自己成为时间的朋友,而不是时间的奴隶。

欢迎来到Python时间!


时间简史

这一节的内容主要基于How to work with dates and time with Python,致谢。

关于任何物理量,首要的事情就是确定如何度量。我们知道,时间的标准度量单位是秒。最初秒是由天(地球自转一圈的时间)来定义的,这样就产生了Universal Time(UT1),也称为民用时(civil time)。世界时是以格林尼治所在地的本初子午线的平子夜时起算的平太阳时(具体意义可以百度百科),即世界时的中午12点就是太阳正好经过格林尼治所在经线的时间(此时对格林尼治当地来说,太阳高度最高)。由于各地的人们都希望把正午(即太阳高度最高的时候)定义为中午12时,这样就产生了wall-clock time,即本地时间。下图显示了时区的划分。


注意时区并不是一个地理概念,它是由各国政府定义的,所以时区并不是按照15度经线的间隔来划分的)。因此,要进行时区的转换,我们必须借助数据库。当前最常用的数据库是Olson Database。在Python中,我们可以通过dateutil.tz来获取这个数据库。

from dateutil.tz import gettz
gettz('Europe/Madrid')

出于更高效地利用光照的目的,一些国家和地区还定义了夏令时(DST,daylight saving time),同样它也不是一个地理概念。

由于潮汐、地震等原因,地球上的每一天都是不一样的(after all, tomorrow is another day?!)。每隔一段时间,我们都需要通过闰年来调整时间,使得我们计量时的一年与地球的公转周期保持同步,毕竟万物生长靠太阳。

UT1的时间精度是毫秒级的。为了得到更精确的时间,国际原子时间(International Atomic Time)就产生了,通过分布在全球的几个实验室里的原子钟,我们得到了纳秒级别精确度的时间间隔。这个时间通过网络授时协议(NTP)向全球同步。

由于IAT与UT1的时间精度不一样,导致两者的一秒实际上并不一样长。即使初次经过校准,经过一段时间漂移,IAT与UT1之间必然会发生偏差,这样就引入了UTC(协调世界时),通过给UTC加上闰秒,来使得两者之间可以相互转换。

这些概念介绍得很简单,理解起来并不容易。其实最需要记住的是,我们有好几种时间系统,在不同的时间系统之间进行转换是一件很复杂的事(因为在何时闰年和闰秒,对普通人来说太难计算了)。当你遇到这样的转换任务时,一定要使用正确实现的库。

现在我们还要介绍一种Unix系统中常用的时间,即POSIX时间。由于c库与unix之间的密切关系,这种时间计量也在很多系统、库(包括其它开发语言)中使用。Posix时间定义为自Unix纪元(格林尼治时间1970年1月1日零时)以来逝去的秒数,但并没有考虑到闰秒因素。

Python中跟时间有关的类型和库非常多,包括: - datetime (date, time, datetime, timedelta, relativedelta) - unix time - calendar - dateutil - pytz

上面这些库是比较常用的Python时间库。了解了时间简史(实际上是计时系统简史)之后,我们就能理解为什么没有一个库能完全处理好时间,因此新的库不断开发出来,并且在功能上相互重叠。对这些时间库做一个全面的介绍并无必要,这篇文章主要基于任务视角,以datetime为主,来说明这些库的使用场景及相互转换。在最后,我们还要介绍一个第三方的时间库--Arrow。

datetime

到目前为止,datetime模块仍然是Python中处理时间问题的当打明星(官方)。datetime库包括了date,time和datetime三种时间类型,其中time可能比较少单独使用。Python在这个库的命名方式上很容易让新手困惑,注意当我们提到datetime时,有时候是指的datetime库,有时候指的是datetime.datetime类型,或者该类型的一个实例对象。

我们来看一些基本的任务。

获取当前时间

  • 获取当前的时刻
import datetime as dt
now = dt.datetime.now()

这样获取的是基于系统默认时区的当前时间(刻)。 * 获取今天日期

import datetime as dt
today = dt.date.today()

时间运算

  • N天前/后
today = dt.date.today()
n = 3
nday_after = today + dt.timedelta(days=n) # 三天后
n = -3
nday_before = today + dt.timedelta(days=n) #三天前
  • 当天开始和结束时间 这个功能在做报表时经常用到,主要使用了combine函数和dt.time.min/dt.time.max常量。这里要注意,这个方法可能会有闰秒问题,因为dt.time.max定义为datetime.time(23, 59, 59, 999999),可以看出它是无法处理闰秒的。当然一般情况下可以忽略。
start = dt.datetime.combine(dt.date.today(), dt.time.min)
end  = dt.datetime.combine(dt.date.today(), dt.time.max)
  • 获取本周最后一天
today = dt.date.today()
sunday = today + dt.timedelta(6 - today.weekday())

注意这里我们使用了weekday()。而date对象还有一个isoweekday()方法,二者的区别,weekday将Monday记作0,而isoweekday将Monday记作1。 * 获取本月最后一天 这时我们要使用calendar了。这个库能处理闰年。

import calendar
day = dt.date(2016, 2, 15)
_, last_day_num = calendar.monthrange(day.year, day.month)
last_day_of_month = dt.date(day.year, day.month, last_day_num)
# 2016是闰年, last_day_num为29
  • 上个月最后一天
day = dt.date(2018, 1, 3)
first_day_of_month = dt.date(day=1, month=day.month, year = day.year)
last_day_of_prev_month = first_day_of_month - dt.timedelta(days = 1)
last_day_of_prev_month
---output---
datetime.date(2017, 12, 31)
  • 两个datetime之间的差 前面的例子已经揭示了两个datetime对象之间的差是一个timedelta对象。有时候我们需要将这个差值与整数表示的秒来比较:
import datetime as dt
t1 = dt.date(2018,3,18)
t2= dt.date(2018,3,19)
print((t2-t1).total_seconds())
print(t2-t1).days
--output--
86400.0
1

正如示例所示,我们使用total_seconds来获取两个datetime(date)之间时差的绝对值。

深入讨论时区

datetime.datetime所代表的时间缺省是不包括时区信息的。前面讲过,时区是一个社会和法律的概念。当datetime对象不包括时区信息时,意味着无法对它进行本地化输出(比如使用本地语言文字和时间表示习惯),与另一个本地化(带时区)的时间对象比较,或者从中知道该时间是否是DST。 如果datetime.datetime不包含时区信息,那么它究竟等于哪一个(时区的)时间呢?

import datetime as dt   
import time
t0= time.time()   
t1 = dt.datetime.now() #生成t1时未指定时区。这里未指定tz参数,因此生成的是一个naive时间对象)   
t2 = t1.astimezone(tz=pytz.timezone("Asia/Chongqing"))   
t3 = t1.astimezone(tz=pytz.timezone("UTC"))
print(t1.timestamp())  # timestamp()返回一个整数值,为POSIX时间   
print(t1.tzinfo) # None  
print(t3.tzinfo) # <DstTzInfo 'Asia/Chongqing' CST+8:00:00 STD>
t1.timestamp() == t2.timestamp() == t3.timestamp() # True   
print(t0 - t1.timestamp()) # < 1e-5

上述代码揭示了datetime对象就是它自己,它在数值上等于该时刻的posix时间。我们通过astimezone方法将它转换成UTC时间表示和北京时间表示,但实际上它们所指的时刻都是一致的。一个datetime时刻是否附加有时区信息,并不改变其所代表的时刻(确实,北京时间的2018年12月8日 19:00:00与伦敦时间的2018年12月8日 11:00:00就是同一时刻)。但当我们将一个时间转化成字符串时,或者从一个字符串表示解析出datetime时,我们必须知道时区信息,毕竟北京时间的19:00与伦敦时间的11:00是同一时间,而与伦敦时间的19:00则不是。

比如下面的转换为字符串例子:

import datetime as dt

print(dt.datetime.now().isoformat())
#output: '2018-12-07T04:36:22.373780'
print(dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
#  output: '2018-12-07 04:36:55'
print(dt.datetime.now(tz=pytz.timezone("Asia/Chongqing")).isoformat())
# output: '2018-12-07T20:41:05.005334+08:00'

在上面的代码中,我们获取当前时间(dt.datetime.now())时,没有指定tz参数。这样在后面输出其字符串时间时,会使用当前系统默认的时区。isoformat的两次输出更是清楚地表明了,当datetime带有时区信息时,是如何影响到其字符串表示的。

时间转换

我们的程序不可能孤立存在。它可能从其它信息源(比如文本文件、数据库、网络)读取到时间变量,或者将一个时间变量传递给这些系统。或者我们使用了第三方的库,他们对时间有着不同的处理方法。这一节讨论常见的转换。

前面的例子已经提到了两种转换,一是将一个datetime对象转换成其字符串表示;二是将其转换成为posix时间表示(即整数值)。现在我们来看看它的逆变换。 * strptime(date_string, format)

dt.datetime.strptime("21/11/06 16:30", "%d/%m/%y %H:%M") #不含时区
dt.datetime.strptime('2018-12-07T22:29:30.187475+08:00', "%Y-%m-%dT%H:%M:%S.%f+08:00" ) #含时区
# output is: datetime.datetime(2006, 11, 21, 16, 30)
# output is: datetime.datetime(2018, 12, 7, 22, 29, 30, 187475)
  • dateutil.parser.parse方法
from dateutil import parser
d1 = parser.parse('Tue Jun 22 12:10:20 2010 EST')
d2 = parser.parse('2015-02-04T20:55:08.914461+00:00')
print(d1, d1.tzinfo)
print(d2, d2.tzinfo)
2010-06-22 12:10:20 None
2015-02-04 20:55:08.914461+00:00 tzutc()

你可能会失望,解析结果d1并不包含时区信息。原因是EST指代的时区并不惟一。d2表明了parser可以很好地处理isoformat格式的时间。 * fromtimestamp(int) 用于将posix时间转换为datetime,当然转换出来的结果是不包含时区的。

import datetime as dt
import time
dt.datetime.fromtimestamp(time.time())
  • 格式字符串
%a 本地简化星期名称
%A 本地完整星期名称
%b 本地简化的月份名称
%B 本地完整的月份名称
%c 本地相应的日期表示和时间表示
%d 月内中的一天(0-31)
%H 24小时制小时数(0-23)
%I 12小时制小时数(01-12)
%j 年内的一天(001-366)
%m 月份(01-12)
%M 分钟数(00=59)
%p 本地A.M.或P.M.的等价符
%S 秒(00-59)
%U 一年中的星期数(00-53)星期天为星期的开始
%w 星期(0-6),星期天为星期的开始
%W 一年中的星期数(00-53)星期一为星期的开始
%x 本地相应的日期表示
%X 本地相应的时间表示
%y 两位数的年份表示(00-99)
%Y 四位数的年份表示(000-9999)
%Z 当前时区的名称
%% %号本身
  • 与ctime之间的转换 略
  • 与数据库时间格式之间的转换 如果数据库存储的是posix时间,那么不会存在转换问题。如果数据库字段是数据库内置的时间类型,而在python中使用datetime来保存时间的话,最好带上时区信息--因为此时我们是跨系统处理时间,中间会经历多种时间转换。一般来说,只要在python中的datetime对象带上了时区,数据库驱动一般应该能正确将其转换成数据库内置时间格式--或者转换回来。

tips

  1. 不同的系统、数据源可能使用不同的时区,当你串行化、跨系统传递/接收一个时间对象时,注意带上时区。
  2. 进行时间算术运算、或者基于timestamp进行计算时,一定只使用UTC。
  3. 对某些时区,一天并不等于24小时。
  4. 参考2列出了一些常见的pitfall,值得一读。

arrow

要使用这么多库才能完成时间的运算,不免让人困惑。一些第三方工具包试图解决这个问题。 arrow是一个值得推荐的时间库。我推荐它的理由有二,一是这名字很赞,一语道破时间的本质特征;二来它在github上已经有5k以上的stars,而且功能全面,API也比较清晰一致。

结论

也许读完了这篇文章,还是觉得在Python中处理时间是一种痛?没关系 ,Time heals everything, just take your time.

reference

  1. 10 things you need to know about Date and Time in Python with datetime, pytz, dateutil & timedelta
  2. How to work with dates and time with Python