De programmeertaal bash is niet eenvoudig om mee te werken. Gelukkig bestaan er tools die het leven van programmeurs wat makkelijker kunnen maken. We geven hier een overzicht van problemen die vaak opduiken bij bash-scripting en hoe je daarmee kunt omgaan.

TL;DR1

We raden de volgende best practices aan

  1. Gebruik shellcheck om je scripts te controleren op veelgemaakte fouten en mogelijke problemen.
  2. Begin je scripts met set -u -o pipefail -o noclobber om veelgemaakte fouten te voorkomen.
  3. Gebruik mktemp om tijdelijke bestanden aan te maken. Vergeet die bestanden dan ook niet te verwijderen, eventueel door middel van het trap commando.
  4. Gebruik bash -x om je scripts in “debugmode” uit te voeren.
  5. Bij twijfel: raadpleeg de bash scripting cheatsheet2.

Shellcheck

De shellcheck linter3 is een zeer handige tool voor algemene troubleshooting bij het schrijven van shell scripts. Voor kleine scripts kan je deze tool rechtstreeks in de browser gebruiken via de website4. Je kunt de tool echter ook lokaal installeren5. Als je Ubuntu of een andere Debian-distro gebruikt, dan gaat dit eenvoudigweg met

$ sudo apt install shellcheck

Je kunt shellcheck vervolgens op gelijk welk bestand uitvoeren. Als je bijvoorbeeld een shell script hebt opgeslagen in een bestand test.sh, dan kun je daarop shellcheck uitvoeren met:

$ shellcheck test.sh

De shellcheck linter produceert dan een aantal berichten die ofwel louter informatief zijn ofwel kunnen wijzen op echte fouten. De uitvoer ziet er bijvoorbeeld als volgt uit:

$ shellcheck test.sh 

In test.sh line 6:
while [[ $teler -lt 5 ]]
         ^----^ SC2154 (warning): teler is referenced but not assigned (did you mean 'teller'?).

For more information:
https://www.shellcheck.net/wiki/SC2154 -- teler is referenced but not assig...

Hier worden we erop gewezen dat we in ons shell script test.sh een variabele teler gebruiken die nog niet gedefinieerd is. Het programma is ook slim genoeg om uit te vissen dat dit wellicht een typfout was, en dat we vermoedelijk teller bedoelden in plaats van teler.

Niet-gedefinieerde variabelen

In veel programmeertalen kun je een variabele pas gebruiken nadat die expliciet gedefinieerd is. Standaard maakt bash echter geen onderscheid tussen variabelen die al gedefinieerd zijn en variabelen die nog niet gedefinieerd zijn. Dit leidt soms tot problemen als je een typfout maakt in de naam van een variabele. Beschouw bijvoorbeeld onderstaand shell script:

#!/bin/bash

teller=0
while ((teler < 5))
do
    echo "$teller"
    ((teller += 1))
done

We hebben hier een typfout gemaakt op regel 4: teler ipv teller. De bash interpreter zal ons hierover niet informeren, met als resultaat dat het script in een oneindige lus gaat. Dergelijke fouten kunnen heel moeilijk op te sporen zijn. Vooral omdat ze zich op heel verschillende manieren kunnen manifesteren. Dankzij het set commando kunnen we bash echter verplichten om te controleren of er reeds een waarde werd toegekend aan een variabele voordat ze gebruikt wordt:

#!/bin/bash

set -u

teller=0
while ((teler < 5))
do
    echo "$teller"
    ((teller += 1))
done

Het set commando is heel veelzijdig. Kijk dus zeker eens naar de documentatie6 voor andere handige opties. In het bijzonder zorgt set -u ervoor dat niet-gedefinieerde variabelen niet gebruikt kunnen worden. Bovenstaand codefragment geeft dan ook volgende foutmelding:

line 6: teler: unbound variable

Je krijgt hier het specifieke regelnummer en de naam van de variabele die niet bestaat, wat voldoende informatie zou moeten zijn om de typfout op te sporen en te corrigeren.

Tijdelijke bestanden

Het zal al eens voorkomen dat je wil werken met data die te groot of te onpraktisch is om volledig in het geheugen te houden. In dat geval kan je gebruikmaken van tijdelijke bestanden: bestanden die alleen maar bestaan tijdens de uitvoering van een script en daarna niet meer op het bestandssysteem moeten bewaard worden. Beschouw bijvoorbeeld dit shell script:

#!/bin/bash

curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt > boek.txt

cat boek.txt | \
    tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
    grep -E '.{4,}' | \
    sort | uniq -c | \
    sort -k1,1nr | head -n10

