Bash, les blocs de code et les variables
Le 6 août 2008, à 14:23 par Ulhume...

Les langages de Shell, dont Bash n'est que la version GNU, sont de vénérables ancêtres aussi vieux que la ligne de commande elle-même. Le Korn Shell ou ksh par exemple a prés de 30 ans.

Suivant la croyance que tout ce qui est nouveau est mieux, Bash, et ses confrère, est souvent relégué au rang d'antiquité bordélique et peu puissante. Mais lorsqu'il est mieux connu, BASH est en réalité assez cohérent et permet de scripter à une vitesse étonnantes des choses très évoluées pour le nombre de lignes de code produites. Ce n'est pas un hasard si le coeur du démarrage de Linux n'est à peu prés écrit qu'en Bash.

Le but de ce tutoriel n'est pas d'expliquer comment fonctionne BASH. Il y a d'excellent tutoriels qui font cela très bien. Mon idée est plus de documenter dans la langue de Molière certains aspects plus pointus de ce langage et en l'occurrence l'utilisation des blocs de codes, des fonctions et des variables.

Les blocs

Tout le monde connaît les fonctions en BASH :

# définition de ma_fonction
 function ma_fonction {
    ls /sys
    ls /proc
 }

 # appel de la fonction
 ma_fonction | grep cpu

Une fonction fonctionne à peu prés comme une commande de base ou un sous-script. On peut lui passer des paramètres ($1, $2...), elle a une valeur de retour avec le mot clef return à la place du exit. On peut même utiliser avec elle les redirections, pipes compris comme nous le voyons dans l'exemple. En somme la seule différence entre une fonction et un script ou une commande externe, c'est qu'il n'y a pas création du nouveau processus.

Maintenant ce que l'on sait moins c'est qu'une fonction en Bash est en réalité un cas particulier d'un concept plus large, celui de bloc de code. Il est ainsi possible de créer des fonctions qui n'ont pas de nom au sein d'un script. Ainsi le code précédent peut s'écrire de la manière suivante :

{
     ls /sys
     ls /proc
} | grep cpu

Autre exemple pratique, d'utilisation d'un bloc pour conditionner l'exécution d'une série de commande au bon déroulement d'une première commande :

      command1 && {
         sous-commande1
         sous-commande2
         ...
      }

En somme un bloc est une fonction anonyme, qui s'exécute tout de suite, qui ne peut avoir de passage paramètre ($1, $2...) et ne peut renvoyer de code de retour (return). Tout le reste est possible. Maintenant comme une fonction est un bloc, la logique nous souffle que s'il est possible de mettre un bloc dans une fonction, il doit aussi être possible de placer une fonction dans une autre fonction :

       function test1 {
            function test2 {
              echo "Je suis dans test2"            
            }
          echo "Je suis dans test1"
           test2           
       }
       test1

Et effectivement cela fonctionne très bien, de même que mettre une fonction dans un bloc anonyme... Maintenant on peut se demander quelle sont les règles de visibilités sur les fonctions. Deux fonctions dans deux bloc distinct peuvent t-elles s'appeler ? La réponse à cette question passe par une petite manipulation :

function test2 {
  echo "Je suis dans test2"            
}
set | grep test2
test.sh

Si on exécute ce script cela nous donne

gaston$./test?sh
test2 ()
echo "Je suis dans test2"
gaston$ 

Ainsi une fonction n'est rien d'autre qu'une variable. Du coup, la réponse à notre question réside dans les régles de visibilités qui leur sont associées.

Pour terminer sur les blocs de code, sachez que TOUT n'est que block de code. Et une commande seule est un bloc dont les accolades ont été rendues optionnelles. Ainsi les deux syntaxe suivantes sont équivalentes :

echo -e "coucou\nsalut\n" | grep "coucou"

# est équivalent à
{
  echo -e "coucou\nsalut\n"
} | {
  grep "coucou"
}

Visibilité des variables

En Bash, les variables d'environnement font parti de l'espace de travail du processus. Par défaut, les variables d'un processus ne sont pas visible par les processus engendrés. Pour que cela puisse ce faire, il faut marquer les variables avec la commande export. En revanche, un processus fils ne peut jamais modifier les variables du processus parent. Ainsi si l'on a un premier script :

       echo "V1 = $V1"
       echo "V2 = $V2"
       V2="valeur torpillée"
