【问题标题】:Adding Period to startDate doesn't produce endDate将 Period 添加到 startDate 不会产生 endDate
【发布时间】:2020-02-22 16:50:17
【问题描述】:

我有两个LocalDates 声明如下:

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添加了错误的句号?

【问题讨论】:

  • 请注意,此问题与stackoverflow.com/questions/41945704 相似,但并不完全相同。我知道在添加句点并将其减去 (date.plus(period).minus(period)) 后,结果并不总是相同的日期。这个问题更多的是关于Period.between函数的不变量。
  • java.time-calendar 算法就是这样工作的。基本上,添加和删除不能相互转换,尤其是当一个或两个日期的日期大于 28 时。有关更多数学背景,另请参阅我的时间库 Time4J 中 AbstractDuration 的类文档。 .
  • @MenoHochschild AbstractDuration 文档指出 t1.plus(t1.until(t2)).equals(t2) == true 的不变性应该成立,我在问为什么 java.time 不是这种情况。

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


【解决方案1】:

如果您查看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);
}

【讨论】:

    【解决方案2】:

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

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

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

    P-1M-1D 添加回开始日期的工作方式符合我的预期。从 10 月 31 日(实际上加上 –1 个月)减去 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] .

      添加的持续时间(期间)到底是什么?

      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 很遗憾没有提供这样的解决方案。这不是错误,但可以视为设计限制。

      替代方案?

      我通过部署上述想法的可逆指标增强了我的时间库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
      

      【讨论】:

        猜你喜欢
        • 2012-11-27
        • 2017-06-16
        • 1970-01-01
        • 2017-02-08
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多