Lección 4 de 8: funciones, arrays y cadenas en Bash. Cuando un script pasa de unas pocas líneas, hace falta ordenarlo en funciones, guardar listas sin que se rompan con los espacios y recortar textos sin llamar a otros programas. Esta lección cubre las funciones y sus argumentos, por qué return no devuelve datos, local, los arrays indexados y asociativos, mapfile, las expansiones ${…} para manejar cadenas y printf.
Funciones: definir y llamar
Una función agrupa órdenes con un nombre, como si fuera una orden nueva. Se define con el nombre, un par de paréntesis vacíos y el cuerpo entre llaves, y se llama escribiendo su nombre, sin paréntesis. Los argumentos llegan igual que a un script: $1, $2… para cada uno, $# para cuántos son y "$@" para todos, cada uno por separado.
#!/usr/bin/env bash
saludar() {
echo "Hola, $1. Tienes $2 mensajes nuevos."
}
describir() {
echo "Me llamaron con $# argumentos:"
for arg in "$@"; do
echo " - $arg"
done
}
saludar Ana 3
saludar "Luis Pérez" 0
describir uno "dos palabras" tressalidaHola, Ana. Tienes 3 mensajes nuevos. Hola, Luis Pérez. Tienes 0 mensajes nuevos. Me llamaron con 3 argumentos: - uno - dos palabras - tres
Dentro de la función, $1 es el primer argumento de la función, no el del script; $0 sigue siendo el nombre del script. Fíjate en "dos palabras": gracias a las comillas llega como un solo argumento, y "$@" lo conserva así al recorrerlos. Hay otra forma de definirlas, function saludar { … }, que es propia de Bash; la de los paréntesis funciona también en sh y es la que usaremos.
El error que te ahorra: llamar a una función antes de definirla
#!/usr/bin/env bash
resumen 12
resumen() {
echo "Total: $1"
}salida./antes.sh: line 3: resumen: command not found código de salida: 0
Bash lee y ejecuta el script de arriba abajo: cuando llega a la línea 3, resumen todavía no existe y la busca como una orden del sistema, que no encuentra. Peor aún, el script sigue adelante y termina con el código 0, el de su última línea (la definición), así que quien lo lance creerá que todo fue bien. Lo bueno es que ShellCheck lo ve sin ejecutar nada:
shellcheckIn antes.sh line 3: resumen 12 ^--------^ SC2218 (error): This function is only defined later. Move the definition up. For more information: https://www.shellcheck.net/wiki/SC2218 -- This function is only defined lat...
Por costumbre, las funciones van arriba y el código que las usa, al final, a menudo dentro de una función main que se llama en la última línea.
return no devuelve datos: devuelve un código de salida
En la mayoría de los lenguajes, return devuelve un valor. En Bash, return solo fija el código de salida de la función, un número de 0 a 255 que indica si fue bien (0) o mal (cualquier otro), igual que el de cualquier orden. Para devolver datos, la función los escribe con echo o printf, y quien la llama los recoge con $( ):
#!/usr/bin/env bash
# Devuelve datos: los escribe en la salida
doble() {
echo $(( $1 * 2 ))
}
# Devuelve un resultado de sí o no: el código de salida
es_par() {
(( $1 % 2 == 0 ))
}
resultado=$(doble 21)
echo "El doble de 21 es $resultado"
for n in 7 10; do
if es_par "$n"; then
echo "$n es par"
else
echo "$n es impar"
fi
done
devuelve_mucho() {
return 300
}
devuelve_mucho
echo "return 300 deja un código de $?"salidaEl doble de 21 es 42 7 es impar 10 es par return 300 deja un código de 44
es_par no necesita ni return: el código de salida de una función es el de su última orden, y (( … )) da 0 si la expresión es verdadera. Por eso se puede usar directamente en un if. Y return 300 se queda en 44, porque el código de salida solo tiene 8 bits: 300 menos 256. Si te encuentras usando return para devolver números, cámbialo por echo. ShellCheck, de hecho, lo marca como error:
shellcheckIn devolver.sh line 25:
return 300
^-^ SC2152 (error): Can only return 0-255. Other data should be written to stdout.
For more information:
https://www.shellcheck.net/wiki/SC2152 -- Can only return 0-255. Other data...local: que las variables no se escapen
Por omisión, todas las variables de Bash son globales, también las que se crean dentro de una función. Es una fuente de errores muy difícil de ver: una función cambia sin querer una variable del resto del script. La solución es declararlas con local:
#!/usr/bin/env bash
contar_sin_local() {
total=0
for n in "$@"; do
total=$(( total + n ))
done
echo "$total"
}
contar_con_local() {
local total=0 n
for n in "$@"; do
total=$(( total + n ))
done
echo "$total"
}
total="ventas de octubre"
contar_sin_local 1 2 3 > /dev/null
echo "Tras contar_sin_local: total=$total"
total="ventas de octubre"
contar_con_local 1 2 3 > /dev/null
echo "Tras contar_con_local: total=$total"
echo "Y la suma, pidiéndola con \$( ): $(contar_con_local 4 5 6)"salidaTras contar_sin_local: total=6 Tras contar_con_local: total=ventas de octubre Y la suma, pidiéndola con $( ): 15
La primera función machacó la variable total del script. Si la hubiéramos llamado dentro de $( ), no habría pasado, porque eso se ejecuta en una subshell, una copia del proceso cuyas variables desaparecen al terminar; pero aquí la llamamos directamente. Regla práctica: toda variable que se use dentro de una función, local, incluidas las de los bucles, como n.
local tiene una trampa: local x=$(orden) devuelve siempre 0, aunque la orden falle, porque el código que queda es el de local. Si te importa saber si falló, decláralo primero y asígnalo después: local x y en otra línea x=$(orden). ShellCheck lo avisa con SC2155. Lo verás con más detalle en las rarezas de Bash.Arrays indexados
Un array guarda varios valores en una sola variable, numerados desde 0. Es la forma correcta de manejar listas en Bash, sobre todo listas de nombres de archivo, que pueden tener espacios. Se crea con paréntesis, se lee con ${lista[i]} y se recorre con "${lista[@]}", siempre entre comillas:
#!/usr/bin/env bash
frutas=(manzana "pera limonera" kiwi)
echo "Primera: ${frutas[0]}"
echo "Segunda: ${frutas[1]}"
echo "Última: ${frutas[-1]}"
echo "Cuántas: ${#frutas[@]}"
frutas+=(mango) # añadir al final
frutas[1]="pera conferencia" # cambiar una
for f in "${frutas[@]}"; do
echo "- $f"
done
echo "Índices: ${!frutas[*]}"
echo "Desde la 1, dos elementos: ${frutas[*]:1:2}"
unset 'frutas[2]' # borrar una deja un hueco
echo "Tras borrar la 2: ${#frutas[@]} elementos, índices ${!frutas[*]}"salidaPrimera: manzana Segunda: pera limonera Última: kiwi Cuántas: 3 - manzana - pera conferencia - kiwi - mango Índices: 0 1 2 3 Desde la 1, dos elementos: pera conferencia kiwi Tras borrar la 2: 3 elementos, índices 0 1 3
Tres detalles que conviene memorizar. ${#frutas[@]} es el número de elementos, no el índice del último. unset borra un elemento pero no renumera: queda un hueco en el índice 2, y por eso, para recorrer, mejor "${frutas[@]}" que un bucle de 0 a N. Y [@] frente a [*]: entre comillas, "${frutas[@]}" da un argumento por elemento, mientras que "${frutas[*]}" los junta todos en uno solo, separados por espacios. Para mostrar una lista, [*]; para pasarla a otra orden o recorrerla, [@].
El error que te ahorra: recorrer un array sin comillas
#!/usr/bin/env bash
archivos=("Informe final.pdf" "notas.txt")
for a in ${archivos[@]}; do
echo "Procesando: $a"
donesalidaProcesando: Informe Procesando: final.pdf Procesando: notas.txt
Sin comillas, Bash vuelve a partir cada elemento por los espacios, y «Informe final.pdf» se convierte en dos archivos que no existen. ShellCheck lo detecta:
shellcheckIn sincomillas.sh line 5:
for a in ${archivos[@]}; do
^------------^ SC2068 (error): Double quote array expansions to avoid re-splitting elements.
For more information:
https://www.shellcheck.net/wiki/SC2068 -- Double quote array expansions to ...La corrección es la de siempre: "${archivos[@]}", con comillas.
Leer un archivo en un array: mapfile
Para meter las líneas de un archivo en un array, la orden es mapfile (o su sinónimo readarray), con -t para quitar el salto de línea del final de cada una. Es mucho más segura que lineas=($(cat archivo)), que parte por espacios y además expande los asteriscos:
#!/usr/bin/env bash
mapfile -t invitados < invitados.txt
echo "Con mapfile: ${#invitados[@]} invitados"
printf ' [%s]\n' "${invitados[@]}"
# shellcheck disable=SC2207 # aquí está mal a propósito
mal=($(cat invitados.txt))
echo "Con \$(cat): ${#mal[@]} elementos"
printf ' [%s]\n' "${mal[@]}"salidaCon mapfile: 3 invitados [Ana Ruiz] [Luis Pérez] [Marta *] Con $(cat): 7 elementos [Ana] [Ruiz] [Luis] [Pérez] [Marta] [invitados.txt] [mapfile.sh]
Con mapfile hay tres invitados, uno por línea. Con $(cat …) salen siete «elementos»: cada palabra por separado, y el asterisco de «Marta *» se ha expandido a los nombres de los archivos de la carpeta. Por cierto, printf repite su formato para cada argumento que sobra, un truco muy útil para listar un array.
Arrays asociativos: claves con nombre
Desde Bash 4 hay arrays cuyas claves son textos en lugar de números, lo que en otros lenguajes se llama diccionario o mapa. Hay que declararlos con declare -A; sin eso, Bash los trataría como indexados y todas las claves irían a parar al índice 0:
#!/usr/bin/env bash
declare -A capitales=(
[España]=Madrid
[México]="Ciudad de México"
[Perú]=Lima
)
capitales[Chile]=Santiago
echo "La capital de México es ${capitales[México]}"
echo "Hay ${#capitales[@]} países"
pais="Argentina"
if [[ -v capitales[$pais] ]]; then
echo "$pais está en la lista"
else
echo "$pais no está en la lista"
fi
# Contar palabras con un array asociativo
declare -A cuenta
for palabra in sol mar sol luna mar sol; do
cuenta[$palabra]=$(( ${cuenta[$palabra]:-0} + 1 ))
done
for palabra in "${!cuenta[@]}"; do
echo "$palabra: ${cuenta[$palabra]}"
done | sortsalidaLa capital de México es Ciudad de México Hay 4 países Argentina no está en la lista luna: 1 mar: 2 sol: 3
[[ -v capitales[$pais] ]] comprueba si una clave existe, y ${cuenta[$palabra]:-0} da 0 cuando la palabra aún no se ha contado. El orden en que "${!cuenta[@]}" devuelve las claves no es el de inserción ni el alfabético, sino el interno de la tabla, así que si necesitas un orden, pásalo por sort, como al final. Y recuerda que macOS trae de serie Bash 3.2, que no tiene arrays asociativos: allí declare -A da error.
Cadenas: longitud, recortes y sustituciones
Bash puede trabajar con el texto de una variable sin llamar a ningún programa externo, con las expansiones ${…}. Son rápidas y funcionan igual en cualquier sistema con Bash. Estas son las más útiles:
#!/usr/bin/env bash
nombre="Ana María López"
echo "Longitud: ${#nombre}"
echo "Las 3 primeras letras: ${nombre:0:3}"
echo "Desde la posición 4: ${nombre:4}"
echo "Las 5 últimas: ${nombre: -5}"
echo "En mayúsculas: ${nombre^^}"
echo "En minúsculas: ${nombre,,}"
frase="el gato y el perro y el pez"
echo "Primer 'el' por 'un': ${frase/el/un}"
echo "Todos los 'el' por 'un': ${frase//el/un}"
echo "Sin espacios: ${frase// /}"salidaLongitud: 15 Las 3 primeras letras: Ana Desde la posición 4: María López Las 5 últimas: López En mayúsculas: ANA MARÍA LÓPEZ En minúsculas: ana maría lópez Primer 'el' por 'un': un gato y el perro y el pez Todos los 'el' por 'un': un gato y un perro y un pez Sin espacios: elgatoyelperroyelpez
El espacio en ${nombre: -5} es obligatorio: sin él, :- significaría otra cosa (el valor por omisión, que ves más abajo). Y las mayúsculas funcionan con letras acentuadas porque el contenedor usa una configuración regional UTF-8 (LANG=C.UTF-8); con LANG=C, saldría «MARíA LóPEZ», con las letras acentuadas sin cambiar.
Recortar por delante y por detrás
Las cuatro expansiones que más se usan en scripts reales son las que quitan un patrón del principio (#) o del final (%) de una variable. Con un signo se quita lo más corto que encaje, y con dos, lo más largo. Sirven, por ejemplo, para separar una ruta, un nombre de archivo y su extensión sin llamar a basename ni a dirname:
#!/usr/bin/env bash
f="/home/ana/fotos/viaje.tar.gz"
echo "Archivo: ${f##*/}"
echo "Carpeta: ${f%/*}"
echo "Sin extensiones: ${f%%.*}"
echo "Extensiones: ${f#*.}"
echo "Última extensión: ${f##*.}"
# Cambiar la extensión de una lista de archivos (solo el nombre)
for img in foto1.jpeg foto2.jpeg retrato.final.jpeg; do
echo "$img -> ${img%.jpeg}.jpg"
donesalidaArchivo: viaje.tar.gz Carpeta: /home/ana/fotos Sin extensiones: /home/ana/fotos/viaje Extensiones: tar.gz Última extensión: gz foto1.jpeg -> foto1.jpg foto2.jpeg -> foto2.jpg retrato.final.jpeg -> retrato.final.jpg
Un truco para recordar cuál es cuál: en el teclado estadounidense, # está a la izquierda de %, igual que el principio de una cadena está a la izquierda de su final.
Valores por omisión
#!/usr/bin/env bash
unset puerto
echo "Puerto: ${puerto:-8080}" # usa 8080 si está vacía, sin cambiarla
echo "Sigue vacía: '${puerto}'"
echo "Puerto: ${puerto:=8080}" # usa 8080 y además la asigna
echo "Ahora vale: '${puerto}'"
usuario=""
echo "Usuario: ${usuario:-invitado}"
destino=""
echo "Copiando a ${destino:?falta la carpeta de destino}"
echo "Esta línea no se ejecuta"salida con errorPuerto: 8080 Sigue vacía: '' Puerto: 8080 Ahora vale: '8080' Usuario: invitado ./omision.sh: line 14: destino: falta la carpeta de destino código de salida: 1
${destino:?mensaje} es la forma más corta de exigir que una variable tenga valor: si está vacía, escribe el mensaje y termina el script con un error. Es especialmente útil antes de órdenes peligrosas: rm -rf "${carpeta:?}/" nunca podrá convertirse en rm -rf / por una variable vacía. Lo verás en la página de seguridad.
- ExpansiónQué haceEjemplo con
v="archivo.txt" ${#v}Longitud11${v:0:7}Subcadena: desde 0, 7 caracteresarchivo${v#*.}/${v##*.}Quita por delante (corto / largo)txt${v%.*}/${v%%.*}Quita por detrás (corto / largo)archivo${v/txt/md}Sustituye la primera coincidencia (//: todas)archivo.md${v^^}/${v,,}Mayúsculas / minúsculasARCHIVO.TXT${x:-def}Valor por omisión si está vacíadef${x:=def}Por omisión y además lo asignadef${x:?msg}Error y fin del script si está vacía(sale con error)
printf: salida con formato
echo está bien para frases sueltas, pero su comportamiento con opciones como -e o -n cambia de una shell a otra. printf es igual en todas partes y además da formato: ancho de columnas, decimales, ceros a la izquierda. El primer argumento es el formato y el resto, los datos:
#!/usr/bin/env bash printf '%s tiene %d años\n' "Ana" 34 printf 'Precio: $%.2f USD\n' 49.5 printf 'Factura n.º %05d\n' 42 # Una tabla alineada: %-12s rellena por la derecha hasta 12 caracteres printf '%-12s %8s\n' "Producto" "Precio" printf '%-12s %8.2f\n' "Teclado" 49.9 "Monitor" 189 "Cable USB" 7.5 # Guardar el resultado en una variable, sin $( ) printf -v fecha '%04d-%02d-%02d' 2026 10 30 echo "Fecha: $fecha"
salidaAna tiene 34 años Precio: $49.50 USD Factura n.º 00042 Producto Precio Teclado 49.90 Monitor 189.00 Cable USB 7.50 Fecha: 2026-10-30
Fíjate en que %-12s %8.2f se repite solo para las tres parejas de datos. printf -v guarda el resultado en una variable en lugar de escribirlo, sin crear la subshell de $( ).
Ojo con la seguridad: nunca pongas datos en el formato de printf
El primer argumento de printf es un formato, y todo % que contenga se interpreta. Si metes ahí un dato que viene de fuera (un nombre de archivo, lo que escribe un usuario, una línea de un registro), quien controle ese dato controla la salida:
#!/usr/bin/env bash comentario='Descuento del 100%s %d en todo' printf "Comentario: $comentario\n" printf 'Comentario: %s\n' "$comentario"
salidaComentario: Descuento del 100 0 en todo Comentario: Descuento del 100%s %d en todo
La primera línea se ha comido el texto: %s y %d se trataron como huecos a rellenar. En Bash no lleva a ejecutar código, pero sí a salidas corruptas y a registros falsificados. ShellCheck lo detecta:
shellcheckIn formato.sh line 5:
printf "Comentario: $comentario\n"
^-------------------------^ SC2059 (info): Don't use variables in the printf format string. Use printf '..%s..' "$foo".
For more information:
https://www.shellcheck.net/wiki/SC2059 -- Don't use variables in the printf...La regla: el formato, siempre un texto fijo entre comillas simples; los datos, siempre como argumentos aparte con %s.
Más en Los fallos de seguridad típicos de los scripts de Bash.
Retos
Reto 1. Escribe una función mayor que reciba cualquier cantidad de números y escriba el mayor. Pruébala con mayor 4 17 9 y con mayor -3 -8.
Ver la solución
#!/usr/bin/env bash
mayor() {
local max=$1 n
shift
for n in "$@"; do
(( n > max )) && max=$n
done
echo "$max"
}
echo "mayor 4 17 9 -> $(mayor 4 17 9)"
echo "mayor -3 -8 -> $(mayor -3 -8)"salidamayor 4 17 9 -> 17 mayor -3 -8 -> -3
El truco es empezar con el primer argumento como máximo y descartarlo con shift; si empezaras con 0, el segundo caso daría 0, que no es ninguno de los números.
Reto 2. Tienes un array con nombres de archivo. Cuenta cuántos hay de cada extensión con un array asociativo y muestra el resultado ordenado: informe.pdf foto.jpg datos.csv viaje.jpg notas.txt acta.pdf perfil.jpg.
Ver la solución
#!/usr/bin/env bash
archivos=(informe.pdf foto.jpg datos.csv viaje.jpg notas.txt acta.pdf perfil.jpg)
declare -A por_ext
for f in "${archivos[@]}"; do
ext=${f##*.}
por_ext[$ext]=$(( ${por_ext[$ext]:-0} + 1 ))
done
for ext in "${!por_ext[@]}"; do
printf '%-4s %d\n' "$ext" "${por_ext[$ext]}"
done | sortsalidacsv 1 jpg 3 pdf 2 txt 1
${f##*.} se queda con lo que hay tras el último punto, y sort pone orden, porque el de las claves de un array asociativo es imprevisible.
Reto 3. ¿Qué escribe este script? Piénsalo antes de mirar: una función ok() { return 256; }, y después ok; echo $? y if ok; then echo sí; else echo no; fi.
Ver la solución
#!/usr/bin/env bash
ok() {
return 256
}
ok
echo "$?"
if ok; then echo "sí"; else echo "no"; fisalida0 sí
256 no cabe en 8 bits y se queda en 0, que en Bash significa «éxito»: la función que parecía devolver un error grande dice que todo fue bien. Un motivo más para no usar return con números que no sean códigos de salida.
Todos los scripts de esta lección se ejecutaron con Bash 5.3 en Ubuntu 26.04, en un contenedor limpio sin acceso a la red, y se revisaron con ShellCheck 0.11 antes de publicarla; las salidas están copiadas de esa ejecución. ¿Algo se comporta distinto? Mira tu versión con bash --version: macOS trae de serie Bash 3.2, y en Windows hay que usar WSL o Git Bash.
