sobota 7. dubna 2012

Sedy, lehy, regulární výrazy 2 - restart

Už jsem tu dal k dobru pár tzv. onelinerů využívajících sed i awk pro zpracování textu, ale vlastně jsem ze začátku tak úplně nevěděl, o čem to přesně je. Skládání komplikovanějších skriptů z jednotlivých bloků příkazů mi ze začátku lehce motalo hlavu a zjistil jsem, že nejsem sám. Tak v rámci sebevzdělávání zkusím sepsat pár poznámek o programování awk a sed, kterýmžto konáním pro sebe bezpochyby objevím mnoho nového a zároveň tím nejspíš nikomu moc nepomůžu, protože se stačí pořádně podívat do dokumentace a je jasno. Nicméně to proložím nějakým tím příkladem, kde se pokusím řešit jeden úkol oběma nástroji ...

Obecně
Když jsem viděl poprvé skripty v sedu, raději jsem si lehnul.. A usnul. Když jsem se probudil, začal jsem tu nádheru zkoumat blíž. Vypadalo to zajímavě, bylo tomu rozumět asi jako děrným štítkům. Sed je fajnová záležitost, příkazy má jednopísmenné a pokud nějaký komplikovanější jednořádkový skript ukážete nezasvěcenému s tím, že tomu rozumíte a dodáte, že sed je  turingovsky úplný programovací nástroj, určitě bude ohromen. Awk už takový hardcore není, syntaxe není nepodobná C a tudíž se tam dají rozpoznat i nějaká ta klíčová slova, což skripty lehce zpřehledňuje.

Sed i awk mají několik implementací, já budu používat jen ty, které jsou jako výchozí v mém současném Ubuntu. U awk se tak můžete setkat i s variantami gawk, nawk, mawk, přičemž v Ubuntu je jako výchozí mawk. Příkaz awk je linkem na něj přes správce alternativ a běžně se používá jen jako prosté awk. Jenže instalční balíky některých aplikací mohou mít v závislostech nejčastěji gawk, který je novější a má rozšířenou funkčnost. Zároveň přesměrují awk z mawk na gawk a tak jsem si dlouho vůbec neuvědomil, že gawk není v Ubuntu výchozí, tak pozor na to!

Awk není přímo klasický stream editor jako sed, je to jazyk pro formátování textu a nabízí podstatně víc možností, ale často se používá velmi podobným způsobem. Princip stream editoru spočívá v tom, že data ze standardního vstupu se automaticky směrují na standardní výstup s tím, že příkazy stream editoru je modifikují podle daných kritérií. Pokud spustíte sed bez příkazů (sed '' text_file), na jeho výstupu bude přesně to samé, co na vstupu. Awk naproti tomu vypíše pouze to, co mu přikážete, takže stejného (tedy žádného) efektu docílíte pomocí awk '{print}' text_file. Nicméně i sed lze přepínačem -n přepnout do režimu 'tiskni, až ti řeknu'.

Základní věc, kterou je třeba si uvědomit, je fakt, že proudový editor zpracovává proud. Nikoliv elektrický, ale textový. Můžete si to představit jako rouru, do které sypete po řádcích text, který pak protéká jednotlivými příkazovými bloky vašeho skriptu a na konci té roury zase vypadne ven. Do toho jestli a co vypadne, kecá právě váš skript, který defakto běží v cyklu, jehož počet opakování je v základu daný počtem řádků vstupního souboru. Ten textový řádek je vždy přítomen a tak příkazy pro jeho zpracování nemusí dostat žádný argument, ten je prostě daný. V sedu je to jednoznačné, parametr potřebuje jen pár příkazů, třeba pro podmíněný skok, nebo zápis do souboru. Napíšete v awk 'print', nebo v sedu 'p' a vytiskne se aktuální "řádek" v aktuálním stavu. Pokud nějaký příkaz řádek změní, dostane další příkaz ke zpracování již tento změněný.

Awk
V awk je syntaxe jednotlivých bloků programu výraz{příkaz;y}, nebo jen {příkaz;y}. Příkazy, i celé bloky se na řádku oddělují středníkem, nebo novým řádkem. Výrazem může být regulární výraz, který se uzavírá mezi lomítka (/regexp/), podle nějž se hledá na aktuálním řádku odpovídající řetězec a oproti sedu můžete v awk testovat i složitější výrazy, klasické proměnné (které sed neumí vůbec) a speciální proměnné awk jako například BEGIN a END pro první a poslední řádek textového souboru. Každý řádek se načítá do proměnné $0 a podle nastaveného separátoru se automaticky rozdělí na pole, která jsou v proměnných $1$NF, přičemž $NF je proměnná držící počet polí na aktuálním řádku, jehož pořadové číslo je vždy v proměnné $NR. Oddělovač polí je daný proměnnou $FS, která ve výchozím stavu obsahuje mezeru, která ve skutečnosti odpovídá regulárnímu výrazu '[\t| ]+', tedy libovolné kombinaci mezer a tabulátorů. Print konkrétního pole/polí v řádku je jednou z nejpoužívanějších schopností awk, nemusíte řešit počet mezer mezi nimi, napíšete awk '{print $1":"$3}' text_file a vytisknou se všechna první a třetí pole oddělená dvojtečkou. V příkazu print můžete pro oddělení jednotlivých proměnných používat čárku, která automaticky vloží oddělovač daný proměnnou $OFS, což je ve výchozím stavu mezera.

