`java.time.Period.between()` 在处理不同长度的月份时使用的算法是什么?

huangapple go评论68阅读模式
英文:

Algorithm used by java.time.Period.between() when dealing with different-length months?

问题

以下是您提供的代码的翻译部分:

使用java.time.Period.between()在不同长度的月份之间时,为什么下面的代码在操作方向不同的情况下报告不同的结果?

import java.time.LocalDate;
import java.time.Period;
class Main {
  public static void main(String[] args) {
    LocalDate d1 = LocalDate.of(2019, 1, 30);
    LocalDate d2 = LocalDate.of(2019, 3, 29); 
    Period period = Period.between(d1, d2);
    System.out.println("diff: " + period.toString());
    // => P1M29D
    Period period2 = Period.between(d2, d1);
    System.out.println("diff: " + period2.toString());
    // => P-1M-30D
  }
}

这里是我期望它的工作方式:

2019-01-30 => 2019-03-29

  1. 将 2019-01-30 增加一个月 => 2019-02-30,受限于 2019-02-28
  2. 再增加 29 天,得到 2019-03-29

这与 Java 的结果相符:P1M29D

(反向) 2019-03-29 => 2019-01-30

  1. 从 2019-03-29 减去一个月 => 2019-02-29,受限于 2019-02-28
  2. 再减去 29 天,得到 2019-01-30

但 Java 返回 P-1M-30D。我预期是 P-1M-29D

参考文档 中说:

> 该周期是通过删除整数个月,然后计算剩余的天数来计算的,并进行调整以确保两者具有相同的符号。然后,根据 12 个月组成的年来分割月数和年数。如果结束日期的月份大于或等于开始日期的月份,则考虑一个月。例如,从 2010-01-152011-03-18 是一年零两个月三天。

也许我没有仔细阅读,但我认为这段文字没有完全解释我看到的不同行为。

我对 java.time.Period.between 的工作原理有什么误解?具体而言,当“删除整数个月”的中间结果是无效日期时,预期会发生什么?

该算法在其他地方是否有更详细的文档?

英文:

When using java.time.Period.between() across months of varying lengths, why does the code below report different results depending on the direction of the operation?

import java.time.LocalDate;
import java.time.Period;
class Main {
  public static void main(String[] args) {
    LocalDate d1 = LocalDate.of(2019, 1, 30);
    LocalDate d2 = LocalDate.of(2019, 3, 29); 
    Period period = Period.between(d1, d2);
    System.out.println("diff: " + period.toString());
    // => P1M29D
    Period period2 = Period.between(d2, d1);
    System.out.println("diff: " + period2.toString());
    // => P-1M-30D
  }
}

Live repl: https://repl.it/@JustinGrant/BigScornfulQueryplan#Main.java

Here's how I'd expect it to work:

2019-01-30 => 2019-03-29

  1. Add one month to 2019-01-30 => 2019-02-30, which is constrained to 2019-02-28
  2. Add 29 days to get to 2019-03-29

This matches Java's result: P1M29D

(reversed) 2019-03-29 => 2019-01-30

  1. Subtract one month from 2019-03-29 => 2019-02-29, which is constrained to 2019-02-28
  2. Subtract 29 days to get to 2019-01-30

But Java returns P-1M-30D here. I expected P-1M-29D.

The reference docs say:

> The period is calculated by removing complete months, then calculating the remaining number of days, adjusting to ensure that both have the same sign. The number of months is then split into years and months based on a 12 month year. A month is considered if the end day-of-month is greater than or equal to the start day-of-month. For example, from 2010-01-15 to 2011-03-18 is one year, two months and three days.

Maybe I'm not reading this carefully enough, but I don't think this text fully explains the divergent behavior that I'm seeing.

What am I misunderstanding about how java.time.Period.between is supposed to work? Specifically, what is expected to happen when the intermediate result of "removing complete months" is an invalid date?

Is the algorithm documented in more detail elsewhere?

答案1

得分: 1

TL;DR

我在下面的源代码中看到的算法似乎并不假设两个日期之间的 Period 与相同两个日期之间的天数之差具有相同的精度(我甚至怀疑 Period 不应该用于连续时间变量的计算)。

它计算了月份和天数的差异,然后进行调整,以确保两者具有相同的符号。生成的 period 基于这两个值。

主要的挑战在于,将两个月添加到 LocalDate.of(2019, 1, 28) 不等同于向该日期添加 (31 + 28) 天(28 + 31) 天。它只是简单地在 LocalDate.of(2019, 1, 28) 的基础上添加了 2 个月,得到 LocalDate.of(2019, 3, 28)

换句话说,在 LocalDate 的背景下,Period 代表了准确的月数(和导出的年数),但天数对它们计算的月份长度是敏感的。


以下是我看到的源代码(java.time.LocalDate.until(ChronoLocalDate) 最终完成了这个任务):

public Period until(ChronoLocalDate endDateExclusive) {
    LocalDate end = LocalDate.from(endDateExclusive);
    long totalMonths = end.getProlepticMonth() - this.getProlepticMonth();  // 安全
    int days = end.day - this.day;
    if (totalMonths > 0 && days < 0) {
        totalMonths--;
        LocalDate calcDate = this.plusMonths(totalMonths);
        days = (int) (end.toEpochDay() - calcDate.toEpochDay());  // 安全
    } else if (totalMonths < 0 && days > 0) {
        totalMonths++;
        days -= end.lengthOfMonth();
    }
    long years = totalMonths / 12;  // 安全
    int months = (int) (totalMonths % 12);  // 安全
    return Period.of(Math.toIntExact(years), months, days);
}

