Skip to content
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
28 changes: 28 additions & 0 deletions exercises/practice/reverse-string/.approaches/config.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
reversed=$( rev <<< "$string" )
50 changes: 50 additions & 0 deletions exercises/practice/reverse-string/.approaches/introduction.md
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions exercises/practice/reverse-string/.approaches/loops/content.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
reversed=''
for ((i = ${#string} - 1; i >= 0; i--)); do
reversed+="${string:i:1}"
done
13 changes: 13 additions & 0 deletions exercises/practice/reverse-string/.articles/config.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
102 changes: 102 additions & 0 deletions exercises/practice/reverse-string/.articles/performance/content.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
```bash
while IFS= read -d "" -r -n 1 char; do
# do something with $char
echo "$char"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
echo "$char"
echo -n "$char"

done < <(printf "%s" "$string")
```