Pisanie skryptów powłoki


Streszczenie

W wykładzie 3 przedstawimy podstawy programowania skryptów interpretera poleceń, takie jak przekazywanie danych i argumentów, operacje na zmiennych i instrukcje warunkowe.


Wynik instrukcji jako część polecenia

Standardowe wyjście programu może też stać się składową polecenia. Wywołanie takiego programu należy otoczyć odwrotnymi apostrofami (`...`). Przed przystąpieniem do wykonania głównego polecenia uruchamia się program otoczony takimi apostrofami, zbiera jego standardowe wyjście, dzieli na wyrazy i wstawia w miejsce wywołania tego programu. Oto bardzo prosty, ale zarazem niezwykle pouczający przykład:

bash$ `echo pw``echo d`
/home/usr/kazio
bash$ pwd
/home/usr/kazio
bash$ _
Program echo po prostu wypisuje na standardowe wyjście listę swoich argumentów. Wywołanie echo pw powoduje więc wypisanie liter pw, a wywołanie echo d powoduje wypisanie litery d. Wyniki tych wywołań są ze sobą sklejone, co daje słowo pwd. Jako, że jest to pierwsze słowo, jest ono traktowane jako program do wykonania. Program pwd powoduje wypisanie ścieżki do bieżącego katalogu. I właśnie on jest wykonywany.

bash$ rm `find . -name "*.txt"`

To wywołanie powoduje usunięcie wszystkich plików z rozszerzeniem txt z całego drzewa katalogów o korzeniu w bieżącym katalogu (.). Program find wyszukuje takie pliki i wypisuje ich listę na standardowe wyjście, które następnie staję się częścią polecenia rm.

UWAGA: przy bardzo dużych ilościach plików do usunięcia (>32768, ale ta liczba jest zależna od systemu) wywołanie powyższego polecenia zwróci błąd. Należy w takim wypadku użyć programu o nazwie xagrs, które pozwoli przekazać z góry określoną ilość argumentów do rm.

UWAGA: takie użycie find oraz rm jest bardzo niebezpieczne, jeśli mamy pliki, których nazwy zawierają spacje. Aby uczulić czytelnika na ten problem, przedstawimy pouczający przykład. Należy go oczywiście testować w specjalnie do tego celu utworzonym katalogu (katalogi tworzy się poleceniem mkdir), żeby nie pousuwać sobie plików tekstowych z komputera.

