Misceláneo sobre BASH

Dado que hace tiempo que tenía esto en mente y que el miércoles por la mañana dejaré de dar el coñazo por un mes pues os dejo el recuerdo y espero que esto os sirva para algo a todos aquellos que os peleais con BASH y a todos los que quereis aprender BASH scripting y a blablablablablabla....

¡ OJO ! DISCLAIMER: Esto no pretende ser una guía precisa; de hecho seguro que meto la gamba... está basado en mi experiencia y en lo que se aprende de tener que pelearse con situaciones 'raras' en los ebuilds.

Supongo que todo el mundo tiene unas nocioncillas básicas de shell scripting, para los que no las tengan y quieran empezar, antes de seguir leyendo yo recomiendo: http://www.zonasiete.org/manual/ch07.html

-----------------------------------

Conceptos básicos y ejemplos sobre expansiones de ruta

Las expansiones de ruta son extremadamente útiles cuando programamos en BASH, sin embargo es muy típico abusar de ellas o realmente no conocer cómo funcionan. En lugar de soltar un montón de tecnicismos, lo mejor es ir a lo práctico:

[ $ ~/prueba ] ls *
a  aa  ab  ac  b  c
[ $ ~/prueba ] echo *
a aa ab ac b c
[ $ ~/prueba ] ls $(echo *)
a  aa  ab  ac  b  c
[ $ ~/prueba ] ls '*'
ls: *: No such file or directory


Como podemos ver, realmente los comandos normalmente no entienden de 'meta-caracteres' o 'wildcards'. En este caso lo que hace BASH es efectuar la sustitución justo antes de ejecutar el comando; de hecho como vemos echo ha sacado prácticamente lo mismo.

Si además queremos que la expansión también incluya los 'dotfiles' debemos utilizar una opción del shell llamada dotglob

[ $ ~/prueba ] touch .hey-im-hidden
[ $ ~/prueba ] echo *
a aa ab ac b c
[ $ ~/prueba ] ( shopt -s dotglob ; echo * )
a aa ab ac b c .hey-im-hidden


Como ejemplo 'útil', para ver el número de paquetes que tenemos instalados en una Gentoo podemos hacer algo como:

[ $ ~ ] echo /var/db/pkg/*-*/* | wc -w
921

Expansiones de parámetro

Hay muchas veces que nos interesa hacer ciertos cambios sobre las variables a la hora de imprimirlas o utilizarlas. BASH nos permite hacer ciertos reemplazos; veremos algunos de ellos:

Reemplazo de patrones

Una de las cosas que podemos querer es reemplazar ciertas partes de una cadena; en este ejemplo reemplazaremos los números por 'X'. La sintaxis es:

${variable/[/]patrón[/[reemplazo]]}


Si especificamos // se reemplazarán todas las ocurrencias de patrón por reemplazo en caso de ser este ultimo especificado... si no se especificara, todas las ocurrencias se borrarían. En caso de solo especificarse una / entonces solo se efectuaría el reemplazo sobre la primera ocurrencia de patrón.

Veamos algunos ejemplos para ilustrar todo esto:

