På denne siden

Kapittel 15, Automatisering viste hvordan man skriver sitt første skript: en fil med kommandoer, en shebang, en chmod +x, ferdig. Det første skriptet fungerer helt til den dagen et filnavn har et mellomrom i seg, et argument mangler, eller en kommando midt i skriptet feiler uten at noen legger merke til det. Denne guiden handler om verktøyene bash gir deg for den dagen. Samme skall, dypere vann.

Du trenger ingenting installert. Bash er standardskallet på Ubuntu, Fedora og Arch, og alt her er sjekket på bash 5.3 (5.3.9 på Ubuntu og Fedora, 5.3.15 på Arch) med identisk oppførsel hele veien.

Variabler og anførselstegn

Tilordninger har ingen mellomrom rundt =. Skriv name="my file.txt", aldri name = "my file.txt" — med mellomrom leser bash name som en kommando og svarer name: command not found.

Bruker du en variabel, skriver du $name. Å sette anførselstegn rundt den er hele spillet:

#!/bin/bash
name="my file.txt"
ls $name
ls: cannot access 'my': No such file or directory
ls: cannot access 'file.txt': No such file or directory

Uten anførselstegn deles verdien til variabelen på mellomrom før ls i det hele tatt kjører: bash så to filer der du så én. Med anførselstegn overlever filnavnet uskadd:

ls "$name"

Vanen som lønner seg: anførselstegn rundt hver variabel, alltid, selv når du er sikker på at den ikke har noen mellomrom.

Aritmetikk er stedet anførselstegn tar en pause. Doble parenteser regner ut uttrykket: $((2 + 3 * 4)) gir 14 (multiplikasjon binder før addisjon), og resultatet kommer ut som ett ord, så count=$((count + 1)) trenger ingen anførselstegn for å være trygt.

Når en variabel sitter limt fast til annen tekst, bruk krøllparenteser: "${file}.txt" — ellers leser bash $file.txt som en variabel som heter file.txt. Og ${name:-backup} gir backup når name ikke er satt eller er tom, noe som gjør den til den høflige måten å håndtere valgfrie argumenter på: dir="${1:-.}".

Exit-koder

Hver kommando avslutter med en exit-kode, og $? holder koden til den siste. Null betyr suksess. Alt annet betyr at noe har gått galt, og tallet sier hva. En feilende ls /nonexistent avslutter med 2 på GNU ls. Du trenger ikke pugge tall — skill null fra ikke-null, så er du trygg.

Skriptene dine får velge sin egen kode med exit. exit 0 erklærer suksess. exit 1 er standardklagen. Avslutter et skript uten exit, arver det koden til den siste kommandoen det kjørte.

&& og || bygger på dette. cmd && next kjører next bare hvis cmd lykkes. cmd || fallback kjører fallback bare hvis den feiler. Kjeden stopper ved første feil, noe som gjør at one-linere som mkdir -p backups && cp -r files backups/ kan leses akkurat slik de oppfører seg.

Tester: [ ] og [[ ]]

if-setningen kjører en kommando og reagerer på exit-koden. Som oftest er den kommandoen en test:

if [ -f ~/.bashrc ]; then
  echo "bashrc exists"
fi

De kjente operatorene: -f filen finnes, -d mappen finnes, -z tom streng, -n ikke-tom streng, -eq og -lt for sammenligning av tall, og ! foran snur hele testen: [ ! -d "$dir" ] er sant når mappen mangler. Den mest siterte feilen i shell-skripting:

#!/bin/bash
x="a b"
if [ $x = y ]; then
  echo "equal"
