Przejdź do treści
Lekcja 39 z 117

Kurs › Moduł 3 › Lekcja 39

Wynik komendy w zmiennej

⏱ ok. 9 min czytania· moduł 3: Pisanie skryptów

Po tej lekcji: kursant zapisuje wynik komendy do zmiennej przez $( ), rozpoznaje stare odwrotne apostrofy i podstawia wynik komendy jako plik przez <( ), na przykład do porównania dwóch list jedną komendą.

Wstęp

skrypt kopii zapasowej, w którym nazwa pliku to kopia.tar.gz, a obok folder z jednym jedynym plikiem kopia.tar.gz

Ktoś napisał skrypt do kopii zapasowych. Codziennie o północy pakuje dane do kopia.tar.gz. Codziennie. Do tego samego pliku. Czyli codziennie nadpisuje wczorajszą kopię. Kiedy w czwartek ktoś zauważy, że we wtorek skasował ważny plik, okaże się, że kopia z wtorku nie istnieje. Jest tylko dzisiejsza, już bez tego pliku.

Potrzebujesz w nazwie dzisiejszej daty. Datę zna komenda date. Pytanie: jak wynik komendy wsadzić do nazwy pliku albo do zmiennej? Dziś się dowiesz i po tej lekcji skrypty zaczną rozmawiać z systemem, zamiast tylko mówić do niego.

$( ), czyli prosimy o odpowiedź na piśmie

Wyobraź sobie, że urzędnik w okienku wypełnia formularz i w jednej rubryce potrzebuje informacji z innego działu. Wysyła gońca: „idź do działu dat i przynieś dzisiejszą datę”. Goniec wraca z karteczką, urzędnik przepisuje to, co na niej jest, w rubrykę i idzie dalej.

Tym gońcem jest $( ). W środku piszesz komendę, a całość zamienia się na to, co ta komenda wypisała.

dzis=$(date +%F)
echo "Kopia z dnia $dzis"
Kopia z dnia 2026-11-03

date +%F wypisuje datę w formacie rok-miesiąc-dzień. $( ) łapie ten wynik, zamiast puścić go na ekran, i wkłada do pudełka dzis. Teraz skrypt z haka naprawiamy jedną zmianą:

tar czf "kopia-$(date +%F).tar.gz" /dane

Każdy dzień to nowy plik. kopia-2026-11-03.tar.gz, kopia-2026-11-04.tar.gz i tak dalej. Format rok-miesiąc-dzień ma też tę zaletę, że sortuje się alfabetycznie w dobrej kolejności. Format dzień-miesiąc-rok tego nie umie i po pół roku masz w folderze bałagan jak w szufladzie na kable.

Możesz też wstawić $( ) prosto w tekst, bez zmiennej:

echo "Jestem w folderze: $(basename "$(pwd)")"
Jestem w folderze: e39

$( ) można zagnieżdżać. Wewnętrzny goniec przynosi pełną ścieżkę, zewnętrzny wycina z niej ostatni kawałek. Zauważ też cudzysłowy w środku: wewnątrz $( ) zaczyna się nowy, czysty świat i koperty z zewnątrz się z nimi nie gryzą.

Dwa przykłady, które zobaczysz w każdym skrypcie admina:

konta=$(wc -l < /etc/passwd)
echo "Kont w systemie: $konta"
komputer=$(hostname)
echo "Działam na: $komputer"
Kont w systemie: 90
Działam na: vm

Co goniec przynosi, a czego nie

Goniec ma swoje zwyczaje i dobrze je znać.

Po pierwsze, obcina puste linijki z końca wyniku. Każda komenda kończy wynik znakiem nowej linii. Gdyby goniec go nie obciął, każda data miałaby doczepiony Enter i nazwy plików byłyby połamane. Dzięki temu kopia-$(date +%F).tar.gz wychodzi w jednej linijce.

Po drugie, wynik bez cudzysłowu zostaje pocięty na słowa. Porównaj:

lista=$(printf 'a\nb\nc\n')
echo $lista
echo "$lista"
a b c
a
b
c

Bez koperty trzy linijki skleiły się w jedną. W kopercie zostały, jak były. Zasada, która wraca w tym kursie jak bumerang: zmienne i $( ) zawsze w cudzysłowie, chyba że dokładnie wiesz, dlaczego nie.

Po trzecie, goniec przynosi tylko to, co komenda wypisała pierwszym głośnikiem. Błędy z drugiego głośnika dalej lecą na ekran. Jeśli chcesz je też złapać: wynik=$(komenda 2>&1).

Po czwarte, kod wyjścia komendy nie ginie. Zaraz po przypisaniu leży w $?:

wynik=$(grep -c root /etc/passwd)
echo "kod: $? wynik: $wynik"
wynik=$(grep -c nieistnieje /etc/passwd)
echo "kod: $? wynik: $wynik"
kod: 0 wynik: 1
kod: 1 wynik: 0

