Pourquoi utiliser une boucle shell pour traiter du texte est-il considéré comme une mauvaise pratique?

196

L'utilisation d'une boucle while pour traiter du texte est-elle généralement considérée comme une mauvaise pratique dans les shells POSIX?

Comme l'a souligné Stéphane Chazelas , certaines des raisons de ne pas utiliser shell loop sont conceptuelles , fiabilité , lisibilité , performances et sécurité .

Cette réponse explique les aspects de fiabilité et de lisibilité :

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

En termes de performances , la whileboucle et la lecture sont extrêmement lentes lors de la lecture d'un fichier ou d'un tube, car le shell de lecture intégré lit un caractère à la fois.

Qu'en est- il des aspects conceptuels et de sécurité ?

cuonglm
la source
Lié (de l'autre côté de la pièce): Comment yesécrire dans un fichier si rapidement?
Wildcard
1
Le shell de lecture intégré ne lit pas un seul caractère à la fois, il lit une seule ligne à la fois. wiki.bash-hackers.org/commands/builtin/read
A.Danischewski
@ A.Danischewski: Cela dépend de votre shell. Dans bash, il lit une taille de tampon à la fois, essayez dashpar exemple. Voir aussi unix.stackexchange.com/q/209123/38906
jeudi

Réponses:

256

Oui, nous voyons un certain nombre de choses comme:

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

Ou pire:

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

(ne rigole pas, j'en ai vu beaucoup).

Généralement des débutants en scripts shell. Ce sont des traductions littérales naïves de ce que vous feriez dans des langages impératifs tels que C ou python, mais ce n'est pas ainsi que vous faites les choses dans les shells, et ces exemples sont très inefficaces, complètement peu fiables (pouvant potentiellement poser des problèmes de sécurité), et si vous réussissez un jour pour corriger la plupart des bugs, votre code devient illisible.

Conceptuellement

En C ou dans la plupart des autres langues, les blocs de construction ne représentent qu'un niveau au-dessus des instructions de l'ordinateur. Vous dites à votre processeur quoi faire et ensuite quoi faire. Vous prenez votre processeur par la main et vous le gérez: vous ouvrez ce fichier, vous lisez autant d'octets, vous faites ceci, vous le faites avec cela.

Les coquillages sont une langue de niveau supérieur. On peut dire que ce n'est même pas une langue. Ils sont avant tous les interprètes en ligne de commande. Le travail est effectué à l'aide des commandes que vous exécutez et le shell n'a pour but que de les orchestrer.

Un des grands avantages d'Unix est le pipe et les flux stdin / stdout / stderr par défaut que toutes les commandes gèrent par défaut.

En 45 ans, nous n'avons pas trouvé meilleur que cette API pour exploiter la puissance des commandes et les faire coopérer à une tâche. C'est probablement la raison principale pour laquelle les gens utilisent encore des coquilles aujourd'hui.

Vous avez un outil de coupe et un outil de translittération, et vous pouvez simplement faire:

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

Le shell se contente de faire la plomberie (ouvrir les fichiers, configurer les tuyaux, invoquer les commandes) et quand tout est prêt, il coule sans que le shell ne fasse rien. Les outils font leur travail simultanément, efficacement, à leur propre rythme, avec suffisamment de mémoire tampon pour qu’aucun ne bloque l’autre, il est tout simplement magnifique et pourtant si simple.

Invoquer un outil a cependant un coût (et nous le développerons sur le point de performance). Ces outils peuvent être écrits avec des milliers d’instructions en C. Un processus doit être créé, l’outil doit être chargé, initialisé, puis nettoyé, le processus détruit et attendu.

Invoquer, cutc'est comme ouvrir le tiroir de la cuisine, prendre le couteau, l'utiliser, le laver, le sécher, le remettre dans le tiroir. Quand tu fais:

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

C'est comme pour chaque ligne du fichier, extraire l' readoutil du tiroir de la cuisine (très maladroit parce qu'il n'a pas été conçu pour cela ), lire une ligne, laver votre outil de lecture, le remettre dans le tiroir. Planifiez ensuite une réunion pour l' outil echoet cut, sortez-les du tiroir, appelez-les, nettoyez-les, séchez-les, remettez-les dans le tiroir, etc.

Certains de ces outils ( readet echo) sont construits dans la plupart des coques, mais cela ne fait guère de différence ici echoet cutdoit encore être exécuté dans des processus séparés.

C'est comme couper un oignon mais laver son couteau et le remettre dans le tiroir de la cuisine entre chaque tranche.

Ici, le moyen le plus évident consiste à sortir votre cutoutil du tiroir, à trancher tout votre oignon et à le remettre dans le tiroir une fois le travail terminé.

IOW, dans les shells, en particulier pour traiter du texte, vous appelez le moins d’utilitaires possible et les faites coopérer à la tâche. Vous n’exécutez pas des milliers d’outils en ordre en attendant que chacun d’entre eux démarre, nettoient avant d’exécuter le suivant.

Pour en savoir plus, lisez bien Bruce . Les outils internes de traitement de texte de bas niveau dans les shells (à l'exception peut-être de zsh) sont limités, encombrants et ne conviennent généralement pas au traitement de texte général.

Performance

Comme indiqué précédemment, l'exécution d'une commande a un coût. Un coût énorme si cette commande n'est pas intégrée, mais même si elles sont intégrées, le coût est élevé.

Et les shells n’ont pas été conçus pour fonctionner comme ça, ils ne prétendent pas être des langages de programmation performants. Ils ne sont pas, ils sont juste des interprètes en ligne de commande. Donc, peu d'optimisation a été faite sur ce front.

En outre, les shells exécutent des commandes dans des processus distincts. Ces blocs de construction ne partagent pas une mémoire ou un état commun. Quand vous faites un fgets()ou fputs()en C, c'est une fonction dans stdio. stdio conserve les mémoires tampons internes d'entrée et de sortie pour toutes les fonctions stdio, afin d'éviter de faire des appels système coûteux trop souvent.

Les utilitaires shell même BUILTIN correspondant ( read, echo, printf) ne peuvent pas le faire. readest destiné à lire une ligne. S'il lit après le caractère de nouvelle ligne, cela signifie que la prochaine commande que vous exécuterez le manquera. Il readfaut donc lire l’entrée un octet à la fois (certaines implémentations ont une optimisation si l’entrée est un fichier normal dans la mesure où elles lisent des morceaux et recherchent en arrière, mais cela ne fonctionne que pour des fichiers normaux et bashne lit par exemple que des morceaux de 128 octets, c’est-à-dire encore beaucoup moins que les utilitaires de texte feront).

Idem côté sortie, echone peut pas simplement mettre sa sortie en mémoire tampon, il doit la sortir immédiatement car la prochaine commande que vous exécuterez ne partagera pas cette mémoire tampon.

Évidemment, exécuter les commandes de manière séquentielle signifie que vous devez les attendre, c'est une petite danse de planificateur qui donne le contrôle depuis le shell aux outils. Cela signifie également (par opposition à l'utilisation d'instances longues dans un pipeline) que vous ne pouvez pas exploiter plusieurs processeurs en même temps lorsqu'ils sont disponibles.

Entre cette while readboucle et l'équivalent (supposé) cut -c3 < file, dans mon test rapide, il y a un rapport de temps de processeur d'environ 40000 lors de mes tests (une seconde par rapport à une demi-journée). Mais même si vous utilisez uniquement des commandes intégrées au shell:

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

(ici avec bash), cela reste autour de 1: 600 (une seconde contre 10 minutes).

Fiabilité / lisibilité

Il est très difficile d'obtenir ce code correctement. Les exemples que j'ai donnés sont trop souvent vus à l'état sauvage, mais ils ont beaucoup d'insectes.

readest un outil pratique qui peut faire beaucoup de choses différentes. Il peut lire les entrées de l'utilisateur, les diviser en mots pour les stocker dans différentes variables. read linene lit pas une ligne d'entrée, ou peut-être lit-il une ligne d'une manière très spéciale. En fait, il lit les mots de l'entrée, ces mots séparés par une $IFSbarre oblique inversée pouvant être utilisée pour échapper aux séparateurs ou au caractère de nouvelle ligne.

Avec la valeur par défaut de $IFS, sur une entrée comme:

   foo\/bar \
baz
biz

read lineva stocker "foo/bar baz"dans $line, pas " foo\/bar \"comme vous le souhaitiez.

Pour lire une ligne, vous avez besoin de:

IFS= read -r line

Ce n’est pas très intuitif, mais c’est comme ça, souvenez-vous que les coquillages ne devaient pas être utilisés de la sorte.

Pareil pour echo. echoétend les séquences. Vous ne pouvez pas l'utiliser pour des contenus arbitraires comme le contenu d'un fichier aléatoire. Vous avez besoin printfici à la place.

Et bien sûr, il y a l' oubli typique de citer votre variable dans laquelle tout le monde tombe. Donc c'est plus:

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

Maintenant, quelques mises en garde supplémentaires:

  • sauf que zsh, cela ne fonctionne pas si l'entrée contient des caractères NUL alors qu'au moins les utilitaires de texte GNU n'auraient pas le problème.
  • s'il y a des données après la dernière nouvelle ligne, elles seront ignorées
  • Dans la boucle, stdin est redirigé. Vous devez donc faire attention à ce que ses commandes ne lisent pas à partir de stdin.
  • pour les commandes dans les boucles, nous ne faisons pas attention à leur succès ou non. Habituellement, les conditions d'erreur (disque plein, erreurs de lecture, etc.) seront mal gérées, généralement plus mal qu'avec le bon équivalent.

Si nous voulons aborder certaines de ces questions ci-dessus, cela devient:

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

Cela devient de moins en moins lisible.

La transmission de données à des commandes via les arguments ou l'extraction de leur sortie dans des variables soulève un certain nombre d'autres problèmes:

  • la limitation de la taille des arguments (certaines implémentations d'utilitaires de texte ont également une limite, même si l'effet de celles qui sont atteintes est généralement moins problématique)
  • le caractère NUL (également un problème avec les utilitaires de texte).
  • arguments pris comme options quand ils commencent par -(ou +parfois)
  • diverses bizarreries de diverses commandes généralement utilisées dans ces boucles comme expr, test...
  • les opérateurs (limités) de manipulation de texte de divers shells qui gèrent des caractères multi-octets de manière incohérente.
  • ...

Considérations de sécurité

Lorsque vous commencez à utiliser des variables shell et des arguments de commandes , vous entrez dans un champ de mines.

Si vous oubliez de citer vos variables , oubliez le marqueur de fin d'option , travaillez dans des environnements locaux avec des caractères multi-octets (la norme de nos jours), vous êtes certain de présenter des bogues qui deviendront tôt ou tard des vulnérabilités.

Quand vous voudrez peut-être utiliser des boucles.

À déterminer

Stéphane Chazelas
la source
24
Clair (vivement), lisible et extrêmement utile. Merci une fois de plus. C’est en fait la meilleure explication que j’ai jamais vue sur Internet pour expliquer la différence fondamentale entre les scripts shell et la programmation.
Wildcard
2
Ce sont des publications comme celles-ci qui aident les débutants à se familiariser avec les scripts Shell et à comprendre ses différences subtiles. Devrait ajouter une variable de référence en tant que $ {VAR: -default_value} pour vous assurer de ne pas obtenir la valeur null. et définissez -o nounset pour vous crier après avoir référencé une valeur non définie.
unsignedzero
6
@ A.Danischewski, je pense que vous manquez le point. Oui, cutpar exemple, est efficace. cut -f1 < a-very-big-fileest efficace, aussi efficace que vous le feriez si vous l'écriviez en C. Ce qui est terriblement inefficace et sujet à des erreurs est l'appelant cutpour chaque ligne d'une a-very-big-fileboucle dans un shell, ce qui est le but de cette réponse. Cela concorde avec votre dernière déclaration sur l'écriture de code inutile qui me laisse penser que je ne comprends peut-être pas votre commentaire.
Stéphane Chazelas
5
"En 45 ans, nous n'avons pas trouvé meilleur que cette API pour exploiter la puissance des commandes et les faire coopérer à une tâche." - En fait, PowerShell, pour sa part, a résolu le problème redouté de l'analyse syntaxique en faisant circuler des données structurées plutôt que des flux d'octets. La seule raison pour laquelle les shells ne l'utilisent pas encore (l'idée est là depuis un bon moment et s'est fondamentalement cristallisée autour de Java lorsque les types désormais standard de conteneurs de listes et de dictionnaires sont devenus courants) est que leurs responsables ne pouvaient pas encore s'entendre sur le format commun de données structurées à utiliser (.
ivan_pozdeev
6
@OlivierDulac Je pense que c'est un peu d'humour. Cette section sera toujours à déterminer.
Muru
43

En ce qui concerne le concept et la lisibilité, les coques s'intéressent généralement aux fichiers. Leur "unité adressable" est le fichier et "adresse" le nom du fichier. Les shells ont toutes sortes de méthodes pour tester l'existence, le type et le formatage du nom de fichier (commençant par globbing). Les shell ont très peu de primitives pour traiter le contenu du fichier. Les programmeurs shell doivent faire appel à un autre programme pour gérer le contenu des fichiers.

En raison de l'orientation des fichiers et des noms de fichiers, la manipulation de texte dans le shell est très lente, comme vous l'avez noté, mais nécessite également un style de programmation peu clair et contourné.

Bruce Ediger
la source
25

Il y a des réponses compliquées, donnant beaucoup de détails intéressants aux geeks parmi nous, mais c'est vraiment très simple - le traitement d'un fichier volumineux dans une boucle de shell est tout simplement trop lent.

Je pense que le questionneur est intéressant dans un type de script shell typique, qui peut commencer par une analyse syntaxique en ligne de commande, un paramètre d’environnement, la vérification des fichiers et des répertoires et un peu plus d’initialisation, avant de passer à la tâche principale. fichier texte orienté ligne.

Pour les premières parties ( initialization), peu importe que les commandes du shell soient lentes - cela n’exécute que quelques douzaines de commandes, peut-être avec quelques boucles courtes. Même si nous écrivons cette partie de manière inefficace, il faudra généralement moins d'une seconde pour effectuer toute cette initialisation, et c'est très bien, cela ne se produit qu'une fois.

Mais lorsque nous traitons le gros fichier, qui peut contenir des milliers, voire des millions, de lignes, il n’est pas normal que le script shell prenne une fraction de seconde significative (même s’il ne s’agit que de quelques dizaines de millisecondes), car cela pourrait s’ajouter aux heures.

C’est à ce moment-là que nous devons utiliser d’autres outils, et la beauté des scripts de shell Unix est qu’ils nous permettent de le faire très facilement.

Au lieu d'utiliser une boucle pour examiner chaque ligne, nous devons passer le fichier entier à travers un pipeline de commandes . Cela signifie qu'au lieu d'appeler les commandes des milliers, voire des millions de fois, le shell les appelle une seule fois. Il est vrai que ces commandes auront des boucles pour traiter le fichier ligne par ligne, mais ce ne sont pas des scripts shell, elles sont conçues pour être rapides et efficaces.

Unix a de nombreux outils intégrés, du plus simple au plus complexe, que nous pouvons utiliser pour construire nos pipelines. Je commencerais généralement par les plus simples et n'utiliserais que les plus complexes lorsque cela est nécessaire.

J'essayerais également de m'en tenir aux outils standard disponibles sur la plupart des systèmes et de garder mon utilisation portable, bien que ce ne soit pas toujours possible. Et si votre langue préférée est Python ou Ruby, peut-être que l’effort supplémentaire de vous assurer qu’il est installé sur chaque plate-forme sur laquelle votre logiciel doit être exécuté ne vous dérangera peut-être pas :-)

Des outils simples comprennent head, tail, grep, sort, cut, tr, sed, join(lors de la fusion 2 fichiers), et awkune seule ligne, parmi beaucoup d' autres. C'est incroyable ce que certaines personnes peuvent faire avec le filtrage et les sedcommandes.

Lorsque la situation devient plus complexe et que vous devez appliquer une logique à chaque ligne, awkc’est une bonne option - soit une ligne unique (certaines personnes placent des scripts awk entiers dans «une ligne», bien que ce ne soit pas très lisible), ou bien une court script externe.

En awktant que langage interprété (comme votre shell), il est étonnant qu’il puisse traiter le traitement ligne par ligne de manière aussi efficace, mais il est conçu à cet effet et est vraiment très rapide.

Et puis, il existe Perlun grand nombre d'autres langages de script qui sont très efficaces pour le traitement de fichiers texte et qui comportent également de nombreuses bibliothèques utiles.

Et enfin, il y a le bon vieux C, si vous avez besoin d' une vitesse maximale et d'une grande flexibilité (bien que le traitement de texte soit un peu fastidieux). Mais c'est probablement une très mauvaise utilisation de votre temps pour écrire un nouveau programme C pour chaque tâche de traitement de fichier que vous rencontrez. Je travaille beaucoup avec des fichiers CSV, j'ai donc écrit plusieurs utilitaires génériques en C que je peux réutiliser dans de nombreux projets différents. En fait, cela élargit la gamme des «outils Unix simples et rapides» que je peux appeler à partir de scripts shell, ce qui me permet de gérer la plupart des projets en écrivant uniquement des scripts, ce qui est beaucoup plus rapide que l'écriture et le débogage de code C personnalisé à chaque fois!

Quelques dernières astuces:

  • n'oubliez pas de démarrer votre script shell principal avec export LANG=C, sinon de nombreux outils traiteront vos fichiers plain-old-ASCII comme des caractères Unicode, ce qui les ralentira beaucoup
  • Pensez également à paramétrer export LC_ALL=Csi vous souhaitez sortobtenir un ordre cohérent, quel que soit l'environnement!
  • si vous avez besoin de sortvos données, cela prendra probablement plus de temps (et de ressources: processeur, mémoire, disque), alors essayez de minimiser le nombre de sortcommandes et la taille des fichiers à trier.
  • Un seul pipeline, lorsque cela est possible, est généralement le plus efficace - exécuter plusieurs pipelines en séquence, avec des fichiers intermédiaires, peut être plus lisible et plus facile à déboguer, mais augmentera le temps nécessaire à votre programme.
Laurence Renshaw
la source
6
Les pipelines de nombreux outils simples (en particulier ceux mentionnés, comme la tête, la queue, le grep, le tri, la coupe, le transfert, ...) sont souvent utilisés inutilement, en particulier si vous avez déjà un exemple awk dans ce pipeline qui peut le faire. les tâches de ces outils simples aussi. Un autre problème à prendre en compte est que dans les pipelines, vous ne pouvez pas simplement et de manière fiable transmettre des informations d'état des processus situés à l'avant d'un pipeline aux processus qui apparaissent à l'arrière. Si vous utilisez un programme awk pour ces pipelines de programmes simples, vous ne disposez que d'un seul espace d'état.
Janis
14

Oui mais...

La réponse de Stéphane Chazelas est basé sur concept de déléguer toutes les opérations de texte à des binaires spécifiques, comme grep, awk, sedet d' autres.

Comme est capable de faire beaucoup de choses par lui-même, il peut être plus rapide de laisser tomber des fourchettes (même que de faire appel à un autre interprète pour tout faire).

Pour un exemple, jetez un coup d'oeil sur ce post:

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

et

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

tester et comparer ...

Bien sûr

Il n'y a aucune considération pour la saisie de l'utilisateur et la sécurité !

N'écrivez pas d'application web sous !!

Mais pour de nombreuses tâches d’administration de serveur, où pourrait être utilisé à la place d’un , l’utilisation de basins intégré pourrait être très efficace.

Ma signification:

Écrire des outils comme bin utils n’est pas du même genre de travail que l’administration système.

Donc pas les mêmes personnes!

Là où les administrateurs système doivent savoir shell, ils pourraient écrire des prototypes en utilisant son outil préféré (et le plus connu).

Si ce nouvel utilitaire (prototype) est vraiment utile, d'autres personnes pourraient développer un outil dédié en utilisant un langage plus approprié.

F. Hauri
la source
1
Bon exemple. Votre approche est certainement plus efficace que celle de lololux, mais notez que la réponse de tensibai (la bonne façon de faire cette OMI, c'est-à-dire sans utiliser de boucles de shell) est beaucoup plus rapide que la vôtre. Et le vôtre est beaucoup plus rapide si vous n'utilisez . (plus de 3 fois plus vite avec ksh93 dans mon test sur mon système). est généralement la coquille la plus lente. Even est deux fois plus rapide sur ce script. Vous avez également quelques problèmes avec les variables non citées et l'utilisation de . Donc, vous illustrez beaucoup de mes points ici. bashbashzshread
Stéphane Chazelas
@ StéphaneChazelas Je suis d'accord, bash est probablement le shell le plus lent que les gens puissent utiliser aujourd'hui, mais le plus utilisé de toute façon.
F. Hauri
@ StéphaneChazelas J'ai posté une version en Perl sur ma réponse
F. Hauri
1
@Tensibai, vous trouverez Posixsh , awk , sed , grep, ed, ex, cut, sort, join... le tout avec plus de fiabilité que Bash ou Perl.
Wildcard
1
@Tensibai, de tous les systèmes concernés par U & L, la plupart d'entre eux (Solaris, FreeBSD, HP / UX, AIX, la plupart des systèmes Linux embarqués ...) ne sont pas livrés avec l' bashinstallation par défaut. bashest la plupart du temps trouvé que sur Apple et les systèmes GNU macOS (je suppose que ce que vous appelez les principales distributions ), bien que de nombreux systèmes ont aussi comme un ensemble en option (comme zsh, tcl, python...)
Stéphane Chazelas