web-dev-qa-db-fra.com

Pourquoi l'utilisation d'une boucle Shell pour traiter du texte est-elle considérée comme une mauvaise pratique?

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 souligne Stéphane Chazelas , certaines des raisons de ne pas utiliser la boucle Shell sont conceptuelles , fiabilité , lisibilité , performances et sécurité .

Cette réponse explique la fiabilité et lisibilité aspects:

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

Pour les performances , la boucle while et read sont extrêmement lentes lors de la lecture à partir d'un fichier ou d'un tube, parce que lire le shell intégré lit un caractère à la fois.

Que diriez-vous des aspects conceptuels et de sécurité ?

207
cuonglm

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 riez pas, j'en ai vu beaucoup).

Généralement des débutants de script Shell. Ce sont des traductions littérales naïves de ce que vous feriez dans des langages impératifs comme C ou python, mais ce n'est pas comme ça que vous faites les choses dans des shells, et ces exemples sont très inefficaces, complètement peu fiables (conduisant potentiellement à des problèmes de sécurité), et si jamais vous réussissez pour corriger la plupart des bugs, votre code devient illisible.

Conceptuellement

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

Les shells sont un langage de niveau supérieur. On peut dire que ce n'est même pas une langue. Ils sont avant tous les interprètes de ligne de commande. Le travail est effectué par les commandes que vous exécutez et le Shell est uniquement destiné à les orchestrer.

L'une des grandes choses qu'Unix a introduites était le tube et ces flux stdin/stdout/stderr par défaut que toutes les commandes gèrent par défaut.

En 50 ans, nous n'avons pas trouvé mieux que cette API pour exploiter la puissance des commandes et les faire coopérer à une tâche. C'est probablement la principale raison pour laquelle les gens utilisent encore des obus 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 fait juste la plomberie (ouvrez les fichiers, configurez les tuyaux, appelez les commandes) et quand tout est prêt, il coule sans que le Shell fasse quoi que ce soit. Les outils font leur travail en même temps, efficacement à leur propre rythme avec suffisamment de tampon pour que pas l'un ne bloque l'autre, c'est juste beau et pourtant si simple.

