Artisan Numérique

/cli/bash/développement/ Bash, les blocs de code et les variables

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"

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é 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&039; est une fonction
'if&039; n'est pas une fonction, c'est un keyword
'test1&039; est introuvable
'./test.sh&039; n'est pas une fonction, c'est un file

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

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é.