[ $ ~/prueba ] ( v=123abc321 ; echo ${v//[0-9]/X} )
XXXabcXXX
[ $ ~/prueba ] ( v=123abc321 ; echo ${v//[0-9]/} )
abc
[ $ ~/prueba ] ( v=123abc321 ; echo ${v//[0-9]} )
abc
[ $ ~/prueba ] ( v=123abc321 ; echo ${v/[0-9]/X} )
X23abc321


Eliminación de patrones

Además podemos especificar si queremos que el patrón se ajuste al final o al principio de la cadena si el primer caracter de patrón es % o # respectivamente:

[ $ ~/prueba ] ( v=123abc321 ; echo ${v//%[0-9]/X} )
123abc32X
[ $ ~/prueba ] ( v=123abc321 ; echo ${v//#[0-9]/X} )
X23abc321


También podemos especificar conjuntos 'negativos'; por ejemplo, eliminar todo lo que no sea un número de una variable [ creo que este problema se planteó hace un tiempo por aquí ]:

[ $ ~/prueba ] ( v=123abc321 ; echo ${v//[!0-9]} )
123321


Además de esto, podemos utilizar otros dos tipos de reemplazos, pero que en este caso lo que harán será eliminar porciones del contenido de nuestra variable, la sintaxis es:

${variable#[#]patrón}
${variable%[%]patrón}


Esas dos construcciones lo que hacen es eliminar el ajuste de patrón del principio y del final de variable respectivamente.

La diferencia entre usar ## (o %%) y usar # (o %) es que el primero usará el ajuste más largo posible de patrón y el segundo usará el más corto.

Para evitar caer en tecnicismos raros y empezar a perder a la gente, vamos a dar algunos ejemplos:

[ $ ~/prueba ] ( v="esto es una prueba" ; echo ${v#* } )
es una prueba
[ $ ~/prueba ] ( v="esto es una prueba" ; echo ${v##* } )
prueba
[ $ ~/prueba ] ( v="esto es una prueba" ; echo ${v% *} )
esto es una
[ $ ~/prueba ] ( v="esto es una prueba" ; echo ${v%% *} )
esto


Reemplazando 'basename' con built-ins de BASH

Un error muy grave es utilizar el comando basename en shell scripts. Digo que es un error porque basename lamentablemente no se comporta exactamente igual en todos los Unix (lo que restaría portabilidad a nuestro script) y además notaremos que el rendimiento deja muchísimo que desear si la llamada se hace en algoritmos recursivos o iterativos.

La solución es utilizar BASH para eliminar las partes de la ruta que no queremos:

[ $ ~/prueba ] ( v=/usr/src/linux/COPYING ; echo ${v} ; echo ${v%/*} ; echo ${v##*/} )
/usr/src/linux/COPYING
/usr/src/linux
COPYING


Obtener subcadenas y longitudes

También es posible obtener subcadenas de una cadena:

${variable:inicio[:fin]}


En este caso los ejemplos son 'autoexplicativos'.

[ $ ~/prueba ] ( v=1234567 ; echo ${v:2} )
34567
[ $ ~/prueba ] ( v=1234567 ; echo ${v:2:4} )
3456


Además podemos saber en cualquier momento la longitud de una cadena:

${#variable}


Por ejemplo:

[ $ ~/prueba ] ( v=01234 ; echo ${#v} )
5


Arrays en BASH

En BASH es posible utilizar arrays como en cualquier otro lenguaje de programación; con la salvedad de que los arrays los tenemos que 'declarar' en algún momento antes de acceder a ellos. Para hacer esto podemos usar:

miarray=()


Y a partir de ahí accederemos a los elementos de miarray de la siguiente forma:

${miarray[indice]}


Además podemos conocer el número de elementos de un array:

${#miarray[@]}


Veamos un ejemplo:

[ $ ~/prueba ] ( v=() ; v[0]=a ; v[1]=b ; v[2]=c ; echo ${v[@]} ; echo ${#v[@]} )
a b c
3


También se puede inicializar el array con valores desde el principio:

[ $ ~/prueba ] ( v=( "algo" "otra" "cosa" ) ; echo ${v[0]} )
algo


[ vs. [[

Es muy típico ver scripts utilizando '[ ]' para hacer comparaciones; yo no voy a decir que esté mal porque estaría mintiendo; de hecho yo también tengo esa mala costumbre. Aquí intentaré explicar y convencer de por qué utilizar '[[ ]]' es mucho mejor:

Para empezar, '[' es un comando por si solo, y si no me creeis, hagamos la prueba:

[ $ ~/prueba ] which [
/usr/bin/[
[ $ ~/prueba ] /usr/bin/[
/usr/bin/[: missing `]'
[ $ ~/prueba ] [
-bash: [: missing `]'
[ $ ~/prueba ] builtin [
-bash: [: missing `]'


Curioso ¿eh?. el caso es que BASH ya implementa un comando 'builtin' que se comporta de forma similar a /usr/bin/[ pero como podemos ver, no es exactamente lo mismo. BASH lo implementa por pura compatibilidad y para evitar tener que cargar un binario de 25K cada vez que hacemos una comparación.

Pero además tiene otro problemilla muy común; y es que si una variable que vamos a comparar tiene espacios tenemos que tener cuidado de entrecomillarla o tendremos errores de este estilo:

[ $ ~/prueba ] ( v="hola mundo" ; [ ${v} == "hola mundo" ] && echo success || echo fail )
-bash: [: too many arguments
fail


El problema es que BASH está expandiendo la variable antes de llamar al builtin así que la llamada queda así:

[ hola mundo == "hola mundo" ]


Obviamente esto no sigue la sintaxis y obtenemos un error. Si '[' fuera una construcción del lenguaje y no un built-in no tendríamos este problema. Podemos comprobarlo porque '[[' no es un built-in y sin embargo no necesitamos entrecomillar las variables:

[ $ ~/prueba ] builtin [[
-bash: builtin: [[: not a shell builtin
[ $ ~/prueba ] ( v="hola mundo" ; [[ ${v} == "hola mundo" ]] && echo success || echo fail )
success


Además en el caso de bash3 podemos utilizar un operador que no podemos usar en '[ ]': =~ que nos va a permitir comprobar si una variable se ajusta a una expresión regular POSIX o no.

while's y for's un poco especiales

Hay veces que un simple for i in algo o unwhile algo no nos sirven para lo que queremos y tenemos que usar algunos truquillos feos.

Kill that 'seq'

Es también muy común ver que scripts utilizan seq para construir secuencias de números o letras; esto está muy desaconsejado ya que este comando se comporta de forma muy distinta dependiendo de la implementación que esté disponible en el sistema. Además de que no es normal verlo en algunos Unix (no-Linux).

Para reemplazarlo podemos utilizar un reemplazo de BASH que tiene la siguiente sintaxis:

{elem1,elem2,elem3,...,elemN}


Esto se expandirá a:

elem1 elem2 elem3 ... elemN


Además en bash3 tenemos la posibilidad de utilizar:

{inicio..fin}


Podemos ver un par de ejemplos sobre esto:

[ $ ~/prueba ] echo {1,2,3,4,5,6,7,8,9,10}
1 2 3 4 5 6 7 8 9 10
[ $ ~/prueba ] echo {a,b,c}{a,b,c}
aa ab ac ba bb bc ca cb cc
[ $ ~/prueba ] echo {0..4}
0 1 2 3 4
[ $ ~/prueba ] echo {a..d}{a..d}
aa ab ac ad ba bb bc bd ca cb cc cd da db dc dd


Además es posible evitar utilizar seq si utilizamos el for 'a la C':

[ $ ~/prueba ] for (( i=0 ; i<10 ; i++ )) ; do echo -n "${i} "; done ; echo
0 1 2 3 4 5 6 7 8 9


Improve it 'while' you can

Ahora veremos una forma interesante de tratar con flujos utilizando while's. Por ejemplo si queremos tratar todas y cada una de las líneas de un flujo podemos hacer algo como:

while read f ; do
        # hacer algo
done


Un ejemplo de uso me lo encontré hace unos días al intentar arreglar las nuevas manpages-es que se había cargado el nuevo 'maintainer' (upstream) del paquete; aunque en el código aparecen variables propias de los ebuilds creo que se puede entender fácilmente:

# This is needed because manpages-es has broken encodings upstream
for d in {${S1},${S2}} ; do
        cd ${d}
        file -i man?/* | while read f ; do
                iconv -f ${f##*=} \
                        -t ${toencoding} ${d}/${f%%:*} \
                        -o ${D}/usr/share/man/es/${f%%:*}
        done
done


` ` vs. $( )

Es también muy típico ver scripts que utilizan ` ` para ejecutar comandos y asignar su salida a variables, aquí tampoco voy a decir que esto sea una mala práctica; lo que si diré es que utilizar $( ) es muchísimo más limpio y claro; veamos algún ejemplo:

El ejemplo es un poco tonto... pero bien muestra el segundo mayor problema que tiene utilizar ` `:

[ $ ~/prueba ] echo $($(which uname) -m)
i686
[ $ ~/prueba ] echo ``which uname` -m`
-bash: -m: command not found
which uname


¡ BOOOM ! No se pueden anidar 'facilmente'. El otro problema es que es bastante más legible la construcción $( ) que ` `.

-----------------------------------

Gah... seguro que me dejo cosas en el tintero como 'dereferenciación' de variables y tal pero bueno... si hay dudas, críticas, etc etc probad suerte aquí :)

Saludos,
Ferdy
Dudas, críticas... y las gracias donde te las damos?

Ala, durante unos días estaré entretenido. Las 'peculiaridades' de bash scripting se me atragantan un poco (en comparación con otros lenguajes de scripting como Perl), por lo que espero que con un poco de aquí, un poco de abs y un poco de a saber donde me aclare un poco que buena falta me hace.

Por cierto, buen viaje y hasta la vuelta [beer]

Cuidate,

Saludos!
Muy buen documento, Ferdy.

Hay algunas cosas que no sabía. Entre otras, bash3. Ni sabía que existía. Lo cual me lleva a hacer la siguiente pregunta: ¿cómo está de extendido? ¿No resta el uso de sus extensiones, portabilidad al script?

Yo sigo con bash2:

$ bash --version
GNU bash, version 2.05b.0(1)-release (i386-pc-linux-gnu)
Copyright (C) 2002 Free Software Foundation, Inc.


Otra cosilla más: no sabía el uso de for con sintaxis de C. He ido a la página del manual y no he logrado verlo por ningún sitio. Sí está en la [url="http://www.tldp.org/LDP/abs/html/loops1.html#FORLOOPC"]Guía Avanzada[/url], pero sólo da un ejemplo parecido al tuyo. Pero tirando del hilo, he llegado [url="http://www.tldp.org/LDP/abs/html/dblparens.html#CVARS"]aquí[/url], y me ha quedado todo un poco más claro.

Por último una sugerencia a la manipulación de flujos de datos con while. Normalmente se olvida esto:

$ ( a=5 ; echo "4" | while read f;do a=$f;done ; echo $a)
4


Es decir, while se ejecuta en una subshell y los cambiso que se hagan a las variables dentro de él, no se vend esde fuera. Es bastante común olvidarse de ello.

Buenas vacaciones...
Hay algunas cosas que no sabía. Entre otras, bash3. Ni sabía que existía. Lo cual me lleva a hacer la siguiente pregunta: ¿cómo está de extendido? ¿No resta el uso de sus extensiones, portabilidad al script?


Por supuesto... como siempre ir a la última tiene sus problemas. Por lo que yo se bash3 está 'medianamente extendido' así que yo no pondría nada que fuera bash3-only a no ser que tuviera la certeza de que solo se va a usar bajo bash3. Siempre se puede comprobar ${BASH_VERSINFO[0]} para encapsular el código bash3 y crear uno similar para bash2.

Es decir, while se ejecuta en una subshell y los cambiso que se hagan a las variables dentro de él, no se vend esde fuera. Es bastante común olvidarse de ello.


yup.... ya sabía yo que se me olvidaba algo. Añadir que eso puede crear problemas de rendimiento porque hay que 'pagar' el coste de expandir una nueva shell. También causa problemas si usamos exit o return dentro de shells... el proceso 'padre' no se enterará:

[ $ ~ ] ( echo "4" | while read f ; do exit ; done ; echo "No debería llegar aquí" )
No debería llegar aquí


Saludos.Ferdy

-----------------

Pasad todos un buen Julio, os leo a la vuelta. Si alguno cree que hay forma de mejorar este post, que lo diga.

Saludos.Ferdy
3 respuestas