Agregar período a startDate no produce endDate

8

Tengo dos LocalDates declarados de la siguiente manera:

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

Luego calculo el período entre ellos usando Period.between función:

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

Aquí el período tiene la cantidad negativa de meses y días, que se espera dado que endDate es anterior a startDate.

Sin embargo, cuando agrego eso de periodnuevo al startDate, el resultado que obtengo no es el endDate, sino la fecha un día antes:

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

Entonces la pregunta es, ¿por qué no lo invariante

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

espera para estas dos fechas?

¿Es Period.betweenquién devuelve un período incorrecto o LocalDate.plusquién lo agrega incorrectamente?

Ilya
fuente
Tenga en cuenta que esta pregunta es similar a stackoverflow.com/questions/41945704 , sin embargo, no es del todo igual. Entiendo que después de agregar un período y restarlo ( date.plus(period).minus(period)) el resultado no siempre es la misma fecha. Esta pregunta es más sobre Period.betweenlos invariantes de la función.
Ilya
1
Así es como java.timefunciona la aritmética de calendario. Básicamente, agregar y eliminar no son intercambiables entre sí, especialmente si el día del mes de una o ambas fechas es mayor que 28. Consulte también la documentación de la clase de AbstractDuration en mi tiempo lib Time4J para obtener más antecedentes matemáticos ...
Meno Hochschild
@MenoHochschild Los AbstractDurationdocumentos afirman que la invariancia t1.plus(t1.until(t2)).equals(t2) == truedebería mantenerse, y les pregunto por qué no es el caso java.timeaquí.
Ilya

Respuestas:

6

Si observa cómo plusse implementa paraLocalDate

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

ya veras plusMonths(...)y plusDays(...)ahi.

plusMonthsmaneja casos cuando un mes tiene 31 días, y el otro tiene 30. Por lo tanto, el siguiente código se imprimirá en 2019-09-30lugar de inexistente2019-09-31

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

Después de eso, restando un día resulta en 2019-09-29. Este es el resultado correcto, ya que 2019-09-29y 2019-10-31son de 1 mes 1 día de diferencia

El Period.betweencálculo es extraño y en este caso se reduce 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);

donde getProlepticMonthes el número total de meses desde 00-00-00. En este caso, es 1 mes y 1 día.

Desde mi entender, es un error en una Period.betweeny LocalDate#pluspara la interacción períodos negativo, ya que el código siguiente tiene el mismo 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))

pero imprime el correcto 2019-10-31.

El problema es que las LocalDate#plusMonthsfechas normalizadas son siempre "correctas". En el siguiente código, puede ver que después de restar 1 mes del 2019-10-31resultado, se 2019-09-31normaliza a2019-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
fuente
3

Creo que simplemente no tienes suerte. El invariante que has inventado suena razonable, pero no se mantiene en java.time.

Parece que el betweenmétodo solo resta los números de mes y los días del mes y, dado que los resultados tienen el mismo signo, se contenta con este resultado. Creo que estoy de acuerdo en que probablemente se podría haber tomado una mejor decisión aquí, pero como @Meno Hochschild ha dicho correctamente, las matemáticas que involucran los 29, 30 o 31 meses no pueden ser claras, y no me atrevo a sugerir cuál sería la mejor regla estado.

Apuesto a que no lo van a cambiar ahora. Ni siquiera si presenta un informe de error (que siempre puede intentar). Demasiado código ya depende de cómo ha estado funcionando durante más de cinco años y medio.

Agregar P-1M-1Dnuevamente a la fecha de inicio funciona de la manera que hubiera esperado. Restando 1 mes de (realmente sumando –1 mes a) el 31 de octubre se inicia el 30 de septiembre, y restando 1 día se obtiene el 29 de septiembre. Una vez más, no está claro, podría argumentar a favor del 30 de septiembre.

Ole VV
fuente
3

Analizando sus expectativas (en pseudocódigo)

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

Tenemos que discutir varios temas:

  • ¿Cómo manejar unidades separadas como meses o días?
  • ¿Cómo se define la adición de una duración (o "período")?
  • ¿Cómo determinar la distancia temporal (duración) entre dos fechas?
  • ¿Cómo se define la resta de una duración (o "período")?

