Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add approaches for leap #653

Merged
merged 27 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
glennj marked this conversation as resolved.
Show resolved Hide resolved
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 ))
glennj marked this conversation as resolved.
Show resolved Hide resolved
```
~~~~
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) ))
glennj marked this conversation as resolved.
Show resolved Hide resolved
else
return $(( !(year % 4 == 0) ))
glennj marked this conversation as resolved.
Show resolved Hide resolved
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