Executando verificação de segurança...
24

Aritmética de Datas: somar 1 mês não é o mesmo que somar 30 dias (ou 31, ou qualquer outro valor fixo)

Este post é mais um da série sobre datas que estou escrevendo. Os anteriores são:

Em um post anterior já vimos como somar 1 dia a uma data pode ser mais complicado do que parece. Com meses e anos não é diferente, embora as complicações sejam outras.


Não basta "somar 1" no valor do mês e pronto

Por exemplo, se eu tenho a data 01/01/2019 e somo 1 no valor do mês, o resultado é 01/02/2019. Então o algoritmo "somar 1 no mês" está funcionando, não é? Até que você testa com 01/12/2019 e descobre que se somar 1 no mês o resultado é 01/13/2019, mas como não existe mês 13, você deve ajustar o resultado para 01/01/2020. Tudo bem, é só "fazer um if", e essa é a parte fácil.

Agora suponha que eu tenha a data 31/01/2019. Somando um mês, o resultado é 31/02/2019. Mas fevereiro não tem 31 dias, então qual deve ser o resultado?

A resposta certa é que "ninguém sabe ao certo". Não há uma regra oficial para isso, como existe na matemática, na qual a operação de soma é formalmente bem definida. O que existe é uma escolha feita por cada API que implementa a operação de "somar meses a uma data". E muitas vezes elas têm opiniões diferentes sobre qual é a melhor abordagem.

Um ponto que muitas implementações levam em conta é a semântica: se estou somando 1 mês a uma data, então faz todo sentido que o resultado esteja no mês seguinte. Se a data inicial está em janeiro, somar 1 mês sempre deve resultar em alguma data em fevereiro. Nem todas as APIs implementam desta maneira, claro, mas na minha opinião esta abordagem parece fazer mais sentido.

Voltando ao nosso exemplo: somei 1 mês a 31/01/2019, o resultado foi 31/02/2019. Mas fevereiro de 2019 só tem 28 dias, então como eu ajusto o dia 31, de forma que o resultado continue em fevereiro? O que muitas implementações fazem é ajustar para o último dia do mês, resultando em 28/02/2019.

Aritmética de datas é bizarra e contraintuitiva

Esse ajuste - necessário para manter a semântica da operação "somar meses" - acaba gerando uma situação bem estranha. Vamos somar 1 mês a várias datas diferentes, usando o mesmo algoritmo acima:

Data inicial+ 1 mês (sem ajuste)+ 1 mês (após ajuste)
28/01/201928/02/201928/02/2019
29/01/201929/02/201928/02/2019
30/01/201930/02/201928/02/2019
31/01/201931/02/201928/02/2019

Repare que se somarmos 1 mês a 28, 29, 30 ou 31 de janeiro de 2019, o resultado é a mesma data: 28 de fevereiro de 2019. Isso acontece por causa do ajuste feito para manter a semântica da operação: ao somar 1 mês a uma data em janeiro, o resultado deve estar em fevereiro.

Agora imagine que queremos subtrair 1 mês de 28/02/2019. O resultado é 28/01/2019 (foi subtraído 1 do valor do mês, e como o dia 28 é válido em janeiro, nenhum ajuste foi feito).

Isso quer dizer que se eu começar com qualquer uma das datas (28, 29, 30 ou 31 de janeiro), somar 1 mês e depois subtrair 1 mês, o resultado não necessariamente será a data original:

Data inicialsomar 1 mêse depois subtrair 1 mês
28/01/201928/02/201928/01/2019
29/01/201928/02/201928/01/2019
30/01/201928/02/201928/01/2019
31/01/201928/02/201928/01/2019

Pois é, aritmética de datas é tão bizarra e contraintuitiva que nem sempre a soma e subtração são operações inversas.

Somar anos têm os mesmos problemas

Somar 1 ano a uma data é parecido. Na maioria dos casos não haverá problema, pois somar 1 ao valor do ano geralmente funcionará. O único problema é quando temos 29 de fevereiro. Se somarmos 1 ano à data de 29/02/2016, o resultado seria 29/02/2017. Mas 2017 não é um ano bissexto, então fevereiro só tem 28 dias nesse ano. E para manter a semântica (somar 1 ano a uma data em fevereiro deveria resultar em fevereiro do ano seguinte), é feito o ajuste para 28/02/2017, que é o resultado que muitas implementações acabam escolhendo.