Sed
GNU sed nediktuje uzavírání bloků příkazů do složených závorek, ale je třeba je použít pro zanořování podmíněných bloků - /výraz1/{příkaz;/výraz2/{příkaz;y}}. Pokud by byl příkaz podmíněný výrazem2 jen jeden, do závorek se dávat nemusí. Příkazy se mohou oddělovat středníkem, nebo novým řádkem. Připomínám, že píšu o GNU sed, jiné implementace vyžadují rozdělovat jednotlivé bloky skriptu na řádku do samostatných celků uvozených přepínačem -e, nebere středníky a složitější vyrazy se tak píší špatně. Sed neumí proměnné, ale můžete používat buffer (příklad použití dále).

Příklad úvodní
Nejčastěji je sed používán k substitucím, čili nahrazování řeťezců řetězci jinými. Když napíšete:

sed 's/a/OOO/g ; s/OOO/a/g' text_file
v non GNU sed by to bylo
sed -e 's/a/OOO/g' -e 's/OOO/a/g' text_file
a v awk
awk '{gsub("a","OOO"); gsub("OOO","a"); print}' text_file

tak se vlastně vůbec nic nestane. Pokud se tedy v původním textu nebude vyskytovat řetězec 'OOO'. Nejdřív se všechna 'a' v řádku přepíší na 'OOO' a hned na to se přepíší zase zpátky na 'a'. Takto je zpracován každý řádek souboru text_file.

Příkazy mohou být prováděny podmíněně. Většinou je na řádku hledán nějaký výraz, na jehož základě se provede akce.

sed '/Klára/ s/Karel/Emil/g; s/Klára/Gertruda/g' text_file

awk '/Klára/{gsub("Karel","Emil")}; {gsub("Klára","Gertruda")}' text_file

Tady první substituce je podmíněná výskytem Kláry v právě zpracovávaném řádku a pokud je tam s Klárou Karel, bude nemilosrdně předělán na Emila. Jenže jakmile se zbrusu nový Emil setká s Klárou, změní se mu Klára na Gertrudu v druhém příkaze. A protože tento příkaz podmínku nemá, týká se proměna všech Klár v celém textu. Což je opravdu hrozná představa ;) Mimochodem, kdyby tam nebyla ta 'g' (global) proběhla by výměna jen pro první výskyt Karla, resp. Kláry na řádku.

Jednořádkové skripty sedu i awk se většinou dávají do jednoduchých uvozovek, aby se zabránilo shellu interpretovat speciální znaky, jako hvězdičky, vykřičníky apod. Pokud ale budete potřebovat vložit do skriptu proměnnou shellu, je nutné použít uvozovky dvojité a případně escapovat, nebo blok v jednoduchých uvozovek přerušit, aby proměnná shellu byla mimo.

Příklad příkladný

Dostal jsem se třeba k zadání, které znělo:

Potreboval bych ze souboru vypsat jen urcite bloky textu. Jsou ohranicene unikatnim retezcem "###". Uvnitr bloku je slovo co hledam. Dokazi vypsat vsechny bloky ohranicene ###:
cat text_file | sed -n '/###/,/###/p'

Uvedeý sed vyfiltruje defakto liché bloky textu ohraničené řádky s ###. Pokud budeme mít v souboru tohle:

###
1
2
###
3
4
###
5
6
###

zbyde vám tohle:

###
1
2
###
###
5
6
###

Začal jsem tedy awk, v jehož syntaxi mi to v tu chvíli přišlo jednodušší a dostal se zhruba k následujícímu:

awk '/###/{i=(i+1)%2;o=p;p="";getline};r&&!p{print "###\n"substr(o,0,length(o)-1);r=0};{p=p$0"\n"};/hledaný_výraz/&&i{r=1}' text_file

Tedy popravdě, původně jsem vyplodil skript, který vypsal jen první blok, který obsahoval hledaný_výraz a rovnou skončil, tak to nevypadalo až tak retardovaně jako toto, ale zadání bylo na výpis všech takových bloků, nejen prvního, a tak jsem dopsal především to nulování proměnné r. Nicméně vysvětlím i toto neefektivní monstrum a nakonec přidám i nějaké to reálné srovnání časové náročnosti.

Důležité pro pochopení je, že v awk jsou proměnné netypové a dají se testovat a používat bez deklarace, prostě se při prvním použití samy vytvoří jako prázdné/nulové. Při použití ve stringu se bude taková proměnná chovat jako prázný řetězec, při výpočtech jako nula. Dokonce pokud bude v proměnné řetězec a vrazíte ji do matematického výrazu, bude se chovat jako jednička.
Proto ve výše uvedeném skriptu můžu přiřazovat dosud neexistující proměnnou p do proměnné o a testovat proměnnou r, která je na tom stejně.

