在计算机程序中精确的处理日期是困难的。不仅有显而易见的(英语: January, 法语: Janvier, 德语: Januar, 等)国际化需求, 而且得考虑不同的日期系统(并非所有的文化都用基督耶稣的生日作为纪年的开始)。如有高精度或非常大规模的时间需要被处理, 就有额外的方面需要被注意,比如闰秒或时间系统的变化。(公历(阳历, 格里高利历法)在西方被普遍接受是在1582年,但并非所有的国家在同一天接受!)
尽管有关于闰秒, 时区, 夏令时, 阴历的问题, 度量时间却是一个非常简单的概念: 时间的进行是线性的很容易被忽略。一旦时间轴的区域被定义, 任何时间点被从起点时间的流逝就可以确定。这和地理位置或当地时区是独立的 – 对任意指定的时间点, 对任意地区, 从起点的过程是相同的(忽略相对论的矫正)。
--------------------------------------------------------------------------------
可当我们试图根据某些日历解释这一时间点的时候困难来了, 比如, 根据月, 日, 或者年来表示它。在这一步上地理信息变得相关: 在时间上的同一个点对应不同的天的某一时间, 依赖于区域 (比如: 时区)。基于解释日期的修正经常是必要的(今天一个月以后是哪一天?) 并且增加了额外的困难: 上溢和下溢(12月15号的后一个月是下一年), 且不明确(1月30号后的一个月是哪一天?).
在最初的JDK 1.0, 一个时间点, 通过把它解释为java.util.Date类, 它被计算在一起来表示. 虽然相对容易处理, 但它并不支持国际化; 从JDK 1.1.4 或JDK 1.1.5, 多样的负责处理日期的职责被分配到以下类中:
java.util.Date
代表一个时间点.
abstract java.util.Calendar
java.util.GregorianCalendar extends java.util.Calendar
解释和处理Date.
abstract java.util.TimeZone
java.util.SimpleTimeZone extends java.util.TimeZone
代表一个任意的从格林威治的偏移量, 也包含了适用于夏令时(daylight savings rules)的信息.
abstract java.text.DateFormat extends java.text.Format
java.text.SimpleDateFormat extends java.text.DateFormat
变形到格式良好的, 可打印的String, 反之亦然.
java.text.DateFormatSymbols
月份, 星期等的翻译, 作为从Locale取得信息的一种替代选择.
java.sql.Date extends java.util.Date
java.sql.Time extends java.util.Date
java.sql.Timestamp extends java.util.Date
代表时间点, 同时为了在sql语句中使用也包含适当的格式.
注意: DateFormat 和相关的类在java.text.*包. 所有的java.sql.*包中日期处理相关类继承了java.util.Date类. 所有的其它类在java.util.*包中.
这些"新"类来自三个分离的继承层次, 其顶层类(Calendar, TimeZone, and DateFormat)是抽象的. 针对每一个抽象类, Java标准类库提供了一个具体的实现.
java.util.Date
类java.util.Date 代表一个时间点. 在许多应用中, 此种抽象被称为"TimeStamp." 在标准的Java类库实现中, 这个时间点代表Unix纪元January 1, 1970, 00:00:00 GMT开始的毫秒数. 因而概念上来说, 这个类是long的简单封装.
根据此种解释, 类中仅有的没有过期的(除了那些毫秒数的get和set方法)是那些排序方法.
这个类依靠System.currentTimeMillis() 来取得当前的时间点. 因此它的准确度和精度由System的实现和它所调用底层(本质是操作系统)决定.
The java.util.Date API
在最初的 Date类使用中名字和约定引起了无尽的混淆. 然而用0-11计算月, 从1900计算年的决定模仿了C标准类库的习惯, 调用函数 getTime()返回起始于Unix纪元的毫秒数和 getDate()返回星期的决定显然是Java类设计者自己的.
java.util.Calendar
语义
Calendar代表一个时间点(一个"Date"), 用以在特定的区域和时区适当的解释器. 每一个Calendar 实例有一个包含了自纪元开始的代表时间点的long变量.
这意味着Calendar 不是一个(无状态) 变换者或解释器, 也不是一个修改dates的工厂. 它不支持如下方式:
Month Interpreter.getMonth(inputDate) or
Date Factory.addMonth(inputDate)
Instead, Calendar实例必须被初始化到特定的Date. 此Calendar实例可以被修改或查询interpreted属性.
奇怪的是, 此类的instances 总是被初始化为当前时间. 获得一个初始化为任意Date的Calendar 实例是不可能的—API强制程序员通过一系列的在实例上的方法调用, 比如setTime(date)来显式的设置日期.
访问Interpreted 字段和类常量
Calendar 类遵从一不常用的方式来访问interpreted date实例的单个字段. 而不是提供一些dedicated属性 getters和setters方法(比如getMonth()), 它仅提供了一个, 使用一个标示作为参数来获取请求的属性的方法:
int get(Calendar.MONTH) 等等.
注意这个函数总是返回一个int!
这些字段的标示符被定义为Calendar类的public static final变量. (这些identifiers是raw的整数, 没有被封装为一个枚举抽象.)
除了对应这些字段标示(键值), Calendar 类定义了许多附加的public static final 变量来保存这些字段的值. 因此, 为测试某一特定date (由Calendar 的实例calendar表示) 是否在一年的第一个月, 会有像如下的代码:
if (calendar.get(Calendar.MONTH) == Calendar.JANUARY) {...}
注意月份被叫做 JANUARY, FEBRUARY, 等等, 不管location(相对更中性的名字比如: MONTH_1, MONTH_2, 等等). 也有一个字段UNDECIMBER, 被一些(非公历(阳历, 格里高利历法))日历使用, 代表一年的第十三个月.
不幸的是, 键值和值既没有通过名字也没分组成嵌套的inerface来区分.
处理
Calendar 提供了三种办法来修改当前实例代表的日期: set(), add(), 和roll(). set()方法简单的设置特定的字段为期望的值. add() 和 roll() 的不同在于它们处理over- and underflows: add() 传递变更到"较小"或"较大"的字段, 而roll()不影响其它字段. 比如, 当给代表12月15号的Calendar实例增加一个月, 当add()使用年会增加, 但使用roll()不会发生任何变化. 为每一种case对应一个函数的决定的动机是, 它们可能在GUI中不同的使用情形.
由于 Calendar的实现的方式, 它包含冗余的数据: 所有的字段都可以从给定的时区和纪元开始的毫秒数计算出来,反之亦然. 这个类为这些操作分别定义了抽象方法computeFields()和computeTime(), 又定义了complete()方法执行完全的来回旅程. 因为有两套冗余的数据, 这两套数据可能不同步. 根据类的JavaDoc文档, 当发生变更的时候依赖的数据以lazily 的方式重新计算. 当重新计算需要的时候, 子类必须维护一套脏数据标志作为符号.
--------------------------------------------------------------------------------
实现的Leakage
对于一个”新”的日期相关处理类, 不得不说实现的细节在某种程度上被泄漏到了API中. 在这点上, 这是它们有意用作基类的自定义开发的反映, 但它也偶然看出是不充分清晰设计一个公共接口的结果.Calendar 抽象是否维护两个冗余数据集合完全是一个实现的细节, 因而应当对客户类隐藏. 这也包括打算通过继承来重用此类.
附加的功能
Calendar基类提供的附加功能分成三类. 几个静态的工厂方法来获得用任意时区和locales初始化的实例. 如前面提到的, 所有以这种方式获得实例已经被初始化为当前时间. 没有工厂方法被提供来获得初始化为任意时间点的实例.
第二组包含before(Object)和after(Object)方法. 它们接受Object类型的参数, 因而允许这些方法被子类以任意类型的参数覆盖掉.
最后, 有许多附加的方法来获得设置附加的属性, 比如当前的时区. 当中有几个用以查询特定字段在当前Calendar实现下的可能和实际的最大、最小值.
java.util.GregorianCalendar
GregorianCalendar 是仅有可用的Calendar的子类. 它提供了基础Calendar抽象适合于根据在西方的习惯解释日期的实现. 它增加了许多public的构造函数, 也有针对于Gregorian Calendars的方法, 比如isLeapYear().
java.util.TimeZone 和 java.util.SimpleTimeZone
TimeZone 类和其子类是辅助类, 被Calendar用以根据选择的时区来解释日期. 按字面意思来说, 一个时区表示加到GMT上后到当前时区的一定的偏移. 显然, 这个偏移在夏令时有效的时候会发生变化. 因而为了计算对于给定日期和时间的本地时间, TimeZone抽象不仅需要明白当DST有效时的额外偏移, 而且还需明白什么时候DST有效的规则.
抽象基类 TimeZone 提供了基本的处理"raw"(没有考虑夏令时)实际偏移(用毫秒数!)的方法, 但任何关于DST规则的功能实现被留给了子类, 比如SimpleTimeZone. 后者提供了许多方法来指定控制DST开始和结束的规则, 比如在一个月中明确的某一天或某一天随后的周几. 每一个TimeZone 有一个可读的, 本地无关的显示名. 显示名以两种风格: LONG和SHORT呈现.
星期的开始?
Calendar 的文档投入了相当的文字来正确的计算月或年中的weeks. weekday 被认为是一周的开始在因国家的不同而不同. 在美国, 一周通常被认为从周日开始. 在部分欧洲国家一周从周一开始结束于周日.这将影响到哪一周被认为是在一年(或月)第一个完整的周, 和计算一年的周数.
时区由一标示字符串明确的决定. 基类提供静态方法String[] getAvailableIDs()来获得所有已知安装(JDK内带有)的标准时区. (在我的安装内有557个, JDK1.4.1) 假如需要, JavaDoc 定义了严格的建立自定义时区标示符的语法. 也提供了静态工厂方法用以获取 — 指定ID或缺省的当前时区的TimeZone 实例. SimpleTimeZone提供了一些公有的构造函数, 奇怪的是对于一个抽象类, 如TimeZone. (JavaDoc 写到 "子类构造函数调用." 显然, 应该声明为protected.)
java.text.DateFormat
尽管Calendar和相关类处理locale-specific日期的解释,仍有DateFormat 类辅助日期和(人类)可阅读字符串之间的变换. 表示一个时间点时, 会出现附加的本地化问题: 不仅仅在语言, 而且日期格式是地区独立的(美国: Month/Day/Year,德国: Day.Month.Year, 等等). DateFormat 尽力地为程序员管理这些不同.
抽象基类DateFormat不需要(且不允许) 任意的, 程序员定义的日期格式. 作为替代, 它定义了四种格式化风格: SHORT, MEDIUM, LONG, 和FULL (以冗余增加的顺序).对一给定locale和style, 程序员可依靠此类获取适当的日期格式.
抽象基类DateFormat 没有定义静态方法来完成文本和日期之间的格式化和转换. 作为替代, 它定义了几个静态工厂方法来获取被初始化为给定locale和选定style的实例. 既然标准的格式化总是包含日期和时间, 附加工厂方法可用来获取仅处理时间或日期部分的实例. String format(Date)和Date parse(String) 方法然后执行变形. 注意具体的子类可以选择打破这种习惯.
在其内部使用, 解释日期的Calendar对象是可访问和修改的, TimeZone和NumberFormat对象也同样. 然而, 一旦DateFormat 被实例化locale和style就不能再修改.
亦有可用的(抽象的)用以拼接的字符串解析和格式化的方法, 分别接受额外的ParsePosition或FieldPosition参数. 这些方法的每一个都有两个版本. 一个接受或返回Date实例另一个接受或返回普通的Object, 来允许在子类中有选择性的处理Date. 它定义了一些以_FIELD 结尾的public static变量来标示多种可能和FieldPosition一起使用的变量(cf. java.util.Format的Javadoc).
仅有且最常用的DateFormat类的具体实现是SimpleDateFormat. 它提供了所有上述的功能, 且允许定义任意的时间格式. 有一套丰富语法来指定格式化模式; JavaDoc提供了所有细节. 模式可以被指定为构造函数的参数或显式的设置.
Printing a Timestamp: A Cut-and-Paste Example
想象你要用用户定义的格式打印当前的时间; 比如, 到log文件. 以下就是做这些的:
// 创建以下格式的模式: Hour(0-23):Minute:Second
SimpleDateFormat formatter = new SimpleDateFormat( "HH:mm:ss" );
Date now = new Date();
String logEntry = formatter.format(now);
// 从后端读入
try {
Date sometime = formatter.parse(logEntry);
} catch ( ParseException exc ) {
exc.printStackTrace();
}
注意需要被catch的ParseException. 当输入的字符串不能被parse的时候被抛出.
java.sql.*相关类
在java.sql.*包中的日期时间处理类都继承了java.util.Date. 事实上它们三个反映了三种标准SQL92模型的类型需要DATE, TIME, and TIMESTAMP.
像java.util.Date, SQL包中的这三个类是表示一个时间点的数字的简单封装. 分别地Date和Time类忽略关于一天中的时间或日历的日期.
可Timestamp类, 不仅包含到毫秒精度, 通常的时间和日期, 而且允许存储附加的精确到纳秒精度的时间点的数据. (纳秒是一秒的十亿分之一)
除了影射对应的SQL数据类型, 这些类处理与SQL一致的字符串表示的转换. 在这一点, 这三个类中的每一个覆盖了toString()方法. 此外, 每个类提供了静态的工厂方法, valueOf(String), 返回被初始化为传递参数字符串表示的时间的当前调用类的实例. 这三个方法的字符串表示的格式已被SQL标准选定, 且不能被程序员改变.
存储纳秒需要的额外数据, 没有很好的与在Timestamp中其它通常的时间和日期信息的表示一致. 比如, 在Timestamp实例上调用 getTime() 将返回自Unix纪元开始的毫秒数,忽略了纳秒数据. 简单地, 根据JavaDoc文档, hashCode() 方法在子类中没有被覆盖, 因而也忽略了纳秒数据.
java.sql.Timestamp的JavaDoc指出"inheritance relationship (...) 实际表示实现的继承, 而不是类型继承(这违反了继承的初衷). 但即使这句话是错误的, 既然Java没有私有继承的概念(也即继承实现). 所有java.sql.*包中的类应该被设计为封装一个java.util.Date对象, 而不是继承它, 仅暴露需要的方法 — 最起码, 方法比如hashCode() 应该被适当的覆盖.
最后一个评论是关于数据库引擎的时区的处理. 在java.sql.*包中的类不允许显式的设置时区. 数据库服务器(或驱动) 可自由的依据服务器server的当地时区解释这些信息, 且其可能被影响而变化(比如, 因为夏令时).
总结
通过前面的讨论, 很清楚, Java的日期处理相关类并非很复杂, 但是没有被很好设计. 封装被疏漏, APIs结构复杂且没有被很好的组织, 且非常见的思路经常被无缘由的使用. 实现更有其它的莫名奇妙(提议看看Calendar.getInstance(Locale)对于所有可用locale实际返回对象的类型!) 另一方面, the classes manage to treat all of the difficulties inherent in internationalized date handling and, in any case, are here to stay. 希望这篇文章对帮助你搞清它们的用法有所帮助.