Uploaded avatar of sdball

Encontrando anos bissextos com o cal

@sdball
Mais de 2 anos atrás

Este post foi publicado originalmente no site do Stephen e é republicado aqui (levemente reescrito) com permissão

Introdução ao cal

Você sabia que existe um calendário na linha de comando do macOS e do Linux?

$ cal

     July 2021
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31

Uma olhada rápida no tldr mostra como esse utilitário pode ser útil

$ tldr cal

cal

Prints calendar information.

- Display a calendar for the current month:
    cal

- Display previous, current and next month:
    cal -3

- Display a calendar for a specific month (1-12 or name):
    cal -m month

- Display a calendar for the current year:
    cal -y

- Display a calendar for a specific year (4 digits):
    cal year

- Display a calendar for a specific month and year:
    cal month year

- Display date of Easter (Western Christian churches) in a given year:
    ncal -e year

Mas tem uma coisa que ele não tem. Anos bissextos!

O cal conhece anos bissextos, é claro, senão não seria bem um calendário. Mas ele não tem como listá-los.

Reconhecendo anos bissextos, na gambiarra

Vamos construir isso nós mesmos! Claro que poderíamos usar uma linguagem de programação de verdade com uma biblioteca de calendário, mas montar coisas juntando componentes de linha de comando é divertido, e não é como se fôssemos colocar esse cálculo em produção.

Antes de tudo, vamos combinar que fevereiro é o indicador de ano bissexto.

$ cal 02 2021

   February 2021
Su Mo Tu We Th Fr Sa
    1  2  3  4  5  6
 7  8  9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28

$ cal 02 2020

   February 2020
Su Mo Tu We Th Fr Sa
                   1
 2  3  4  5  6  7  8
 9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29

Daria para procurar por "29" e deixar que ele reconheça anos bissextos? Talvez!

$ cal 02 2020 | grep -q 29 && echo "LEAP" || echo "normal"

LEAP

$ cal 02 2021 | grep -q 29 && echo "LEAP" || echo "normal"

normal

Até aí tudo bem? Mas aposto que você já viu o problema.

$ for year in {2020..2030}; do
  printf "$year ";
  cal 02 "$year" | grep -q 29 && echo "LEAP" || echo "year";
done

2020 LEAP
2021 year
2022 year
2023 year
2024 LEAP
2025 year
2026 year
2027 year
2028 LEAP
2029 LEAP
2030 year

2029 NÃO é um ano bissexto. Ele só corresponde por causa do 29 em 2029.

$ cal 02 2029

   February 2029
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28

Poderíamos eliminar esse resultado problemático tirando de consideração a linha inteira com "February".

$ cal 02 2029 | grep -v Feb

Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28

Agora eu sustento que, se encontrarmos um "29" no resultado, então é REALMENTE um ano bissexto.

$ for year in {2020..2030}; do
  printf "$year ";
  cal 02 "$year" | grep -v Feb | grep -q 29 && echo "LEAP" || echo "year";
done

2020 LEAP
2021 year
2022 year
2023 year
2024 LEAP
2025 year
2026 year
2027 year
2028 LEAP
2029 year

Isso bate com os anos bissextos de verdade?

A página Leap Year da Wolfram Research traz esta lista como os anos bissextos da primeira metade do século XXI.

2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

Vamos ver como nos saímos

$ for year in {2000..2051}; do cal 02 "$year" | grep -v Feb | grep -q 29 && echo $year; done
2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

Pronto!

Mas esse negócio de grep no 29 é fácil demais. Vamos ficar mais esquisitos!

Reconhecendo anos bissextos, ainda mais na gambiarra

A diferença entre um ano bissexto e um ano normal é aquele dia extra, o 29. Daria para checar o número de caracteres da saída do cal para reconhecer anos bissextos?

Dá sim. Mas tem uma pegadinha para passar primeiro. Deixa eu mostrar o que quero dizer.

Sabemos que 2020 é um ano bissexto e que 2021 não é. Vamos comparar o número de caracteres gerados para fevereiro de cada um desses anos. Fazemos isso com o prático utilitário wc, mais especificamente wc -c, para contar os caracteres em bytes.

$ printf "hello" | wc -c

5

5 caracteres em "hello" (usamos printf em vez de echo porque senão teríamos também um caractere de nova linha). Ótimo!

$ cal 02 2020 | wc -c

184
$ cal 02 2021 | wc -c

184

Aí está o obstáculo. Cada mês tem uma saída diferente (2020 tem aquele dia 29), mas os dois têm o mesmo número de caracteres porque o cal adiciona espaços para garantir uma formatação consistente. Espaços são caracteres contáveis tanto quanto números.