Invoquer un outil a cependant un coût (et nous le développerons sur le point de la 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 cut, c'est comme ouvrir le tiroir de la cuisine, prendre le couteau, l'utiliser, le laver, le sécher, le remettre dans le tiroir. Quand vous faites:

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

C'est comme pour chaque ligne du fichier, obtenir l'outil read du tiroir de la cuisine (très maladroit parce que il n'a pas été conçu pour ça ), lisez une ligne, lavez votre lecture outil, remettez-le dans le tiroir. Planifiez ensuite une réunion pour l'outil echo et cut, sortez-les du tiroir, invoquez-les, lavez-les, séchez-les, remettez-les dans le tiroir, etc.

Certains de ces outils (read et echo) sont intégrés dans la plupart des shells, mais cela ne fait guère de différence ici puisque echo et cut doivent encore être exécuter dans des processus distincts.

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

Ici, la manière la plus évidente est de récupérer votre outil cut dans le tiroir, de couper votre oignon entier et de le remettre dans le tiroir une fois le travail terminé.

IOW, dans des shells, en particulier pour traiter du texte, vous invoquez le moins d'utilitaires possible et les faites coopérer à la tâche, et non pas exécutez des milliers d'outils en séquence en attendant que chacun démarre, s'exécute, se nettoie avant d'exécuter le suivant.

Lectures complémentaires dans la bonne réponse de Bruce . Les outils internes de traitement de texte de bas niveau dans les shells (sauf peut-être pour zsh) sont limités, encombrants et généralement inadaptés 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 n'ont aucune prétention à être des langages de programmation performants. Ils ne le sont pas, ce ne sont que des interprètes en ligne de commande. Donc, peu d'optimisation a été faite sur ce front.

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

Les utilitaires Shell intégrés même correspondants (read, echo, printf) ne peuvent pas faire cela. read est destiné à lire une ligne. S'il lit au-delà du caractère de nouvelle ligne, cela signifie que la prochaine commande que vous exécuterez le manquera. Donc read doit 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, mais cela ne fonctionne que pour les fichiers réguliers et bash, par exemple, ne lit que des blocs de 128 octets, ce qui est encore beaucoup moins que les utilitaires de texte).

De même côté sortie, echo ne peut pas simplement tamponner sa sortie, il doit la sortir immédiatement car la prochaine commande que vous exécuterez ne partagera pas ce tampon.

Évidemment, l'exécution séquentielle des commandes signifie que vous devez les attendre, c'est une petite danse de planificateur qui donne le contrôle à partir du shell et des outils et inversement. Cela signifie également (par opposition à l'utilisation d'instances d'outils de longue durée dans un pipeline) que vous ne pouvez pas exploiter plusieurs processeurs en même temps lorsqu'ils sont disponibles.

Entre cette boucle while read Et l'équivalent (supposément) cut -c3 < file, Dans mon test rapide, il y a un ratio de temps CPU d'environ 40000 dans mes tests (une seconde contre une demi-journée). Mais même si vous n'utilisez que des modules internes Shell:

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

(ici avec bash), c'est toujours autour de 1: 600 (une seconde vs 10 minutes).

Fiabilité/lisibilité

Il est très difficile d'obtenir ce bon code. Les exemples que j'ai donnés sont vus trop souvent dans la nature, mais ils ont de nombreux bugs.

read est 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 line Ne lit pas une ligne d'entrée, ou peut-être lit une ligne d'une manière très spéciale. Il lit en fait les mots de l'entrée, ces mots séparés par $IFS Et où la barre oblique inverse peut ê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 line Stockera "foo/bar baz" Dans $line, Pas " foo\/bar \" Comme vous vous y attendez.

Pour lire une ligne, il vous faut en fait:

IFS= read -r line

Ce n'est pas très intuitif, mais c'est comme ça, rappelez-vous que les obus n'étaient pas destinés à être utilisés comme ça.

Idem pour echo. echo développe les séquences. Vous ne pouvez pas l'utiliser pour des contenus arbitraires comme le contenu d'un fichier aléatoire. Vous avez besoin de printf ici à la place.

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

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

Maintenant, quelques mises en garde supplémentaires:

  • à l'exception de zsh, cela ne fonctionne pas si l'entrée contient des caractères NUL alors qu'au moins 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
  • à l'intérieur de la boucle, stdin est redirigé, vous devez donc faire attention à ce que les commandes qu'il contient ne lisent pas à partir de stdin.
  • pour les commandes dans les boucles, nous ne prêtons pas attention à leur réussite ou non. Habituellement, les conditions d'erreur (disque plein, erreurs de lecture ...) seront mal gérées, généralement plus mal qu'avec l'équivalent correct .

Si nous voulons aborder certains de ces problèmes 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.

Il existe un certain nombre d'autres problèmes liés à la transmission de données aux commandes via les arguments ou à la récupération de leur sortie dans des variables:

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

Considérations de sécurité

Lorsque vous commencez à travailler avec les variables Shell et les arguments des commandes , vous ' re entrant dans un champ de mines.

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

Lorsque vous souhaitez utiliser des boucles.

À déterminer

271
Stéphane Chazelas

En ce qui concerne la conception et la lisibilité, les shells sont généralement intéressés par les fichiers. Leur "unité adressable" est le fichier et "l'adresse" est le nom du fichier. Les shells ont toutes sortes de méthodes pour tester l'existence de fichier, le type de fichier, la mise en forme du nom de fichier (en commençant par la globalisation). Les shells ont très peu de primitives pour gérer le contenu des fichiers. Les programmeurs shell doivent invoquer un autre programme pour gérer le contenu des fichiers.

En raison de l'orientation du fichier et du nom de fichier, la manipulation de texte dans le shell est vraiment lente, comme vous l'avez noté, mais nécessite également un style de programmation peu clair et déformé.

43
Bruce Ediger

Il y a des réponses compliquées, donnant beaucoup de détails intéressants pour les geeks parmi nous, mais c'est vraiment assez simple - le traitement d'un gros fichier dans une boucle Shell est tout simplement trop lent.

Je pense que l'interrogateur est intéressant dans un type typique de script Shell, qui peut commencer par une analyse en ligne de commande, des paramètres d'environnement, la vérification des fichiers et des répertoires, et un peu plus d'initialisation, avant de passer à son travail principal: passer par un grand fichier texte orienté ligne.

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

Mais lorsque nous commençons à traiter le gros fichier, qui pourrait avoir des milliers ou des millions de lignes, c'est pas bien pour que le script Shell prenne une fraction significative de seconde (même si ce n'est que quelques dizaines de millisecondes) pour chaque ligne, car cela pourrait ajouter des heures.

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

Au lieu d'utiliser une boucle pour regarder chaque ligne, nous devons passer tout le fichier un pipeline de commandes. Cela signifie qu'au lieu d'appeler les commandes des milliers ou 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 et elles sont conçues pour être rapides et efficaces.

Unix a de nombreux outils intégrés merveilleux, allant du simple au complexe, que nous pouvons utiliser pour construire nos pipelines. Je commençais généralement par les plus simples et j'utilisais des plus complexes uniquement lorsque cela était nécessaire.

J'essaierais é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, cela ne vous dérangera peut-être pas l'effort supplémentaire de s'assurer qu'il est installé sur chaque plate-forme sur laquelle votre logiciel doit fonctionner :-)

Les outils simples incluent head, tail, grep, sort, cut, tr, sed, join (lors de la fusion de 2 fichiers) et awk one-liners, parmi beaucoup d'autres. C'est incroyable ce que certaines personnes peuvent faire avec les commandes de correspondance de modèles et sed.

Quand cela devient plus complexe, et que vous devez vraiment appliquer une logique à chaque ligne, awk est une bonne option - soit une ligne (certaines personnes mettent des scripts awk entiers sur 'une ligne', bien que ce ne soit pas très lisible) ou dans un court script externe.

Comme awk est un langage interprété (comme votre Shell), il est étonnant qu'il puisse effectuer un traitement ligne par ligne si efficacement, mais il est spécialement conçu pour cela et c'est vraiment très rapide.

Et puis il y a Perl et un grand nombre d'autres langages de script qui sont très bons pour traiter les fichiers texte, et qui sont également livrés avec de nombreuses bibliothèques utiles.

Et enfin, il y a du bon vieux C, si vous avez besoin vitesse maximum et 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 fichiers 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 effet, cela élargit la gamme des `` outils Unix simples et rapides '' que je peux appeler à partir de mes scripts Shell, donc je peux gérer la plupart des projets en écrivant uniquement des scripts, ce qui est beaucoup plus rapide que d'écrire et de déboguer du code C sur mesure à chaque fois!

Quelques derniers conseils:

  • n'oubliez pas de démarrer votre script Shell principal avec export LANG=C, ou de nombreux outils traiteront vos fichiers plain-old-ASCII comme Unicode, ce qui les rend beaucoup plus lents
  • pensez également à définir export LC_ALL=C si vous voulez que sort produise un ordre cohérent, quel que soit l'environnement!
  • si vous avez besoin de sort vos données, cela prendra probablement plus de temps (et ressources: CPU, mémoire, disque) que tout le reste, essayez donc de minimiser le nombre de commandes sort et la taille des fichiers qu'ils trient
  • un seul pipeline, lorsque cela est possible, est généralement plus efficace - l'exécution de plusieurs pipelines en séquence, avec des fichiers intermédiaires, peut être plus lisible et débogable, mais augmentera le temps nécessaire à votre programme
26
Laurence Renshaw

Oui mais...

Le bonne réponse de Stéphane Chazelas est basé sur le concept Shell de déléguer chaque opération de texte à des binaires spécifiques, comme grep, awk, sed et autres.

Comme bash est capable de faire beaucoup de choses par lui-même, la suppression de forks peut devenir plus rapide (même que d'exécuter un autre interpréteur pour faire tout le travail).

Par exemple, jetez un oeil sur ce post:

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

et

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

tester et comparer ...

Bien sûr

Il n'y a aucune considération à propos de entrée utilisateur et sécurité!

N'écrivez pas d'application Web sous bash !!

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

Ma signification:

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

Donc pas les mêmes personnes!

Lorsque les administrateurs système doivent connaître Shell, ils pourraient écrire prototypes en utilisant son préféré (et le meilleur 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é.

15
F. Hauri