Przejdź do treści
Lekcja 42 z 117

Kurs › Moduł 4 › Lekcja 42

Listy (tablice)

⏱ ok. 9 min czytania· moduł 4: Napisy, listy i przetwarzanie danych

Po tej lekcji: kursant tworzy tablice w Bashu, dokłada do nich elementy, liczy je, przechodzi po nich pętlą bez gubienia spacji i wczytuje plik do tablicy przez mapfile.

Wstęp

skrypt, który ma serwer1=web01, serwer2=web02, serwer3=db01 i trzy prawie identyczne bloki ping

Znasz takie skrypty. Trzy serwery, trzy zmienne, trzy kopie tego samego kodu. A potem szef mówi: „doszło nam dwanaście maszyn”. I zaczyna się kopiuj-wklej, w którym zawsze gdzieś zostaje serwer7 zamiast serwer8.

Dziś zrobimy z tego jedną listę, jedną pętlę i jeden plik z nazwami serwerów. Doszedł serwer? Dopisujesz linijkę do pliku. Skryptu nie ruszasz.

Tablica, czyli szuflada z przegródkami

Zwykła zmienna to pudełko z napisem. Jedno pudełko, jedna rzecz. Tablica to szuflada z przegródkami, jak ta na sztućce. Jedna nazwa na froncie szuflady, a w środku ponumerowane przegródki: zero, jeden, dwa…

Tak, od zera. Programiści liczą od zera i nie ma od tego odwołania.

Tablicę tworzysz nawiasami okrągłymi, elementy oddzielasz spacją:

serwery=(web01 web02 db01)
echo "${serwery[0]}"
echo "${serwery[2]}"
web01
db01

Numer przegródki, czyli indeks, piszesz w nawiasie kwadratowym. I klamry są tu obowiązkowe. Zaraz w błędach zobaczysz, co się dzieje bez nich.

Cała szuflada naraz to @, a liczba elementów to znany już krzyżyk:

echo "${serwery[@]}"
echo "${#serwery[@]}"
web01 web02 db01
3

Dokładanie i wyjmowanie

Dokładanie na koniec to += z nawiasem. Nawias jest ważny, zaraz powiem dlaczego.

serwery+=(backup01)
serwery+=("mail 01")
echo "${#serwery[@]}"
echo "${serwery[-1]}"
5
mail 01

[-1] to ostatni element, [-2] przedostatni. [macOS] Ujemne indeksy działają od Basha 4.2, na systemowym Bashu z Maca nie.

A bez nawiasu? serwery+=backup01 nie dokłada nowego elementu, tylko dokleja tekst do pierwszego. Wychodzi web01backup01. Serwer, którego nikt nigdy nie kupił.

Kawałek tablicy, czyli plaster, robisz tak jak przy tekście, od którego i ile:

echo "${serwery[@]:1:2}"
web02 db01

A wyrzucenie elementu to unset. I tu pierwsza niespodzianka:

unset 'serwery[1]'
echo "${#serwery[@]}"
echo "${!serwery[@]}"
4
0 2 3 4

Element zszedł, ale numeracja się nie przesunęła. Przegródka numer 1 jest teraz pusta. ${!serwery[@]}, z wykrzyknikiem, pokazuje, które numery w ogóle są zajęte. To jak bloki na osiedlu po rozbiórce: jest blok 0, 2, 3 i 4, a jedynki już nie ma. Listonosz musi o tym wiedzieć. Twoja pętla też.

Pętla po tablicy: taśma przy kasie

To jest serce lekcji. Pętla to taśma przy kasie. Tablica to zakupy w koszyku. Wykładasz wszystko na taśmę i kasjerka bierze po kolei.

for s in "${serwery[@]}"; do
    echo "Sprawdzam: $s"
done
Sprawdzam: web01
Sprawdzam: web02
Sprawdzam: db01
Sprawdzam: backup01
Sprawdzam: mail 01

Zobacz ostatnią linijkę. mail 01 ze spacją przeszedł w całości. Bo "${serwery[@]}" w cudzysłowie ma specjalną moc: każdy element dostaje własną kopertę.

A teraz to samo bez cudzysłowu:

for s in ${serwery[@]}; do
    echo "Sprawdzam: $s"
done
...
Sprawdzam: backup01
Sprawdzam: mail
Sprawdzam: 01

Nagle mamy serwer mail i serwer 01. Bez koperty Bash rozciął element na spacji. Kasjerka przepuściła ser osobno i dziury z sera osobno. Zasada na całe życie: "${tablica[@]}", zawsze w cudzysłowie.

A kiedy potrzebujesz numeru? Pętla po indeksach:

for i in "${!serwery[@]}"; do
    echo "$i: ${serwery[i]}"
done
0: web01
1: web02
2: db01
3: backup01
4: mail 01

I to działa także po unset, bo idziesz po numerach, które naprawdę istnieją. Wewnątrz nawiasu kwadratowego nie musisz pisać $i, Bash liczy tam jak w (( )).