Czyli masz i odpowiedź na piśmie, i kciuk. Możesz nawet napisać if wynik=$(komenda); then, a bramkarz sprawdzi kciuk, a wynik i tak trafi do pudełka.

Stare odwrotne apostrofy

W starych skryptach i na Stack Overflow z dawnych lat zobaczysz to samo zapisane inaczej:

dzis=`date +%F`

To są odwrotne apostrofy, ten znak pod Escape, obok jedynki. Robią to samo co $( ). Kiedyś były jedyną drogą, dziś są jak telefon z kręconym kablem: działa, ale nikt rozsądny nie kupuje nowego.

Dlaczego? Bo nie da się ich łatwo zagnieżdżać. Ten sam przykład z folderem w starej wersji wygląda tak:

echo "Jestem w folderze: `basename \`pwd\``"

Wewnętrzne apostrofy trzeba poprzedzić ukośnikami, bo inaczej Bash nie wie, który apostrof zamyka który. Przy trzech poziomach robi się z tego szyfr Enigmy. Do tego w środku odwrotnych apostrofów ukośniki zachowują się inaczej niż wszędzie indziej, co opisuje osobny akapit w man bash. A na wielu czcionkach odwrotny apostrof wygląda prawie jak zwykły, więc w tutorialu łatwo pomylić jeden z drugim.

ShellCheck na odwrotne apostrofy mówi krótko: Use $(...) notation instead of legacy backticks. Rozpoznawaj je, kiedy czytasz cudze skrypty, ale sam pisz $( ).

<( ), czyli wynik komendy udający plik

Czasem komenda nie chce tekstu, tylko pliku. Klasyka: diff, który porównuje dwa pliki. Chcesz porównać, jakie pliki leżą w dwóch folderach. Po staremu robisz dwa pliki tymczasowe, porównujesz i sprzątasz. Trzy komendy plus pamiętanie o sprzątaniu, o którym nikt nie pamięta.

Wyobraź sobie, że urzędnik wymaga dwóch dokumentów na papierze. Zamiast drukować, podsuwasz mu dwie tuby, z których tekst wyjeżdża na bieżąco. On myśli, że czyta kartki. Ty nie zużyłeś ani jednej.

diff <(ls stary) <(ls nowy)
2d1
< b.conf
3a3
> d.conf

<( ) uruchamia komendę w środku, a w jej miejsce wstawia nazwę pliku, z którego można czytać jej wynik. diff dostał dwie „nazwy plików” i nawet nie wie, że to nie są prawdziwe pliki. Wynik czytamy tak: b.conf jest tylko w starym folderze, d.conf tylko w nowym.

Co to za nazwa? Sprawdźmy:

echo <(true)
/dev/fd/63

To nie plik na dysku, tylko furtka do rury, do której jest podpięta komenda. O tym za chwilę pod spodem.

Prawdziwe zastosowania admina: porównanie listy pakietów na dwóch serwerach, porównanie konfiguracji przed zmianą i po, sprawdzenie, czego brakuje na jednej liście względem drugiej. Za każdym razem jedna linijka zamiast plików tymczasowych.

Jak to działa pod spodem

$( ) to wynajęcie drugiego kucharza. Bash tworzy kopię samego siebie, czyli podpowłokę, każe jej wykonać komendę, a jej pierwszy głośnik podpina do rury, z której sam czyta. Kiedy kucharz skończy, Bash ma cały wynik, obcina końcowe Entery i wstawia go w miejsce $( ).

Kopia jest kopią. Wszystko, co zrobi się w środku, zostaje w środku:

x=1
y=$(x=2; echo "$x")
echo "x=$x y=$y"
x=1 y=2

W środku x było dwa, ale na zewnątrz dalej jest jeden. Kucharz na zapleczu może przestawić sobie wszystkie garnki, ale Twoja kuchnia zostaje nietknięta. Przez to cd wewnątrz $( ) też nie zmienia folderu skryptu.

<( ) działa podobnie, ale zamiast czekać na koniec i zbierać wynik, Bash od razu uruchamia komendę w tle, jej wyjście podpina do rury i przekazuje nazwę tej rury jako /dev/fd/63 albo podobną. Na Linuksie /dev/fd/ to folder, w którym każdy proces widzi swoje otwarte pliki pod numerami. Numer zero to wejście, jeden to pierwszy głośnik, dwa to drugi. A 63 to rura, którą Bash otworzył specjalnie dla diff. Komenda czyta z niej jak z pliku, tylko nie może się w nim cofać, bo rura płynie w jedną stronę.

Ściąga flag

KomendaFlaga / elementCo robi
$( )x=$(komenda)wynik komendy do zmiennej
$( )"$(komenda)"wynik w kopercie, bez cięcia na słowa
$( )$(komenda 2>&1)złap też błędy
$( )$(< plik)zawartość pliku bez cat
odwrotne apostrofystara postaćto samo co $( ), nie używaj w nowych skryptach
<( )diff <(a) <(b)wynik komendy jako plik do czytania
<( )done < <(komenda)wynik komendy na wejście pętli
>( )tee >(komenda)plik, który przekazuje dane do komendy
date+%Fdata RRRR-MM-DD
date'+%F %T'data i godzina
basenamebasename ścieżkaostatni kawałek ścieżki