Polopatě:
Pokud je na aktuálním řádku řetězec ###, aktualizuje se proměnná i, která díky operaci modulo dvěma střídá hodnoty 0 a 1, čímž indikuje sudé a liché bloky, uloží se proměnná p do proměnné o, p se vymaže a načte další řádek text_file. Dále se zkontroluje, zda je nenulová/neprázdná proměnná r, která indikuje přítomnost hledaného výrazu v právě zpracovávaném bloku, a zároveň zda je prázdná proměnná p, což signalizuje konec bloku (byla vymazána, protože se objevil řádek začínající ###). Pokud je tato podmínka splněna, vytiskne se separátor ### a za něj obsah proměnné o bez posledního znaku nového řádku, načež se snuluje r. Nebyla-li předchozí podmínka splněna, přidá se do proměnné p aktuální řádek spolu se znakem nového řádku. Nakonec, pokud proměnná i dovolí, se zkontroluje, zda aktuální řádek obsahuje hledaný řetězec a v kladném případě nastaví proměnnou r na 1. Tím končí řádek, načte se automaticky nový a celé se to opakuje. Dokud nebude dosažen konec zpracovávaného textového souboru.

Pak jsem to tedy přepsal na:

awk '{if (!/###/){if (i)p=p$0"\n"} else{if (i&&p~/hledaný_výraz/){print $0"\n"substr(p,0,length(p)-1)}else p="";i=(i+1)%2}}' text_file

Zbavil jsem se proměnné o a r a testování každého řádku na hledaný_výraz, místo toho se obsah proměnné p, do které už nestrkám irelevantní řádky, prohledá až po dosažení konce relevantního bloku. Zápis se příliš nezkrátil, ale efektivnější to je výrazně.

No a nyní sed. Jednoduchost řešení mě překvapila, neb jsem teprve objevil odkládací buffer.. :

sed -n '/###/,/###/{/###/{x;/hledaný_výraz/p;d};H}' text_file

Jen ta redundance separátoru ubírá skriptu na kráse, to by se dalo vylepšit hezkou proměnnou. Shellu samozřejmě, sed je přeci neumí.

A co že to dělá?

Zjednodušeně:
Pomocí příkazu H jsou do bufferu načítány jednotlivé řádky, které prošly vstupním filtrem a pokaždé, když se narazí na řádek obsahující řetězec ###, dojde k vyvolání bufferu, který již obsahuje celý předešlý blok(byl-li nějaký), jeho prohledání na hledaný řetězec a případně jeho tisk. Jinak se buffer "vynuluje" a pokračuje se dál.

Polopaticky:
Normálně sed vytiskne každý řádek vstupního souboru, i ty prázdné, a tak je tu přepínač -n, který říká: tiskni, až ti řeknu. První podmínka /###/,/###/ filtruje intervaly řádků ohraničené řetězcem ### , tedy, jak bylo řečeno, vybere jen ty liché, a na ně je aplikován kód ve složených závorkách. Na každý řádek odpovídajícího bloku je aplikován test, zda neobsahuje ### a pokud ano, provede se kód v dalších složených závorkách. Příkaz x vymění obsahy odkládacího bufferu (kde je v tu dobu načtený celý předchozí blok, pokud nějaký byl) a pattern space (tak se nazývá to místo, kam se načítají a kde zpracovávají jednotlivé řádky). Dále se obsah bufferu přenesený do pattern space prohledá na výskyt hledaného výrazu a pokud je výsledek pozitivní, vytiskne se příkazem p. V případě, že hledaný řetězec nalezen nebyl, pattern space se vymaže a v bufferu zbyde separátor ###, k němuž se začnou přidávat další řádky příkazem H.

Časová náročnost
Takže mám tři skripty, které mají přesně ten samý výstup, ale zcela určitě jim to trvá různě dlouho. Vyrobil jsem textový sobor o velikosti 143076 znaků na 34971 řádcích, s 1520 relevantními bloky, na konec jsem umístil bloky s hledaným řetězcem, a poštval na ně mé výtvory..

Můj první awk bastl dosáhl času:
0m0.028s

ten druhý:
0m0.018s

no a sed samozřejmě s přehledem vyhrál:
0m0.010s


Tož tak. Nebo nějak podobně.


Odkazy k tématu:
http://www.ics.muni.cz/bulletin/articles/33.html
http://www.grymoire.com/Unix/Sed.html
http://www.grymoire.com/Unix/Awk.html
http://sed.sourceforge.net/sed1line_cz.html

Žádné komentáře:

Okomentovat

Zkuste prosím při komentováni používat místo volby Anonymní volbu Název/adresa URL, kde vyplníte nějakou přezdívku, adresu zadávat nemusíte. Vědět, které příspěvky jsou od jednoho člověka, je fajn. Díky.

Pokud by se vám náhodou odeslaný komentář na stránce nezobrazil, vytáhnu ho z koše hned jak si toho všimnu. I Google spam filter se občas sekne.