正如可以看出的,当月份差异与天数差异的符号不同时,会进行符号调整(是的,它们分别计算)。在你的示例中,totalMonths > 0 && days < 0totalMonths < 0 && days > 0 都适用于每个计算。

正好当月份差异为正时,期间的天数使用了纪元日来计算,从而产生准确的结果。当有必要将新的结束日期剪切以适应月份长度时,它仍然可能受到影响,例如:

jshell&gt; LocalDate.of(2019, 1, 31).plusMonths(1)
$42 ==&gt; 2019-02-28

但是在你的示例中,这种情况不会发生,因为你不能简单地向方法提供无效的结束日期,比如:

// Period.between(LocalDate.of(2019, 1, 31), LocalDate.of(2019, 2, 30)

这会导致结果期间中的天数被剪切。

然而,当时间差异为负时,情况就不同了:

//task: account for the 1-day difference
jshell&gt; LocalDate.of(2019, 5, 30).plusMonths(-1)
$50 ==&gt; 2019-04-30

jshell&gt; LocalDate.of(2019, 5, 31).plusMonths(-1)
$51 ==&gt; 2019-04-30

并且,使用 period 和本地日期:

jshell&gt; Period.between(LocalDate.of(2019, 3, 31), LocalDate.of(2019, 2, 28))
$39 ==&gt; P-1M-3D //3 天?它没有考虑到二月的长度(2<3 && 28<31)

jshell&gt; Period.between(LocalDate.of(2019, 3, 31), LocalDate.of(2019, 1, 31))
$40 ==&gt; P-2M

在你的情况下(第二个调用),-30(30 - 29) - 31 的结果,其中 31 是一月的天数。

我认为这里的要点是不要在时间值计算中使用 Period。在 时间 上下文中,我认为 是一个概念上的单位。当一个月被定义为一个抽象期间时(比如在计算每月租金支付时),期间会工作得很好,但在涉及连续时间时,它们通常会失败。

英文:

TL;DR

The algorithm I see in the source (copied below) does not seem to assume that a Period between two dates is expected to have the accuracy that the number of days between the same two dates would (I even suspect Period is not meant to be used in calculations on continuous time variables).

It computes the difference in months and days, then adjusts to make sure both have the same sign. The resulting period is built on the grounds of these two values.

The main challenge is that adding two months to LocalDate.of(2019, 1, 28) is not the same thing as adding (31 + 28) days or (28 + 31) days to that date. It's simply adding 2 months to LocalDate.of(2019, 1, 28), which gives LocalDate.of(2019, 3, 28).

In other words, in the context of LocalDate, Periods represent an accurate number of months (and derived years), but days are sensitive to the lengths of months they're computed into.


This is the source I'm seeing (java.time.LocalDate.until(ChronoLocalDate) is ultimately doing the job):

public Period until(ChronoLocalDate endDateExclusive) {
    LocalDate end = LocalDate.from(endDateExclusive);
    long totalMonths = end.getProlepticMonth() - this.getProlepticMonth();  // safe
    int days = end.day - this.day;
    if (totalMonths &gt; 0 &amp;&amp; days &lt; 0) {
        totalMonths--;
        LocalDate calcDate = this.plusMonths(totalMonths);
        days = (int) (end.toEpochDay() - calcDate.toEpochDay());  // safe
    } else if (totalMonths &lt; 0 &amp;&amp; days &gt; 0) {
        totalMonths++;
        days -= end.lengthOfMonth();
    }
    long years = totalMonths / 12;  // safe
    int months = (int) (totalMonths % 12);  // safe
    return Period.of(Math.toIntExact(years), months, days);
}

As can be seen, the sign adjustment is made when the month difference has a different sign from the day difference (and yes, they're computed separately). Both totalMonths &gt; 0 &amp;&amp; days &lt; 0 and totalMonths &lt; 0 &amp;&amp; days &gt; 0 are applicable in your examples (one to each calculation).

It just happens that when the period difference in months is positive, the period's day is computed using epoch days, thus producing an accurate result. It would still be potentially affected when there's necessity to clip the new end date to fit into the month length - such as in:

jshell&gt; LocalDate.of(2019, 1, 31).plusMonths(1)
$42 ==&gt; 2019-02-28

But this can't happen in your example because you simply can't supply an invalid end date to the method, as in

// Period.between(LocalDate.of(2019, 1, 31), LocalDate.of(2019, 2, 30)

for the resulting number of days in the resulting period to be clipped.

When the time difference in months is negative, however, it happens:

//task: account for the 1-day difference
jshell&gt; LocalDate.of(2019, 5, 30).plusMonths(-1)
$50 ==&gt; 2019-04-30

jshell&gt; LocalDate.of(2019, 5, 31).plusMonths(-1)
$51 ==&gt; 2019-04-30

And, using periods and local dates:

jshell&gt; Period.between(LocalDate.of(2019, 3, 31), LocalDate.of(2019, 2, 28))
$39 ==&gt; P-1M-3D //3 days? It didn&#39;t look at February&#39;s length (2&lt;3 &amp;&amp; 28&lt;31)

jshell&gt; Period.between(LocalDate.of(2019, 3, 31), LocalDate.of(2019, 1, 31))
$40 ==&gt; P-2M

In your case (second call), -30 is the result of (30 - 29) - 31, where 31 is the number of days in January.

I think the short story here is not to use Period for time value calculations. In the context of time, I suppose month is a notional unit. Periods will work well when a month is defined as an abstract period (such as in calculations of monthly rent payments), but they'll usually fail when it comes to continuous time.

huangapple
  • 本文由 发表于 2020年10月17日 08:22:13
  • 转载请务必保留本文链接:https://go.coder-hub.com/64397929.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定