Adicionar Período ao startDate não produz endDate

8

Eu tenho dois LocalDates declarados da seguinte maneira:

val startDate = LocalDate.of(2019, 10, 31)  // 2019-10-31
val endDate = LocalDate.of(2019, 9, 30)     // 2019-09-30

Então eu calculo o período entre eles usando a Period.betweenfunção:

val period = Period.between(startDate, endDate) // P-1M-1D

Aqui o período tem a quantidade negativa de meses e dias, o que é esperado, dado que endDateé anterior a startDate.

No entanto, quando adiciono isso de periodvolta ao startDate, o resultado que estou obtendo não é o endDate, mas a data um dia antes:

val endDate1 = startDate.plus(period)  // 2019-09-29

Então a questão é: por que o invariante não

startDate.plus(Period.between(startDate, endDate)) == endDate

espera para essas duas datas?

É Period.betweenquem retorna um período incorreto ou LocalDate.plusquem o adiciona incorretamente?

Ilya
fonte
Observe que esta pergunta é semelhante a stackoverflow.com/questions/41945704 , mas não é a mesma coisa. Entendo que após adicionar um período e subtraí-lo de volta ( date.plus(period).minus(period)), o resultado nem sempre é a mesma data. Esta questão é mais sobre Period.betweeninvariantes da função.
Ilya
1
É assim que a java.timearitmética do calendário funciona. Basicamente, adicionar e remover não é conversível entre si, principalmente se o dia do mês de uma ou de duas datas for maior que 28. Consulte também a documentação da classe AbstractDuration no meu tempo, lib Time4J para obter mais informações matemáticas ...
Meno Hochschild
@MenoHochschild Os AbstractDurationdocumentos estão afirmando que a invariância t1.plus(t1.until(t2)).equals(t2) == truedeve se manter, e eu estou perguntando por que não é o caso java.timeaqui.
Ilya

Respostas:

6

Se você olhar como plusé implementado paraLocalDate

@Override
public LocalDate plus(TemporalAmount amountToAdd) {
    if (amountToAdd instanceof Period) {
        Period periodToAdd = (Period) amountToAdd;
        return plusMonths(periodToAdd.toTotalMonths()).plusDays(periodToAdd.getDays());
    }
    ...
}

você verá plusMonths(...)e plusDays(...)ali.

plusMonthslida com casos em que um mês tem 31 dias e o outro 30. Portanto, o código a seguir será impresso em 2019-09-30vez de inexistente2019-09-31

println(startDate.plusMonths(period.months.toLong()))

Depois disso, subtrair um dia resulta em 2019-09-29. Este é o resultado correto, uma vez 2019-09-29e 2019-10-31têm 1 mês 1 dia de intervalo

O Period.betweencálculo é estranho e, neste caso, resume-se a

    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);

onde getProlepticMonthé o número total de meses entre 00-00-00. Nesse caso, são 1 mês e 1 dia.

Pelo meu entendimento, é um bug em um Period.betweenLocalDate#plus interação de períodos negativos e , pois o código a seguir tem o mesmo significado

val startDate = LocalDate.of(2019, 10, 31)
val endDate = LocalDate.of(2019, 9, 30)
val period = Period.between(endDate, startDate)

println(endDate.plus(period))

mas imprime o correto 2019-10-31.

O problema é que LocalDate#plusMonthsnormaliza a data para estar sempre "correta". No código a seguir, você pode ver que, após subtrair 1 mês do 2019-10-31resultado, 2019-09-31ele é normalizado para2019-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);
}
Evgeny Bovykin
fonte
3

Eu acredito que você está simplesmente sem sorte. O invariante que você inventou parece razoável, mas não é válido em java.time.

Parece que o betweenmétodo apenas subtrai os números do mês e os dias do mês e, como os resultados têm o mesmo sinal, está contente com esse resultado. Acho que concordo que provavelmente uma decisão melhor poderia ter sido tomada aqui, mas como @Meno Hochschild afirmou corretamente, a matemática envolvendo os 29, 30 ou 31 meses dificilmente pode ser clara, e não ouso sugerir qual seria a melhor regra fui.

Aposto que eles não vão mudar isso agora. Nem mesmo se você registrar um relatório de erro (que você pode sempre tentar). Muito código já conta com o funcionamento de mais de cinco anos e meio.

Adicionar P-1M-1Dnovamente à data de início funciona da maneira que eu esperava. Subtrair 1 mês de (realmente adicionando –1 mês a) a 31 de outubro gera 30 de setembro e subtrair 1 dia gera 29 de setembro. Novamente, não está claro, você poderia argumentar a favor de 30 de setembro.

Ole VV
fonte
3

Analisando sua expectativa (em pseudo-código)

startDate.plus(Period.between(startDate, endDate)) == endDate

