Ujjgyakorlat: Üres sor ellenőrzése kizárólag shell belső parancsokkal

 ( Zahy | 2016. április 23., szombat - 10:54 )

Én napi feladataimhoz még ma is gyakran közönséges shell scriptet használok. Ilyenkor rendszeresen szükséges ellenőrizni, hogy valamilyen szöveges adatban van-e üres sor. Erre egy egyszerű grep pont megfelelő. Rákeresünk az adatban az üres sort jelentő regexp-re ( ^$ ), és a státusz kód jelzi.

Természetesen ezzel is van baj: létezik még a világban olyan rendszer, amelyiknél a grep nem ismeri a -q opciót, így ha hordozható kódot kell írni, akkor a triviális megoldást bonyolítani kell, és nem a grep-pel, hanem a shell-lel kell eldobatni az esetlegesen megjelenő fölösleges kimenetet. Azaz nem így:

if echo "$VAR" | grep -q '^$' ; then
# van üres sor

hanem így:

if echo "$VAR" | grep '^$' > /dev/null ; then
# van üres sor

A napokban viszont az egyik megoldandó probléma kapcsán elégedetlen voltam a kapott teljesítménnyel, így felmerült bennem, hogy ha a tízezres nagyságrendű fölösleges processz létrehozása helyett (merthogy egy ekkora számban lefutó ciklusban történt mindez) a shell belső parancsaival csinálnám az üres sor ellenőrzését, nyilván gyorsabb lenne a dolog. Viszonylag hamar beugrott, hogy melyek azok a shell konstrukciók, amelyek alkalmasak lehetnek, de valahogy nem nagyon sikerült a működő megvalósítást megtalálni. (Mindezt nehezítette az, hogy mint általában ilyen agymenésekkor mindig, a feladatot úgy szerettem volna megoldani, hogy ne használjak ki valami olyasmit, amit kizárólag a bash ismer - nálam az alap, hogy lehetőség szerint a kereskedelmi UNIX-okban kicsit sűrűbben előforduló Korn-shellben is működő módszert találjak.) Tegnap éjszaka aztán kigyököltem a megoldásokat. A könnyű tesztelés és olvashatóság érdekében shell-függvényt csináltam. Annak neve lehet valami értelmes, kódja lehet a francban a valódi futáshoz képest (tehát nem zavarja az olvashatóságot) - sőt az autoloading function nevű játékkal totál el is lehet rejteni :-) ; szóval az úgy jó.

a) Az elsőként próbált eszköz a millió éve létező case parancs, amely a többszörös elágaztatáshoz mintaillesztést végez. Ezt ugyan kezdők könnyen elrontják, mert nem a grep-nél is működő regexp-et, hanem a fájlnevek megadásánál is használható un. globbing-ot használ, de az adott feladat szempontjából ez nem sok vizet zavar - kicsit másként kell írni a mintát. (Ráadásul case aztán van minden shellben.) A triviálisnak (bár kellemetlenül hosszúnak) tűnő kód így nézett ki:

isa() {
case "$1" in
"*

*" ) # ezt a shell egy db, üres sort is tartalmazó sztringnek tekinti, aminek az eleje és vége bármi lehet
return 0
;;
*) # minden egyéb
return 1
;;
esac
}

(OK, itt most a fv neve nem túl logikus, sőt a case-beli második minta is elhagyható.) A lényeg az első ágban levő glob-minta: "* ENTER ENTER *" . Ha elhagynám az idézőjeleket, akkor a shell anyázna, és első ránézésre logikus is, hogy kell az idézőjel (vagy aposztróf, mindegy), hiszen az ENTER-ek szerves részét képezik a sztringnek. Sajnos akárhogy csűrtem-csavartam, nem akaródzott sikerülni.

b) Másodikként a test parancs ugrott be. Ezt a parancsot modern shellekben három különböző módon is lehet írni: test feltétel, [ feltétel ] , és az újabb shellekben van egy [[ feltétel ]] formája is. Ez utóbbi a lényeg, ekkor ugyanis a sztring egyenlőség vizsgálatánál (régen a = b, mostanában szeretik a logikusabbnak tűnő a == b formát használni) nagyon trükkösen az egyenlőségjel jobb oldalán nem sztringet, hanem mintát (természetesen a shell-ben megszokott módon: globbingot) vizsgál. Azaz ha ezt írom:

