Bash - skrypty złożone

Instrukcja wyboru


case jest instrukcją wyboru, która jest krótszą alternatywą dla instrukcji if, gdy testujemy jedną wartość. Jej składnia to:

case wartość in
  wzorzec1)
    instrukcje1
    ;;
  wzorzec2)
    instrukcje2
    ;;
  ...
esac

Wzorce mają formę podobną do wzorców plików, tzn. może to być konkretna wartość, a może też zawierać znaki * i ?. case dopasowuje wartość do jednego z wzorców. Dla pierwszego dopasowanego wzorca wykonywane są odpowiadające mu instrukcje. Jeśli nie uda się dopasować do żadnego z wzorców, to nie są wykonywane żadne instrukcje.

#!/bin/sh
 
case $1 in
  "")
    echo "Prawidłowe wywołanie to: $0 plik"
    exit 1
    ;;
  *.txt) # jeśli plik tekstowy, to go uruchamiamy edytor
    pico $1
    ;;
  *.sh)  # jeśli skrypt to go uruchamiamy
    ./$1
    ;;
  *)     # to oznacza wszystkie wartości; dalsze wzorce nie mają już sensu
    echo "Nieznany rodzaj pliku '$1'"
    ;;
esac

Pętle


while

Pętla while ma składnię

while warunek; do
  instrukcje
done

Interpreter tak długo wykonuje instrukcje, jak długo jest spełniony warunek. Podobnie, jak przy instrukcji if, warunek jest poleceniem, które jest uruchamiane przy każdym obrocie pętli. Jeśli status wyjścia jest równy zero, to wykonywane są instrukcje podane w bloku pętli.

Przykład.

zm=""
while [ "$zm" != koniec ]; do
  echo -n "Wpisz coś (słowo 'koniec' aby zakończyć): "
  read zm
  echo "Wpisałeś '$zm'"
done

until

Pętla until jest bardzo podobna do pętli while:

until warunek; do
  instrukcje
done

Różnica polega na tym, że pętla jest wykonywana tak długo, jak warunek jest nieprawdziwy (przeciwnie do tego jak ma się to w pętli while). Na przykład pętla z poprzedniego przykładu mogła by wyglądać tak:

until [ "$zm" == koniec ]; do
  ...
done

for

Pętla for ma dwie formy. Pierwsza forma służy wykonywania bloku instrukcji dla każdej wartości argumentów z listy, a druga forma ma bardziej złożoną składnię i jest zapożyczona z języka C.

for dla list

Ta wersja ma postać:

for zm in lista; do
  instrukcje
done

gdzie lista jest listą wartości. Listę podajemy w analogiczny sposób, jak argumenty poleceniu, czyli na przykład możemy używać wzorców nazw plików do podania wielu nazw plików naraz. Instrukcje w bloku są wykonywane dla każdej wartości znajdującej się na liście. W danym obrocie wartość z listy przypisywana jest na zmienną zm.

Przykład:

for f in *; do # * rozwija się do listy wszystkich plików/katalogów znajdujących się w bieżącym katalogu
  if [ -d "$f" ]; then
    echo "Katalog '$f'"
  elif [ -f "$f" ]; then
    echo "Plik '$f'"
  else
    echo "Inny typ '$f'"
  fi
done

Listą może być też ciąg wartości. Na przykład, aby wyświetlić kwadraty wybranych liczb, możemy te liczby umieścić na liście:

for i in 1 5 100 99; do
  echo "Kwadrat $i = $((i * i))"
done

Listą może też być wynik innego polecenia:

# Wyszukanie wszystkich tych plików o rozszerzeniu txt,
# dla których ostatnia linia zawiera napis "Autor: Jan Kowalski"
for f in `find . -name "*.txt" -type f`; do
  if [ "`tail -1 $f`" == "Autor: Jan Kowalski" ]; then
    echo "Plik '$f' posiada już podpis"
  fi
done

Można także w liście umieścić argumenty skryptu:

# Wypisywanie argumentów skryptu
n=1
for arg in "$@"; do
  echo "Argument $n: '$arg'"
  let "n++"
done

Listę można utworzyć przez połączenie dwóch innych list, na przykład 0 "$@" jest listą składającą się z elementu 0 oraz z wszystkich argumentów skryptu.

for w stylu C

Bardziej skomplikowana wersja pętli for w stylu C ma postać:

for ((inicjacja; warunek; post_modyfikacja)); do
  instrukcje
done

inicjacja, warunek i post_modyfikacja są wyrażeniami takimi jak wyrażenia używane w konstrukcjach $(( ... )) i (( ... )). Działanie jest następujące.

Na początku i tylko raz, jest uruchamiane wyrażenie inicjacja. To wyrażenie zazwyczaj ma za zadanie zainicjowanie zmiennych używanych do iterowania pętli.

Następnie przed każdą iteracją wyliczane jest wyrażenie warunek. Jeśli jest ono fałszywe, wykonywanie pętli kończy się. Jeśli jest ono prawdziwe, wykonywany jest blok instrukcje.