O que podemos fazer?

Podemos remover todos os espaços! Assim sobram só os caracteres que queremos contar.

Um ótimo utilitário para remover caracteres é o comando tr. O comando tr traduz dados do tipo string. Por exemplo, ele pode transformar todos os caracteres a em caracteres A.

$ echo "aardvark" | tr a A

AArdvArk

Ele também pode apagar todos os caracteres especificados, que é exatamente o que queremos.

No nosso caso, queremos apagar todos os caracteres de espaço em branco, sejam quais forem (espaços, tabulações, novas linhas etc.). Existe um agrupamento útil chamado classe de caracteres que permite fazer exatamente isso. A classe de caracteres [:space:] atinge todos esses tipos diferentes de espaço em branco.

$ cal 02 2020 | tr -d '[:space:]'

February2020SuMoTuWeThFrSa1234567891011121314151617181920212223242526272829
$ cal 02 2021 | tr -d '[:space:]'

February2021SuMoTuWeThFrSa12345678910111213141516171819202122232425262728

Ótimo! Dá para contar o número de caracteres para encontrar anos bissextos agora? Com certeza!

$ cal 02 2020 | tr -d '[:space:]' | wc -c

75
$ cal 02 2021 | tr -d '[:space:]' | wc -c

73

Agora vamos imprimir alguns anos bissextos!

$ for year in {2000..2051}; do
  cal 02 "$year" |
    tr -d '[:space:]' |
    wc -c |
    grep -q 75 && echo "$year";
done

2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

Gambiarra sobre gambiarra, e funciona!

Qual você acha que é mais rápido? Pois é, eu também não faço ideia. O caminho que "remove o February" talvez seja mais rápido? Mas eu ficaria surpreso se houvesse uma grande diferença.

Nada de suposições. Vamos descobrir!

Existe um ótimo utilitário chamado hyperfine que permite avaliar o desempenho de várias chamadas de linha de comando

Qual é mais rápido? O hyperfine nos conta

Aqui estão as flags que estou passando para o hyperfine, junto com os dois comandos.

--style basic
    Plain output styling
--export-markdown hyperfine.md
    Export results as markdown
--warmup 5
     Do five runs before benchmarking
--ignore-failure
     Ignore non-zero exits

Estamos interessados apenas em medir o cálculo de um ano, e não o tempo necessário para gerar o laço de anos. Então vou medir apenas a checagem do ano bissexto para um ano específico com cada abordagem.

$ hyperfine --style basic --export-markdown hyperfine.md --warmup 5 --ignore-failure "cal 02 2050 | grep -v Feb | grep -q 29" "cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75"

Benchmark #1: cal 02 2050 | grep -v Feb | grep -q 29
  Time (mean ± σ):       1.8 ms ±   0.3 ms    [User: 0.7 ms, System: 1.9 ms]
  Range (min … max):     1.0 ms …   3.6 ms    493 runs

  Warning: Command took less than 5 ms to complete. Results might be inaccurate.
  Warning: Ignoring non-zero exit code.

Benchmark #2: cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75
  Time (mean ± σ):       1.9 ms ±   0.4 ms    [User: 0.9 ms, System: 2.7 ms]
  Range (min … max):     0.7 ms …   5.9 ms    811 runs

  Warning: Command took less than 5 ms to complete. Results might be inaccurate.
  Warning: Ignoring non-zero exit code.

Summary
  'cal 02 2050 | grep -v Feb | grep -q 29' ran
    1.07 ± 0.28 times faster than 'cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75'
Command Mean [ms] Min [ms] Max [ms] Relative
cal 02 2050 | grep -v Feb | grep -q 29 1.8 ± 0.3 1.0 3.6 1.00
cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75 1.9 ± 0.4 0.7 5.9 1.07 ± 0.28

Como esperado, praticamente nenhuma diferença entre eles em termos de tempo de relógio, MAS o caminho que "remove o February" pode ser ligeiramente mais rápido.

E assim chega ao fim esta jornada de montar ferramentas de linha de comando.

Resumindo

  • Você pode criar novos recursos de linha de comando montando ferramentas existentes em um pipeline de dados
  • Um pouco de criatividade consegue resultados surpreendentemente complexos a partir de peças simples
  • O Hyperfine é uma ferramenta útil para medir o desempenho de comandos de linha de comando

Próximos passos

  • Explore o uso do comando cal.
  • Tente montar um pipeline que conta todos os caracteres que não são espaços de um ano inteiro: cal 2020
  • Tente rodar esse pipeline de contagem do ano inteiro para cada um dos anos de 1750 a 1759: percebe algo estranho?
30 de janeiro de 2024 · Achou útil?