Skip to content

Commit

Permalink
Add approaches for leap (#653)
Browse files Browse the repository at this point in the history
Add approaches for leap

Co-authored-by: András B Nagy <[email protected]>
Co-authored-by: Isaac Good <[email protected]>
  • Loading branch information
3 people authored Jan 16, 2024
1 parent efa875c commit 5e65747
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 0 deletions.
47 changes: 47 additions & 0 deletions exercises/practice/leap/.approaches/boolean-chain/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Chaining Boolean expressions

```bash
year=$1
if (( year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) )); then
echo true
else
echo false
fi
```

The Boolean expression `year % 4 == 0` checks the remainder from dividing `year` by 4.
If a year is evenly divisible by 4, the remainder will be zero.
All leap years are divisible by 4, and this pattern is then repeated whether a year is not divisible by 100 and whether it's divisible by 400.

Parentheses are used to control the [order of precedence][order-of-precedence]:
logical AND `&&` has a higher precedence than logical OR `||`.

| year | divisible by 4 | not divisible by 100 | divisible by 400 | result |
| ---- | -------------- | ------------------- | ---------------- | ------------ |
| 2020 | true | true | not evaluated | true |
| 2019 | false | not evaluated | not evaluated | false |
| 2000 | true | false | true | true |
| 1900 | true | false | false | false |

By situationally skipping some of the tests, we can efficiently calculate the result with fewer operations.
Although in an interpreted language like Bash, that is less crucial than it might be in another language.

~~~~exercism/note
The `if` command takes a _list of commands_ to use as the boolean conditions:
if the command list exits with a zero return status, the "true" branch is followed;
any other return status folls the "false" branch.
The double parentheses is is a builtin construct that can be used as a command.
It is known as the arithmetic conditional construct.
The arithmetic expression is evaluated, and if the result is non-zero the return status is `0` ("true").
If the result is zero, the return status is `1` ("false").
Inside an arithmetic expression, variables can be used without the dollar sign.
See [the Conditional Constructs section][conditional-constructs] in the Bash manual.
[conditional-constructs]: https://www.gnu.org/software/bash/manual/bash.html#Conditional-Constructs
~~~~

[order-of-precedence]: https://www.gnu.org/software/bash/manual/bash.html#Shell-Arithmetic
7 changes: 7 additions & 0 deletions exercises/practice/leap/.approaches/boolean-chain/snippet.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
year=$1
if (( year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) )); then
echo true
else
echo false
fi

40 changes: 40 additions & 0 deletions exercises/practice/leap/.approaches/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"introduction": {
"authors": [
"glennj"
],
"contributors": [
"BNAndras",
"IsaacG"
]
},
"approaches": [
{
"uuid": "4e53dfc9-2662-4671-bb00-b2d927569070",
"slug": "boolean-chain",
"title": "Boolean chain",
"blurb": "Use a chain of Boolean expressions.",
"authors": [
"glennj"
]
},
{
"uuid": "8a562c42-3c04-4833-8322-bc0323539954",
"slug": "ternary-operator",
"title": "Ternary operator",
"blurb": "Use a ternary operator of Boolean expressions.",
"authors": [
"glennj"
]
},
{
"uuid": "c28ae2d8-9f8a-4359-b687-229b42573eef",
"slug": "external-tools",
"title": "External tools",
"blurb": "Use external tools to do date addition.",
"authors": [
"glennj"
]
}
]
}
61 changes: 61 additions & 0 deletions exercises/practice/leap/.approaches/external-tools/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# External Tools

Calling external tools is a natural way to solve problems in Bash: call out to a specialized tool, capture the output, and process it.

Using GNU `date` to find the date of the day after February 28:

```bash
year=$1
next_day=$(date -d "$year-02-28 + 1 day" '+%d')
if [[ $next_day == 29 ]]; then
echo true
else
echo false
fi
```

Or, more concise but less readable:

```bash
[[ $(date -d "$1-02-28 + 1 day" '+%d') == 29 ]] \
&& echo true \
|| echo false
```

Working with external tools like this is what shells were built to do.

From a performance perspective, it takes more work (than builtin addition) to:

* copy the environment and spawn a child process,
* connect the standard I/O channels,
* wait for the process to complete and capture the exit status.

Particularly inside of a loop, be careful about invoking external tools as the cost can add up.
Over-reliance on external tools can take a job from completing in seconds to completing in minutes (or worse).

~~~~exercism/caution
Take care about using parts of dates in shell arithmetic.
For example, we can get the day of the month:
```bash
day=$(date -d "$some_date" '+%d')
next_day=$((day + 1))
```
That looks innocent, but if `$some_date` is `2024-02-08`, then:
```bash
$ some_date='2024-02-08'
$ day=$(date -d "$some_date" '+%d')
$ next_day=$((day + 1))
bash: 08: value too great for base (error token is "08")
```
Bash treats numbers starting with zero as octal, and `8` is not a valid octal digit.
Workarounds include using `%_d` or `%-d` to avoid the leading zero, or specify base-10 in the arithmetic (the `$` is required in this case).
```bash
next_day=$(( 10#$day + 1 ))
```
~~~~
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
year=$1
next_day=$(date -d "$year-02-28 + 1 day" '+%d')
[[ $next_day == "29" ]] && echo true || echo false
63 changes: 63 additions & 0 deletions exercises/practice/leap/.approaches/introduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Introduction