Hier slaan we een tekst van het internet op in een tekstbestand onder de naam boek.txt. Dit bestand heeft geen andere functie dan kort gebruikt te worden in ons script om een lijst met veelgebruikte woorden te produceren. Het gaat hier dus duidelijk om een tijdelijk bestand, maar de manier waarop we er hier mee omgaan is problematisch omwille van een aantal redenen:

  1. We gebruiken een hardgecodeerde naam boek.txt. Als je bash scripts schrijft, weet je meestal niet waar die scripts precies uitgevoerd gaan worden. Het is dus best mogelijk dat er in de huidige directory al een bestand bestaat met de naam boek.txt. Als dit bestand waardevolle data bevat, zal de gebruiker die kwijt zijn nadat jouw script klaar is. Het bestand werd immers overschreven, zonder dat de gebruiker van je shell script daarop gewezen werd.

  2. Je kan er niet van uitgaan dat de huidige directory schrijfpermissies heeft. Het is dus goed mogelijk, afhankelijk van waar jouw script uitgevoerd wordt, dat jouw script zal crashen bij het aanmaken van het tijdelijk bestand omdat je geen rechten hebt om bestanden aan te maken op die locatie.

  3. Tijdelijke bestanden moeten op het einde altijd verwijderd worden, anders verspillen ze ruimte op het bestandssysteem.

We kunnen deze problemen makkelijk oplossen:

#!/bin/bash

bestand=$(mktemp)
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt > "${bestand}"

cat "${bestand}" | \
    tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
    grep -E '.{4,}' | \
    sort | uniq -c | \
    sort -k1,1nr | head -n10

rm "${bestand}"

We gebruiken hier het mktemp commando om een tijdelijk bestand aan te maken (documentatie7). Dit commando dient speciaal voor situaties waar we op een veilige manier tijdelijke bestanden willen aanmaken. Als je dit commando uitvoert, krijg je bijvoorbeeld volgend resultaat:

$ mktemp
/tmp/tmp.OTuvHUq60B

Je zal merken dat dit bestand ook direct aangemaakt wordt op het bestandssysteem. Als je mktemp aanroept, zal het dus eerst bepalen waar het veilig is om een tijdelijk bestand aan te maken (in dit geval onder de /tmp directory). Vervolgens zal het een bestandsnaam genereren (in dit geval tmp.OTuvHUq60B) en dit bestand ook aanmaken op het bestandssysteem. De absolute padnaam van het bestand wordt uitgeschreven naar stdout, zodat je het in een variabele kunt opslaan om later te gebruiken. Vergeet achteraf ook niet om het tijdelijke bestand terug te verwijderen!

Let op

Telkens je mktemp aanroept, wordt er een nieuw bestand aangemaakt. Roep je mktemp bijvoorbeeld drie keer aan in hetzelfde script, dan zal je drie verschillende tijdelijke bestanden krijgen. Het is dus de bedoeling dat je mktemp slechts een keer aanroept per tijdelijk bestand dat je wil maken.

Bovenstaande aanpak is nog niet helemaal perfect. Wat gebeurt er namelijk als je een tijdelijk bestand aanmaakt, maar jouw script crasht of wordt afgesloten voordat je het bestand kunt verwijderen? Je kunt proberen om overal rm commando’s te plaatsen, maar dit wordt snel onhandelbaar met veel tijdelijke bestanden. Bovendien levert het geen garantie omdat jouw script ook geforceerd kan afgesloten worden, bijvoorbeeld wanneer het geheugen op is of er een tijdslimiet overschreden is. Om dit op te lossen, kunnen we gebruikmaken het trap commando om signalen op te vangen. Beschouw dit script:

#!/bin/bash

bestand=$(mktemp)
trap "rm ${bestand}" EXIT INT TERM

curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt > "${bestand}"

cat "${bestand}" | \
    tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
    grep -E '.{4,}' | \
    sort | uniq -c | \
    sort -k1,1nr | head -n10

De syntaxis van het trap commando is

trap [COMMANDO] [SIGNALEN...]

Hier gebruiken we trap "rm ${bestand}" EXIT INT TERM om bash te vragen om het commando rm $bestand uit te voeren wanneer ons script de signalen EXIT, INT of TERM ontvangt. Het EXIT signaal wijst op een gewone beëindiging, bijvoorbeeld als het script zelf het exit commando uitvoert of als het einde van het script bereikt wordt. De andere signalen, INT en TERM, worden vaak gebruikt als het script geforceerd wordt afgesloten door een ander proces (bv. kill). Deze techniek geeft een veel betere garantie dat tijdelijke bestanden effectief terug verwijderd zullen worden.