Po każdej iteracji wykonywane jest wyrażenie post_modyfikacja. To wyrażenie zazwyczaj ma za zadanie modyfikowanie zmiennych.

Przykład. Kilka sposobów wypisania liczb od 1 do 10:

i=1
while ((i <= 10)); do
  echo -n "$i "
  let "i++"
done
echo
 
for i in 1 2 3 4 5 6 7 8 9 10; do
  echo -n "$i "
done
echo
 
for i in `seq 1 10`; do
  echo -n "$i "
done
echo
 
for ((i = 1; i <= 10; i++)); do
  echo -n "$i "
done
echo

Polecenie seq służy do generowanie ciągów arytmetycznych.

break i continue

Wewnątrz pętli dostępne są dwa dodatkowe polecenia:

break powoduje natychmiastowe przerwanie wykonywanej pętli. continue powoduje zakończenie aktualnej iteracji pętli i przejście do następnej iteracji.

while true; do
  read a
  if [ "$a" = "koniec" ]; then
    break
  elif [ "$a" = "dalej" ]; then
    continue
  fi
  echo "Wpisałeś '$a'"
done

Funkcje

Wewnątrz skryptu można pisać własne funkcje, które spełniają rolę podprogramu. Ma to miejsce na przykład wtedy, gdy pewną czynność chcemy wykonać wielokrotnie w różnych miejscach skryptu i chcemy uniknąć kopiowania kodu. Funkcję deklarujemy w skrypcie w następujący sposób:

nazwa_funkcji ()
{
  instrukcje
}

Wywoływanie funkcji i argumenty

Zadeklarowana funkcja dostępna jest dla potrzeb skryptu jak nowe polecenie. Wywołujemy ją używając jej nazwy. Możemy przekazywać argumenty w ten sam sposób, w jaki przekazujemy je poleceniom.

# Przykład pokazujący deklarację i wywołanie funkcji z parametrami
 
wypisz_argumenty ()
{
  echo -n "Jest $# argumentów:"
  for i in "$@"; do
    echo -n " '$i'"
  done
  echo
}
 
wypisz_argumenty
wypisz_argumenty "$@"
wypisz_argumenty raz dwa trzy cztery pięć

Zasięg deklaracji

Deklaracja funkcji jest instrukcją, w wyniku której dostępne staje się nowe polecenie. Czyli na przykład można deklarować funkcję wewnątrz bloków, a nie można używać funkcji, których deklaracja następuje później.

f    # błąd - funkcja nie jest zadeklarowana
if [ "$USER" = bashtest ]; then
  f ()
  {
    echo "Pierwsza wersja f"
  }
else
  f ()
  {
    echo "Druga wersja f"
  }
fi
f    # funkcja f może być zadeklarowana na dwa sposoby, zależnie od tego jaki użytkownik uruchomił skrypt

Status wyjścia

Ponieważ funkcja zachowuje się jak polecenie, to może też zwracać status wyjścia. Domyślnie zwracane jest zero. Aby wyjść z funkcji z zadanym statusem służy polecenie return.