[ "alma" = "a*" ]

akkor két sztringet hasonlít, míg ha azt írom, hogy

[[ "alma" = "a*" ]]

akkor a jobb oldal minta. Ugye logikus? (OK, itt van egy kis csalás. * ) Azaz fentiek alapján logikusnak tűnik, hogy az üres sort vizsgáló kód nézzen ki így:

isa2() {
if [[ "$1" = "*

*" ]] ; then
return 0
else
return 1
fi
}

(Persze az else ág kidobásával ezt is lehetne rövidíteni.) Heuréka. Megvan. Kivéve, hogy nem működik. Ez sem. Nem működik a pdksh-val, nem működik a ksh93-mal, nem működik a bash-sal.
Ugye Murphy egyik törvénye szerint, ha nem működik, vegyünk nagyobb kalapácsot olvassuk el a kézikönyvet. És ha nehezen is, de megtaláltam a kritikus félmondatot (amúgy mind a három fent említett shell doksijában ott van értő olvasás esetén). A case illetve test parancsoknál a minta (pattern) megadásakor, amennyiben akár csak egyetlen karaktert is takarunk a shell elől, akkor onnantól azt a shell nem glob-nak, hanem fix sztringnek tekinti. (Márpedig azt a nyűves ENTER-t takarni kell, az már mindegy, hogy idézőjel, aposztróf, vagy backslash segítségével.) Ekkor azért kicsit akadozott a levegővételem, hiszen korábban már kipróbáltam idézőjel nélkül (a case-nél), most kipróbáltam a test-nél is, pont ugyanúgy nem szerette a több sorba tördelt mintát. Aztán tovább értelmezgetve a manualt, egyszer végre csak kiverte a szememet a következő kitétel (még mindig a case és test patternek kapcsán): a mintákon helyettesítések történnek, de szavakra tördelés nem. Azaz a ~x, $x, ${x}, $( y ), $(( z )) (bashban ez utóbbi írható a $[ z ] alakban is, de az már nem hordozható írásmód) formákat a shell feldolgozza, de ha az eredményben szóköz, tabulátor vagy soremelés van, attól azt még nem kezdi feldrabolni, változatlanul egy egységnek tekinti. (Ráadásul a test-nél szerepel, hogy nem kell az a nyomorult idézőjel, sőt!) Node ha nincs szótördelés, akkor nem kell az idézőjel; viszont ha a helyettesítés megtörténik, akkor egyszerűen rakjuk bele a mintát egy változóba, és legyen ott egy változóhivatkozás.

Azaz a megoldás a következő: először is legyen egy változónk, ami tartalmazza a kívánt mintát

NLGLOB='*

*'

majd pedig ezt a változót használva vizsgáljuk meg az illeszkedést
a) a minden shellben működő case paranccsal

isa() {
case "$1" in
$NLGLOB )
return 0
;;
esac
return 1
}