Primero veamos las unidades. Los días no son un problema porque son la unidad de calendario más pequeña posible y cada fecha de calendario difiere de cualquier otra fecha en enteros enteros de días. Entonces siempre tenemos un pseudocódigo igual si es positivo o negativo:

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

Sin embargo, los meses son difíciles porque el calendario gregoriano define los meses calendario con diferentes duraciones. Entonces, puede surgir la situación de que la adición de cualquier número entero de meses a una fecha puede causar una fecha no válida:

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

La decisión de java.timereducir la fecha de finalización a una válida, aquí [2019-09-30], es razonable y corresponde a las expectativas de la mayoría de los usuarios porque la fecha final aún conserva el mes calculado. Sin embargo, esta adición que incluye una corrección de fin de mes NO es reversible , vea la operación revertida llamada resta:

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

El resultado también es razonable porque a) la regla básica de la suma mensual es mantener el día del mes lo más posible yb) [2019-08-30] + P1M = [2019-09-30].

¿Cuál es la adición de una duración (período) exactamente?

En java.time, a Periodes una composición de elementos que consta de años, meses y días con cualquier cantidad parcial entera. Por lo tanto, la adición de a Periodse puede resolver a la suma de los importes parciales a la fecha de inicio. Como los años son siempre convertibles a 12 múltiplos de meses, primero podemos combinar años y meses y luego sumar el total en un solo paso para evitar efectos secundarios extraños en los años bisiestos. Los días se pueden agregar en el último paso. Un diseño razonable como hecho en java.time.

¿Cómo determinar el derecho Periodentre dos fechas?

Primero discutamos el caso cuando la duración es positiva, lo que significa que la fecha de inicio es anterior a la fecha de finalización. Entonces siempre podemos definir la duración determinando primero la diferencia en meses y luego en días. Este pedido es importante para lograr un componente de mes porque, de lo contrario, cada duración entre dos fechas solo consistiría en días. Usando sus fechas de ejemplo:

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

Técnicamente, la fecha de inicio se adelanta por la diferencia calculada en meses entre el inicio y el final. Luego, el día delta como diferencia entre la fecha de inicio movida y la fecha de finalización se agrega a la fecha de inicio movida. De esta manera podemos calcular la duración como P1M1D en el ejemplo. Hasta ahora muy razonable.

¿Cómo restar una duración?

El punto más interesante en el ejemplo de adición anterior es que, por accidente, NO hay corrección de fin de mes. Sin embargo, java.timeno puede hacer la resta inversa. Primero resta los meses y luego los días:

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

Si, en java.timecambio, hubiera intentado revertir los pasos de la suma anterior, entonces la elección natural habría sido restar primero los días y luego los meses . Con este orden cambiado, obtendríamos [2019-09-30]. El orden cambiado en la resta ayudaría siempre que no hubiera una corrección de fin de mes en el paso de suma correspondiente. Esto es especialmente cierto si el día del mes de cualquier fecha de inicio o finalización no es mayor que 28 (la duración mínima posible del mes). Lamentablemente, java.timeha definido otro diseño para la resta Periodque conduce a resultados menos consistentes.

¿Es la suma de una duración reversible en la resta?

Primero tenemos que entender que el orden de cambio sugerido en la resta de una duración de una fecha de calendario dada no garantiza la reversibilidad de la adición. Ejemplo de contador que tiene una corrección de fin de mes en la adición:

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

Cambiar el orden no es malo porque produce resultados más consistentes. Pero, ¿cómo curar las deficiencias restantes? El único camino que queda es cambiar también el cálculo de la duración. En lugar de usar P3M1D, podemos ver que la duración P2M31D funcionará en ambas direcciones:

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

Entonces, la idea es cambiar la normalización de la duración calculada. Esto se puede hacer observando si la suma del delta del mes calculado es reversible en un paso de resta, es decir, evita la necesidad de una corrección de fin de mes. java.timedesafortunadamente no ofrece tal solución. No es un error, pero puede considerarse como una limitación de diseño.

¿Alternativas?

He mejorado mi biblioteca de tiempo Time4J con métricas reversibles que implementan las ideas dadas anteriormente. Ver el siguiente ejemplo:

    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
fuente