Als je veel bestanden moet verwijderen, of als het opruimen redelijk complex is, kun je ook deze strategie gebruiken:

#!/bin/bash

function cleanup
{
    # ... doe hier jouw cleanup ...
}

trap cleanup EXIT INT TERM

# ... rest van het script ...

Op deze manier zal de functie cleanup opgeroepen worden wanneer het script afsluit, en kun je complexere code uitvoeren.

Een barst in de pijpleiding

Het komt in bash vaak voor dat je een reeks commando’s verbindt via pipes:

curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt | \
    tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
    grep -E '.{4,}' | \
    sort | uniq -c | \
    sort -k1,1nr | head -n10

In dit voorbeeld downloaden we een bestand van het Internet en voeren we een hoop transformaties uit op de data. Wat gebeurt er echter als een van deze commando’s faalt? Het is goed mogelijk dat curl er niet in slaagt het bestand te downloaden, omdat internetverbindingen onbetrouwbaar kunnen zijn. In dat geval zal curl een exit status teruggeven die wijst op een connectieprobleem (zoals exit status 6 of 7; zie de documentatie8). De overige commando’s in de pipeline gaan echter deze exit status niet controleren. Bovendien kunnen ze allemaal perfect werken met lege data: ze doen dan immers gewoon niks en geven lege data terug. Het beste dat je kunt verwachten in dat geval is een foutmelding op de terminal, maar het script zal zich van geen kwaad bewust zijn. We kunnen dit op een aantal manieren oplossen:

  1. Voer de aanroep naar curl apart uit en controleer de exit status. Dit werkt voor curl, maar wat als je een pipeline hebt waar verschillende tussentijdse commando’s op allerlei manieren kunnen falen? Dan zou je elk commando apart moeten uitvoeren en controleren, wat het nut van pipelines volledig ondermijnt.

  2. Controleer of het eindresultaat van de pipeline niet leeg is. Dit is opnieuw geen elegante oplossing, omdat een leeg resultaat over het algemeen niet noodzakelijk een fout resultaat is. Dit is misschien OK voor dit specifieke voorbeeld, maar is zeker geen algemeen aanvaardbare techniek.

  3. Controleer de exit status van de pipeline.

We bekijken de laatste optie in wat meer detail. Wat als we dit doen:

#!/bin/bash

curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt | \
    tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
    grep -E '.{4,}' | \
    sort | uniq -c | \
    sort -k1,1nr | head -n10

if [[ $? -eq 0 ]]
then
    echo "OK!"
else
    echo "Error!"
fi

Je zal merken dat dit niet werkt: de exit status die we controleren in $? is standaard de exit status van het laatst beëindigde commando, in dit geval head. Het commando head zal in deze context bijna nooit een fout geven, waardoor onze controle dus waardeloos wordt. Zelfs als alle commando’s behalve head fouten geven, zullen we nog altijd OK! uitschrijven.

Gelukkig bestaat er een manier om het gedrag van bash te veranderen, zodat de exit status van een pipeline gelijkgesteld wordt aan de exit status van het laatste (meest rechtse) commando dat faalt. Dit kan met set -o pipefail:

#!/bin/bash

set -o pipefail

curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt | \
    tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
    grep -E '.{4,}' | \
    sort | uniq -c | \
    sort -k1,1nr | head -n10

if [[ $? -eq 0 ]]
then
    echo "OK!"
else
    echo "Error!"
fi

Als gelijk welk commando in de pipeline nu een niet-nul exit status teruggeeft, zal $? ook niet nul zijn. Je kan ook nog strikter zijn en eisen dat bash het script onmiddellijk eindigt zodra eender welk commando een niet-nul exit status geeft. Dit kan je doen met set -e of (equivalent) met set -o errexit. Merk op dat deze optie niets verandert aan het gedrag van voorwaardelijke statements (zoals if en test) of lussen (zoals for, while en until), omdat de exit status daar een noodzakelijk onderdeel van de functionaliteit vormt. Anders zou het script afgebroken kunnen worden bij elke if of while en dat is natuurlijk niet de bedoeling.

Werkbestanden overschrijven

Het kan soms verleidelijk zijn om een bestand dat je bewerkt direct te overschrijven met het resultaat. Neem bijvoorbeeld dit shell script:

#!/bin/bash

cat test.txt | tr 'a-z' 'A-Z' > test.txt