b) vagy a modernebb shellekben működő [[ paranccsal

isa() {
if [[ $1 = $NLGLOB ]] ; then
return 0
fi
return 1
}

Ez utóbbi persze elképesztően bőbeszédű írásmód, simán ki lehet használni, hogy egy shell-függvény visszatérési értéke az utolsó végrehajtott parancs státuszkódja, ily módon az egész bonyolult konstrukció sokkal egyszerűbben is írható:

isa() {
[[ $1 = $NLGLOB ]]
}

Ez viszont eléggé sugallja, hogy ehhez az egyetlen művelethez már rohadtul fölösleges a shell-függvény.

Azaz végeredményként az jött ki, hogy egy db plusz változó bevezetésével elérhetjük, hogy a bonyolult és drága grep/awk/akármi helyett egy olcsó, belső parancs intézze ezt az ellenőrzést.

QED

(A probléma szempontjából teljesen lényegtelen, csak érdekességképpen: a kiinduló feladatnál a lassúságot szinte 100%-ra merem állítani, hogy nem (csak) a tízezres nagyságrendű grep (és processzkreálás, meg egyebek) okozzák, hanem inkább az, hogy ugyanebben a ciklusban ugyanilyen nagyságrendű ldapsearch is fut - márpedig a lekérdezést nem lehet megspórolni, úgyhogy ha lesz egy kis időm, átstrukturálom a kódot, mert nagy eséllyel ha csak egy db ldapsearch-öt indítanék, és annak az egynek adnám oda szépen sorban a lekédezendő adatokat, akkor nem csak a sok processzlétrehozást úsznám meg, hanem a minden csatlakozáskori autentikációt is, ami szinte biztos, hogy szintén erőteljesen belejátszik a lassúságba. Az megint más kérdés, hogy ha csak egyetlen ldapsearch lesz, akkor az adatfeldolgozásban már pont nem fog szerepet játszani az, hogy a válaszban van-e üres sor vagy nincs. Azaz ha az elején gyanítottam volna, hogy mennyire nem leszek boldog az eredménytől, valószínűleg ez az üres soros probléma sem merült volna fel bennem. (Ja és tudom, hogy egyes emberek szerint pont nem shell-scripttel kellene ilyen feladatokat megoldani, hanem ..., de.)

(*) Végül a fent említett turpisság. Helyesen így kell írni, és akkor már igaz is lesz. Ha ezt írom:

[ "alma" = "a*" ]

akkor két sztringet hasonlít, azaz az eredmény FALSE, míg ha azt írom, hogy

[[ alma = a* ]]

akkor a jobb oldal minta, így az eredmény TRUE. Azaz az első forma hamis, a második forma igaz státuszkóddal tér vissza. (És az elsőben kell az idézőjel, a másodikban meg tilos.)

Szerk:
Mint az egyik hozzászólásból kiderült, pár dolgot elrontottam. A kevésbé érdekes, hogy az elején, ahol azt taglalom, hogy eredetileg hogy csináltam, ott a

printf '%s' "$VAR" | grep '^$' > /dev/null

lenne a helyes forma (az eredeti echo esetén kapunk egy fölösleges soremelést, de a különböző shell-el echo megvalósításai eltérnek abban, hogy mi módon kell elnyomni ennek az ENTER-nek a kiírását).

A fontosabb viszont az, hogy mivel a globbing megvalósításban egyértelműen 2 db ENTER szerepel annak jelzésére, hogy van-e ott üres sor, ez a teszt egyértelműen elbukik a kicsit is trükkös, "eleve üres sorral kezdődik az adat" esetében. Ez nyilván megengedhetetlen. A baj, hogy globbing esetén (ellentétben ugye a regexp-pel) nincs egyszerű eszköz ennek megadására - azaz jobb híjján csak úgy tudom kikerülni a problémát, ha nem egy, hanem két összehasonlítást használok (ettől persze már végképp elveszik a dolog szépsége). Azaz a jelenlegi megoldás: két változó kell a két glob-minta megadására, és ezeket kell aztán hasonlítani, persze mind a case, mind a test esetén (ha valaki tesztelni / használni akarná, nyilván csak az egyik isa-fv kell) :

NLBEG='
*' # globbing esetén mindig a szöveg elején illeszkedik, azaz ez azt jelzi, hogy az első karakter sormelés
NLGLOB='*

*' # ez ugyanaz, mint eddig
isa() { # megvalósítás case-sel
case "$1" in
$NLBEG | $NLGLOB ) return 0 ;;
esac
return 1
}

isa() { megvalósítás test-tel
[[ $1 = $NLBEG || $1 = $NLGLOB ]]
}

Remélem most már nincs benne hiba :-)

Hozzászólás megjelenítési lehetőségek

A választott hozzászólás megjelenítési mód a „Beállítás” gombbal rögzíthető.

Köszi, hogy leírtad ilyen szépen kifejtve, hasznosnak találtam, sikerült hasonlókba belefutnom mostanában :)

Tetszett, hasznos, én is végigolvastam. :)