⚠ Typowy błąd

Ten błąd kosztował już wielu adminów godzinę życia, bo nie ma żadnego komunikatu. Liczymy konta z powłoką Bash:

#!/bin/bash
# zle.sh — liczy konta z powłoką bash (z błędem)

licznik=0
grep 'bash$' /etc/passwd | cut -d: -f1 | while IFS= read -r linia; do
    echo "Znalazłem: $linia"
    licznik=$(( licznik + 1 ))
done
echo "Razem: $licznik"
Znalazłem: root
Znalazłem: ubuntu
Znalazłem: eryk
...
Razem: 0

Pętla znalazła kilkanaście kont, a na końcu „Razem: 0”. Jak to?

Każdy kawałek rury działa w osobnej podpowłoce. Także pętla while za ostatnią kreską. Czyli liczył kucharz na zapleczu, w swojej kopii zmiennej licznik. Kiedy pętla się skończyła, kucharz poszedł do domu razem ze swoim wynikiem, a Ty masz swoje stare zero. ShellCheck to wyłapuje: licznik was modified in a subshell. That change might be lost.

Naprawa: zamiast wpychać dane do pętli rurą, podaj je pętli jak plik przez < <( ):

#!/bin/bash
# dobre.sh — liczy konta z powłoką bash

licznik=0
while IFS= read -r linia; do
    echo "Znalazłem: $linia"
    licznik=$(( licznik + 1 ))
done < <(grep 'bash$' /etc/passwd | cut -d: -f1)
echo "Razem: $licznik"
Znalazłem: root
Znalazłem: ubuntu
Znalazłem: eryk
...
Razem: 16

Pierwsza strzałka to zwykłe przekierowanie wejścia, tak jak done < plik. Druga, razem z nawiasem, to <( ), czyli „plik” z wynikiem komendy. Spacja między nimi jest obowiązkowa. Teraz pętla działa w głównej powłoce, a podpowłoka tylko dostarcza dane. Licznik zostaje u Ciebie.

✎ Zadanie

  1. Napisz skrypt stan.sh, który zapisze do zmiennych nazwę komputera, dzisiejszą datę z godziną, liczbę procesów w systemie (ps -e --no-headers | wc -l) i procent zajętości dysku / jako samą liczbę. Podpowiedź: df --output=pcent /, potem tail -n 1, potem tr -dc '0-9'. Wypisz to wszystko w jednej linijce, a jeśli dysk jest zajęty w ponad 80 procentach, dopisz ostrzeżenie.
  2. Zrób dwa foldery z kilkoma plikami, częściowo takimi samymi, częściowo różnymi, i jedną komendą diff z <( ) pokaż, czego brakuje w którym.
Pokaż rozwiązanie
#!/bin/bash
# stan.sh — jednolinijkowy raport o stanie serwera

komputer=$(hostname)
teraz=$(date '+%F %T')
procesy=$(ps -e --no-headers | wc -l)
zajete=$(df --output=pcent / | tail -n 1 | tr -dc '0-9')

echo "$teraz | $komputer | procesów: $procesy | dysk /: $zajete%"

if (( zajete > 80 )); then
    echo "UWAGA: na / zostało mało miejsca!"
fi
2026-11-03 16:35:46 | vm | procesów: 138 | dysk /: 38%

Najciekawsza jest linijka z dyskiem. df --output=pcent / wypisuje nagłówek Use% i pod nim 38%, ze spacją na początku. tail -n 1 zostawia ostatnią linijkę, a tr -dc '0-9' usuwa wszystko, co nie jest cyfrą. Zostaje czyste 38, z którym kalkulator (( )) umie już porównywać. A żeby sprawdzić ostrzeżenie bez zapychania dysku, podmień na próbę zmienną ręcznie: zajete=91. Testowanie alarmu przeciwpożarowego zapalniczką, a nie podpalaniem biura.

Druga część:

mkdir -p stary nowy
touch stary/{a,b,c}.conf nowy/{a,c,d}.conf
diff <(ls stary) <(ls nowy)
2d1
< b.conf
3a3
> d.conf

b.conf jest tylko po lewej, d.conf tylko po prawej. Zauważ też, że pliki zrobiłeś nawiasami klamrowymi z modułu pierwszego. Wszystko, czego się uczysz, zaczyna się składać w jedno. Jak IKEA, tylko instrukcja jest czytelna.

Co dalej

Nasze skrypty robią się coraz dłuższe i te same kawałki zaczynają się powtarzać. W następnej lekcji zamkniemy je w funkcjach, czyli przyciskach w windzie: jeden przycisk, cała procedura. I wyjaśnimy wreszcie, czemu return nie zwraca tego, co myślisz.