将 Period 添加到 startDate 不会产生 endDate

Adding Period to startDate doesn't produce endDate

我有两个 LocalDate 声明如下:

val startDate = LocalDate.of(2019, 10, 31)  // 2019-10-31
val endDate = LocalDate.of(2019, 9, 30)     // 2019-09-30

然后我使用 Period.between 函数计算它们之间的周期:

val period = Period.between(startDate, endDate) // P-1M-1D

这里的周期有负数的月数和天数,这是预期的,因为 endDate 早于 startDate

然而,当我将 period 添加回 startDate 时,我得到的结果不是 endDate,而是一天前的日期:

val endDate1 = startDate.plus(period)  // 2019-09-29

所以问题是,为什么不变量

startDate.plus(Period.between(startDate, endDate)) == endDate

保留这两个日期?

Period.between谁returns句号不对,还是LocalDate.plus谁加错了?

如果您查看 plus 是如何针对 LocalDate

实施的
@Override
public LocalDate plus(TemporalAmount amountToAdd) {
    if (amountToAdd instanceof Period) {
        Period periodToAdd = (Period) amountToAdd;
        return plusMonths(periodToAdd.toTotalMonths()).plusDays(periodToAdd.getDays());
    }
    ...
}

你会在那里看到 plusMonths(...)plusDays(...)

plusMonths 处理一个月有 31 天,另一个有 30 天的情况。所以下面的代码将打印 2019-09-30 而不是不存在的 2019-09-31

println(startDate.plusMonths(period.months.toLong()))

之后,减去 1 天得到 2019-09-29。这是正确的结果,因为 2019-09-292019-10-31 相隔 1 个月 1 天

Period.between 计算很奇怪,在这种情况下归结为

    LocalDate end = LocalDate.from(endDateExclusive);
    long totalMonths = end.getProlepticMonth() - this.getProlepticMonth();
    int days = end.day - this.day;
    long years = totalMonths / 12;
    int months = (int) (totalMonths % 12);  // safe
    return Period.of(Math.toIntExact(years), months, days);

其中 getProlepticMonth 是从 00-00-00 开始的总月数。在这种情况下,它是 1 个月零 1 天。

根据我的理解,这是 Period.betweenLocalDate#plus 中负周期交互的错误,因为以下代码具有相同的含义

val startDate = LocalDate.of(2019, 10, 31)
val endDate = LocalDate.of(2019, 9, 30)
val period = Period.between(endDate, startDate)

println(endDate.plus(period))

但它打印出正确的 2019-10-31

问题是 LocalDate#plusMonths 将日期标准化为始终 "correct"。在下面的代码中,您可以看到从 2019-10-31 中减去 1 个月后的结果是 2019-09-31,然后将其归一化为 2019-10-30

public LocalDate plusMonths(long monthsToAdd) {
    ...
    return resolvePreviousValid(newYear, newMonth, day);
}

private static LocalDate resolvePreviousValid(int year, int month, int day) {
    switch (month) {
        ...
        case 9:
        case 11:
            day = Math.min(day, 30);
            break;
    }
    return new LocalDate(year, month, day);
}

我相信你只是运气不好。您发明的不变量听起来很合理,但在 java.time.

中不成立

看来between方法只是减去月份数字和月份天数,因为结果符号相同,所以对这个结果很满意。我想我同意这里可能会做出更好的决定,但正如@Meno Hochschild 正确指出的那样,涉及 29、30 或 31 个月的数学很难明确,我不敢提出更好的规则去过。

我打赌他们现在不会改变它。即使您提交错误报告(您可以随时尝试)也不行。太多的代码已经依赖它已经运行了五年半以上。

P-1M-1D 添加回开始日期的方式与我预期的一样。从 10 月 31 日减去 1 个月(实际上是加上 –1 个月)得到 9 月 30 日,减去 1 天得到 9 月 29 日。同样,这不是明确的,你可以争论支持 9 月 30 日。

分析您的期望(伪代码)

startDate.plus(Period.between(startDate, endDate)) == endDate

我们要讨论几个话题:

  • 如何处理单独的单位,如月或日?
  • 如何定义添加持续时间(或"period")?
  • 如何确定两个日期之间的时间距离(持续时间)?
  • 持续时间(或"period")的减法是如何定义的?

让我们先看看单位。天没有问题,因为它们是可能的最小日历单位,并且每个日历日期都与任何其他日期相差整整天数。所以我们在伪代码中总是等于正数或负数:

startDate.plus(ChronoUnit.Days.between(startDate, endDate)) == endDate

然而,月份是棘手的,因为公历定义了不同长度的日历月。因此可能会出现这样的情况,即在日期中添加任何整数月份都会导致无效日期:

[2019-08-31] + P1M = [2019-09-31]

java.time 将结束日期缩短为有效日期的决定 - 此处为 [2019-09-30] - 是合理的并且符合大多数用户的期望,因为最终日期仍保留计算出的日期月。但是,这个 包含月末修正的加法是不可逆的 ,请参阅称为减法的还原操作:

[2019-09-30] - P1M = [2019-08-30]

结果也是合理的,因为 a) 月份加法的基本规则是尽可能保留日期 b) [2019-08-30] + P1M = [2019-09- 30].

添加一个持续时间(周期)到底是什么?

java.time中,一个Period是由年、月、日和任意整数部分组成的项目组合。因此,添加 Period 可以解析为将部分金额添加到开始日期。由于年份总是可以转换为月份的 12 倍数,我们可以先合并年份和月份,然后一步添加总数,以避免闰年出现奇怪的副作用。可以在最后一步添加天数。 java.time.

中的合理设计

如何判断两个日期之间的Period正确?

我们先讨论持续时间为正的情况,即开始日期早于结束日期。然后我们总是可以通过首先确定月数然后再确定天数的差异来定义持续时间。此顺序对于实现月份部分很重要,否则两个日期之间的每个持续时间都将只包含几天。使用您的示例日期:

[2019-09-30] + P1M1D = [2019-10-31]

从技术上讲,开始日期首先会向前移动计算出的开始和结束之间的月差。然后将作为移动开始日期和结束日期之间的差异的日期增量添加到移动开始日期。这样我们就可以将持续时间计算为示例中的 P1M1D。到目前为止还算合理。

如何减去时长?

前面的加法示例中最有趣的一点是,意外地没有月末更正。然而 java.time 无法进行反向减法。 它首先减去月份,然后减去天数:

[2019-10-31] - P1M1D = [2019-09-29]

如果 java.time 之前尝试反转加法的步骤,那么 自然的选择会是先减去天数,然后再减去月份 。通过更改后的顺序,我们将得到 [2019-09-30]。只要在相应的加法步骤中没有月末更正,减法中更改的顺序就会有所帮助。如果任何开始日期或结束日期的日期不大于 28(可能的最小月份长度),则尤其如此。不幸的是,java.time 定义了另一种减去 Period 的设计,这导致结果不太一致。

在减法中加一个时长是可逆的吗?

首先我们必须明白,从给定日历日期减去持续时间时建议的更改顺序并不能保证加法的可逆性。添加了月末修正的反例:

[2011-03-31] + P3M1D = [2011-06-30] + P1D = [2011-07-01] (ok)
[2011-07-01] - P3M1D = [2011-06-30] - P3M = [2011-03-30] :-(

改变顺序也不错,因为它会产生更一致的结果。但 如何补齐剩余的不足?剩下的唯一方法就是也更改持续时间的计算。不使用P3M1D,我们可以看到持续时间P2M31D将在两个方向上起作用:

[2011-03-31] + P2M31D = [2011-05-31] + P31D = [2011-07-01] (ok)
[2011-07-01] - P2M31D = [2011-05-31] - P2M = [2011-03-31] (ok)

所以我的想法是改变计算持续时间的规范化。这可以通过查看计算出的月份增量的加法在减法步骤中是否可逆来完成——即避免月末修正的需要。很遗憾,java.time 没有提供这样的解决方案。这不是错误,但可以被视为设计限制。

备选方案?

我通过部署上述想法的可逆指标增强了我的时间库 Time4J。请参阅以下示例:

    PlainDate d1 = PlainDate.of(2011, 3, 31);
    PlainDate d2 = PlainDate.of(2011, 7, 1);

    TimeMetric<CalendarUnit, Duration<CalendarUnit>> metric =
        Duration.inYearsMonthsDays().reversible();
    Duration<CalendarUnit> duration =
        metric.between(d1, d2); // P2M31D
    Duration<CalendarUnit> invDur =
        metric.between(d2, d1); // -P2M31D

    assertThat(d1.plus(duration), is(d2)); // first invariance
    assertThat(invDur, is(duration.inverse())); // second invariance
    assertThat(d2.minus(duration), is(d1)); // third invariance