tr '[:lower:]' '[:upper:]' <<<locsemege
LOCSEMEGE

$ NLGLOB='*
> 
> *'
$ declare -f isa compare
isa () 
{ 
    case "$1" in 
        $NLGLOB)
            return 0
        ;;
    esac;
    return 1
}
compare () 
{ 
    echo "$1" | grep '^$' > /dev/null;
    grep_ret=$?;
    isa "$1";
    isa_ret=$?;
    if [ $grep_ret != $isa_ret ]; then
        echo "behavior change: grep: $grep_ret isa: $isa_ret";
    fi
}
$ compare 'valami
> valami'
$ compare 'valami
> 
> valami'
$ compare ''
behavior change: grep: 0 isa: 1
$ compare '
> valami'
behavior change: grep: 0 isa: 1
$ compare 'valami
> '
behavior change: grep: 0 isa: 1

A nem POSIX másik megoldást nem próbáltam, de mivel a pattern ott is ugyanaz...

Hm. Az én leírásomban a grep kapcsán van egy hiba, de ezt csak most látom a te kódodban. Ez a helyes forma:

echo -n "$VAR" | grep '^$' > /dev/null

Ezzel már csak a '
valami' paraméterre ad más választ. Egyelőre még nem világos, hogy miért.

Az echo -n se POSIX.

De első körben talán azt kellene eldönteni, hogy mi is lenne az elvárt viselkedés.
Szerintem a 'valami
' és '
valami' tartalmaznak üres sort, ezért az eredeti echo |grep-es változatnak volt igaza. Abban viszont nem vagyok biztos, hogy a '' üres string esetében minek kellene történnie.

Menjünk szépen sorban.

először: Megnéztem az opengroup-on az echo-t - valóban igazad van, nincs opcióról szó, ellenben szerepelnek az AT&T-féle echoban megszokott C-szerű szekvenciák. Azaz ha bárhol emlegettem volna, hogy POSIX akarok lenni, akkor a helyes forma ez lett volna:

echo "$VAR"'\c' | grep ...

Kész szerencse, hogy én nem nagyon beszéltem POSIX-ról. Igazából azért -n-eztem, mert emlékeim szerint nekem töbször volt ezt ismerő echo a kezemben, mint a másik. De azt hittem, egyértelmű, hogy az eredetileg leírt példámat az első hozzászólásod után utólag azért tartottam hibásnak, mert az echo hozzárak egy (az adott körülmények között) fölösleges soremelést, azaz csak látszólag azonos dolgokat vetettél össze, míg a valóságban nem. És pontosan ezért írtam azt is, hogy nem értem, hogy amelyik ezzel a módosítással is különböző, az miért is különböző. Viszont íme lopás az opengroup-os oldalról, egy valódi POSIX-megfelelő kód (más kérdés, hogy hány ember írja így, és nem echo-val:

printf '%s' "$VAR" | grep ...

Ennek nagy előnye, hogy abszolút hordozható (khm) módon pontosan csak a VAR változó tartalma kerül át a grep-nek.

másodszor: Abban egyetértünk, hogy a 'valami
' is, és a '
valami' is tartalmaz üres sort, és teljesen egyértelmű (nekem), hogy a '' viszont nem tartalmaz üres sort. Sajnos ez már erősen filozófiai probléma, hogy a sor a definíciónk szerint attól sor, hogy van a végén soremelés vagy elegendő a fájl végének ott lennie. Szerintem pontosan ezért van a különbség a grep-es forma, és a shell globos forma között - hisz a glob mintájában én deklaráltan 2 db ENTER-t írtam elő - márpedig ez sem a "legelső sor üres", sem a "legutolsó sor üres, de nincs mögötte soremelés" esetben sem teljesül. Azaz előállt egy gyakorlati probléma - van olyan kód, amelyik hibásan kezeli fenti helyzetek valamelyikét.

"Azaz ha bárhol emlegettem volna, hogy POSIX akarok lenni"

Kereskedelmi UNIX-ok shelljeit említetted, a korn mellett akad több, ami ugyanúgy nem szereti az echo -n szerű "bsdizmusokat", mint a "bashizmusokat".

"egy valódi POSIX-megfelelő kód (más kérdés, hogy hány ember írja így, és nem echo-val:

printf '%s' "$VAR" | grep ..."

Jelen! A projektekben, amikben szórakozásból részt veszek, ez az elvárt forma, ha egy változó tartalmát változatlanul kell kiírni.

"És pontosan ezért írtam azt is, hogy nem értem, hogy amelyik ezzel a módosítással is különböző, az miért is különböző."

Azt én se értem azóta se :) Biztos vagyok benne, hogy valami erősen facepalmos magyarázata lesz ;)

