Алгоритм, используемый java.time.Period.between() при работе с месяцами разной длины?

#java #jodatime #java-time #jsr310

#java #jodatime #java-время #jsr310

Вопрос:

Почему при использовании 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
  }
}
  

Живой реплик: https://repl.it/@JustinGrant/BigScornfulQueryplan#Main.java

Вот как я ожидаю, что это сработает:

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-15 до 2011-03-18 — это один год, два месяца и три дня.

Возможно, я недостаточно внимательно читаю это, но я не думаю, что этот текст полностью объясняет расходящееся поведение, которое я вижу.

Что я не понимаю в том, как java.time.Period.between это должно работать? В частности, что ожидается, когда промежуточный результат «удаления полных месяцев» является недопустимой датой?

Задокументирован ли алгоритм более подробно в другом месте?

Ответ №1:

TL; DR

Алгоритм, который я вижу в источнике (скопирован ниже), похоже, не предполагает, что ожидается, что a Period между двумя датами будет иметь точность, равную количеству дней между одними и теми же двумя датами (я даже подозреваю Period , что он не предназначен для использования в вычислениях с непрерывными временными переменными).

Он вычисляет разницу в месяцах и днях, затем корректирует, чтобы убедиться, что оба имеют одинаковый знак. Результирующий период строится на основе этих двух значений.

Основная проблема заключается в том, что добавление двух месяцев к LocalDate.of(2019, 1, 28) — это не то же самое, что добавление (31 28) days или (28 31) days к этой дате. Это просто добавление 2 месяцев к LocalDate.of(2019, 1, 28) , что дает LocalDate.of(2019, 3, 28) .

Другими словами, в контексте LocalDate Period s представляют точное количество месяцев (и производных лет), но дни чувствительны к длинам месяцев, в которые они вычисляются.


Это источник, который я вижу ( java.time.LocalDate.until(ChronoLocalDate) в конечном итоге выполняет работу):

 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 > 0 amp;amp; days < 0) {
        totalMonths--;
        LocalDate calcDate = this.plusMonths(totalMonths);
        days = (int) (end.toEpochDay() - calcDate.toEpochDay());  // safe
    } else if (totalMonths < 0 amp;amp; days > 0) {
        totalMonths  ;
        days -= end.lengthOfMonth();
    }
    long years = totalMonths / 12;  // safe
    int months = (int) (totalMonths % 12);  // safe
    return Period.of(Math.toIntExact(years), months, days);
}
  

Как видно, корректировка знака производится, когда разница в месяце отличается от разницы в дне (и да, они вычисляются отдельно). Оба totalMonths > 0 amp;amp; days < 0 и totalMonths < 0 amp;amp; days > 0 применимы в ваших примерах (по одному для каждого вычисления).

Просто так получается, что когда разница в периодах в месяцах положительна, день периода вычисляется с использованием дней эпохи, что дает точный результат. Это все равно может быть потенциально затронуто, когда возникает необходимость обрезать новую конечную дату, чтобы вписаться в длину месяца, например, в:

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

Но этого не может произойти в вашем примере, потому что вы просто не можете указать недопустимую конечную дату для метода, как в

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

для того, чтобы результирующее количество дней в результирующем периоде было обрезано.

Однако, когда разница во времени в месяцах отрицательна, это происходит:

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

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

И, используя периоды и локальные даты:

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

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

В вашем случае (второй вызов) -30 является результатом (30 - 29) - 31 , где 31 — количество дней в январе.

Я думаю, что короткая история здесь не предназначена Period для вычисления временных значений. В контексте времени я полагаю, что месяц является условной единицей. Периоды будут хорошо работать, когда месяц определен как абстрактный период (например, при расчетах ежемесячных арендных платежей), но они обычно терпят неудачу, когда дело доходит до непрерывного времени.

Комментарии:

1. Это очень полезная информация, спасибо! Знаете ли вы, существует ли формальная спецификация, которая определяет поведение в этом случае? (Я не очень хорошо знаком с Java.) Мне любопытно, почему здесь было выбрано асимметричное поведение. Кстати, я спрашиваю, потому что я помогаю определить, как следующее поколение JavaScript будет обрабатывать это вычисление, и я не убежден, что различное поведение в положительных и отрицательных случаях является разумным. Но прежде чем использовать всю экосистему JS для симметричного решения, я хотел бы понять, почему Java решила сделать это по-другому.

2. @JustinGrant Я, к сожалению, не вижу, чтобы это было четко задокументировано, и хотел бы увидеть другие / лучшие ответы от тех, кто ближе к дизайну этого API. Проблема не в Period том, что она так велика Period , как при использовании в операциях с датами; но на этом стыке должна быть документация. Прежде чем углубляться, разумно ожидать, что поведение будет симметричным. Но я бы не сказал, что они «выбрали» его асимметричным, поскольку добавление количества месяцев к дате вступает в силу в соответствии с капризами календарных месяцев.

3. Во время разработки JSR-310 был запущен поток, указывающий на эту проблему, но поток так и не был решен. Я изо всех сил пытаюсь найти какое-либо обоснование для несоответствия ATM, так что, вероятно, это ошибка, но на данный момент она может быть неисправимой. Таким образом, я думаю, что JS должен использовать симметричный подход, гарантируя, что date.plus(period) он работает эквивалентно date.plus(period.TotalMonths).plus(period.days), т.е.. два отдельных шага.

4. @JodaStephen спасибо за понимание. Возможно, это просто мое невежество, но я все еще не вижу, как это решит проблему, поскольку date.plus(x.toTotalMonths()) ее уже нельзя надежно инвертировать с date.plus(x.toTotalMonths()).plus(-x.toTotalMonths()) помощью — и это не Period ошибка.

5. Как вы говорите, идеального ответа на проблему нет, потому что месяцы имеют переменную длину. Вопрос в том, какие крайние случаи вы готовы бросить под автобус. Разумно стремиться start.plus(Period.between(start,end) == end . Помимо этого, все ставки отменяются. Попробуйте множество примеров с високосным / невисокосным годом и положением февраля, чтобы получить всевозможные странные результаты. Например, попробуйте с 2020-01-30 по 2020-03-29 и с 2019-12-30 по 2020-03-29, и вы обнаружите, что алгоритм JSR-310 верен в обоих направлениях.