Przejdź do treści
Lekcja 52 z 117

Kurs › Moduł 5 › Lekcja 52

Opcje jak w prawdziwych programach

⏱ ok. 13 min czytania· moduł 5: Skrypty, które się nie sypią

Po tej lekcji: kursant dodaje do swoich skryptów opcje w stylu -v, -d FOLDER, -h i --help przez getopts, pisze porządny ekran pomocy i sprawdza dane od użytkownika, zanim skrypt cokolwiek zrobi.

Wstęp

ls --help, długa ściana opcji przewija się przez ekran

Wpisz ls --help. Albo grep --help. Każdy porządny program zachowuje się tak samo: ma opcje z myślnikiem, ma pomoc, a jak wpiszesz bzdurę, mówi dokładnie, co jest nie tak.

$ ls --zle
ls: unrecognized option '--zle'
Try 'ls --help' for more information.

A nasze skrypty? ./skrypt.sh /var/log 30 tak. Co to jest „tak”? Co znaczy 30? A jak zamienisz kolejność, to skrypt usunie pliki z folderu „30” starsze niż „/var/log” dni? Za pół roku nawet ty nie będziesz pamiętał, który argument był który. Twój kolega na pewno nie, i zadzwoni do ciebie w niedzielę.

Dziś skrypt dostanie opcje jak prawdziwy program. Na koniec będziemy mieli narzędzie, które sprząta stare pliki, domyślnie niczego nie kasuje, a zapytane o pomoc odpowiada jak dobrze wychowany urzędnik.

Argumenty po kolei kontra opcje

Argumenty pozycyjne, $1, $2, $3, to jak formularz, w którym nie ma podpisanych pól. Musisz pamiętać, że w trzeciej kratce wpisuje się PESEL, a w piątej nazwisko panieńskie matki. Pomylisz kratki i wniosek idzie do kosza. Albo gorzej, zostaje rozpatrzony.

Opcje to formularz z podpisami przy polach: -d to folder, -n to liczba dni. Kolejność nie ma znaczenia, część pól jest nieobowiązkowa, a do części wpisujesz wartość.

Umowa w świecie Uniksa wygląda tak:

  • -v — przełącznik, włączony albo nie.
  • -d FOLDER — opcja z wartością.
  • -vu — kilka przełączników sklejonych razem, to samo co -v -u.
  • -- — koniec opcji, dalej są już zwykłe argumenty.
  • -h i --help — pomoc.

Do czytania tego w Bashu jest wbudowana komenda getopts.

getopts krok po kroku

getopts to urzędnik, który bierze z kupki argumentów po jednej opcji naraz. Wołasz go w pętli while. Za każdym razem mówi, jaką opcję znalazł, a jeśli opcja ma wartość, kładzie ją do zmiennej OPTARG. Kiedy dojdzie do czegoś, co nie zaczyna się od myślnika, kończy.

#!/bin/bash
while getopts "vd:" opcja; do
    echo "opcja=$opcja OPTARG=${OPTARG:-} OPTIND=$OPTIND"
done
shift $((OPTIND - 1))
echo "zostało argumentów: $# -> $*"

Napis "vd:" to spis dozwolonych opcji. Każda litera to opcja. Dwukropek po literze znaczy: „ta opcja wymaga wartości”. Czyli v to przełącznik, a d: to opcja z wartością.

./demo1.sh -v -d /tmp plik1 plik2
opcja=v OPTARG= OPTIND=2
opcja=d OPTARG=/tmp OPTIND=4
zostało argumentów: 2 -> plik1 plik2

OPTIND to numer następnej kartki do przeczytania. Po pętli shift $((OPTIND - 1)) zrzuca ze stosu wszystkie przeczytane opcje i zostają same zwykłe argumenty, czyli pliki. Znasz shift z lekcji o argumentach. Tu przesuwa o tyle, ile trzeba, jednym ruchem.

Sklejanie też działa:

./demo1.sh -vd/tmp plik1
opcja=v OPTARG= OPTIND=1
opcja=d OPTARG=/tmp OPTIND=2
zostało argumentów: 1 -> plik1

-vd/tmp to dla getopts -v i -d /tmp. Zauważ, że po v OPTIND dalej wynosi 1, bo urzędnik jeszcze nie skończył tej kartki.

A to ważne: opcje muszą być przed zwykłymi argumentami.

./demo1.sh plik1 -v
zostało argumentów: 2 -> plik1 -v