There are various idiomatic approaches to solve Leap.
You can use a chain of Boolean expressions to test the conditions.

## General guidance

The key to solving Leap is to know if the year is evenly divisible by `4`, `100` and `400`.
To determine that, you will use the [modulo operator][modulo-operator].

## Approach: Arithmetic expression: chain of Boolean expressions

```bash
year=$1
if (( year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) )); then
echo true
else
echo false
fi
```

For more information, check the [Boolean chain approach][approach-boolean-chain].

## Approach: Arithmetic expression Ternary operator of Boolean expressions

```bash
year=$1
if (( year % 100 == 0 ? year % 400 == 0 : year % 4 == 0 )); then
echo true
else
echo false
fi
```

For more information, check the [Ternary operator approach][approach-ternary-operator].

## Approach: External tools

Bash is naturally a "glue" language, making external tools easy to use.
Calling out to a tool that can manipulate dates would be another approach to take.
GNU `date` is an appropriate tool for this problem.

```bash
year=$1
next_day=$(date -d "$year-02-28 + 1 day" '+%d')
[[ $next_day == "29" ]] && echo true || echo false
```

Add a day to February 28th for the year and see if the new day is the 29th.
For more information, see the [external tools approach][approach-external-tools].

## Which approach to use?

- The chain of Boolean expressions should be the most efficient, as it proceeds from the most likely to least likely conditions.
It has a maximum of three checks.
It is the most efficient approach when testing a year that is not evenly divisible by `100` and is not a leap year, since the most likely outcome is eliminated first.
- The ternary operator has a maximum of only two checks, but it starts from a less likely condition.
- Using external tools to do `datetime` addition may be considered a "cheat" for the exercise, and it will be slower than the other approaches.

[modulo-operator]: https://www.gnu.org/software/bash/manual/bash.html#Shell-Arithmetic
[approach-boolean-chain]: https://exercism.org/tracks/bash/exercises/leap/approaches/boolean-chain
[approach-ternary-operator]: https://exercism.org/tracks/bash/exercises/leap/approaches/ternary-operator
[approach-external-tools]: https://exercism.org/tracks/bash/exercises/leap/approaches/external-tools
68 changes: 68 additions & 0 deletions exercises/practice/leap/.approaches/ternary-operator/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Ternary operator

```bash
year=$1
if (( year % 100 == 0 ? year % 400 == 0 : year % 4 == 0 )); then
echo true
else
echo false
fi
```

A [conditional operator][ternary-operator], also known as a "ternary conditional operator", or just "ternary operator".
This structure uses a maximum of two checks to determine if a year is a leap year.

It starts by testing the outlier condition of the year being evenly divisible by `100`.
It does this by using the [remainder operator][remainder-operator]: `year % 100 == 0`.
If the year is evenly divisible by `100`, then the expression is `true`, and the ternary operator returns the result of testing if the year is evenly divisible by `400`.
If the year is _not_ evenly divisible by `100`, then the expression is `false`, and the ternary operator returns the result of testing if the year is evenly divisible by `4`.

| year | divisible by 4 | not divisible by 100 | divisible by 400 | result |
| ---- | -------------- | -------------------- | ---------------- | ------------ |
| 2020 | false | not evaluated | true | true |
| 2019 | false | not evaluated | false | false |
| 2000 | true | true | not evaluated | true |
| 1900 | true | false | not evaluated | false |

Although it uses a maximum of two checks, the ternary operator tests an outlier condition first, making it less efficient than another approach that would first test if the year is evenly divisible by `4`, which is more likely than the year being evenly divisible by `100`.

## Refactoring for readability

This is a place where a helper function can result in more elegant code.

```bash
is_leap() {
local year=$1
if (( year % 100 == 0 )); then
return $(( !(year % 400 == 0) ))
else
return $(( !(year % 4 == 0) ))
fi
}

is_leap "$1" && echo true || echo false
```

The result of the arithmetic expression `year % 400 == 0` will be `1` if true and `0` if false.
The value is negated to correspond to the shell's return statuses: `0` is "success" and `1` is "failure.
Then the function can be used to branch between the "true" and "false" output.

The function's `return` statements can be written as

```bash
(( year % 400 != 0 ))
# or even
(( year % 400 ))
```

Without an explicit `return`, the function returns with the status of the last command executed.
The `((` construct will be the last command.

~~~~exercism/note
It is unfortunate that the meaning of the shell's exit status (`0` is success) is opposite to the arithmetic meaning of zero (failure, the condition is not met).
In the author's opinion, the cognitive dissonance of negating the condition reduces readability, but using `year % 400 != 0`, is worse.
I prefer the more explicit version with the `return` statement and the explicit conversion of the arithmetic result to a return status.
~~~~

[ternary-operator]: https://www.gnu.org/software/bash/manual/bash.html#Shell-Arithmetic
[remainder-operator]: https://www.gnu.org/software/bash/manual/bash.html#Shell-Arithmetic
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
year=$1
if (( year % 100 == 0 ? year % 400 == 0 : year % 4 == 0 )); then
echo true
else
echo false
fi

0 comments on commit 5e65747

Please sign in to comment.