diff --git a/exercises/practice/reverse-string/.approaches/config.json b/exercises/practice/reverse-string/.approaches/config.json new file mode 100644 index 00000000..e1a07a00 --- /dev/null +++ b/exercises/practice/reverse-string/.approaches/config.json @@ -0,0 +1,28 @@ +{ + "introduction": { + "authors": [ + "glennj" + ], + "contributors": [] + }, + "approaches": [ + { + "uuid": "351af57f-efd4-4a8b-967b-e635428937da", + "slug": "external-tools", + "title": "External tools", + "blurb": "Use external tools to reverse a string.", + "authors": [ + "glennj" + ] + }, + { + "uuid": "4363f61c-5caa-4b52-abe9-30ae671817fc", + "slug": "loops", + "title": "Looping", + "blurb": "Loop over the indices of the string.", + "authors": [ + "glennj" + ] + } + ] +} diff --git a/exercises/practice/reverse-string/.approaches/external-tools/content.md b/exercises/practice/reverse-string/.approaches/external-tools/content.md new file mode 100644 index 00000000..92bea26b --- /dev/null +++ b/exercises/practice/reverse-string/.approaches/external-tools/content.md @@ -0,0 +1,27 @@ +# External Tools + +The GNU Linux core utilities contain lots of goodies. +To reverse strings, use `rev`. + +```bash +$ echo "Hello, World!" | rev +!dlroW ,olleH +``` + +`rev` also works with files to reverse each line. + +```bash +$ printf '%s\n' one two three > myfile +$ rev myfile +eno +owt +eerht +``` + +There are other ways to do this, but none are a simple as `rev`. + +```bash +grep -o . <<< "$string" | tac | paste -s -d '' +perl -lne 'print scalar reverse' <<< "$string" +# etc +``` diff --git a/exercises/practice/reverse-string/.approaches/external-tools/snippet.txt b/exercises/practice/reverse-string/.approaches/external-tools/snippet.txt new file mode 100644 index 00000000..7f7646f3 --- /dev/null +++ b/exercises/practice/reverse-string/.approaches/external-tools/snippet.txt @@ -0,0 +1 @@ +reversed=$( rev <<< "$string" ) diff --git a/exercises/practice/reverse-string/.approaches/introduction.md b/exercises/practice/reverse-string/.approaches/introduction.md new file mode 100644 index 00000000..0a4f1987 --- /dev/null +++ b/exercises/practice/reverse-string/.approaches/introduction.md @@ -0,0 +1,50 @@ +# Introduction + +To reverse a string in bash, there are two basic approaches. + +## Approach: External tools + +The most straightforward approach is to call out the `rev` utility. + +```bash +reversed=$( echo "$string" | rev ) +# or +reversed=$( rev <<< "$string" ) +``` + +For more details, see the [External tools approach][app-external]. + +## Approach: Loop over the string indices + +To reverse a string with just bash, loop over the indices, extract the character at that index, and add it to the accumulating result string. + +```bash +# forwards +reversed='' +for ((i = 0; i < ${#string}; i++)); do + reversed="${string:i:1}$reversed" +done + +# or backwards +reversed='' +for ((i = ${#string} - 1; i >= 0; i--)); do + reversed+="${string:i:1}" +done +``` + +For more details, go to the [Looping approach][app-loop]. + +## Which approach to use? + +Calling out to `rev` makes this exercise extremely trivial. +In a production environment, it's exactly the right approach to take. + +If you're interested in learning about bash loops and parameter expansion, experimenting with the loop approach is more interesting. + +Thinking about performance generally isn't something you would care that much about with a shell script. +However, working with strings can be surprisingly expensive in bash. +The [Performance article][art-perf] takes a deeper dive. + +[app-external]: /tracks/bash/exercises/reverse-string/approaches/external-tools +[app-loop]: /tracks/bash/exercises/reverse-string/approaches/loops +[art-perf]: /tracks/bash/exercises/reverse-string/articles/performance diff --git a/exercises/practice/reverse-string/.approaches/loops/content.md b/exercises/practice/reverse-string/.approaches/loops/content.md new file mode 100644 index 00000000..af103464 --- /dev/null +++ b/exercises/practice/reverse-string/.approaches/loops/content.md @@ -0,0 +1,43 @@ +# Looping + +In a bash loop, we are looping over the indices of the characters. +Bash strings and arrays use zero-based indexing. + +```bash +reversed='' +for ((i = 0; i < ${#string}; i++)); do + reversed="${string:i:1}$reversed" +done +``` + +- We loop from zero up to (but not including) the string length (`${#string}`). +- Extracting the character at index `i` is done with the `${var:offset:length}` parameter expansion. +- We _prepend_ the character to the accumulating variable to reverse the string. + +Finding the string length is a surprisingly expensive operation in bash (more details in the [Performance article][art-perf]). +We don't have to re-calculate it for every loop iteration, just do it once. + +```bash +reversed='' +len=${#string} +for ((i = 0; i < len; i++)); do + reversed="${string:i:1}$reversed" +done +``` + +An alternate way to calculate it just once is to loop backwards. + +```bash +reversed='' +for ((i = ${#string} - 1; i >= 0; i--)); do + reversed+="${string:i:1}" +done +``` + +- Here, we start the loop at one less than the string length, which is the index of the last character, and we loop down to (and including) zero. +- Since we're accessing the characters in the reverse order, we'll _append_ to the accumulating variable. + +Another performance note: accessing each character with this parameter expansion is still slow: bash has to walk the string until reaching the desired index. +The most efficient solution is discussed in the [Performance article][art-perf]). + +[art-perf]: /tracks/bash/exercises/reverse-string/articles/performance diff --git a/exercises/practice/reverse-string/.approaches/loops/snippet.txt b/exercises/practice/reverse-string/.approaches/loops/snippet.txt new file mode 100644 index 00000000..89ae1d62 --- /dev/null +++ b/exercises/practice/reverse-string/.approaches/loops/snippet.txt @@ -0,0 +1,4 @@ +reversed='' +for ((i = ${#string} - 1; i >= 0; i--)); do + reversed+="${string:i:1}" +done diff --git a/exercises/practice/reverse-string/.articles/config.json b/exercises/practice/reverse-string/.articles/config.json new file mode 100644 index 00000000..7b80cc0c --- /dev/null +++ b/exercises/practice/reverse-string/.articles/config.json @@ -0,0 +1,13 @@ +{ + "articles": [ + { + "uuid": "b8011164-e0fb-4fc3-ba8d-1b8a67f4ee4a", + "slug": "performance", + "title": "Performance considerations", + "blurb": "Compare the performances of the various reverse-string approaches.", + "authors": [ + "glennj" + ] + } + ] +} diff --git a/exercises/practice/reverse-string/.articles/performance/content.md b/exercises/practice/reverse-string/.articles/performance/content.md new file mode 100644 index 00000000..71ecfba1 --- /dev/null +++ b/exercises/practice/reverse-string/.articles/performance/content.md @@ -0,0 +1,102 @@ +# Performance + +We usually don't care that much about performance with shell scripts. +When we know something is slow, we can avoid it. +This matters much more when the operation is done inside loop. + +## String length is slow + +Obtaining the length of a string is a surprisingly expensive operation in bash. +With large strings and/or large loops, performance can be significantly impacted. +Storing the length in a variable helps significantly. + +Demonstrating an empty loop, iterating over the string indices. + +```bash +$ printf -v string "%32767s" foo +$ time for ((i = 0; i < ${#string}; i++)); do true; done + +real 0m1.448s +user 0m1.443s +sys 0m0.005s + +$ len=${#string} +$ time for ((i = 0; i < len; i++)); do :; done + +real 0m0.159s +user 0m0.159s +sys 0m0.000s +``` + +If we can loop backwards, we don't even need to save the length to a variable. +We get a tiny improvement since bash does not need to access the variable contents for each iteration. + +```bash +$ time for ((i = ${#string} - 1; i >= 0; i--)); do true; done + +real 0m0.151s +user 0m0.151s +sys 0m0.000s +``` + +## Substrings can be slow + +Whether we go backwards or forwards, we still have to extract the character at the given index. +Re-using our 32,767 character string: + +```bash +$ time for ((i = 0; i < len; i++)); do + char="${string:i:1}" + done + +real 0m9.188s +user 0m9.188s +sys 0m0.000s +``` + +That's 9 seconds just to read each character. +What if we can iterate over the characters directly? +We can, with a while-read loop. + +## While-Read loops + +We'll _redirect_ the string into loop so `read` can extract one character at a time. + +```bash +$ time while IFS= read -d "" -r -n 1 char; do + true + done < <(printf "%s" "$string") + +real 0m0.336s +user 0m0.276s +sys 0m0.060s +``` + +There's a 27x improvement. + +What are we doing there? + +- `< <(printf "%s" "%string)` is redirecting (`<`) a [process substitution][process-subst] (`<(...)`). + We could use a [here-string][here-string] (`<<< "$string"`), but that appends a newline. + Using `printf` outputs the string without adding a trailing newline. +- `IFS= read -d "" -r -n 1 char`: There's a lot going on there with this [`read` command][read]. + - `IFS=`: Normally, `read` will trim leading and trailing whitespace. + More exactly, characters in `$IFS` are removed from the start and end of the text that `read` captures. + By default, IFS contains space, tab and newline. + Our goal is to read each character of the string, including whitespace. + The empty assignment assigns the empty string to IFS _only for the duration of the `read` command_ so that whitespace characters are not treated specially. + - `-d ""`: Normally, `read` will read up to a newline and stop there. + We typically use a while-read loop to read lines from a file. + But here, we want every character in the string including newlines. + Bash uses null terminated strings under the hood, so a bash string cannot contain a null character. + `-d ""` sets the line-ending delimiter to the null character. + - `-r`: Backslashes are handled specially by `read`. + We want to handle backslashes just like a normal character. + - `-n 1`: This limits `read` to take just one character from the input. + +Bash is strongly optimized for reading from and writing to IO streams. +Although this while-read loop is ugly, it's the fastest way for bash to process text one character at a time. + +[process-subst]: https://www.gnu.org/software/bash/manual/bash.html#Process-Substitution +[here-string]: https://www.gnu.org/software/bash/manual/bash.html#Here-Strings +[read]: https://www.gnu.org/software/bash/manual/bash.html#index-read diff --git a/exercises/practice/reverse-string/.articles/performance/snippet.md b/exercises/practice/reverse-string/.articles/performance/snippet.md new file mode 100644 index 00000000..4214a467 --- /dev/null +++ b/exercises/practice/reverse-string/.articles/performance/snippet.md @@ -0,0 +1,6 @@ +```bash +while IFS= read -d "" -r -n 1 char; do + # do something with $char + echo "$char" +done < <(printf "%s" "$string") +```