Pessoalmente, eu prefiro o "ajuste semântico", pois me parece mais "óbvio" e "natural" (entre aspas porque nada é trivial na aritmética de datas): somar 1 mês a uma data deveria resultar em uma data no mês seguinte, e somar 1 ano deveria resultar no mesmo mês do ano seguinte, mesmo que o preço a se pagar sejam as situações estranhas citadas anteriormente.

Apesar de muitas linguagens seguirem por este caminho, nem todas fazem essas operações desta maneira. Vamos ver alguns exemplos abaixo:

Java

Se você estiver usando o Java >= 8, use a API java.time. Para representar uma data (somente o dia, mês e ano), você pode usar a classe java.time.LocalDate:

// 31 de janeiro de 2016
LocalDate data = LocalDate.of(2016, 1, 31);
// somar 1 mês = 29 de fevereiro de 2016
data = data.plusMonths(1);
// somar 1 ano = 28 de fevereiro de 2017
data = data.plusYears(1);

O pacote java.time possui várias outras classes diferentes que podem ser usadas dependendo da situação. Temos, por exemplo, java.time.LocalDateTime para representar uma data e hora, java.time.ZonedDateTime para representar uma data e hora em um timezone (fuso horário) específico, etc. E estas classes também possuem os métodos plusMonths para somar meses e plusYears para somar anos. Ambos fazem os ajustes descritos acima (ajusta para o último dia do mês para manter a semântica).

Um detalhe é que as classes do java.time são imutáveis, então métodos como plusMonths e plusYears sempre retornam outra instância com os valores modificados. Por isso você deve atribuir o retorno do método em alguma variável.

Se você estiver usando Java 6 e 7, pode usar o Threeten Backport, um backport do java.time. Ele basicamente possui as mesmas classes e métodos do java.time, a diferença é que o nome do pacote é org.threeten.bp. Ou seja, com exceção dos import's, o código ficará igual ao do exemplo acima.

Obviamente, você também pode usar a API legada (java.util.Date e java.util.Calendar):

// 31 de janeiro de 2016
Calendar cal = Calendar.getInstance();
cal.set(2016, Calendar.JANUARY, 31);
// somar 1 mês = 29 de fevereiro 2016
cal.add(Calendar.MONTH, 1);
// somar 1 ano = 28 de fevereiro 2017
cal.add(Calendar.YEAR, 1);
// obter o java.util.Date 
Date date = cal.getTime();

Vale lembrar que Calendar usa os meses indexados em zero (janeiro é zero, fevereiro é 1, etc). Usar as constantes (como Calendar.JANUARY) ajuda a diminuir esta confusão (mas lembre-se que o valor dessa constante continua sendo zero).

C#

Em C# você pode usar um DateTime, que possui os métodos AddMonths e AddYears.

// 31 de janeiro de 2016
DateTime date = new DateTime(2016, 1, 31);
// somar 1 mês = 29 de fevereiro de 2016
date = date.AddMonths(1);
// somar 1 ano = 28 de fevereiro de 2017
date = date.AddYears(1);

Ambos também fazem os ajustes já citados para manter a semântica das operações, e os métodos AddMonths e AddYears retornam outra instância de DateTime com os valores modificados.

Python

Em Python você pode usar o módulo datetime. Se quiser trabalhar com somente a data (apenas o dia, mês e ano), pode usar um date. Infelizmente, não é possível usar timedelta, pois este só possui dias, mas não meses ou anos.

Nesse caso, uma alternativa é usar o módulo dateutil, disponível no PyPI, que possui a classe relativedelta:

from datetime import date
from dateutil.relativedelta import relativedelta

# 31 de janeiro de 2016
d = date(2016, 1, 31)
# somar 1 mês = 29 de fevereiro de 2016
d = d + relativedelta(months=1)
# somar 1 ano = 28 de fevereiro de 2017
d = d + relativedelta(years=1)

Como podemos ver, também são feitos os devidos ajustes semânticos nos resultados.