bash$ touch ala
bash$ touch "ala kot.txt"
bash$ ls -1
ala
ala kot.txt
bash$ rm `find . -name "*.txt"`
rm: nie można usunąć `kot.txt': Nie ma takiego pliku ani katalogu
bash$ ls -1
ala kot.txt

Poleceniem touch utworzyliśmy dwa pliki o nazwach ala i ala kot.txt, następnie wypisaliśmy na terminal listę plików znajdujących się w bierzącym katalogu. Polecenie find zwróciło następujący ciąg znaków: ./ala kot.txt, program rm skasował plik ala a następnie próbował skasować (nieistniejący) plik kot.txt. Prawidłowe wykonanie operacji kasowania plików z rozszerzeniem wygląda następująco:

find . -name "*.txt" -exec rm "{}" \;

Więcej informacji na temat (bardzo praktycznego) trybu "-exec" polecenia find można znaleźć w podręczniku użytkownika polecenia find.

"./plik" jest w tym wypadku tożsame z "plik". Trzeba wiedzieć, że kropka jest interpretowana jako bierzący katalog, natomiast dwie kropki ("..") są interpretowane jako katalog nadrzędny w stosunku do bierzącego.


Cudzysłowy, apostrofy, ...

Na klawiaturze znajdziemy trzy znaki tego rodzaju: apostrof ('), odwrotny apostrof (`), i cudzysłów ("). Każdy z nich ma swoje znaczenie w interpreterze poleceń.

Odwrotny apostrof omówiono w punkcie Wynik instrukcji jako część polecenia. Pozostałe dwa znaki służą do przekazywania jako argumentów programów napisów, które zawierają odstępy. Polecenie:

bash$ rm duzy plik
oznacza zlecenie usunięcia dwóch plików (duzy i plik). Jeśli chcemy usunąć plik o nazwie duzy plik musimy ją otoczyć apostrofami albo cudzysłowem:
bash$ rm 'duzy plik'
albo
bash$ rm "duzy plik"
Apostrof i cudzysłów mają niemal takie samo znaczenie. Dzięki temu, że są dwa ich rodzaje, można je zagnieżdżać. Zagnieżdżać można jednak tylko różne rodzaje tych konstrukcji: apostrofy wewnątrz cudzysłowów i cudzysłowy wewnątrz apostrofów:
bash$ rm "ala ' ma ' kota"
albo
bash$ rm 'ala " ma " kota'
Zlecamy tu usunięcie jednego pliku o skomplikowanej nazwie. Natomiast:
bash$ rm "ala " ma " kota"
zostanie zinterpretowane jako zlecenie usunięcia trzech plików "ala " (z odstępem na końcu), ma (bez żadnych odstępów) oraz " kota" (z odstępem na początku).

Otoczenie napisu apostrofami albo cudzysłowami powoduje też, że interpreter nie rozwija wzorców nazw plików występujących w tym napisie. To właśnie dlatego w punkcie Wynik instrukcji jako część polecenia otoczono jeden z argumentów programu find cudzysłowami ("*.txt").

Otoczenie napisu apostrofami powoduje dodatkowo, że interpreter uważa napisy postaci $VAR za zwykły napis a nie odwołanie do zmiennej. Cudzysłowy nie mają takiej mocy i napisy typu $VAR są wewnątrz cudzysłowów traktowane jako odwołania do zmiennych, np.

bash$ VAR=witam
bash$ echo $VAR
witam
bash$ ls
plik1 plik2 plik3
bash$ echo $VAR *
witam plik1 plik2 plik3
bash$ echo "$VAR *"
witam *
bash$ echo '$VAR *'
$VAR *
bash$ _

Taka gramatyka może być niewygodna, na przykład jeśli mamy program, który zapisuje i zwraca na terminal swój pierwszy argument i musimy przekazać skomplikowany komunikat, to możemy uzyskać na przykład coś takiego:

bash$ kotek="skacze"
bash$ piesek="biega, gryzie"
bash$ ./program 'zmienne "$kotek" i "$piesek" mają wartości: "'"$kotek"'", "'"$piesek"'"'
zmienne "$kotek" i "$piesek" mają wartości: "skacze", "biega, gryzie"
bash$ _

Używając różnych cudzysłowów można łatwo popełnić błąd.

Istnieje konstrukcja gramatyczna, która pozwala obejść problem zagnieżdżania cudzysłowów: wewnątrz cudzysłowu (") można używać dwuznakowego symbolu \", który oznacza "dosłowny" cudzysłów. Aby uzyskać dosłowny odwrotny ukośnik (\), należy wpisać go dwukrotnie. Symbol \$ oznacza dosłowny znak dolara, czyli `echo "\$zmienna"` jest tożsame z `echo '$zmienna'`. Używając tej konstrukcji możemy napisać:

bash$ kotek="skacze"
bash$ piesek="biega, gryzie"
bash$ ./program "zmienne \"\$kotek\" i \"\$piesek\" mają wartości: \"$kotek\", \"$piesek\""
zmienne "$kotek" i "$piesek" mają wartości: "skacze", "biega, gryzie"
bash$ _
Apostrof (')Cudzysłów (")Odwrotny apostrof (`)
*nie rozwijanie rozwijarozwija
$ZMIENNAnie rozwijarozwijarozwija
\"traktuje jak \"traktuje jak "traktuje jak \"

Zmienne

Język skryptów interpretera jest pełnym językiem programowania, więc są w nim także zmienne. Nazwa zmiennej musi rozpoczynać się od litery, za którą może wystąpić ciąg liter, cyfr i podkreśleń. Nadając zmiennej wartość po prostu odwołujemy się do jej nazwy, np.

bash$ zm=wart
bash$ _
To oznacza nadanie zmiennej zm wartości wart.

Odwołując się do wartości zmiennej, poprzedzamy jej nazwę znakiem $, np.

bash$ echo $zm
wart 
bash$ echo zm
zm
bash$ _ 
W drugim poleceniu zm nie będzie traktowane jako odwołanie do zmiennej (brak $). To polecenie wypisze więc po prostu słowo zm.

Zmienne tablicowe

Bash udostępnia jednowymiarowe, dynamiczne tablicowe. Są one indeksowane są przy pomocy liczb całkowitych, począwszy od zera.
Tablica tworzona jest jeśli wykonywane jest przypisanie do jakiejś zmiennej przy pomocy składni zmienna[indeks]=wartość. Indeks tablicy traktowany jest jako wyrażenie arytmetyczne, które musi po interpretacji dać liczbę większą bądź równą zero.
Inną metodą tworzenia tablicy jest przypisanie w formie zmienna=(element0 element1 element2... elementN)
Do elementu tablicy można odwoływac się używając ${zmienna[indeks]}. Jeśli za indeks podamy * lub @, to odwołamy się do całej tablicy (czyli do wszystkich jej elementów, rozdzielonych spacjami).
Przykłady użycia tablic zobaczymy w następnym wykładzie.

Długość zmiennej

Poprzez odwołanie do ${#ZMIENNA} możemy uzyskać długość zmiennej, np.
bash$ zmienna="ala"
bash$ echo ${#zmienna}
3
bash$ _ 
Ilość elementów tablicy możemy uzyskać przez odwołanie ${#tab[@]}, a długość jednego elementu o indeksie $N poprzez ${#tab[$N]}.

Obliczanie wyrażeń arytmetycznych

Do wyznaczania wartości wyrażeń arytmetycznych można użyć albo konstrukcji $[ ... ], albo programu expr. W pierwszym przypadku korzystamy z mechanizmu wbudowanego w bash-a. Jest on szybszy i odrobinę wygodniejszy w użyciu. Ponieważ wiadomo, że wszystko pomiędzy $[, a ] jest interpretowane jak wyrażenie, nie trzeba poprzedzać zmiennych znakiem dolara $. Podobnie, nie trzeba zabezpieczać * przed potraktowaniem jak wzorzec.

bash$ echo $[1 + 2]
3
bash$ i=5
bash$ echo $i
5
bash$ i=$[i + 3]
bash$ echo $i
8
bash$ i=$[i * 4]
bash$ echo $i
32
bash$ _ 
Argumenty wywołania progrmu expr powinny być wyrażeniem arytmetycznym, którego wartość zostanie wypisana na standardowe wyjście, np.:
bash$ expr 1 + 2
3
bash$ i=5
bash$ echo $i
5
bash$ i=`expr $i + 3`
bash$ echo $i
8
bash$ i=`expr $i \* 4`
bash$ echo $i
32
bash$ _ 
Zauważ, że w wypadku mnożenia (*) operator arytmetyczny poprzedzono odwrotnym ukośnikiem (\), ponieważ gwiazdka ma specjalne znaczenie -- jest zastępowana przez listę plików, których nazwy pasują do wzorca *, czyli po prostu wszystkich plików w danym katalogu. Poprzedzenie gwiazdki odwrotnym ukośnikiem wyłącza to jej specjalne znaczenie.

Czytanie standardowego wejścia

Wewnątrz skryptu czasem trzeba przeczytać coś ze standardowego wejścia. Do tego celu służy polecenie read, które czyta jedną linię ze standardowego wejścia, dzieli ją na słowa i przypisuje kolejne słowa do zmiennych, których nazwy są argumentami wywołania read. W ostatniej zmiennej umieszczana jest reszta, tzn. wszystkie słowa, których nie przypisano do poprzednich zmiennych. Wywołanie:

read a b c
Powoduje odczytanie jednej linii ze standardowego wejścia skryptu i przypisanie jej pierwszego słowa na zmienną a, drugiego słowa na zmienną b, a pozostałych słów (trzeciego, czwartego etc.) na zmienną c. Przypuśćmy, że linię Ala ma kota, piszą na płotach podano na standardowe wejście następującego skryptu.
read a b c
echo a = $a
echo b = $b
echo c = $c
Skrypt wypisze wówczas:
a = Ala
b = ma
c = kota, piszą na płotach


Zmienne środowiskowe

Każdy program ma środowisko, które składa się ze zmiennych i ich wartości. Owo środowisko jest przekazywane do wszystkich uruchamianych przez ten program programów. Program potomny ma dokładnie takie same środowisko jak program macierzysty. Przykładami takich zmiennych środowiskowych są PATH (lista katalogów, w których należy szukać programów) i HOME (katalog domowy bieżącego użytkownika).

To samo dotyczy interpretera poleceń. Pewna cześć jego zmiennych należy do środowiska i jest przekazywana programom uruchamianym przez interpreter. Działanie programu może zależeć od wartości tych zmiennych. Zwykłe zmienne nie są przekazywane programom potomnym, zmienne środowiskowe są.

Aby wskazać, że zmienna jest środowiskowa, należy przypisanie do niej poprzedzić słowem export, albo wydać polecenie export z jej nazwą jako argumentem, np.

bash$ MYPROG_HOME=/usr/home/myprog
bash$ export MYPROG_HOME
bash$ _
albo
bash$ export MYPROG_HOME=/usr/home/myprog
bash$ _
Jeśli teraz wywołamy bash to będziemy mogli odczytać w nim wartość zmiennej MYPROG_HOME. Gdybyśmy nie użyli słowa export, w potomnym interpreterze wartość tej zmiennej byłaby pusta:
bash$ MYCONFIG=/usr/home/kazio/conf.txt
bash$ bash
bash$ echo $MYCONFIG

bash$ exit
bash$ export MYCONFIG
bash$ bash
bash$ echo $MYCONFIG
/usr/home/kazio/conf.txt
bash$ exit
bash$ _ 

Argumenty skryptu

W wywołaniu skryptu można podać argumenty, np.

skrypt1 ala ma kota
Do tych argumentów można się w skrypcie odwoływać poprzez symbole. $1, $2, ..., $9, których wartościami są odpowiednio pierwszy, drugi, ..., dziewiąty argument skryptu. Kolejne argumenty można uzyskać poprzez użycie nieco innego symbolu, np. dla argumentu dwudziestego będzie to ${20}. Dodatkowo: Argumenty możemy "przesuwać" za pomocą polecenia shift. Powoduje ono:
  1. Bezpowrotną utratę wartości pierwszego argumentu.
  2. Zmniejszenie $# o jeden.
  3. Usunięcie wartości pierwszego argumentu z początku $1.
  4. Przesunięcie wartości z $2 do $1 (teraz drugi argument jest dostępny przez $1).
  5. Przesunięcie wartości z $3 do $2.
  6. ...
  7. Przesunięcie wartości z $9 do $8.
  8. Udostępnienie wartości dziesiątego argumentu za pomocą $9... itd.
Rozważmy na przykład następujący skrypt:
echo Program $0 wywołano z $# argumentami
echo -- pierwszy:   $1
echo -- drugi:      $2
echo -- trzeci:     $3
echo -- wszystkie:  $@
shift
echo Program $0 wywołano z $# argumentami
echo -- pierwszy:   $1
echo -- drugi:      $2
echo -- trzeci:     $3
echo -- wszystkie:  $@
Gdy wywołamy go za pomocą polecenia: skrypt1 ala ma kota otrzymamy w wyniku:
Program skrypt1 wywołano z 3 argumentami
-- pierwszy:   ala
-- drugi:      ma
-- trzeci:     kota
-- wszystkie:  ala ma kota
Program skrypt1 wywołano z 2 argumentami
-- pierwszy:   ma
-- drugi:      kota
-- trzeci:     
-- wszystkie:  ma kota
Poleceniu shift można podać argument. Wywołanie shift n oznacza n-krotne wykonanie shift.

Zadanie dla czytelnika

Napisać skrypt wywoływany w następujący sposób:

nty arg1 arg2 arg3 ...
Ma on wczytać ze standardowego wejścia liczbę n i wypisać swój n-ty argument. Rozwiązanie.


Instrukcja warunkowa

Interpreter umożliwia też wykonywanie instrukcji warunkowej. Oto jej składnia:

if warunek1; then 
  instrukcje1
elif warunek2; then
  instrukcje2
...
else 
  instrukcje_else
fi
Frazy elif i else są opcjonalne. Liczba fraz elif może być dowolna. Jeśli prawdziwy jest warunek1, to wykonywane są instrukcje1, w przeciwnym przypadku (warunek1 jest fałszywy), jeśli prawdziwy jest warunek2, to wykonywane są instrukcje2 itd. Jeśli żaden z warunków nie jest prawdziwy, to wykonuje się instrukcje_else.


Podsumowanie

W wykładzie 3 przedstawiliśmy podstawowe narzędzia programistyczne interpretera poleceń, co pozwoli nam na pisanie skryptów o podstawowej funkcjonalności.


Słownik

argument skryptu
Słowa przekazane przy wywołaniu skryptu; są dostępne w treści skryptu; odczytujemy je za pomocą symboli $1, $2, ..., $9, $@ i $#.
instrukcja warunkowa
Instrukcja języka skryptowego umożliwiająca warunkowe wykonanie instrukcji.
instrukcja wyboru
Instrukcja języka skryptowego umożliwiająca uzależnienie wyboru instrukcji do wykonania od dopasowania pewnego wyrażenia do zbioru wzorców.
interpreter poleceń
Program, który oczekuje na polecenia użytkownika i po ich otrzymaniu je, wykonuje je. Wykonuje także polecenia języka skryptowego.
pętla
Konstrukcja programistyczna umożliwiająca wielokrotne wykonanie tej samej instrukcji.
podprogram
Funkcja albo procedura zadeklarowana i/lub wywoływana w skrypcie.
przekierowanie wejścia-wyjścia
Możliwość skojarzenia strumieni wejścia-wyjścia programu z plikami.
przetwarzanie potokowe
Wykonywanie programów w taki sposób, że standardowe wyjście jednego programu jest przekazywane na standardowe wejście innego.
skrypt
Ciąg poleceń interpretera zapisany w pliku. Zawiera wywołania programów, innych skryptów i instrukcje strukturalne.
standardowe wejście
Strumień, z którego większość programów czyta dane wejściowe.
standardowe wyjście
Strumień, do którego większość programów wysyła dane wyjściowe.
standardowe wyjście diagnostyczne
Strumień, do którego większość programów wysyła informacje o błędach, ostrzeżeniach i innych nieprawidłowościach.
środowisko programu
Składa się ze zmiennych i ich wartości. Jest przekazywane do wszystkich uruchamianych przez ten program programów. Program potomny ma dokładnie takie same środowisko jak program macierzysty.
tło
Miejsce wykonywania programów, które działając nie powstrzymują możliwości wprowadzania następnych poleceń.
zmienna
Nazwane miejsce przechowania wartości w interpreterze poleceń.
zmienna środowiskowa
Zmienna, która należy do środowiska programu.

Zadania

  1. (3p.) Napisać skrypt, który sprawdza, czy ma n-ty parametr i wypisuje stosowną informację.
  2. (3p.) Napisać skrypt, który znajdzie w bieżącym katalogu wszystkie pliki, które zawierają w swojej treści swoja nazwę.
  3. (4p.) Napisać skrypt, który wczyta plik zawierający liczby (po jednej w wierszu) i wypisze ich maksimum, minimum i sumę. Jeśli plik jest pusty, to wypisze odpowiedni komunikat.

Strona przygotowana przez Marcina Kubicę i Krzysztofa Stencla.