script1.sh

Et un second qui définit deux variables, dont une est marquée par export :

       V1="Valeur 1"
       V2="valeur2"
       export V2
       ./script1.sh

       # la valeur est inchangée malgrès l'action de script1.sh
       echo "V2 = $V2"
script2.sh

A l'exécution du second script nous voyons bien que V1 n'est pas renseigné mais que V2 l'est. En revanche, nous constatons bien que le sous-script n'a pas réussi à torpiller la valeur de la variable parent. Les données peuvent donc "descendre" mais pas "remonter".

Maintenant qu'en est-il pour une fonction ? Et bien comme c'est une variable, les mêmes règles s'appliquent : elle ne sera pas visible par les processus fils. Pour qu'elle le soit, il faut l'exporter. Si si, avec un paramètre magique en plus ça fonctionne très bien : export -f ma_fonction.

Maintenant que nous voyons bien les régles entre le processus courant et ceux engendrés, comment cela se passe-t-il pour les blocs et les fonctions ? Pour tester cela, nous pouvons modifier un peu notre ./script2.sh en supprimant la ligne export V2 et en lançant le script de manière un peu différente, par un source ./script1.sh. Et là... nos deux variables sont renseignées sans qu'aucune ne soit exportée. La "magie" réside dans le mot clef source. Lorsque l'on "source" un script, bash le charge... dans un bloc. On en déduit que les règles de visibilité changent un peu lorsque l'on reste dans le même processus. A noter avant d'aller plus loin que la syntaxe source script est équivalente à la plus connue . ./script (attention au premier point, suivi d'un espace).

La régle dans les blocs d'un même processus est donc que l'inverse de celle qui nous obligeait à utiliser export : les variables (et donc les fonctions) créées dans un bloc sont visibles partout ailleurs. Pour changer ce comportement, vous pouvez marquer, uniquement dans un bloc "fonction", les variables comme local. Attention cependant, ce changement de visibilité interdit juste la variable d'être vu des blocs parents, elle ne la cache pas des blocs enfants.

      function test {
         # une variable déclarée comme locale
         local v1=12

         # un bloc enfant...
         {
            echo "la variable reste visible ici : $v1"
         }
      }
      test
      echo "Mais là, elle est invisible : $v1"

Un cas bien tordu est celui d'un bloc de code (fonction ou pas) qui est pipé sur un second bloc de code. Dans la mesure où le pipe induit la création d'un sous-processus dans lequel est exécuté le code du second bloc, c'est la règle de visibilité entre scripts qui s'applique, pas celle des variables internes. Du coup le code suivant ne marchera jamais et la valeur v1 ne sera pas modifiée.
v1="V1"
echo "Valeur modifiée" | read v1
echo $v1

Pour terminer sur la visibilité, il nous reste une possibilité à explorer. Celle de rendre une variable accessible pour un bloc seulement. Tout le monde connais la syntaxe suivante :

variable=valeur commande

L'idée sous-jacente est qu'une copie des variables est faite à l'arrivée sur cette ligne et que la variable variable y est insérée. Cette copie est ensuite passée à la commande. Ainsi la variable en question n'est visible QUE de cette commande. Et bien sur la même chose est possible avec un bloc de code, mais uniquement de type "fonction" :

function test
{
         echo "v1 : $v1"
}

v1="salute"  test
echo "v1 : $v1"

Référencement indirect

Maintenant que nous avons une bonne compréhension des variables, voyons comment aller plus loin en y accédant par adressage indirecte. L'idée est de disposer d'une variable qui contient le nom d'une autre variable et de pouvoir lire et changer la valeur de cette seconde variable. Le tout est de connaître la syntaxe :

V1=12
echo "Lecture directe : $V1"
nom_de_variable="V1"
echo "Lecture indirecte : ${!nom_de_variable}"

Maintenant voyons comment écrire de manière indirecte en utilisant la fonction bien pratique eval

eval ${nom_variable}=valeur

Un peu d'introspection

Alors maintenant nous savons adresser des variables de manière indirecte. Vu que les fonctions sont elles aussi des variables, il doit y avoir moyen de faire avec elles la même chose. Commençons par écrire une fonction qui vérifie que la fonction dont le nom sera passé en paramètre existe. Pour cela nous utilisons la fonction type. Cette dernière utilisée avec le paramètre -t suivi d'un nom de variable, nous renvoie une chaîne indiquant le type de cette variable : alias, keyword, builtin, file ou function. C'est le dernier cas qui nous intéresse :

  function test {
      echo "Hello World"
  }

  function teste {
    t=$(type -t $1)
    if [ $? -ne "0" ] ; then
      echo "'$1' est introuvable"
    else
      if [ "$t" == "function" ] ; then
          echo "'$1' est une fonction"
        else
          echo "'$1' n'est pas un fonction, c'est un $t"
      fi
    fi
  }        

teste "test"
teste "if"
teste "test1"
teste "./test.sh"

Ce qui nous donne à l'exécution

gaston$./test.sh
'test' est une fonction
'if' n'est pas une fonction, c'est un keyword
'test1' est introuvable
'./test.sh' n'est pas une fonction, c'est un file
gaston$ 

Grâce à cela nous pouvons savoir si une fonction existe et même récupérer la liste des fonctions existantes en parcourant la liste des variables (genre renvoyée par set). Mais cela ne servirait pas à grand chose si l'on ne pouvait pas exécuter de manière indirecte une fonction :

nom_fonction=test
$nom_fonction arguments

Alors quel est l'intérêt de cette méthode ? Simplement de pouvoir construire des "hooks". Des hooks sont des fonctions qui son exécutées pour modifier un comportement si elles existent. Nous pouvons donc faire des petits plugins en bash très facilement. Pour cela écrivons le code suivant :

function callback
{
  name=$1
  shift
  kind=$(type -t "$name")
  if [ "$kind" == "function" ] ; then
     $name $*
     return 0
  else
    return 1
  fi
}

plugins=$(ls ./dossier_plugins/*.plugin)

for plugin in $plugins ; do
  # on sauve le nom de fichier
  file=$plugin
  # on supprime le dossie
  plugin=$(basename $plugin)
  # on supprime l'extension
  plugin=${plugin%.*}
  # on source le script
  . $file
  # on cherche à exécuter la fonction hook_description si elle existe
  description=$(callback ${plugin}_description)

  # si le hook s'est bien exécuté...
  if [ $? -eq 0 ] ; then
    # on exécute la fonction permettant de récupérer la valeur
    value=$(callback ${plugin}_execute)

    # affichage
    echo "$description = $value"
  fi
done  
lire_capteurs.sh

Maintenant, il suffit de créer un sous dossier dossier_plugins et d'y mettre par exemple

function hd_occupation_description {
  echo "Taux d'occupation du disque";
}

function hd_occupation_execute {
  df /dev/sda1 | grep /dev/sda1 | awk '{print $5}'
}
hd_occupation.plugin

Encore un autre pour la température cette fois :

function hd_temp_description {
  echo "Température du disque";
}

function hd_temp_execute {
  hddtemp -n /dev/hda
}
hd_temp.plugin

Voilà, maintenant nous pouvons lancer notre script

gaston$./lire_capteurs
Taux d'occupation du disque = 64%
Température du disque = 41
gaston$ 

En quelques lignes de bash, nous avons un "nagios du pauvre" qui s'étend de manière très simple en ajoutant des plugins dans le dossier ./dossier_plugins.

Conclusion

Il ne s'agit pas de construire d'énormes applications en bash plus que de l'utiliser pour ce qu'il est, à savoir le plus efficace moyen de commander un système unix. Un programme écrit en python, perl ou ruby sera sans nul doute plus robuste ou offrira plus de fonctionnalités, mais la compacité de la ligne de commande permet à bash de battre des records de vitesse pour des tâches simples et répétitives. De plus son côté "standard" fait que n'importe quelle distribution sera capable de manger du bash, ce qui est un gros plus de portabilité.

Commentaires

advaya, le 11 September, 2008 - 16:32

hummm.... désolé mais j'ai un problème avec bash, et je ne trouve pas la solution. Je l'écris ici, même si c'est pas forcément le lieu adapté, en espérant que quelqu'un saura me répondre.

C'est au sujet des wildcards du type ensemble : [a-z] ne devrait me sortir que les lettre minuscules en théorie ; or quand je fais un

$ touch essai.tex touch Essai.tex ls [a-z]ssai.tex
essai.tex Essai.tex

Et ce n'est pas a priori normal qu'il me liste le second fichier, n'est-ce pas ? Plus fort encore :

$ ls [a-e]ssai.tex ls [a-g]ssai.tex
essai.tex
essai.tex  Essai.tex

et donc tant que l'intervalle ne dépasse pas 'e', il ne me sort bien que le premier fichier, tout en minuscules. Mais si l'intervalle dépasse, il me sort les deux (avec minuscule et majuscule). Autre tentative :

$ ls [A-D]ssai.tex ls [A-E]ssai.tex ls [A-G]ssai.tex
ls: ne peut accéder [A-D]ssai.tex: Aucun fichier ou répertoire de ce type
essai.tex Essai.tex
essai.tex Essai.tex

La, en majuscule, dès que l'ensemble contient 'E', il me sort systématiquement les deux.

La logique voudrait que j'en déduise que l'ordre alphabétique dans la construction des ensemble est en fait : a,A,b,B,c,C etc ..., ce qui expliquerait ce comportement. Mais c'est contraire à ce que je croyais jusqu'ici sur bash.

J'ai regardé s'il n'y avait pas d'options spécifiques mais rien trouvé ... j'avoue ne pas comprendre ... any ideas ? (et est-ce que je suis le seul dans ce cas ?)

EDIT : je viens de tenter ça :
ls [A-e]ssai.tex
essai.tex

ce qui aurait tendance à confirmer ce que je suppose sur l'ordre alphabétique et les wildcards ... y a-t-il moyen de changer ça ?

advaya, le 12 September, 2008 - 13:01

Je me réponds à moi-même, puisqu'on m'a fait comprendre d'où venait le problème : il s'agit en fait d'un problème de locale. Si on veut utiliser les ensembles de type [a-z] [A-Z] avec uyne distinction minuscule/majuscule en bash, il suffit par exemple d'utiliser la locale par défaut (C):

export LC_ALL=C

Et là ça fonctionne Smiling

advaya, le 23 September, 2008 - 18:02

Désolé, encore un autre hors-sujet, enfin presque ...

En somme la seule différence entre une fonction et un script ou une commande externe, c'est qu'il n'y a pas création du nouveau processus.

Merci, ça répond à une question que je me suis posé un moment (qu'est-ce qui différencie un script d'une fonction ?). Du reste, je suis tombé par hasard (faute de frappe dans un script Sticking out tongue ) sur la variable $$, qui permet de mettre cela en évidence puisqu'elle renvoit le PID du processus d'où elle est utilisée.

Par exemple, si dans bash on tappe

echo $$
6653
ps aux | grep bash | grep -v grep | awk '{ print $2 }'
6653

Cela renvoit bien le PID de bash lui-même. Maintenant, si on fait une fonction pid_func comme suit :

function pid_func { echo PID : $$; }

et un script pid_script totalement identique on aura :

pid_func
PID : 6653   # puisque la fonction est dans bash, le pid reste le même

source pid_script
PID : 6653   # même PID puisque le script est "sourcé" dans bash

./pid_script
PID : 12654  # PID différent, puisque le script exécuté engendre un nouveau processus

Plus ça va, plus je trouve bash plaisant Smiling

Emmanel , le 15 November, 2008 - 04:10

En lieu et place de

ps aux | grep bash | grep -v grep | awk '{ print $2 }'

on peut utiliser plus court pour le même résultat :

ps aux|awk '{if ($11~/bash/) print $2}'

Poster un nouveau commentaire

Le contenu de ce champ est gardé secret et ne sera pas montré publiquement.
  • Allowed HTML tags: <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • To highlight piece of code, just surround them with <code type="language"> Your code &tl;/code>>. Language can be java,c++,bash,etc... Everything Geshi support.
  • Les lignes et les paragraphes vont à la ligne automatiquement.
  • Textual smileys will be replaced with graphical ones.
  • Les adresses de pages web et de messagerie électronique sont transformées en liens automatiquement.

Plus d'informations sur les options de formatage

Connexion utilisateur
Les derniers bavardages...