getopts zobaczył plik1, uznał, że opcje się skończyły, i poszedł do domu. -v zostało zwykłym argumentem. [dla zaawansowanych: programy GNU, jak ls, przestawiają argumenty i przyjmują opcje także na końcu. getopts w Bashu trzyma się starszej, POSIX-owej zasady: pierwszy argument bez myślnika kończy opcje.]

Gdy użytkownik poda bzdurę

Domyślnie getopts sam wypisuje błędy po angielsku:

demo1.sh: illegal option -- x
demo1.sh: option requires an argument -- d

Działa, ale wygląda jak komunikat z lat osiemdziesiątych. Lepiej przejąć obsługę błędów. Robi się to dwukropkiem na początku spisu opcji: ":vd:". To tak zwany tryb cichy. getopts przestaje sam krzyczeć i zamiast tego zostawia ci znaki:

  • ? w zmiennej opcji i nieznaną literę w OPTARG, kiedy opcji nie ma na liście,
  • : w zmiennej opcji i literę w OPTARG, kiedy opcji brakuje wartości.

Klasyczna obsługa to case z lekcji o menu:

#!/bin/bash
while getopts ":vd:" opcja; do
    case $opcja in
        v) echo "gadatliwie" ;;
        d) echo "folder: $OPTARG" ;;
        :) echo "Opcja -$OPTARG wymaga wartości." >&2; exit 2 ;;
        \?) echo "Nieznana opcja: -$OPTARG" >&2; exit 2 ;;
    esac
done
$ ./demo2.sh -x
Nieznana opcja: -x
kod 2
$ ./demo2.sh -d
Opcja -d wymaga wartości.
kod 2

\? ma backslash, bo sam ? w case to wzorzec „dowolny jeden znak” i złapałby wszystko. Kod 2 to umowa: błąd w sposobie użycia. Zobacz, co zwracają porządne programy, kiedy podasz złą opcję:

$ bash -c 'cd -Z'
bash: line 1: cd: -Z: invalid option
cd: usage: cd [-L|[-P [-e]] [-@]] [dir]
cd kod 2
ls kod 2
grep kod 2

Wszystkie dwójki. Jest się kogo trzymać. Konwencjom kodów wyjścia poświęcimy kawałek lekcji 54.

Dwie pułapki getopts

Pierwsza: opcja z wartością zjada wszystko, co stoi po niej.

$ ./demo2.sh -d -v
folder: -v
kod 0

Użytkownik zapomniał podać folderu, a getopts uznał, że folder nazywa się -v. Skrypt poszedł dalej. Dlatego wartości trzeba potem sprawdzić, o tym za chwilę.

Druga: getopts nie zna długich opcji.

$ ./demo2.sh --help
Nieznana opcja: --
kod 2

Dla getopts --help to opcja o nazwie -, a za nią sklejone h, e, l, p. Pierwszy minus jest nieznany, więc koniec. Długie opcje trzeba obsłużyć ręcznie. Dla samego --help wystarczy prosta pętla przed getopts:

for arg in "$@"; do
    if [[ $arg == "--help" ]]; then
        pomoc
        exit 0
    fi
done

Ekran pomocy

Pomoc piszemy jako funkcję z here-dokiem z lekcji 44. To najwygodniejszy sposób na kilkanaście linijek tekstu:

PROGRAM=${0##*/}

pomoc() {
    cat <<KONIEC
Użycie: $PROGRAM -d FOLDER [-n DNI] [-u] [-v]

Wypisuje pliki w FOLDERZE starsze niż DNI dni.
Domyślnie niczego nie usuwa.

Opcje:
  -d FOLDER   gdzie szukać (wymagane)
  -n DNI      ile dni (domyślnie: 30)
  -u          naprawdę usuń znalezione pliki
  -v          pokaż rozmiar i datę każdego pliku
  -h, --help  ta pomoc
KONIEC
}

${0##*/} to nożyczki z lekcji 41: obcinają ścieżkę i zostaje sama nazwa skryptu, bez ./ czy /usr/local/bin/. Nawiasy kwadratowe w linijce „Użycie” to umowa: w kwadratowych to, co nieobowiązkowe. WIELKIE litery to miejsce na twoją wartość. Ta sama umowa, co w man i w każdym --help.

Jeszcze jedna zasada: pomoc, o którą ktoś poprosił, idzie na standardowe wyjście i kończy się kodem 0. Komunikat o błędzie idzie na wyjście błędów i kończy się kodem 2. Ktoś, kto zrobi ./stare.sh --help | less, chce przewijać pomoc, a nie dostać ją rozlaną po ekranie.

Sprawdzanie danych

Pamiętasz bramkarza z lekcji o if? Teraz stawiamy całą ochronę przy wejściu. Zasada: sprawdź wszystko, zanim zrobisz cokolwiek. Skrypt, który usunie połowę plików, a potem odkryje, że liczba dni była bez sensu, jest gorszy niż skrypt, który od razu odmówi.

blad() {
    echo "$PROGRAM: $1" >&2
    echo "Spróbuj: $PROGRAM -h" >&2
    exit 2
}

(( $# == 0 )) || blad "niepotrzebne argumenty: $*"
[[ -n $folder ]] || blad "brak wymaganej opcji -d FOLDER"
[[ -d $folder ]] || blad "nie ma takiego folderu: $folder"
[[ $dni =~ ^[0-9]+$ ]] || blad "-n musi być liczbą całkowitą, a jest: $dni"
dni=$((10#$dni))   # 08 to osiem, a nie błąd w systemie ósemkowym
(( dni >= 1 )) || blad "-n musi być co najmniej 1"

Każda linijka to jeden bramkarz: „sprawdzam, a jak nie pasuje, wylatujesz”. Wzór ^[0-9]+$ to wyrażenie regularne z lekcji 45: od początku do końca same cyfry, co najmniej jedna. Bez minusów, bez przecinków, bez „trzydzieści”.

Co robi 10#, wyjaśnię w typowym błędzie. Bo ta linijka powstała dokładnie z tego błędu.

Całe narzędzie

#!/bin/bash
# stare.sh — wypisuje (albo usuwa) pliki starsze niż N dni
set -euo pipefail

PROGRAM=${0##*/}
folder=""
dni=30
usuwaj=0
gadatliwie=0

pomoc() {
    cat <<KONIEC
Użycie: $PROGRAM -d FOLDER [-n DNI] [-u] [-v]

Wypisuje pliki w FOLDERZE starsze niż DNI dni.
Domyślnie niczego nie usuwa.

Opcje:
  -d FOLDER   gdzie szukać (wymagane)
  -n DNI      ile dni (domyślnie: 30)
  -u          naprawdę usuń znalezione pliki
  -v          pokaż rozmiar i datę każdego pliku
  -h, --help  ta pomoc
KONIEC
}

blad() {
    echo "$PROGRAM: $1" >&2
    echo "Spróbuj: $PROGRAM -h" >&2
    exit 2
}

# --help nie jest dla getopts, łapiemy je ręcznie
for arg in "$@"; do
    if [[ $arg == "--help" ]]; then
        pomoc
        exit 0
    fi
done

while getopts ":d:n:uvh" opcja; do
    case $opcja in
        d) folder=$OPTARG ;;
        n) dni=$OPTARG ;;
        u) usuwaj=1 ;;
        v) gadatliwie=1 ;;
        h) pomoc; exit 0 ;;
        :) blad "opcja -$OPTARG wymaga wartości" ;;
        \?) blad "nieznana opcja -$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))