Het is de bedoeling om alle kleine letters in het bestand test.txt om te zetten naar hoofdletters. Een onverwacht probleem dat je hier kunt tegenkomen, is dat het bestandtest.txt leeg is nadat dit script klaar is. Ongeacht wat de oorspronkelijke inhoud was. Dit komt door een soort race condition: uit de hoorcolleges zal je je herinneren dat een pipeline van commando’s ervoor zorgt dat alle commando’s tegelijkertijd uitgevoerd worden. Dit is een soort optimalisatie, zodat de pipeline zo snel mogelijk uitgevoerd kan worden. Het zorgt er ook voor dat het bestand test.txt tegelijkertijd geopend wordt met schrijfpermissies (voor de I/O redirection na tr) en leespermissies (voor cat). Dit kan voor problemen zorgen: tegelijkertijd lezen van en schrijven naar hetzelfde bestand is vragen om problemen. We kunnen dit probleem opnieuw oplossen met opnieuw een speciale optie van set:

#!/bin/bash

set -o noclobber

cat test.txt | tr 'a-z' 'A-Z' > test.txt

De optie noclobber zorgt ervoor dat we geen bestanden kunnen overschrijven die reeds bestaan, waardoor we deze race condition vermijden. Het script zal nu een foutmelding genereren in plaats de informatie uit het bestand verloren te laten gaan:

line 5: test.txt: cannot overwrite existing file

De optie is vernoemd naar het fenomeen clobbering9. Dat is de term die men in de Unix-wereld gebruikt voor het overschrijven van bestaande bestanden. Clobbering is vaak (maar niet altijd) een fout, en wordt hoe dan ook beschouwd als slechte programmeerstijl. Een betere oplossing is een bijkomend (tijdelijk) bestand te gebruiken in plaats van het originele bestand direct te overschrijven.

Debugmode

Soms kan je met de beste wil van de wereld, ondanks bovenstaande tips, een probleem met je script nog steeds niet vinden. Dan kan het helpen als bash naar de terminal extra informatie uitschrijft over welke commando’s er precies uitgevoerd worden met welke argumenten, en welke waarden er aan de variabelen toegekend worden. Je kunt een dergelijke “debugmode” inschakelen met de -x optie van bash. Stel dat het bestand test.sh dit shell script bevat:

#!/bin/bash

while read teller noemer woord; do
    woordlengte=${#woord}
    breuklengte=$((woordlengte * teller / noemer))
    echo -n "${woord:0:${breuklengte}}"
done

echo

Stel dat bestand question.txt deze inhoud heeft:

3 7 Manatee
3 7 cheetah
-4 7 hamster

We voeren het script test.sh uit met dit bestand:

$ bash test.sh < question.txt
Mancheham

De verwachte uitvoer is Manchester, maar we krijgen Mancheham. Wat is er aan de hand? We bekijken de uitvoering van het shell script eens in debugmode:

$ bash -x test.sh < question.txt 
+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ echo -n Man
Man+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ echo -n che
che+ read teller noemer woord
+ woordlengte=7
+ breuklengte=-4
+ echo -n ham
ham+ read teller noemer woord
+ echo

De fout doet zich voor na de derde read, wanneer breuklengte=-4. We hebben geen rekening gehouden met negatieve getallen. Dit lossen we als volgt op:

#!/bin/bash

while read teller noemer woord; do
    woordlengte=${#woord}
    breuklengte=$((woordlengte * teller / noemer))
    if ((teller >= 0)); then
        echo -n "${woord:0:${breuklengte}}"
    else
        echo -n "${woord:$((woordlengte + breuklengte))}"
    fi
done

echo

Nu zien we dat alles correct werkt:

$ bash -x test.sh < question.txt 
+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ (( teller >= 0 ))
+ echo -n Man
Man+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ (( teller >= 0 ))
+ echo -n che
che+ read teller noemer woord
+ woordlengte=7
+ breuklengte=-4
+ (( teller >= 0 ))
+ echo -n ster
ster+ read teller noemer woord
+ echo
$ bash test.sh < question.txt 
Manchester

Zoals verwacht schrijft het script nu de tekst Manchester uit.

De bash scripting cheatsheet

Tenslotte raden we ook aan om de bash scripting cheatsheet10 bij de hand te houden. Raadplaag het als je twijfelt hoe je een bepaald stuk code moest schrijven. De cheatsheet documenteert veelgebruikte structuren in bash, zoals while read lussen en voorwaardelijke statements. Dit kan vaak sneller zijn dan de code iedere keer te googelen op StackOverflow of op te zoeken met ChatGPT.