3.6.10. Trucos y consejos

El epígrafe está dedicado a exponer algunas prácticas de escritura que particularmente al autor le parecen recomendables.

3.6.10.1. Gestión de errores

Es recomendable, cuando se produce un error que interrumpe la ejecución, escribir un mensaje de error en la salida de errores y acabar con un código distinto a 0. Algo así:

echo "ERROR. Se ha producido bla, bla, bla" >&2
exit 1

Lo habitual es que estos errores puedan producirse en distintos puntos del programa y que tengamos que repetir la estructura. Una buena práctica es crear una función para tratar los errores:

#
# Trata los errores.
# $1: Codigo de salida. Si es 0, se considera que el mensaje es sólo una
#     advertencia y no se interrumpe la ejecución.
# $*: Mensaje de información que se mostrará por la salida de errores.
#
error() {
   local EXITCODE=$1
   shift

   if [ $EXITCODE -eq 0 ]; then
      echo "¡ATENCIÓN! "$* >&2
   else
      echo "ERROR. "$* >&2
      exit $EXITCODE
   fi
}

De este modo, para generar errores basta con hacer:

error 2 Opción desconocida

o bien, si se desea escribir un mensaje de advertencia, sin llegar a interrumpir la ejecucuón:

error 0 Comportamiento indefinido. Puede que no obtenga lo que espera.

Nota

Si en el script se decide escribir mensajes en el registro, lo lógico es que sustituyamos los echo de la función anterior por llamadas a la función log. El primer mensaje puede ser de gravedad ERROR y el segundo debe ser de gravedad CRIT, puesto que provoca la salida inmediata del programa.

3.6.10.2. Ayuda en línea

En un script que use opciones en línea es indispensable que preparemos una función de ayuda que se ejecute al usar el script con las opciones -h o --help. De hecho, es conveniente no usar jamás estas opciones para otra labor que no sea mostrar ayuda, ya que más de un usuario tendrá la tentación de ejecutarlo por primera vez con una de estas dos opciones para conocer cómo se usa y, si el script realiza alguna operación que supone un efecto irreversible, las consecuencias pueden ser desastrosas.

Lo más conveniente es crear una función de ayuda que se invoque luego desde el código principal e imprima por pantalla la ayuda pertinente:

help() {
   echo "$(basename $0) [opciones] fichero:
   Programa que hace tal cosa... (lo describimos brevemente).

Opciones:

 -h, --help                 Muestra esta misma ayuda.
 -o, --output <FICHERO>     Fichero donde almacenar la salida en vez de
                            mostrarla por pantalla.
"
}

Nota

Conviene que nos aseguremos de que estas líneas no tendrá un ancho mayor a 80 caracteres.

3.6.10.3. Valores predeterminados

Cuando un script define valores predeterminados que se usan en caso de que el usuario no ios defina (a través de opciones en línea, por ejemplo), lo aconsejable es colocar tales valores al comienzo del script, en previsión de que por alguna razón algún usuario estime oportuno modificar tales valores:

#!/bin/sh

#
# Valores predeterminados.
#
INI=1
FIN=100
LAPSO=.5

# Aquí comenzamos el script...

for x in $(seq $INI $FIN); do
   echo $x
   sleep $LAPSO
done

De este modo, se le deja muy fácil hacer los cambios oportunos. Ahora bien, si queremos ser aún más elegantes, podemos permitir que cambie estos valores a través de variables de ambiente:

#!/bin/sh

#
# Valores predeterminados.
#
INI=${CTD_INI:-1}
FIN=${CTD_FIN:-100}
LAPSO=${CTD_LAPSO:-.5}


# Aquí comenzamos el script... etc...

En este caso, es conveniente evitar que el nombre de alguna de nuestras variables pueda coincidir con el nombre de una variable de ambiente ya existente. Por ese motivo conviene construir el nombre con un prefijo que puede ser el nombre del programa o un apócope formado a partir de él. En el ejemplo, se ha supuesto que el nombre del programa es contador y con él se ha construido el prefijo CTD. Obrando así, ejecutar el programa con un lapso diferente a medio segundo es tan sencillo como:

$ LAPSO=2 ,/mi_programa.sh

3.6.10.4. Función join

La mayoría de los lenguajes disponen de una función (o un método) que permite concatenar los elementos de un array (de cadenas) usando como separador un carácter. La shell carece de ella, pero podemos implementarla haciendo uso de la propiedad de la variable $* al encerrarse entre comillas dobles:

join() {
   local IFS="$1"
   shift

   echo "$*"
}

De esta forma, componer una dirección IP si se tienen sus cuatro octetos es tan fácil como:

$ join . 192 168 1 14
192.168.1.14

Lamentablemente, la función anterior sólo es útil si el elemento de unión es un único carácter. Si consta de varios caracteres, es preciso echar mano de printf, aprovechando que éste comando repite el patrón si este no es capaz de consumir el resto de argumentos suministrados[1]:

join() {
   local glue="$1"
   shift

   printf -- "$1"
   shift
   [ $# -gt 0 ] && printf -- "$glue%s" "$@"
}

Esta función permite lo siguiente:

$ join " -- " aa bb cc
aa -- bb -- cc

3.6.10.5. Función split

Esta es la función que hace la tarea inversa: dada una cadena, descomponerla según un carácter que se considera separador. Por ejemplo, descomponer la cadena «a_b_c» para obtener los componentes por separado «a», «b» y «c».

Con bash si tiene sentido crear una función para esta tarea, ya que bash soporta arrays:

#
# Descompone una cadena según un carácter separador
# $1: nombre del array en que se almacenará el resultado.
# $2: caracter separador.
# $3: Cadena a descomponer
# !!ATENCiÓN!! Sólo para BASH. No vale para POSIX sh.
#
split() {
   local sep="$2" arr="$1"
   shift 2

   IFS="$sep" read -ra $arr <<<"$*"
}

Definida esta función, nos bastaría para descomponer la cadena y almacenar los elementos en un array llamado arr lo siguiente:

split arr _ "a_b_c"

En el estándar POSIX en cambio, no hay forma de almacenar los componentes en un array, por lo que tenemos dos alternativas, aunque ninguna de las dos se puede implementar como función:

  • Utilizar el único array que existe: el que almacena los argumentos posicionales:

    cadena="a_b_c"
    IFS=_
    set -- $cadena
    unset IFS
    
  • Utilizar read:

    a="a_b_c"
    echo "$a" | {
       IFS=_ read -r x y z
       echo x
       echo y
       echo z
    }
    

    Esta solución tiene el inconveniente de que sólo es válida cuando sabemos de antemanos cuántos serán los elementos en los que se descompondrá la cadena.

3.6.10.6. Consulta de usuarios

Debe tenerse presente que la consulta del fichero /etc/passwd sólo da información de los usuarios locales, pero no de usuarios de red que pueden estar definidos a través de OpenLDAP o Samba. Lo más correcto cuando en un script queremos consultar o comprobar algún dato de usuario es hacer uso de la orden getent:

$ getent passwd bartolo

Advertencia

Caso distinto es que, además, pretendamos modificar con el script los datos. En ese caso, la herramienta de modificación dependerá del soporte que defina al usuario y tendremos que implementar un método distinto para cada soporte.

En ese caso, lo más conveniente es aislar en una o varias funciones las tareas de modificación, de manera que si se desea cambiar el «backend», baste con reimplementar el código de estas funciones.

Además, cuando nuestro script requiere que guardemos en variables los datos de los usuarios, es muy pertinente una construcción de este tipo:

getent passwd nombre_usuario | {
   IFS=: read -r user _ uid gid gecos home shell
   # ¡Atención! Recuerde que ni estas variables ni otras que defina dentro de
   # este bloque existirán fuera, porque se ejecuta en una subshell.
   # Aquí procederemos a usar esas variables como mejor convenga. Por ejemplo:
   echo "El usuario se llama: $user"
}

y si lo que queremos es tratar a todos los usuarios

getent passwd | while IFS=: read -r user _ uid gid gecos home shell; do
   # Lea lo expuesto en el código anterior.
done

que es la aplicación a este caso particular de lo expuesto al hablar de la función split.

3.6.10.7. Simulación de acciones

En ocasiones puede interesarnos que nuestro script no llegue a ejecutar las acciones, pero que presente las órdenes que habría ejecutado. Un buen ejemplo es el de aquellos script cuya misión es facilitarnos la tarea de construir una orden compleja con muchos argumentos.

Para ello, podemos construir la siguiente función:

execute() {
   [ -n "$VERBOSE" ] && echo "$@" > /dev/tty
   [ -z "$SIMULATE" ] && "$@"
}

que permite ejecutar cualquier orden anteponiendo la palabra execute. Por ejemplo:

execute ls /

En ausencia de las variables VERBOSE y SIMULATE, la orden se ejecuta normalmente. Si se le da algún valor a la variable VERBOSE, se mostrará por pantalla cuál es la orden ejecutada; y si se le da valor a la variable SIMULATE, no se ejecutará.

3.6.10.8. Funciones ejecutadas por órdenes externas

Hay órdenes como xargs o find que toman como argumento otras órdenes con el fin de ejecutarlas. Ahora bien, puede ocurrir que la segunda orden sea algo compleja y requiramos hacer un pequeño script para llevarla a cabo. Por ejemplo, tenemos este código para pasar a mayúsculas:

cat "$1" | tr '[:lower:]'  [':upper:']

y, por otro lado, este otro que busca ficheros y los muestra en mayúsculas:

find -type f -exec toupper.sh '{}' \;

Nuestro problema es que el pequeño código para convertir en mayúsculas debemos colocarlo en un script aparte, ya que find requiere una orden externa. Si la segunda línea ya se encontraba dentro de un script, la consecuencia es que tendremos que trocear en dos script independientes nuestro script para poder llevarlo a cabo.

La pregunta es ¿no hay forma de incluir la línea de código dentro de una función y hacer que find sea capaz de ejecutarla? La respuesta inmediata es que no, pero podemos buscarnos argucias para lograrlo.

Apegándonos estrictamente al estándar la solución está en añadir un argumento al script de manera que, cuando se incluya en su invocación, se limite a ejecutar la función:

#!/bin/sh

toupper() {
   cat "$1" | tr '[:lower:]' '[:upper:]'
}

# El tratamiento será más complejo
# si el propio script requiere otros argumentos.
if [ "$1" = "-x" ]; then
   toupper "$2"
   exit 0
fi

find -maxdepth 1 -type f -name "*.sh" -exec "$0" -x "{}" \;

En bash, se pueden exportar también funciones, así que eso podemos hacer y usarla en una subshell:

#!/bin/bash

toupper() {
   cat "$1" | tr '[:lower:]' '[:upper:]'
}

export -f toupper
find -maxdepth 1 -type f -name "*.sh" -exec bash -c "toupper '{}'" \;

Notas al pie