Perché usare un loop di shell per elaborare il testo è considerato una cattiva pratica?


196

L'uso di un ciclo while per elaborare il testo è generalmente considerato una cattiva pratica nelle shell POSIX?

Come ha sottolineato Stéphane Chazelas , alcuni dei motivi per non utilizzare il loop shell sono concettuali , affidabilità , leggibilità , prestazioni e sicurezza .

Questa risposta spiega gli aspetti di affidabilità e leggibilità :

while IFS= read -r line <&3; do
  printf '%s\n' "$line"
done 3< "$InputFile"

Per quanto riguarda le prestazioni , il whileciclo e la lettura sono tremendamente lenti durante la lettura da un file o una pipe, perché la shell di lettura incorporata legge un carattere alla volta.

Che ne dici di aspetti concettuali e di sicurezza ?


Correlato (l'altro lato della medaglia): come si yesscrive in un file così rapidamente?
Wildcard il

1
La shell di lettura integrata non legge un singolo carattere alla volta, legge una sola riga alla volta. wiki.bash-hackers.org/commands/builtin/read
A.Danischewski

@ A.Danischewski: dipende dalla tua shell. In bash, legge una dimensione del buffer alla volta, provare dashad esempio. Vedi anche unix.stackexchange.com/q/209123/38906
cuonglm

Risposte:


256

Sì, vediamo una serie di cose come:

while read line; do
  echo $line | cut -c3
done

O peggio:

for line in `cat file`; do
  foo=`echo $line | awk '{print $2}'`
  echo whatever $foo
done

(non ridere, ne ho visti molti).

Generalmente dai principianti degli script di shell. Quelle sono ingenue traduzioni letterali di ciò che faresti in linguaggi imperativi come C o Python, ma non è così che fai le cose nelle shell, e quegli esempi sono molto inefficienti, completamente inaffidabili (potenzialmente portando a problemi di sicurezza), e se mai riuscirai per correggere la maggior parte dei bug, il codice diventa illeggibile.

concettualmente

In C o nella maggior parte delle altre lingue, i blocchi predefiniti sono solo un livello sopra le istruzioni del computer. Di 'al tuo processore cosa fare e poi cosa fare dopo. Prendi il tuo processore per mano e lo gestisci micro: apri quel file, leggi tanti byte, lo fai, lo fai con esso.

Le conchiglie sono un linguaggio di livello superiore. Si potrebbe dire che non è nemmeno una lingua. Sono davanti a tutti gli interpreti della riga di comando. Il lavoro viene svolto da quei comandi che esegui e la shell ha il solo scopo di orchestrarli.

Una delle grandi cose che Unix ha introdotto è stata la pipe e quei flussi stdin / stdout / stderr predefiniti che tutti i comandi gestiscono di default.

In 45 anni, non abbiamo trovato meglio di quell'API per sfruttare la potenza dei comandi e farli cooperare a un'attività. Questo è probabilmente il motivo principale per cui le persone usano ancora le shell oggi.

Hai uno strumento di taglio e uno strumento di traslitterazione e puoi semplicemente fare:

cut -c4-5 < in | tr a b > out

La shell sta semplicemente facendo le tubature (apri i file, installa i tubi, invoca i comandi) e quando è tutto pronto, scorre semplicemente senza che la shell faccia nulla. Gli strumenti svolgono il loro lavoro contemporaneamente, in modo efficiente al loro ritmo con sufficiente buffering in modo che non uno blocchi l'altro, è semplicemente bello eppure così semplice.

Il richiamo di uno strumento ha tuttavia un costo (e lo svilupperemo sul punto di prestazione). Tali strumenti possono essere scritti con migliaia di istruzioni in C. È necessario creare un processo, caricare, inizializzare lo strumento, quindi ripulirlo, distruggere il processo e attendere.

Invocare cutè come aprire il cassetto della cucina, prendere il coltello, usarlo, lavarlo, asciugarlo, rimetterlo nel cassetto. Quando lo fai:

while read line; do
  echo $line | cut -c3
done < file

È come per ogni riga del file, ottenere lo readstrumento dal cassetto della cucina (molto goffo perché non è stato progettato per quello ), leggere una riga, lavare lo strumento di lettura, rimetterlo nel cassetto. Quindi programmare una riunione per lo strumento echoe cut, estrarli dal cassetto, richiamarli, lavarli, asciugarli, rimetterli nel cassetto e così via.

Alcuni di questi strumenti ( reade echo) sono costruiti nella maggior parte delle shell, ma da allora non fa quasi differenza echoe cutdevono ancora essere eseguiti in processi separati.

È come tagliare una cipolla ma lavare il coltello e rimetterlo nel cassetto della cucina tra una fetta e l'altra.

Qui il modo più ovvio è quello di estrarre lo cutstrumento dal cassetto, tagliare tutta la cipolla e rimetterla nel cassetto dopo aver completato l'intero lavoro.

IOW, nelle shell, in particolare per elaborare il testo, invochi il minor numero possibile di utility e le fai cooperare all'attività, non esegui migliaia di strumenti in sequenza in attesa che ciascuno si avvii, venga eseguito, ripulito prima di eseguire il successivo.

Ulteriori letture nella bella risposta di Bruce . Gli strumenti interni di elaborazione di testo di basso livello nelle shell (tranne forse per zsh) sono limitati, ingombranti e generalmente non adatti all'elaborazione di testo generale.

Prestazione

Come detto in precedenza, l'esecuzione di un comando ha un costo. Un costo enorme se quel comando non è incorporato, ma anche se sono integrati, il costo è grande.

E le shell non sono state progettate per funzionare in questo modo, non hanno alcuna pretesa di essere linguaggi di programmazione performanti. Non lo sono, sono solo interpreti da riga di comando. Quindi, su questo fronte è stata fatta poca ottimizzazione.

Inoltre, le shell eseguono comandi in processi separati. Quei blocchi non condividono una memoria o uno stato comuni. Quando fai un fgets()o fputs()in C, questa è una funzione in stdio. stdio mantiene buffer interni per input e output per tutte le funzioni stdio, per evitare di effettuare chiamate di sistema costose troppo spesso.

I corrispondenti anche utilità incorporati della shell ( read, echo, printf) non possono farlo. readè pensato per leggere una riga. Se supera il carattere di nuova riga, significa che il prossimo comando che eseguirai mancherà. Quindi readdeve leggere l'input un byte alla volta (alcune implementazioni hanno un'ottimizzazione se l'input è un file normale in quanto leggono blocchi e cercano di nuovo, ma ciò funziona solo per file regolari e bashad esempio legge solo blocchi di 128 byte che è ancora molto meno di quanto faranno le utility di testo).

Lo stesso sul lato output, echonon può semplicemente bufferizzare il suo output, deve emetterlo immediatamente perché il comando successivo che eseguirai non condividerà quel buffer.

Ovviamente, eseguire i comandi in sequenza significa che devi aspettarli, è una piccola danza dello scheduler che dà il controllo dalla shell, agli strumenti e viceversa. Ciò significa anche (al contrario di utilizzare istanze di strumenti di lunga durata in una pipeline) che non è possibile sfruttare più processori contemporaneamente quando disponibili.

Tra quel while readciclo e l'equivalente (presumibilmente) cut -c3 < file, nel mio test rapido, c'è un rapporto tempo CPU di circa 40000 nei miei test (un secondo contro mezza giornata). Ma anche se usi solo i builtin della shell:

while read line; do
  echo ${line:2:1}
done

(qui con bash), è ancora circa 1: 600 (un secondo contro 10 minuti).

Affidabilità / leggibilità

È molto difficile ottenere quel codice giusto. Gli esempi che ho dato sono visti troppo spesso in natura, ma hanno molti bug.

readè uno strumento utile che può fare molte cose diverse. Può leggere l'input dell'utente, dividerlo in parole per memorizzarlo in diverse variabili. read linenon senza leggere una riga di input, o forse legge una riga in un modo molto speciale. In realtà legge le parole dall'ingresso quelle separate da $IFSe dove la barra rovesciata può essere usata per sfuggire ai separatori o al carattere di nuova riga.

Con il valore predefinito di $IFS, su un input come:

   foo\/bar \
baz
biz

read linememorizzerà "foo/bar baz"in $line, non " foo\/bar \"come ci si aspetterebbe.

Per leggere una riga, in realtà hai bisogno di:

IFS= read -r line

Non è molto intuitivo, ma è così, ricorda che le shell non dovevano essere usate in quel modo.

Lo stesso per echo. echoespande le sequenze. Non puoi usarlo per contenuti arbitrari come il contenuto di un file casuale. È necessario printfqui invece.

E, naturalmente, c'è la tipica dimenticanza di citare la tua variabile in cui tutti cadono. Quindi è di più:

while IFS= read -r line; do
  printf '%s\n' "$line" | cut -c3
done < file

Ora, qualche avvertimento in più:

  • tranne zshche, ciò non funziona se l'input contiene caratteri NUL mentre almeno le utility di testo GNU non avrebbero il problema.
  • se ci sono dati dopo l'ultima riga, verranno saltati
  • all'interno del ciclo, lo stdin viene reindirizzato, quindi è necessario prestare attenzione che i comandi in esso contenuti non leggano dallo stdin.
  • per i comandi all'interno dei loop, non stiamo prestando attenzione al successo o meno. Di solito, le condizioni di errore (disco pieno, errori di lettura ...) verranno gestite in modo inadeguato, di solito più male che con l' equivalente corretto .

Se vogliamo affrontare alcuni di questi problemi sopra, ciò diventa:

while IFS= read -r line <&3; do
  {
    printf '%s\n' "$line" | cut -c3 || exit
  } 3<&-
done 3< file
if [ -n "$line" ]; then
    printf '%s' "$line" | cut -c3 || exit
fi

Sta diventando sempre meno leggibile.

Esistono numerosi altri problemi con il passaggio dei dati ai comandi tramite gli argomenti o il recupero del loro output in variabili:

  • la limitazione della dimensione degli argomenti (alcune implementazioni di utilità di testo hanno un limite anche lì, sebbene l'effetto di quelli raggiunti sia generalmente meno problematico)
  • il carattere NUL (anche un problema con le utility di testo).
  • argomenti presi come opzioni quando iniziano con -(o +talvolta)
  • varie stranezze di vari comandi tipicamente utilizzati in quei loop come expr, test...
  • gli operatori (limitati) di manipolazione del testo di varie shell che gestiscono caratteri multi-byte in modi incoerenti.
  • ...

Considerazioni sulla sicurezza

Quando inizi a lavorare con variabili shell e argomenti ai comandi , stai entrando in un campo minato.

Se dimentichi di citare le tue variabili , dimentica la fine del marker di opzione , lavora in locali con caratteri multi-byte (la norma in questi giorni), sei sicuro di introdurre bug che prima o poi diventeranno vulnerabilità.

Quando si desidera utilizzare i loop.

TBD


24
Chiaro (vividamente), leggibile ed estremamente utile. Grazie ancora. Questa è in realtà la migliore spiegazione che ho visto ovunque su Internet per la differenza fondamentale tra scripting di shell e programmazione.
Carattere jolly

2
Sono post come questi che aiutano i principianti a conoscere gli script Shell e vedere le sue sottili differenze. Dovresti aggiungere una variabile di riferimento come $ {VAR: -default_value} per assicurarti di non ottenere un valore nullo. e imposta -o nounset per urlare contro di te quando fai riferimento a un valore non definito.
unsignedzero,

6
@ A.Danischewski, penso che ti stia perdendo il punto. Sì, cutad esempio, è efficiente. cut -f1 < a-very-big-fileè efficiente, efficiente come se lo scrivessi in C. Ciò che è terribilmente inefficiente e soggetto a errori è invocare cutper ogni riga di a a-very-big-filein un ciclo di shell che è il punto sollevato in questa risposta. Ciò concorda con la tua ultima affermazione sulla scrittura di codice non necessario che mi fa pensare che forse non capisco il tuo commento.
Stéphane Chazelas,

5
"In 45 anni, non abbiamo trovato meglio di quell'API per sfruttare la potenza dei comandi e farli cooperare a un compito." - in realtà, PowerShell, per esempio, ha risolto il temuto problema di analisi passando attorno a dati strutturati piuttosto che a flussi di byte. L'unico motivo per cui le shell non lo usano ancora (l'idea è stata lì per un bel po 'e si è sostanzialmente cristallizzata un po' intorno a Java quando la lista dei tipi di contenitori di dizionari e dizionari ora standard è diventata mainstream) è che i loro manutentori non potevano ancora concordare sul formato di dati strutturati comuni da usare (.
ivan_pozdeev

6
@OlivierDulac Penso che sia un po 'di umorismo. Quella sezione sarà per sempre TBD.
muru,

43

Per quanto riguarda concettuale e leggibilità, le shell sono generalmente interessate ai file. La loro "unità indirizzabile" è il file e "indirizzo" è il nome del file. Le shell hanno tutti i tipi di metodi di test per l'esistenza dei file, il tipo di file, la formattazione del nome del file (a partire dal globbing). Le conchiglie hanno pochissime primitive per gestire il contenuto dei file. I programmatori Shell devono invocare un altro programma per gestire il contenuto dei file.

A causa dell'orientamento del file e del nome file, la manipolazione del testo nella shell è molto lenta, come hai notato, ma richiede anche uno stile di programmazione poco chiaro e contorto.


25

Ci sono alcune risposte complicate, che forniscono molti dettagli interessanti per i geek tra noi, ma è davvero abbastanza semplice: l'elaborazione di un file di grandi dimensioni in un loop di shell è troppo lenta.

Penso che l'interrogatore sia interessante in un tipico tipo di script di shell, che può iniziare con un po 'di analisi della riga di comando, impostazione dell'ambiente, controllo di file e directory e un po' più di inizializzazione, prima di passare al suo lavoro principale: passare attraverso un grande file di testo orientato alla linea.

Per le prime parti ( initialization), di solito non importa che i comandi della shell siano lenti - esegue solo poche dozzine di comandi, forse con un paio di cicli brevi. Anche se scriviamo quella parte in modo inefficiente, di solito ci vorrà meno di un secondo per fare tutta quella inizializzazione, e va bene - succede solo una volta.

Ma quando passiamo all'elaborazione del file di grandi dimensioni, che potrebbe avere migliaia o milioni di righe, non va bene che lo script della shell impieghi una frazione significativa di secondo (anche se sono solo poche decine di millisecondi) per ogni riga, in quanto ciò potrebbe aggiungere fino a ore.

Questo è quando abbiamo bisogno di usare altri strumenti, e la bellezza degli script della shell Unix è che ci rendono molto facile farlo.

Invece di usare un ciclo per guardare ogni riga, dobbiamo passare l'intero file attraverso una pipeline di comandi . Ciò significa che, anziché chiamare i comandi migliaia o milioni di volte, la shell li chiama una sola volta. È vero che quei comandi avranno dei loop per elaborare il file riga per riga, ma non sono script di shell e sono progettati per essere veloci ed efficienti.

Unix ha molti meravigliosi strumenti integrati, che vanno dal semplice al complesso, che possiamo usare per costruire le nostre condotte. Di solito inizierei con quelli semplici e utilizzerei solo quelli più complessi quando necessario.

Vorrei anche provare a utilizzare gli strumenti standard disponibili sulla maggior parte dei sistemi e cercare di mantenere il mio utilizzo portatile, sebbene ciò non sia sempre possibile. E se la tua lingua preferita è Python o Ruby, forse non ti dispiacerà lo sforzo extra di assicurarti che sia installato su ogni piattaforma su cui il tuo software deve funzionare :-)

Semplici strumenti comprendono head, tail, grep, sort, cut, tr, sed, join(quando si uniscono 2 file), e awkone-liners, tra molti altri. È sorprendente ciò che alcune persone possono fare con la corrispondenza dei modelli e i sedcomandi.

Quando diventa più complesso, e devi davvero applicare una logica ad ogni riga, awkè una buona opzione - o una riga (alcune persone mettono interi script awk in 'una riga', anche se non è molto leggibile) o in un breve script esterno.

Dato che awkè un linguaggio interpretato (come la tua shell), è sorprendente poter eseguire l'elaborazione riga per riga in modo così efficiente, ma è appositamente progettato per questo ed è davvero molto veloce.

E poi c'è Perlun numero enorme di altri linguaggi di scripting che sono molto bravi nell'elaborazione di file di testo e sono anche dotati di molte librerie utili.

E infine, c'è una buona vecchia C, se hai bisogno della massima velocità e alta flessibilità (anche se l'elaborazione del testo è un po 'noiosa). Ma è probabilmente un pessimo uso del tuo tempo per scrivere un nuovo programma C per ogni diversa attività di elaborazione dei file che incontri. Lavoro molto con i file CSV, quindi ho scritto diverse utilità generiche in C che posso riutilizzare in molti progetti diversi. In effetti, questo amplia la gamma di "strumenti Unix semplici e veloci" che posso chiamare dai miei script di shell, quindi posso gestire la maggior parte dei progetti scrivendo solo script, che è molto più veloce della scrittura e del debug del codice C su misura ogni volta!

Alcuni suggerimenti finali:

  • non dimenticare di avviare il tuo script di shell principale export LANG=C, o molti strumenti tratteranno i tuoi file ASCII semplici come Unicode, rendendoli molto più lenti
  • considerare anche l'impostazione export LC_ALL=Cse si desidera sortprodurre un ordine coerente, indipendentemente dall'ambiente!
  • se hai bisogno dei sorttuoi dati, probabilmente ci vorrà più tempo (e risorse: CPU, memoria, disco) di tutto il resto, quindi cerca di ridurre al minimo il numero di sortcomandi e la dimensione dei file che stanno ordinando
  • una singola pipeline, quando possibile, è generalmente più efficiente: l'esecuzione di più pipeline in sequenza, con file intermedi, può essere più leggibile e debug, ma aumenterà il tempo impiegato dal programma

6
Le pipeline di molti strumenti semplici (in particolare quelli citati, come testa, coda, grep, sort, cut, tr, sed, ...) sono spesso usate inutilmente, in particolare se in quella pipeline hai già un'istanza awk che può fare anche i compiti di quei semplici strumenti. Un altro problema da considerare è che nelle condutture non è possibile passare in modo semplice e affidabile informazioni sullo stato dai processi sul lato anteriore di una conduttura ai processi che appaiono sul lato posteriore. Se si utilizza per tali pipeline di programmi semplici un programma awk, si dispone di un singolo spazio degli stati.
Janis,

14

Si ma...

La corretta risposta di Stéphane Chazelas si basa sul concetto di delegare ogni operazione di testo per i binari specifici, come grep, awk, sede altri.

Poiché è in grado di fare molte cose da solo, rilasciare le forchette può diventare più veloce (anche che eseguire un altro interprete per fare tutto il lavoro).

Per esempio, dai un'occhiata a questo post:

https://stackoverflow.com/a/38790442/1765658

e

https://stackoverflow.com/a/7180078/1765658

prova e confronta ...

Ovviamente

Non si tiene conto dell'input e della sicurezza dell'utente !

Non scrivere applicazioni Web sotto !!

Ma per molte attività di amministrazione del server, in cui potrebbe essere usato al posto della , l'uso di builth bash potrebbe essere molto efficiente.

Il mio significato:

Scrivere strumenti come bin utils non è lo stesso tipo di lavoro dell'amministrazione di sistema.

Quindi non le stesse persone!

Dove gli amministratori di sistema devono sapere shell, potrebbero scrivere prototipi usando il suo strumento preferito (e più noto).

Se questa nuova utility (prototipo) è davvero utile, alcune altre persone potrebbero sviluppare strumenti dedicati utilizzando un linguaggio più appropriato.


1
Buon esempio. Il tuo approccio è sicuramente più efficiente di quello di lololux, ma nota come la risposta di tensibai (il modo giusto per fare questo IMO, cioè senza usare i loop di shell) è ordini di grandezza più veloci del tuo. E il tuo è molto più veloce se non lo usi bash. (oltre 3 volte più veloce con ksh93 nel mio test sul mio sistema). bashè generalmente il guscio più lento. Anche zshè due volte più veloce su quello script. Hai anche alcuni problemi con le variabili non quotate e l'utilizzo di read. Quindi in realtà stai illustrando molti dei miei punti qui.
Stéphane Chazelas,

@ StéphaneChazelas Sono d'accordo, bash è probabilmente il guscio più lento che le persone possano usare oggi, ma il più usato comunque.
F. Hauri,

@ StéphaneChazelas Ho pubblicato una versione perl sulla mia risposta
F. Hauri

1
@Tensibai, troverete POSIXsh , Awk , Sed , grep, ed, ex, cut, sort, join... il tutto con maggiore affidabilità rispetto Bash o Perl.
Wildcard il

1
@Tensibai, di tutti i sistemi interessati da U&L, la maggior parte di essi (Solaris, FreeBSD, HP / UX, AIX, la maggior parte dei sistemi Linux incorporati ...) non vengono bashinstallati per impostazione predefinita. bashè in gran parte si trovano solo su Apple MacOS e sistemi GNU (suppongo che è quello che si chiama principali distribuzioni ), anche se molti sistemi hanno anche come un pacchetto opzionale (come zsh, tcl, python...)
Stéphane Chazelas
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.