Se quiser, também pode usar um datetime, a diferença é que este também possui o horário. E se você criá-lo com d = datetime(2016, 1, 31), o horário é automaticamente setado para meia-noite.

PHP

Em PHP você pode usar a classe DateTime para criar a data, e em seguida usar o método add, passando como parâmetro um DateInterval. Só que, diferente de Java, .NET e Python, no PHP não é feito o ajuste semântico. Então somar 1 mês a uma data em janeiro pode resultar em uma data em março, e somar 1 ano a uma data em fevereiro também pode resultar em uma data em março:

$d = new DateTime();
// muda para 31 de janeiro de 2016
$d->setDate(2016, 1, 31);
// somar 1 mês = 2 de março de 2016
$d->add(new DateInterval("P1M"));

// muda para 29 de fevereiro de 2016
$d->setDate(2016, 2, 29);
// somar 1 ano = 1 de março de 2017
$d->add(new DateInterval("P1Y"));

O detalhe é que DateInterval recebe uma string que representa uma duração no formato ISO 8601. No caso, P1M corresponde a uma duração de 1 mês, e P1Y corresponde a uma duração de 1 ano.

Diferente do que ocorre em Java e C#, a classe DateTime não é imutável, portanto o método add muda os valores da própria instância, não sendo necessário atribuir o seu valor em outra variável.

O método add só foi introduzido no PHP 5.3.0. Para a versão 5.2.0, uma alternativa é usar o método modify: $d->modify('+1 month');. E para versões anteriores, existe a função strtotime:

echo date('d/m/Y', strtotime('2016-01-31 + 1 months')); // 02/03/2016

Lembrando que strtotime retorna um timestamp, que em seguida é passado para date, que por sua vez retorna uma string (e não uma data). No caso, a string contém a data no formato "dia/mês/ano".

De qualquer forma, nenhum destes métodos faz o ajuste semântico. Caso você queira este comportamento, terá que fazer manualmente.

JavaScript

Em JavaScript você pode usar Date. O suporte à aritmética de datas não é lá essas coisas, mas tem como fazer:

let d = new Date(2016, 0, 31); // 31 de janeiro de 2016
// somar 1 mês = 2 de março de 2016
d.setMonth(d.getMonth() + 1);

// 29 de fevereiro de 2016
d = new Date(2016, 1, 29);
// somar 1 ano = 1 de março de 2017
d.setFullYear(d.getFullYear() + 1);

Assim como acontece com java.util.Calendar, os meses são indexados em zero. E da mesma forma que o PHP, não são feitos os devidos ajustes para manter a semântica.

Neste caso, uma alternativa (que não seja fazer um monte de if's para tratar estes casos) é usar alguma lib como o Moment.js, que consegue fazer as operações de somar meses e anos fazendo os ajustes necessários para manter a semântica.

// 31 de janeiro de 2016
let d = moment([2016, 0, 31]);
// somar 1 mês = 29 de fevereiro de 2016
d.add(1, 'month');
// somar 1 ano = 28 de fevereiro de 2017
d.add(1, 'year');

A grande vantagem de usar uma API de datas é que ela já trata dos casos especiais. Você só terá um problema se quiser um comportamento diferente (como o ajuste semântico em linguagens que não o fazem, ou vice-versa). E como já dito aqui, se você está tentando implementar essas operações manualmente, mas apenas como um exercício com fins puramente educacionais, é um desafio interessante. Mas se for para código que vai para a produção, não invente. Use uma API de datas para somar os meses e anos e pronto. Pois aqui só arranhamos a superfície, ainda existem detalhes que podem complicar mais essas contas, como por exemplo o horário de verão (conforme já visto anteriormente).


Texto adaptado deste post do meu blog.

Carregando publicação patrocinada...
5

Realmente, isso não é algo tão trivial assim. Acredito que todo mundo tenha passado por uma etapa da infância onde perguntou para alguém mais velho: "E quem nasce dia 29 de fevereiro, faz aniversário quando?".

(...) qual deve ser o resultado?
A resposta certa é que "ninguém sabe ao certo".

Concordo. Eu acredito que isso varia muito de contexto para contexto. Se eu precisasse implementar uma "soma de mês", perguntaria "para que você precisa disso?". Acho que minha ficha caiu para essa necessidade quando precisei lidar com algo parecido, quando ainda estava aprendendo JavaScript, e vi que a biblioteca Moment.js tinha duas funções especiais, startOf e endOf, para obter o começo ou fim de um período (mês, por exemplo).

Ótimo artigo.

4

"E quem nasce dia 29 de fevereiro, faz aniversário quando?"

Conheço gente que nasceu em 29/02 e em anos não-bissextos comemoram em 1 de março - já que dia 28 é "antes", e "comemorar antes dá azar" :-)

