Miért nem módosulnak a bash ciklusban módosított változók a cikluson kívül?

botond küldte be 2018. 07. 03., k – 21:28 időpontban

A minap belebotlottam egy apró érdekességbe shell script készítése közben. Elsőre furcsállottam a dolgot, de aztán kis utánajárással sikerült megoldani a problémát, és végül már egyértelművé is vált a dolog. Gondoltam megosztom, hátha másnak is hasznára válik, és meg tud spórolni ezzel egy kis keresgélést.

Először működő példának vegyünk egy egyszerű while ciklust, ami előtt beállítunk egy számlálót 0-ra, majd a ciklusban léptetjük ezt a számlálót és kiiratjuk az értéket, végül a ciklus után is kiírjuk a változó értékét.

1
2
3
4
5
6
7
#!/bin/bash
c=0                                 # Változó kezdőérték
while [[ $c < 5 ]] ; do             # ciklus indul
    (( c++ ))                       # számláló növelése
    echo "$c. futás"
done 
echo "Számláló értéke: $c"         # változó kiiratása a cikluson kívül.

A példa pontosan azt is csinálja, amire számítunk. A kimenet pedig:

1. futás
2. futás
3. futás
4. futás
5. futás
Számláló értéke: 5

Tehát itt nincs semmi furcsaság, úgy működik, ahogyan kell. De mi van akkor, ha például egy könyvtárban lévő fájlokon szeretnénk végighaladni, amikkel műveleteket szeretnénk végezni, és utólag feldolgozni vagy megjeleníteni a ciklusban használt és módosított változókat

 

 

A probléma

Adott tehát egy könyvtár, amiben most a tesztünk ideje alatt van négy darab fájl. Ezeken szeretnénk végighaladni, és különböző műveleteket végezni, majd a ciklus végeztével megjeleníteni a ciklusban összegyűjtött adatokat.

Az egyszerűség kedvéért most itt is csak növelünk egy számlálót a ciklusban, aminek lépésenként kiírjuk az értékét, utána a talált fájl nevét. Végül pedig a cikluson kívül ugyanígy kiiratjuk a számláló értékét:

1
2
3
4
5
6
7
8
#!/bin/bash
c=0                                 # változó kezdőérték
find . -type f |                    # fájlok keresése a könyvtárban és csővezetéken a ciklusba továbbítása
while read fn; do                   # ciklus indul
    (( c++ ))                       # számláló növelése
    echo "$c. futás: $fn"          # a számláló és a fájlnév kiiratása
done
echo "Számláló értéke: $c"         # változó kiiratása a cikluson kívül.

Itt a kimenetünk már érdekesebb, mivel a ciklus után a kiiratott számláló értéke 0:

1. futás: ./probafile_3
2. futás: ./valtozok_elerese_ciklusokban.sh
3. futás: ./probafile_2
4. futás: ./probafile_1
Számláló értéke: 0

A ciklus rendesen tette a dolgát, csak az utána lévő számláló kiíratásnál jött elő a furcsaság.

 

Megoldás

Elsőre amikor belebotlik az ember, okozhat egy kis fejtörést ez a jelenség, de miután megvan a megoldás, már teljesen egyértelmű a dolog.

Ennek az érdekessége, abban rejlik, hogy a bash a csővezetékekben a parancsokat külön szálakon futtatja, azaz subshell-ekben. Idézet a bash man oldalából: "Each command in a pipeline is executed as a separate process (i.e., in a subshell).". A subshell-ekben lévő változók pedig megsemmisülnek, miután lefutottak benne a parancsok. Alapból nem lenne gond a subshell-el, de jelen esetben ez rossz helyzetben van, mivel csak a while ciklusunkra terjed ki, ahonnan később ki szeretnénk nyerni a változóink értékét. Így tehát a ciklus végeztével elvesznek az abban használt változóink, mivel azután a subshell megsemmisül.