fi
check.sh: line 3: [: too many arguments

Uten anførselstegn ble $x delt i to ord, og [ telte tre argumenter der en sammenligning trenger to. (Stien i bash sine feilmeldinger er skriptfilnavnet, og tallet er den skyldige linja, så check.sh: line 3: peker på if-en. Lagre kodebiten som check.sh, så ser du nøyaktig dette.) En tom variabel feiler annerledes:

check.sh: line 3: [: =: unary operator expected

Bash har en løsning. [[ ]] er den oppgraderte testen, og den deler ikke variabler i ord i det hele tatt — [[ $x = y ]] virker med mellomrom, med tomme verdier og uten et eneste anførselstegn i sikte. Inni [[ ]] kan du også skrive && og || direkte. Inni [ ] er de syntaksfeil, og skriptet dør med [: missing `]'.

Mønstersamsvar er noe som bare [[ ]] får lov til. På høyre side av == er en verdi uten anførselstegn et glob: [[ $s == hel* ]] er sant for hello. Setter du anførselstegn rundt mønsteret, sammenlignes det bokstavelig i stedet.

En felle verdt å kjenne til selv om du aldri trår i den: inne i [ ] er tegnene < og > redireksjoner, ikke sammenligninger. [ "zzz" > out ] returnerer sant og oppretter, som en bieffekt, en fil som heter out. Inne i [[ ]] sammenligner de strenger. Kort versjon: i bash-skript, bruk [[ ]].

Løkker

for-løkken går gjennom en liste:

for f in *.txt; do
  echo "found: $f"
done

*.txt er et helt vanlig glob, så løkken får én runde per fil som treffer — og koden inni løkken setter anførselstegn rundt "$f", fordi et filnavn uten anførselstegn deler seg i ord når det har et mellomrom i seg. Å telle fungerer på samme måte: for i in 1 2 3; do echo "$i"; done.

For å gå gjennom en fil linje for linje er redireksjonsformen den du bør lære deg. Kapittel 13 viste > som sender utdata inn i en fil; < peker samme pil den andre veien og mater en fil inn i en kommando. Og read line henter neste linje av fila inn i en variabel som heter line:

while read line; do
  echo "got: $line"
done < notes.txt

To særegenheter, begge bekreftet på den harde måten. En siste linje uten avsluttende linjeskift når aldri koden inni løkken — printf 'a\nb' gir løkken én linje, og b forsvinner i det stille. Større overraskelse: pipe-formen av denne løkken mister variablene dine.

counter=0
cat notes.txt | while read line; do
  counter=$((counter+1))
done
echo "counter after pipe loop: $counter"
counter after pipe loop: 0

Tre linjer gikk inn, og telleren sto på 0 etterpå. Løkken på høyre side av en pipe kjører i et underskall — en barnekopi av skriptet — og variablene dens dør med det. done < file-formen unngår hele problemet, og derfor bruker denne guiden den.

case

Når ett skript gjør flere jobber avhengig av ett ord, er case det rette verktøyet:

#!/bin/bash
case "$1" in
  start)
    echo "starting"
    ;;
  stop)
    echo "stopping"
    ;;
  *)
    echo "Usage: $0 start|stop"
    ;;
esac

Mønstrene er globs, så h*) treffer hello og help. Første gren som treffer kjører, og deretter avslutter case — den faller ikke videre til senere grener. *)-grenen er den som tar alt, og en bruksmelding der er måten skript lærer deg sitt eget grensesnitt på. ($0 er skriptets egen sti: i en brukslinje skriver den ut det du skrev for å kjøre skriptet.)

;; mellom grenene er obligatorisk. Utelater du én midt inne, nekter bash å kjøre hele skriptet med syntax error near unexpected token ')'. Den siste ;; før esac er valgfri. Skriver du den likevel, slipper du at grener som er limt inn fra et annet sted biter deg.

Funksjoner

En funksjon er en navngitt blokk med kommandoer:

greet() {
  echo "Hello, $1"
}

greet "Ada"

Inne i en funksjon er $1 funksjonens eget første argument — skriptets $1 røres ikke av kallet. return 2 setter funksjonens exit-kode (sjekk den med $? etter kallet). exit ville avsluttet hele skriptet, noe en hjelpefunksjon sjelden bør gjøre.

Variabler opprettet inne i en funksjon er globale som standard, og det lekker. Nøkkelordet local holder dem inne:

total=0

add() {
  local amount="$1"
  total=$((total + amount))
}

Å bruke local på alt en funksjon oppretter, er billig forsikring. Uten den vil to funksjoner som begge bruker en variabel som heter count, en dag overskrive hverandre på måter som tar en time å finne.

$@ — alle argumentene på én gang

Ét argument var nok for skriptene så langt, men skript som behandler filer vil som regel ha alle sammen. Bash har en snarvei for hele listen: "$@". Inn i anførselstegnene utvides den til hvert argument skriptet fikk, med hvert ett helt — så vanen med anførselstegn gjelder her også.

#!/bin/bash
for f in "$@"; do
  echo "processing: $f"
done

Kjør den med to filer, én av dem med et mellomrom i navnet, og løkken kjører to ganger med filnavnet intakt begge ganger:

$ bash process.sh "my file.txt" second.txt
processing: my file.txt
processing: second.txt

Uten anførselstegnene ville filen med mellomrommet blitt delt i to ødelagte navn — hele grunnen til at del 1 var så nøye. Trenger du antallet i stedet for verdiene, står det i $#.

Når skriptene går galt

To innstillinger gjør stille katastrofer om til høylytte, og de står som de to første linjene i de fleste skript som oppfører seg:

#!/bin/bash
set -eu

set -e stopper skriptet ved den første kommandoen som feiler, i stedet for å stape videre som om ingenting hadde hendt. Med den satt, avslutter en feilende ls skriptet der og da. Uten den skrives feilmeldingen ut, skriptet fortsetter, og alt som var avhengig av fila som manglet, produserer søppel to linjer lenger ned.

Den har unntak, og de er fornuftige så snart du ser dem. En kommando inne i en if-betingelse får lov til å feile — det er det betingelsen er til for. Det samme gjelder en kommando som ikke står sist i en &&-kjede: false && true dreper ikke skriptet, men en feilende siste kommando i kjeden gjør det. En while read-løkke som når slutten av fila si, er heller ingen feil. Løkken tar bare slutt.

set -u gjør variabler som ikke er satt, til en feil i stedet for tomme strenger. Med den stopper en skrivefeil som $naem skriptet med feilen naem: unbound variable. Uten set -u ville skrivefeilen lydløst blitt utvidet til ingenting, og skriptet ville gått videre uten argumentet sitt. Det finnes flere av samme slag — set -o pipefail er ett av dem — men med de to kommer et lite skript langt.

trap: opprydding

Skript som oppretter ting, skylder verden en opprydding, selv når de dør underveis. trap planlegger en kommando som skal kjøre når skriptet avslutter:

#!/bin/bash
set -eu

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

echo "working in $tmp"

mktemp -d oppretter en fersk mappe med unikt navn og skriver ut stien til den. Å pakke en kommando inn i $( ) kjører den og fanger utdataen (samme triks som kapittel 15 brukte på datostempelet), så tmp ender opp med mappenavnet.

Fellen på EXIT utløses når skriptet slutter, uansett hvordan det slutter — nådd den siste linja, truffet en exit, eller drept av set -e etter en feilende kommando. Opprydding skrevet én gang, øverst, dekker alle veier.

Avbrudd har sin egen felle. trap 'echo interrupted' INT kjører kommandoen sin når skriptet mottar SIGINT, avbruddssignalet. En kuriositet: et skript som allerede ignorerte SIGINT da det startet, holder fram med å ignorere det. Feller hører hjemme i dine egne skript, ikke i skript som andre programmer starter og signaliserer til.

Backup-skriptet, nå voksen

Kapittel 15 sin versjon:

#!/bin/bash
# backup.sh — copy a directory to ~/backups with a date stamp
dir="$1"
stamp="$(date +%Y%m%d)"
cp -r "$dir" "$HOME/backups/$dir-$stamp"
echo "Backed up $dir to backups/$dir-$stamp"

Det fungerer, og det feiler på alle de stille måtene. Kjørt uten argument, kopierer det ingenting og sier ingenting nyttig. Rettes mot en mappe som ikke finnes, klager cp, og skriptet melder likevel suksess. Her er det igjen:

#!/bin/bash
# backup.sh — copy a directory to ~/backups with a date stamp
set -eu

dir="${1:-}"
if [ -z "$dir" ]; then
  echo "Usage: $0 directory"
  exit 1
fi

if [ ! -d "$dir" ]; then
  echo "No such directory: $dir"
  exit 1
fi

stamp="$(date +%Y%m%d)"
mkdir -p "$HOME/backups"
cp -r "$dir" "$HOME/backups/$dir-$stamp"
echo "Backed up $dir to backups/$dir-$stamp"

Samme jobb. Forskjellene ligger alle i hvordan det feiler: et manglende argument gir nå en brukslinje, et feil navn gir en tydelig klage, en feilende kopi stopper skriptet før suksessmeldingen som lyver, og hver utvidelse står i anførselstegn. Det er det «holder mål» betyr i praksis — ikke flere funksjoner, bare færre stille feil.

Hvor du står nå

Skriptene dine kan ta imot argumenter uten å gjette, feile høylytt, rydde opp etter seg, og overleve filnavn med mellomrom i seg. Når et skript vokser ut av én skjerm, kjør det gjennom ShellCheck — lim det inn, og de klassiske feilene fra denne guiden blir påpekt ved navn. GNU Bash-manualen dokumenterer alt denne guiden dro over. Kapittel 15, Automatisering er fortsatt stedet for cron-delen av automatiseringen, og kommandolisten har one-linerne.