nós temos que discutir vários tópicos:

  • como lidar com unidades separadas, como meses ou dias?
  • como é definida a adição de uma duração (ou "período")?
  • como determinar a distância temporal (duração) entre duas datas?
  • como é definida a subtração de uma duração (ou "período")?

Vamos primeiro olhar para as unidades. Os dias não são um problema, pois são a menor unidade possível do calendário e cada data do calendário difere de qualquer outra data em números inteiros completos de dias. Portanto, sempre temos no pseudo-código igual se positivo ou negativo:

startDate.plus(ChronoUnit.Days.between(startDate, endDate)) == endDate

Os meses, no entanto, são complicados porque o calendário gregoriano define os meses com comprimentos diferentes. Portanto, pode ocorrer que a adição de qualquer número inteiro de meses a uma data possa causar uma data inválida:

[2019-08-31] + P1M = [2019-09-31]

A decisão de java.timereduzir a data de término para uma data válida - aqui [30/09/2019] - é razoável e corresponde às expectativas da maioria dos usuários, porque a data final ainda preserva o mês calculado. No entanto, essa adição, incluindo uma correção de final de mês, NÃO é reversível . Consulte a operação revertida chamada subtração:

[30/09/2019] - P1M = [30/08 2019]

O resultado também é razoável porque: a) a regra básica da adição do mês é manter o dia do mês o máximo possível eb) [30/08/2019] + P1M = [30-09-2019].

Qual é a adição de uma duração (período) exatamente?

Em java.time, a Periodé uma composição de itens que consiste em anos, meses e dias com quaisquer valores parciais inteiros. Portanto, a adição de a Periodpode ser resolvida com a adição de valores parciais à data de início. Como os anos são sempre conversíveis em 12 múltiplos de meses, primeiro podemos combinar anos e meses e, em seguida, adicionar o total em uma etapa para evitar efeitos colaterais estranhos nos anos bissextos. Os dias podem ser adicionados na última etapa. Um design razoável como feito em java.time.

Como determinar o direito Periodentre duas datas?

Vamos discutir primeiro o caso em que a duração é positiva, o que significa que a data inicial é anterior à data final. Sempre podemos definir a duração, determinando primeiro a diferença em meses e depois em dias. Esse pedido é importante para obter um componente do mês porque, caso contrário, cada duração entre duas datas consistiria apenas em dias. Usando suas datas de exemplo:

[2019-09-30] + P1M1D = [2019-10-31]

Tecnicamente, a data de início é adiantada pela diferença calculada em meses entre o início e o fim. Em seguida, o delta do dia como diferença entre a data de início movida e a data final é adicionado à data de início movida. Dessa forma, podemos calcular a duração como P1M1D no exemplo. Até agora, tão razoável.

Como subtrair uma duração?

O ponto mais interessante no exemplo de adição anterior é que, por acidente, NÃO há correção de final de mês. mesmo assimjava.time falha em fazer a subtração reversa. Primeiro, subtrai os meses e depois os dias:

[2019-10-31] - P1M1D = [2019-09-29]

Se java.time, em vez disso, tentasse reverter as etapas da adição antes, a escolha natural seria subtrair primeiro os dias e depois os meses . Com essa ordem alterada, teríamos [2019-09-30]. A ordem alterada na subtração ajudaria desde que não houvesse correção de final de mês na etapa de adição correspondente. Isso é especialmente verdadeiro se o dia do mês de qualquer data inicial ou final não for maior que 28 (a duração mínima possível do mês). Infelizmente java.time, definiu outro design cuja subtração Periodleva a resultados menos consistentes.

A adição de uma duração é reversível na subtração?

Primeiro, precisamos entender que a ordem alterada sugerida na subtração de uma duração de uma determinada data do calendário não garante a reversibilidade da adição. Exemplo de contador que possui uma correção de final de mês na adição:

[2011-03-31] + P3M1D = [2011-06-30] + P1D = [2011-07-01] (ok)
[2011-07-01] - P3M1D = [2011-06-30] - P3M = [2011-03-30] :-(

Alterar a ordem não é ruim porque gera resultados mais consistentes. Mas como curar as deficiências restantes? A única maneira que resta é alterar também o cálculo da duração. Em vez de usar o P3M1D, podemos ver que a duração P2M31D funcionará nas duas direções:

[2011-03-31] + P2M31D = [2011-05-31] + P31D = [2011-07-01] (ok)
[2011-07-01] - P2M31D = [2011-05-31] - P2M = [2011-03-31] (ok)

Portanto, a ideia é alterar a normalização da duração calculada. Isso pode ser feito verificando se a adição do delta do mês calculado é reversível em uma etapa de subtração - ou seja, evita a necessidade de uma correção no final do mês. java.timeinfelizmente não oferece essa solução. Não é um bug, mas pode ser considerado uma limitação de design.

Alternativas?

Aprimorei minha biblioteca de tempo Time4J por métricas reversíveis que implementam as idéias fornecidas acima. Veja o seguinte exemplo:

    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
Meno Hochschild
fonte