Nem gondol feltétlenül ilyesmire az ember ciklusok írása közben, amíg egyszer csak szüksége nem lesz a benne lévő változók értékére a ciklus után is. De így már teljesen más a dolog fekvése,  a megoldás innentől már egyszerű: Szervezzük külön blokkba a ciklust és az utána lévő részt, ahol szükség van a belső változók értékére:

1
2
3
4
5
6
7
8
9
#!/bin/bash
c=0                                 # változó kezdőérték
find . -type f | {                  # fájlok keresése a könyvtárban és csővezetéken a ciklusba továbbítása
    while read fn; do               # ciklus indul
        (( c++ ))                   # számláló növelése
        echo "$c. futás: $fn"      # a számláló és a fájlnév kiiratása
    done
    echo "Számláló értéke: $c"     # változó kiiratása a cikluson kívül.
}

Itt tehát annyit változtattunk, hogy a csővezeték utáni részt külön kódblokkba {} helyeztük, amiben a számláló kiiratása is benne van. Így az egész egy parancsnak minősül a csővezeték – és így a subshell – szempontjából. A kimenetünk pedig a következő:

1. futás: ./probafile_3
2. futás: ./valtozok_elerese_ciklusokban.sh
3. futás: ./probafile_2
4. futás: ./probafile_1
Számláló értéke: 4

Ez így működik is szépen, a ciklus után is hozzáférünk a benne létrehozott vagy módosított változókhoz, amíg még ugyanebben a kódblokkban vagyunk.

De mi van ha hosszú vagy összetett a scriptünk, amiben például jóval a ciklus után lenne szükség a változókra, így nem szeretnénk a további részeket is beágyazni ebbe a kódblokkba? Erre is van megoldás.

Alternatív megoldás

Ha nem szeretnénk felborítani a kódunk struktúráját és logikáját, akkor ezt a feladatot megoldhatjuk másképp is, mégpedig a csővezeték használatának elkerülésével. Erre lesz segítségünkre a here string-es kivitelezés, aminek a lényege, hogy nem használunk csővezetéket, így ugyanebben a futási szálban adhatjuk át az elemeket a ciklusnak:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
c=0                                 # változó kezdőérték
while read fn; do                   # ciklus indul
    (( c++ ))                       # számláló növelése
    echo "$c. futás: $fn"          # a számláló és a fájlnév kiiratása
done <<< "$(find . -type f)"        # ciklus elemek átadása a herestring-el
 
# további kódrészek ugyanebben a kódblokkban
# ...
 
echo "Számláló értéke: $c"         # változó kiiratása jóval a ciklus után.

Bár most a fájlkeresést oldottuk meg subshell-es lekérdezéssel, itt mégsem zavar be, mivel maga a ciklus és az azt követő kódrészek ugyanabban a shellben futnak, így a változók később is elérhetők maradnak a script további részében. A fájlok listájának lekérésénél pedig nem számít, hogy nem ugyanazon a szálon lett lefuttatva a find parancs.

A kimenet pedig pontosan megegyezik az előzővel:

1. futás: ./probafile_3
2. futás: ./valtozok_elerese_ciklusokban.sh
3. futás: ./probafile_2
4. futás: ./probafile_1
Számláló értéke: 4

Tehát láthatjuk, hogyan kerülhetjük el a csővezeték használatát, amikor annak egy nem kívánatos mellékhatásába ütközünk, ami miatt feleslegesen bonyolítani kellene a kódunkat.

 

Itt még érdemes megjegyezni, hogy a ciklust követő herestring megadásánál idézőjelek közé tettük a subshellből kapott kimenetet (amit egyébként akár a ciklus előtt is betehettük volna egy változóba), mert az idézőjelek között nem vesznek el a sortörések a kapott fájlnevek közül. Idézőjelek nélkül egy egysoros kimenetet kapnánk, amit egy lépésben dolgozna fel a ciklus, tehát nem a kívánt eredmény jönne létre.

 

Így utólag már teljesen logikus a dolog, és ezek után legközelebb biztosan eszébe jut az embernek, hogy hogyan tudja egyből elkerülni a csővezetékek okozta kellemetlenségeket.