Nem facepalm, hanem teljesen logikus, módosítottam az eredeti szöveget.

Nem lenne jobb átállnod másik script nyelvre bizonyos feladatokhoz? Nem kötekedésnek szánom, csak kíváncsi vagyok.

De nyilván, de a megszokás nagy úr.

uhmm, ize, ha mar egy kicsit is bonyolitod a dolgot, akkor inkabb perl hasznalj shell helyett.
nincs hatranyra, nade mindenhol ugyanugy mukodik es _sokkal_ tobb mindenre hasznalhato.

a shell scripteket erdemes meghagyni a trivialis, apro feladatokra, ahol ennyit kell tokolni a megoldas keresesevel, akkor az univerzalisabb eszkozokre áttérés már belefér
--
Live free, or I f'ing kill you.

Valószínűleg át kellene, de míg fenti shelleket használom az interaktív környezetben, addig én még nem láttam olyan perverz valakit, akinek /usr/bin/perl lenne a shellje - én se ilyen vagyok, de valószínűleg az kéne ahhoz, hogy azok a konstrukciók, amik kapásból megvannak shellben, azokat kapásból perlben is kirázzam a kisujjamból. (Ha nem lett volna világos, fenti agymenés egy egyszeri probléma megoldása *után* merült fel, és kb keresztrejtvényként - azaz agytorna - működött.)

ok
de ez azert volt egy bonyolult keresztrejtveny, mert a shell eszkozkezlete brutalisan korlatozott

perl-t shell-nek hasznalni, es még csak Larry Wall által irt tutorialban láttam, soha sem probaltam ki, de elvileg nem lehetetlen :D

--
Live free, or I f'ing kill you.

A Perl-shellel ötlettel csak annyi a probléma, hogy -- valamilyen -- shell a legtöbb rendszeren része az alaptelepítésnek, viszont léteznek olyanok, amelyeknél a Perl pl. nem.

(Ezen felül a mai átlagos rendszergazdák már nem állnak neki Perl-t tanulni...)

G.
============================================
"Share what you know. Learn what you don't."

Az a probléma, hogy meg kell küzdeni a "semmi" idézőjelekkel történő megvédésével, no meg a regexp feldolgozásával.

Az első verzió a test, ami external.
A második pedig a builtin test a [[...]].
Ez utóbbinál nem kell processzt indítani.
Az empty mint regexp is megkerülhető egy egyszerű trükkel: [[ A$VAR = A ]] && echo empty
Se processz indítás, se regexp, se idézőjel - nincs mit megvédeni - ,a röpködő gobokról nem is beszélve!

Szabály (most találtam ki :))): Ha eszedbe jut regexp-et használni, akkor legyen sok adat és azt egy paranccsal kell előfeldolgozni. (sed, *grep, awk)

"Az első verzió a test, ami external."

...is lehet. De nem mindig az. Ahogyan a coreutils-beli [ --help mondja:

MEGJEGYZÉS: a parancsértelmező rendelkezhet a(z) test és/vagy [ egy saját
változatával, amely általában helyettesíti az itt leírt változatot. Az az által
támogatott kapcsolókkal kapcsolatos részletekért forduljon a parancsértelmező
dokumentációjához.

Így aztán:

gabor@dome:~$ type -a test
test egy beépített parancs
test egy /usr/bin/test

(ez Ubuntun, Bash esetén történik)
___
Arany János: Grammatika versben

Igazad van.
Más nem tűnt fel?