给startDate添加时间段不会产生endDate。

8

我有两个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返回了错误的时间段,还是LocalDate.plus没有正确添加时间段?


请注意,这个问题看起来与 https://dev59.com/7Z7ha4cB1Zd3GeqPnK80 相似,但并不完全相同。 我知道在添加周期后再减去它(date.plus(period).minus(period)),结果并不总是相同的日期。 这个问题更多地涉及到Period.between函数的不变性。 - Ilya
1
这就是 java.time 日历算术的工作原理。基本上,添加和删除不能相互转换,特别是如果一个或两个日期的月份大于28天。有关更多数学背景,请参阅我的时间库Time4J中的AbstractDuration类文档... - Meno Hochschild
@MenoHochschild,“AbstractDuration”文档指出不变式“t1.plus(t1.until(t2))。equals(t2)== true”应该成立,我想知道为什么在这里使用“java.time”时不是这种情况。 - Ilya
3个回答

6

如果你查看 LocalDate 中的 plus 实现

@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()))

接着减去一天,结果是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交互时存在Bug。因为以下代码具有相同的含义。

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方法将日期标准化为始终“正确”的形式。在下面的代码中,您可以看到从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);
}

3
我认为你只是运气不好。您发明的不变量听起来很合理,但在java.time中不起作用。
看起来between方法只是减去月份和日期,并且由于结果有相同的符号,所以对此结果感到满意。我认为我同意可能在这里可以做出更好的决策,但是正如@Meno Hochschild正确指出的那样,涉及月份的29、30或31的数学问题几乎无法明确,我敢不建议更好的规则是什么。
我打赌他们现在不会改变它。即使您提交错误报告(您总是可以尝试),太多代码已经依赖它工作了五年半以上。
将P-1M-1D添加回开始日期就像我预期的那样有效。从10月31日减去一个月(实际上是加上-1个月)得到9月30日,再减去1天,得到9月29日。同样,这并不是明确的,您可以提出支持9月30日的观点。

3
分析您的期望(伪代码)
startDate.plus(Period.between(startDate, endDate)) == endDate

我们需要讨论几个话题:

  • 如何处理像月份或天数这样的单独单位?
  • 如何定义持续时间(或“期间”)的添加?
  • 如何确定两个日期之间的时间距离(持续时间)?
  • 如何定义持续时间(或“期间”)的减法?

让我们先看看时间单位。 天没有问题,因为它们是可能日历单位中最小的单位,每个日历日期都以完整的天数相差。 因此我们在伪代码中始终保持相等,无论是正还是负:

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]。

什么是持续时间(period)的加法?

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] :-(

改变顺序并不是坏事,因为它会产生更一致的结果。但是如何治愈剩余的缺陷呢?唯一剩下的方法就是改变持续时间的计算方式。我们可以看到P2M31D的持续时间在两个方向上都能起作用,而不是使用P3M1D。

[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并没有提供这样的解决方案。这不是一个bug,但可以被视为设计上的限制。

替代方案?

我通过可逆指标增强了我的时间库 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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接