# sprawdzanie danych
(( $# == 0 )) || blad "niepotrzebne argumenty: $*"
[[ -n $folder ]] || blad "brak wymaganej opcji -d FOLDER"
[[ -d $folder ]] || blad "nie ma takiego folderu: $folder"
[[ $dni =~ ^[0-9]+$ ]] || blad "-n musi być liczbą całkowitą, a jest: $dni"
dni=$((10#$dni))   # 08 to osiem, a nie błąd w systemie ósemkowym
(( dni >= 1 )) || blad "-n musi być co najmniej 1"

licznik=0
while IFS= read -r -d '' plik; do
    if (( gadatliwie )); then
        printf '%s  %8s B  %s\n' "$(date -r "$plik" +%F)" "$(stat -c %s -- "$plik")" "$plik"
    else
        printf '%s\n' "$plik"
    fi
    if (( usuwaj )); then
        rm -f -- "$plik"
    fi
    licznik=$((licznik + 1))
done < <(find "$folder" -type f -mtime +"$dni" -print0)

if (( usuwaj )); then
    echo "Usunięto plików: $licznik"
else
    echo "Znaleziono plików: $licznik (nic nie usunięto, dodaj -u)"
fi

Zbiera wszystko z modułu: set -euo pipefail, pętlę z -print0 odporną na spacje, cudzysłowy, -- przed nazwami plików. ShellCheck: cisza.

Zwróć uwagę na projekt: niebezpieczna akcja wymaga jawnej opcji. Bez -u skrypt tylko pokazuje. Dokładnie tak, jak w rozwiązaniu zadania z lekcji 49: najpierw patrzysz, potem kasujesz. Teraz ta ostrożność jest wbudowana w narzędzie.

ostrzeżenie: „Opcję -u testuj najpierw na folderze ze śmieciami testowymi albo na maszynie wirtualnej.”

Przygotowałem folder testowy smietnik z plikami w różnym wieku, także w podfolderze ze spacją. Uruchamiamy:

$ ./stare.sh -d smietnik
smietnik/duzy.log
smietnik/a.log
smietnik/stare logi/b c.log
Znaleziono plików: 3 (nic nie usunięto, dodaj -u)

$ ./stare.sh -vd smietnik
2026-07-26      2048 B  smietnik/duzy.log
2026-09-24         0 B  smietnik/a.log
2026-09-24         0 B  smietnik/stare logi/b c.log
Znaleziono plików: 3 (nic nie usunięto, dodaj -u)

$ ./stare.sh -u -d smietnik -n 60
smietnik/duzy.log
Usunięto plików: 1

A teraz próbujemy go zepsuć:

$ ./stare.sh
stare.sh: brak wymaganej opcji -d FOLDER
Spróbuj: stare.sh -h
$ ./stare.sh -d /nie/ma
stare.sh: nie ma takiego folderu: /nie/ma
Spróbuj: stare.sh -h
$ ./stare.sh -d smietnik -n trzydzieści
stare.sh: -n musi być liczbą całkowitą, a jest: trzydzieści
Spróbuj: stare.sh -h
$ ./stare.sh -d smietnik -x
stare.sh: nieznana opcja -x
Spróbuj: stare.sh -h
$ ./stare.sh -d smietnik extra
stare.sh: niepotrzebne argumenty: extra
Spróbuj: stare.sh -h

Za każdym razem kod 2, komunikat mówi dokładnie, co nie tak, i podpowiada, gdzie szukać pomocy. Nie usunięto ani jednego pliku. Tak wygląda skrypt, który można dać koledze.

Jak to działa pod spodem

getopts to komenda wbudowana w Basha, a nie osobny program. Musi taka być, bo zmienia stan powłoki: ustawia zmienną opcji, OPTARG i OPTIND. Osobny program nie mógłby zmienić zmiennych Basha, tak jak kucharz w kuchni nie przestawi ci krzeseł na sali.

Przy każdym wywołaniu getopts patrzy na argument numer OPTIND. Jeśli zaczyna się od myślnika, bierze kolejną literę i szuka jej w spisie. Jeśli po literze w spisie stoi dwukropek, wartością jest reszta tej samej kartki (-d/tmp) albo cała następna kartka (-d /tmp). Kiedy kartka się skończy, OPTIND rośnie o jeden. Kiedy trafi na argument bez myślnika albo na --, zwraca kod różny od zera i pętla while się kończy.

OPTIND na starcie skryptu wynosi 1. Jeśli używasz getopts w funkcji, zadeklaruj w niej local OPTIND. Inaczej drugie wywołanie funkcji zacznie czytać od miejsca, w którym skończyło pierwsze, i nic nie znajdzie.

Ściąga flag

KomendaFlagaCo robi
getopts "vd:" opcja—v przełącznik, d: opcja z wartością
getopts ":vd:" opcja: na początkutryb cichy: sam obsługujesz błędy
$OPTARG—wartość opcji albo litera błędnej opcji
$OPTIND—numer następnego argumentu do przeczytania
shift $((OPTIND - 1))—zostawia same zwykłe argumenty
case\?)nieznana opcja (w trybie cichym)
case:)brak wartości opcji (w trybie cichym)
local OPTIND—getopts w funkcji bez śmieci z poprzedniego wywołania
${0##*/}—sama nazwa skryptu do komunikatów
$((10#$n))—liczba dziesiętna, nawet z zerem na początku
exit0 / 2pomoc na życzenie / błąd użycia
getopt--longzewnętrzny program GNU z długimi opcjami (nieprzenośny)

⚠ Typowy błąd

Użytkownik podaje -n 08, bo przecież „osiem dni” można zapisać z zerem, jak w kalendarzu:

$ ./stare.sh -d smietnik -n 08
stare.sh: line 59: ((: 08: value too great for base (error token is "08")
stare.sh: -n musi być co najmniej 1
Spróbuj: stare.sh -h

Bash w arytmetyce traktuje liczbę z zerem na początku jako liczbę w systemie ósemkowym. A w systemie ósemkowym cyfry 8 nie ma. Jak próba zapłacenia w Biedronce banknotem 8 złotych. Wyrażenie się wywala, (( )) zwraca porażkę i bramkarz wyrzuca użytkownika z kompletnie mylącym komunikatem „co najmniej 1”. Przy -n 07 byłoby jeszcze podstępniej: wszystko by zadziałało, bo 7 ósemkowo to też 7. Błąd czekałby do pierwszego 08 albo 09.

Naprawa: po sprawdzeniu, że są same cyfry, wymuś system dziesiętny: dni=$((10#$dni)). 10# znaczy „czytaj to dziesiętnie”. Ten sam problem trafia skrypty, które biorą godzinę albo dzień miesiąca z date +%H czy date +%d, bo tam zera na początku są zawsze.

✎ Zadanie

Dopisz do skryptu sprawdz.sh z lekcji 35 opcje -v (pokazuje dodatkowo rozmiar i właściciela) i -h (pomoc) przez getopts.

Pokaż rozwiązanie
#!/bin/bash
# sprawdz.sh — mówi, czy ścieżka to plik, folder, czy nic

uzycie() {
    echo "Użycie: $0 [-v] [-h] ŚCIEŻKA"
    echo "  -v  pokaż też rozmiar i właściciela"
    echo "  -h  ta pomoc"
}

gadatliwy=0
while getopts ":vh" opcja; do
    case "$opcja" in
        v) gadatliwy=1 ;;
        h) uzycie; exit 0 ;;
        '?') echo "Nieznana opcja: -$OPTARG" >&2; uzycie >&2; exit 1 ;;
    esac
done
shift $((OPTIND - 1))

if [[ $# -ne 1 ]]; then
    uzycie >&2
    exit 1
fi

sciezka=$1
if [[ -f "$sciezka" ]]; then
    echo "$sciezka to plik"
elif [[ -d "$sciezka" ]]; then
    echo "$sciezka to folder"
else
    echo "$sciezka nie istnieje"
    exit 1
fi

if (( gadatliwy )); then
    stat -c 'rozmiar: %s B, właściciel: %U' -- "$sciezka"
fi
$ ./sprawdz.sh -v /etc/hostname
/etc/hostname to plik
rozmiar: 3 B, właściciel: root
$ ./sprawdz.sh -x /etc
Nieznana opcja: -x
Użycie: ./sprawdz.sh [-v] [-h] ŚCIEŻKA
  -v  pokaż też rozmiar i właściciela
  -h  ta pomoc

Dwie linijki robią tu całą robotę. getopts ":vh" mówi: znam opcje v i h, a dwukropek na początku znaczy „błędy zgłaszam sam, po swojemu”. A shift $((OPTIND - 1)) zdejmuje z taśmy wszystkie przerobione opcje, więc po nim $1 to już ścieżka, a nie -v. Bez tego shift skrypt sprawdzałby, czy istnieje plik o nazwie -v, i uczciwie odpowiadał, że nie istnieje. Technicznie prawda, praktycznie bezużyteczne.

stat -c wyciąga z pliku dokładnie te pola, które chcesz: %s to rozmiar w bajtach, %U to nazwa właściciela. A -- przed ścieżką mówi „tu kończą się opcje”, na wypadek gdyby ktoś nazwał plik -v. Zawsze znajdzie się ktoś taki.

macOS: tamtejszy stat jest z rodziny BSD i zamiast -c ma -f z innymi literkami. Na Linuksie i w kursie używamy wersji GNU.

Co dalej

Nasze skrypty mają już opcje, pomoc i bramkarzy. Ale prędzej czy później któryś i tak zrobi coś dziwnego, a ty nie będziesz wiedział, w której linijce. W następnej lekcji szukamy błędów jak detektyw: bash -n, bash -x, set -x z własnym PS4, ShellCheck na serio i krótki rzut oka na strace, czyli podsłuchiwanie, o co program prosi jądro.