将Period添加到startDate不会产生endDate

时间:2019-10-26 18:45:55

标签: kotlin java-time localdate period date-difference

我有两个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添加不正确的句点?

3 个答案:

答案 0 :(得分:6)

如果您了解如何为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()))

此后,减去一天将得到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将日期归一化为始终“正确”。在以下代码中,您可以看到,从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);
}

答案 1 :(得分:3)

我相信你简直不走运。您发明的不变式听起来很合理,但在java.time中不成立。

似乎between方法只减去月份数字和月份中的几天,并且由于结果具有相同的符号,因此对该结果感到满意。我想我同意在这里可能会做出更好的决定,但是正如@Meno Hochschild正确指出的那样,很难涉及涉及29、30或31个月的数学运算,而且我不敢建议会有更好的规则曾经。

我敢打赌他们现在不会改变它。即使您提交了错误报告(也可以随时尝试),也没有。已有超过五年半的时间,太多的代码已经依赖于它的工作方式。

P-1M-1D重新添加到开始日期的工作方式与我期望的一样。从10月31日(实际上加–1个月)开始到9月30日减去1个月,然后从9月29日减去1天,则9月29日再次变为收益。这又不是很明确,您可以主张9月30日。

答案 2 :(得分: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]

确切的持续时间(期间)是多少?

java.time中,Period是由年,月和日组成的项的组成,具有任意整数的部分金额。因此,可以将Period的添加解析为开始日期的部分金额的添加。由于年份总是可以转换为12倍的月份,因此我们可以先合并年份和月份,然后一步又相加,以避免leap年产生奇怪的副作用。可以在最后一步中添加日期。如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很遗憾没有提供这种解决方案。它不是错误,但可以视为设计限制。

替代品?

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