Добавление периода к startDate не создает endDate - PullRequest
8 голосов
/ 26 октября 2019

У меня есть два LocalDate s, объявленных следующим образом:

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 ]

6 голосов
/ 27 октября 2019

Если вы посмотрите, как 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-29 и 2019-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.between и LocalDate#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 нормализует дату, чтобы всегда быть "правильной". В следующем коде вы можете видеть, что после вычитания 1 месяца из 2019-10-31 результат равен 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 голосов
/ 04 ноября 2019

Анализируя ваши ожидания (в псевдокоде)

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]

Результат также является разумным, потому что а) основное правило сложения месяца состоит в том, чтобы максимально сохранить дневной день месяца и б) [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] :-(

Изменение заказа не является плохим, поскольку дает более согласованные результаты. Но как вылечить оставшиеся недостатки? Остался только один способ - изменить расчет продолжительности. Вместо использования P3M1D мы можем видеть, что продолжительность P2M31D будет работать в обоих направлениях:

[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
3 голосов
/ 28 октября 2019

Я считаю, что вам просто не повезло. Инвариант, который вы изобрели, звучит разумно, но не сохраняется в java.time.

Кажется, что метод between просто вычитает номера месяцев и дни месяца, и так как результаты имеют одинаковыезнак, доволен этим результатом. Я думаю, что я согласен, что, возможно, лучшее решение могло бы быть принято здесь, но, как правильно сказал @Meno Hochschild, математика, включающая 29, 30 или 31 месяц, вряд ли может быть четкой, и я не смею предположить, какое из лучших правил имело быбыл.

Бьюсь об заклад, они не собираются изменить его сейчас. Даже если вы подаете отчет об ошибке (которую вы всегда можете попробовать). Слишком много кода уже полагается на то, как он работает более пяти с половиной лет.

Добавление P-1M-1D назад к дате начала работает так, как я и ожидал. Вычитание 1 месяца из (на самом деле добавляя –1 месяц к) 31 октября дает 30 сентября, а вычитание 1 дня - 29 сентября. Опять же, это не совсем ясно, вместо этого можно поспорить в пользу 30 сентября.

...