Nożyczki działają na całej szufladzie

Pamiętasz nożyczki z poprzedniej lekcji? One działają też na tablicy, na każdym elemencie naraz:

pliki=(access.log error.log syslog.log)
echo "${pliki[@]%.log}"
echo "${pliki[@]^^}"
access error syslog
ACCESS.LOG ERROR.LOG SYSLOG.LOG

Jeden ruch, trzy pliki bez rozszerzenia. Jak krajalnica do jajek.

Jest jeszcze ${tablica[*]}, z gwiazdką. W cudzysłowie skleja wszystko w JEDEN tekst. Przydaje się do wypisania listy w jednej linijce:

echo "Padnięte: ${padniete[*]}"

Zasada: @ do pętli, * do wypisywania.

Skąd brać tablice: pliki, wyniki komend, mapfile

Tablicę rzadko wpisujesz ręcznie. Zwykle bierzesz ją skądś.

Z listy plików:

logi=(logi/*.log)
echo "${#logi[@]}"
printf '%s\n' "${logi[@]}"
3
logi/a.log
logi/b.log
logi/stary log.log

Gwiazdkę rozwija sam Bash, a każdy plik ląduje w osobnej przegródce, nawet ze spacją w nazwie.

A z pliku tekstowego? Tu wchodzi mapfile. Ma też drugą nazwę, readarray, to ta sama komenda. Mamy plik z serwerami:

web01
web02
db01
backup01
mapfile -t serwery < serwery.txt
echo "Wczytano: ${#serwery[@]}"
declare -p serwery
Wczytano: 4
declare -a serwery=([0]="web01" [1]="web02" [2]="db01" [3]="backup01")

Każda linijka pliku to jedna przegródka. Prosto jak składanie ubrań do szuflady, jeden sweter na przegródkę.

Po co -t? Bez niego każdy element ma na końcu znak nowej linii:

declare -a bez=([0]=$'web01\n' [1]=$'web02\n' ...)

I potem ping szuka serwera o nazwie „web01 plus Enter”. Nie znajdzie. -t to „obetnij końcówkę”. Używaj zawsze.

A z wyniku komendy? Przez podstawienie procesu < <( ), które znałeś z lekcji 39:

mapfile -t webowe < <(grep web serwery.txt)
echo "${#webowe[@]}"
2

Jest też sposób na podzielenie jednej linijki na kawałki, read -a:

IFS=: read -r -a pola <<< "root:x:0:0:root:/root:/bin/bash"
echo "${pola[0]} ${pola[5]} ${pola[-1]}"
root /root /bin/bash

IFS=: mówi, że separatorem jest dwukropek. Linijka z /etc/passwd rozpada się na siedem przegródek. Te trzy strzałki <<< to here-string, jeszcze o nich porozmawiamy za dwa lekcje.

[macOS] mapfile i readarray weszły w Bashu 4.0. Na systemowym Bashu 3.2 z Maca ich nie ma.

Wszystko razem: ping.sh

#!/bin/bash
# ping.sh – sprawdza listę serwerów z pliku
plik=${1:-serwery.txt}
mapfile -t serwery < "$plik"

echo "Serwerów do sprawdzenia: ${#serwery[@]}"
padniete=()
for s in "${serwery[@]}"; do
    if ping -c 1 -W 1 "$s" > /dev/null 2>&1; then
        echo "OK    $s"
    else
        echo "BRAK  $s"
        padniete+=("$s")
    fi
done

echo "Nie odpowiada: ${#padniete[@]}"
if (( ${#padniete[@]} > 0 )); then
    echo "Lista: ${padniete[*]}"
fi
./ping.sh lista.txt
Serwerów do sprawdzenia: 3
OK    localhost
OK    127.0.0.1
BRAK  web99.nieistnieje.invalid
Nie odpowiada: 1
Lista: web99.nieistnieje.invalid

Zobacz, co tu się dzieje. Pierwsza tablica przychodzi z pliku. Druga, padniete, startuje pusta, (), i rośnie w pętli. Na końcu liczymy, ile urósło. ping -c 1 wysyła jedno zapytanie, a -W 1 czeka najwyżej sekundę. [macOS] Na Macu ping ma inne opcje, tam -W liczy się w milisekundach, więc ten skrypt odpalaj na Linuksie.

Doszedł serwer? Dopisujesz linijkę do pliku. Skrypt zostaje, jak był. To jest cała idea.

Jak to działa pod spodem

Tablica w Bashu to nie jest blok pamięci z równymi przegródkami, jak w języku C. To lista par: numer i tekst. Dlatego może mieć dziury i dlatego unset niczego nie przesuwa. Bash po prostu wyrzuca jedną parę z listy.

Podpatrzysz to przez declare -p:

serwery[10]=nowy
declare -p serwery
declare -a serwery=([0]="web01" [2]="db01" [3]="backup01" [4]="mail 01" [10]="nowy")

Pięć elementów, a najwyższy numer to 10. Dlatego ${#serwery[@]} to nie to samo co „ostatni indeks plus jeden”.

I ważna rzecz: w Bashu wszystko jest tekstem. W tablicy nie ma liczb, są napisy, które wyglądają jak liczby. Bash zamienia je na liczby dopiero w (( )).

Ściąga flag

KomendaFlagaCo robi
a=(x y z)nawiasytworzy tablicę
${a[0]}[n]element numer n (od zera)
${a[-1]}ujemny indeksostatni element (Bash 4.2+)
"${a[@]}"@ w cudzysłowiewszystkie elementy, każdy osobno
"${a[*]}"* w cudzysłowiewszystkie elementy sklejone w jeden tekst
${#a[@]}#liczba elementów
${!a[@]}!lista zajętych indeksów
${a[@]:1:2}:od:ileplaster tablicy
a+=(x)+=( )dokłada element na koniec
unset 'a[1]'usuwa element (bez przesuwania!)
declare -p a-ppokazuje tablicę z numerami
mapfile -t a < plik-twczytuje plik, linijka = element, bez znaków nowej linii
mapfile -t a < <(cmd)< <( )wczytuje wynik komendy
read -r -a a-adzieli jedną linijkę na elementy według IFS

⚠ Typowy błąd

Wydaje się oczywiste: wynik komendy przez rurę prosto do mapfile.

cat serwery.txt | mapfile -t z_rury
echo "Z rury: ${#z_rury[@]}"
Z rury: 0

Zero. Żadnego błędu, tablica pusta. Dlaczego? Każdy kawałek rury działa w osobnym procesie, w podpowłoce. mapfile grzecznie wypełnił tablicę, tylko że w swoim procesie. Jak ten się skończył, tablica poszła razem z nim do kosza. To jak zostawienie zakupów w sąsiednim sklepie.

ShellCheck to wyłapie:

SC2030 (info): Modification of z_rury is local (to subshell caused by pipeline).

Naprawa: zamiast rury podstawienie procesu.

mapfile -t serwery < <(cat serwery.txt)

A najlepiej, gdy to plik, po prostu mapfile -t serwery < serwery.txt.

Drugi klasyk, krótko: brak [@].

serwery=(web01 web02 db01)
echo "$serwery"
echo "${#serwery}"
web01
5

Sama nazwa tablicy to tylko pierwsza przegródka. A ${#serwery} to nie liczba serwerów, tylko długość słowa web01. Pięć liter. Raport powie, że masz pięć serwerów, i będzie się mylił do pierwszego audytu.

✎ Zadanie

  1. Wczytaj mapfile-em do tablicy nazwy wszystkich kont z /etc/passwd (pierwsza kolumna, podpowiedź: cut -d: -f1). Wypisz, ile jest kont, które jest pierwsze, a które ostatnie.
  2. W pętli wypisz tylko konta, których nazwa zaczyna się na „s”, razem z numerem porządkowym liczonym od 1.

Rozwiązanie znajdziesz pod spodem.

Pokaż rozwiązanie

Zadanie: wczytać konta z /etc/passwd do tablicy, podać ich liczbę, pierwsze i ostatnie, a potem wypisać z numerem te na „s”.

#!/bin/bash
mapfile -t konta < <(cut -d: -f1 /etc/passwd)
echo "Kont: ${#konta[@]}"
echo "Pierwsze: ${konta[0]}"
echo "Ostatnie: ${konta[-1]}"
for i in "${!konta[@]}"; do
    if [[ ${konta[i]} == s* ]]; then
        echo "$((i + 1)). ${konta[i]}"
    fi
done
Kont: 34
Pierwsze: root
Ostatnie: eryk
4. sys
5. sync
19. systemd-network
20. systemd-timesync
23. systemd-resolve
26. syslog
33. sshd
...

U mnie, na świeżym Ubuntu Server, wyszły 34 konta. U Ciebie liczba i numery mogą być inne, bo prawie każdy zainstalowany pakiet usługi dokłada własne konto. Pierwsze zawsze będzie root, bo on wszedł do budynku pierwszy i nikt go nie przesunął.

Dwie rzeczy. cut -d: -f1 bierze pierwszą kolumnę, a < <( ) wlewa wynik do mapfile bez rury, więc tablica nie ginie w podpowłoce. A numer porządkowy to i + 1, bo ludzie liczą od jedynki, nawet jeśli Bash nie. [na ekranie: „konto numer 0” przekreślone, „konto numer 1”]

Co dalej

Tablica numeruje przegródki: zero, jeden, dwa. A co, jeśli chcesz je podpisać nazwami? „web01 → 10.0.0.11”, „db01 → 10.0.0.21”. W następnej lekcji szatnia z nazwiskami, czyli słowniki. I tu użytkownicy Maca muszą już naprawdę zainstalować nowego Basha.