pytanie_tak_nie ()
{
  while true; do
    if [ $# -ge 1 ]; then
      echo -n "$1 (tak/nie)? "
    fi
    read odp
    if [ "$odp" = tak ]; then
      return 0
    elif [ "$odp" = nie ]; then
      return 1
    fi
  done
}
 
if pytanie_tak_nie "Czy chcesz usłyszeć pytanie"; then
  until pytanie_tak_nie "Czy chcesz zakończyć ten skrypt"; do
    : # : jest wbudowaną pustą instrukcją
  done
fi

Podshelle

Uruchamianie skryptów wiąże się z tworzeniem nowego procesu. Wszystko, co dostaje skrypt, to środowisko i argumenty. Tworząc nowy proces, możemy mu przekierować wejście i wyjście. Ponadto, jeśli uruchamiamy inny skrypt, to nie widzi on zmiennych, które zadeklarowaliśmy (chyba, że je wyeksportowaliśmy do środowiska). Po powrocie z wywołania innego skryptu bądź programu mamy pewność, że zadeklarowane zmienne nie zostały zmienione, podobnie jak argumenty skryptu. Wiemy też, że katalog bieżący jest niezmieniony.

Tego typu własności mogą być pożądane, gdy chcemy wykonać ciąg poleceń w skrypcie. Istnieje możliwość zrobienia tego bez pisania pliku z nowym skryptem. Można to zrobić w aktualnym skrypcie używając konstrukcji z nawiasami:

( instrukcje )

Instrukcje między nawiasami są uruchomione w nowym shellu. Dziedziczy po naszym skrypcie wszystkie zmienne:

bashtest@host:~$ a=2; ( echo $a; a=3; echo $a ); echo $a
2
3
2
bashtest@host:~$ ( cd /tmp ); echo $PWD
/home/bashtest
bashtest@host:~$

Konstrukcja ( ... ) przydaje się też do grupowania poleceń po to, aby używać przekierowań lub potoku do całej grupy instrukcji.

(
  if [ ! -d "$1" ]; then
    echo "$1 nie jest katalogiem
    exit 1 # wychodzi tylko z podshella
  fi
  cd "$1"
  for i in *.txt; do
    if [ -f "$i" ]; then
      echo "==== Do zrobienia w pliku '$i' ===="
      grep TODO "$i"
    fi
  done
) | less

Powyższy kawałek skryptu wyszuka w katalogu podanym w pierwszym argumencie wszystkie pliki o rozszerzeniu txt i dla każdego z nich wyświetli główkę oraz wszystkie linie zawierające tekst TODO. Wynik będzie można przejrzeć programem less.

Uruchamianie nowych procesów nie jest zbyt szybkie. Jeśli zależy nam na szybkości, należy unikać tworzenia nowych procesów. Na przykład

cat plik | grep wzorzec

można zastąpić poleceniem

grep wzorzec plik

które uruchamia tylko jeden proces, a nie dwa, jak w poprzednim wywołaniu (dodatkowy proces to polecenie cat).

Warto wiedzieć, że wiele poleceń jest wbudowanych w Basha i nie są dla nich uruchamiane nowe procesy. Takie polecenia to na przykład: cd, echo, test. Jeśli chcemy zgrupować polecenia, to zamiast używać konstrukcji ( ... ) można używać { ... }. Różnica jest taka, że nie jest uruchamiany nowy shell, a co się z tym wiąże, wszelkie modyfikacje dokonane wewnątrz tego bloku są widoczne po wyjściu z tego bloku.

bashtest@host:~$ a=2; { echo $a; a=3; echo $a; }; echo $a; 
2
3
3
bashtest@host:~$

Referencje

W jaki sposób dobrać się do zmiennej, której nazwa znajduje się na innej zmiennej. Otóż możemy użyć do tego polecenia eval:

eval argumenty

Działa ono tak, że tworzona jest instrukcja składająca się z podanych argumentów. Niby nic w tym specjalnego, ale jest to w pewien sposób użyteczne. Na przykład, gdy w argumentach znajduje się wywołanie do zmiennej (np. $zmienna), wtedy wpierw wstawiana jest wartość zmiennej, potem tworzona jest instrukcja i dopiero na końcu jest wykonywana. Pozwala nam to tworzyć polecenia, w których skład wchodzą wartości zmiennych.

bashtest@host:~$ a=wartość
bashtest@host:~$ echo $a
wartość
bashtest@host:~$ b=a
bashtest@host:~$ echo $b
a
bashtest@host:~$ eval echo \$$b
wartość
bashtest@host:~$

Taki sposób przechowywania informacji o zmiennej (tzn. pamiętanie tylko nazwy zmiennej) nazywamy referencjami. W ten sposób możemy przekazywać zmienne funkcjom.

dopisz_y ()
{
  for z in "$@"; do
    eval $z=\$\{$z\}y
  done
}
 
a=dom
b=kąt
dopisz_y a b
echo "a=$a" # wypisze a=domy
echo "b=$b" # wypisze b=kąty

Widzimy, że tutaj odwołanie do zmiennej wymaga nawet użycia formy ${...}. Zauważmy, że gdybyśmy mieli w czwartej linii taką instrukcję

    eval $z=\$${z}y

to nie byłoby to poprawne. Otóż dla z=a wartością $z jaki i ${z} było by a, więc polecenie utworzone przez eval do wykonania brzmiałoby a=$ay.

Pliki specjalne

W systemie Linux dostępne są tak zwane pliki specjalne. Na przykład /dev/null. Jest to taki plik, który zawsze, jak z niego czytamy, jest plikiem pustym, a wszystko, co do niego wpiszemy, znika. Ten plik jest przydatny gdy chcemy, aby wyjście polecenia nie zostało nigdzie wyświetlone.

polecenie 2>/dev/null >/dev/null

Ignorowanie wyjścia jest przydatne, gdy interesuje nas tylko status wyjścia.

Innym plikiem specjalnym jest /dev/zero. Wszystko co się wpisze do tego pliku, również znika, ale gdy czytamy z tego pliku otrzymujemy zawsze bajty, których wartość binarna wynosi 0. Ten plik można wykorzystać do utworzenia pliku o określonej wielkości. Na przykład, aby utworzyć plik 1m.dat składający się dokładnie z jednego megabajta znaków możemy użyć polecenia

dd if=/dev/zero of=1m.dat bs=1M count=1

Polecenie dd służy do kopiowania z jednego pliku do drugiego określonej liczby bajtów.

Inne pliki specjalne znajdują się w katalogu /proc. W katalogu tym są najróżniejsze informacje o systemie, o procesach. Podamy tu dla przykładu dwa pliki: /proc/cpuinfo, /proc/meminfo. Pierwszy wyświetla informacje o procesorach znajdujących się w danym komputerze, a drugi wyświetla informacje o dostępnej pamięci operacyjnej.