Apesar de parecer bobo, isso pode ter implicações em sistemas e situações mais sérias. Se não me engano (carece de fontes), juridicamente considera-se 1 de março caso o ano não seja bissexto (ou seja, se alguém nasceu em 29/02/2020, será considerado maior de idade somente a partir de 01/03/2038). Imagine as consequências de uma implementação errada em um sistema que fosse verificar a idade para esses casos (ou em qualquer sistema que tenha restrições de idade).

Também já vi isso dar problema em outros tipos de sistemas. Por exemplo, tinha um job que deveria rodar sempre no último dia do mês, e claro que quando chegou 29/02 não rodou (pois já havia rodado no dia 28). Ou o clássico "mandar email no aniversário do cliente", que ignorava quem nasceu em 29/02.

Os problemas de somar 1 mês costumam aparecer também quando precisa gerar recorrência (por exemplo, data de vencimento de parcelas). Sempre precisa decidir o que fazer quando a data cai nos últimos dias do mês. E pra variar, a resposta sempre é "depende".

4

Realmente, a aritmética de datas é mais complexa do que parece à primeira vista. Esse desafio levou à criação do Unix epoch, uma contagem linear do tempo em segundos (ou milissegundos/nanosegundos para maior precisão) desde 1º de janeiro de 1970. Mas mesmo com essa padronização, existem ainda mais complexidades.

Além dos anos bissextos, temos regras específicas para anos centenários, que são bissextos apenas se forem divisíveis por 400. Por exemplo, 2000 foi bissexto, mas 1900 não foi, assim como 2100 também não vai ser. Vocês sabiam dessa?

Há também os segundos intercalares adicionados ou subtraídos do Tempo Universal Coordenado (UTC) para compensar as irregularidades na rotação da Terra (e que não têm nada haver com anos bisextos), algo que o Unix time não contabiliza, podendo causar discrepâncias.

Finalmente, é imporante observar que, no mundo da contabilidade, especialmente para fins legais, um mês pode ser exatamente 30 dias. O método 30/360 é uma convenção utilizada em finanças para simplificar todas estas complexidades. Assumindo que todos os meses têm 30 dias e que o ano tem 360 dias. Na legislação brasileira, ele pode ser aplicado em diferentes contextos, como no cálculo de juros, férias, adicionais salariais, entre outros.

1
1

Parabéns pelo conteúdo, é bom sabermos as situações que poderemos passar e já ter visto como resolver. Esse conteúdo é primordial para qualquer programador

1

ao se trabalhar com data é comum o iniciante ter dificuldade.

eu aprendi depois de muito bater cabeça que uma das formas mais interessantes de data é definida pela ISO, trazendo os valores em ordem de grandeza:
ano mês dia hora minuto segundo.

ao se trabalhar com esse formato você consegue até representar datas por um unico valor numérico legível , como:
202312022202 para a data 02/12/2023 às 22:02.

2

A norma que vc se refere é a ISO 8601. Vale lembrar que é um padrão sobre o formato de uma data/horário.

Só uma correção: entre a data e o horário sempre tem a letra "T" maiúscula, então o seu exemplo seria 20231202T2202 ou ainda 2023-12-02T22:02 (eu prefiro o segundo por ser mais legível, mas ambos são válidos).


E como curiosidade, existe também a RFC 3339, que é similar, mas não exatamente igual à ISO 8601. Uma das diferenças é que ela permite um espaço em vez do "T", ou seja, 2023-12-02 22:02 é válido na RFC 3339, mas não na ISO 8601.

Por outro lado, a RFC 3339 não permite o formato simplificado, sem os separadores - e :. Há outras diferenças, veja mais aqui.