En la lección anterior colamos una línea sin explicarla: readonly CIUDADES=(Valencia Sevilla Bilbao Madrid), recorrida luego con for ciudad in "${CIUDADES[@]}". Eso es un array, y es la estructura de datos que le faltaba a informe-diario.sh para dejar de leer el CSV una vez por ciudad. Bash tiene dos tipos: los indexados, listas ordenadas con índice numérico, y los asociativos, mapas de clave arbitraria a valor, que son la respuesta nativa a "cuántas incidencias tiene cada ciudad" sin salir del shell. Al terminar esta lección sabrás acumular contadores en una sola pasada y jubilar el sort | uniq -c del Módulo 2 cuando el resultado tenga que usarse dentro del script.
Contenido
- Crear arrays indexados
- Acceder a elementos y a todo el array
"${arr[@]}"frente a"${arr[*]}"- Longitud, índices y recorrido
- Añadir, rebanar y borrar
- Arrays asociativos
- El patrón contador
- Poblar arrays:
mapfileyIFS - Pasar arrays a funciones
- Tabla de operaciones
- Aplicación en
informe-diario.sh
- Crear arrays indexados
Hay tres formas, y todas producen lo mismo:
ciudades=(Valencia Sevilla Bilbao Madrid) # literal, la habitual
declare -a repartidores # declaración explícita, vacío
repartidores[0]="alopez"; repartidores[1]="mgarcia"
repartidores[5]="jruiz" # los índices pueden saltarPuntos clave: los elementos van separados por espacios, no por comas ((a, b) crea los elementos a, y b); si un elemento contiene espacios hay que entrecomillarlo, ciudades=("San Sebastián" Valencia); los índices empiezan en 0 y son dispersos, de modo que el array anterior tiene 3 elementos con índices 0, 1 y 5 y no hay huecos vacíos en medio; y declare -a no es obligatorio, pero documenta la intención y hace falta si quieres el array vacío desde el principio. Una variable normal es, en realidad, el elemento 0 de un array: x=hola; echo "${x[0]}" imprime hola.
- Acceder a elementos y a todo el array
ciudades=(Valencia Sevilla Bilbao Madrid)
echo "${ciudades[0]}" # Valencia
echo "${ciudades[-1]}" # Madrid (índice negativo: desde el final, Bash 4.3+)
echo "$ciudades" # Valencia ← ¡trampa! sin índice equivale a [0]Las llaves son obligatorias. $ciudades[1] no accede al elemento 1: Bash expande $ciudades (elemento 0) y deja [1] como texto literal. Siempre ${ciudades[1]}.
El índice puede ser cualquier expresión aritmética: ${ciudades[i+1]} funciona, y dentro de los corchetes las variables no necesitan $.
"${arr[@]}" frente a "${arr[*]}"
"${arr[@]}" frente a "${arr[*]}"Es exactamente la misma distinción que "$@" frente a "$*" de 03-05, y por la misma razón:
| Expansión | Resultado |
|---|---|
"${arr[@]}" |
Un argumento por elemento, con los espacios internos intactos |
"${arr[*]}" |
Un único argumento, elementos unidos por el primer carácter de IFS |
${arr[@]} sin comillas |
Se dividen también los espacios dentro de cada elemento |
libros=("El Quijote" "La Regenta")
for x in "${libros[@]}"; do echo "[$x]"; done # [El Quijote] [La Regenta] → 2 vueltas ✓
for x in "${libros[*]}"; do echo "[$x]"; done # [El Quijote La Regenta] → 1 vuelta
for x in ${libros[@]}; do echo "[$x]"; done # [El][Quijote][La][Regenta] → 4, desastreRegla sin excepciones: para recorrer o pasar arrays, "${arr[@]}" con comillas. "${arr[*]}" solo sirve para una cosa útil, unir los elementos en una cadena legible aprovechando IFS: IFS=', '; echo "Ciudades: ${ciudades[*]}"; unset IFS imprime Ciudades: Valencia, Sevilla, Bilbao, Madrid.
- Longitud, índices y recorrido
echo "${#ciudades[@]}" # 4 → número de elementos
echo "${#ciudades[2]}" # 6 → longitud de la CADENA del elemento 2 ("Bilbao")
echo "${!ciudades[@]}" # 0 1 2 3 → lista de índices existentesCuidado con la asimetría: ${#arr[@]} cuenta elementos, pero ${#arr[i]} cuenta caracteres. Y ${!arr[@]} con ! delante devuelve las claves, no los valores; es la única forma segura de recorrer un array disperso:
repartidores[0]="alopez"; repartidores[5]="jruiz"
for i in "${!repartidores[@]}"; do printf 'índice %d → %s\n' "$i" "${repartidores[$i]}"; done
# índice 0 → alopez
# índice 5 → jruizSi hubieras hecho for (( i=0; i<${#repartidores[@]}; i++ )) recorrerías los índices 0 y 1, y el elemento 5 no aparecería nunca. Ese for estilo C solo es fiable en arrays sin huecos.
- Añadir, rebanar y borrar
ciudades+=(Zaragoza) # añade al final; NO uses ciudades=(... Zaragoza)
ciudades+=(Málaga Vigo) # se pueden añadir varios de golpe
echo "${ciudades[@]:1:2}" # Sevilla Bilbao → rebanada: desde 1, dos elementos
echo "${ciudades[@]: -2}" # los dos últimos (ojo al espacio antes del -2)El borrado tiene una particularidad que sorprende a todo el mundo:
ciudades=(Valencia Sevilla Bilbao Madrid)
unset 'ciudades[1]'
echo "${#ciudades[@]}" # 3
echo "${!ciudades[@]}" # 0 2 3 ← el índice 1 ha desaparecido, NO se ha reindexado
ciudades=("${ciudades[@]}") # así se reindexa a 0,1,2
unset 'ciudades' # así se borra el array enterounset deja un hueco: el array pasa a ser disperso, y solo reconstruyéndolo vuelve a ser compacto. Las comillas en unset 'ciudades[1]' evitan que el globbing interprete los corchetes como una clase de caracteres si existe un fichero con ese nombre.
- Arrays asociativos
Disponibles desde Bash 4 (2009). Recuerda de 01-02 cómo comprobar tu versión, porque en macOS el /bin/bash del sistema sigue siendo la 3.2: (( BASH_VERSINFO[0] >= 4 )) || { echo "Se requiere Bash 4+" >&2; exit 5; }.
A diferencia de los indexados, declare -A es obligatorio: sin él, Bash trata las claves como expresiones aritméticas y todo acaba en el índice 0.
declare -A zona # OBLIGATORIO
zona["Valencia"]="Levante"; zona["Sevilla"]="Sur"
zona[Bilbao]="Norte" # comillas solo si la clave lleva espacios
echo "${zona[Valencia]}" # Levante
echo "${#zona[@]}" # 3
echo "${!zona[@]}" # Sevilla Valencia Bilbao ← ORDEN NO GARANTIZADOTambién se pueden crear de una vez: declare -A zona=([Valencia]=Levante [Sevilla]=Sur).
El orden de las claves es el de la tabla hash interna, no el de inserción ni alfabético. Si necesitas salida ordenada, ordena tú:
Para comprobar si una clave existe, sin confundirla con una que existe pero vale cadena vacía, se usa if [[ -v zona[Madrid] ]]; then .... [[ -v ]] (Bash 4.2+) pregunta por la existencia de la variable o elemento. La alternativa antigua, [[ -n "${zona[Madrid]}" ]], solo mira si el valor no está vacío, que no es lo mismo. Borrar una clave: unset 'zona[Madrid]'.
- El patrón contador
Este es el motivo principal por el que existen los arrays asociativos en un script de operaciones. La forma del Módulo 2 —grep ',incidencia,' "$RUTA_CSV" | cut -d, -f3 | sort | uniq -c— es bonita en la terminal pero inútil dentro del script: el resultado es texto, y para usar el número de Sevilla habría que volver a parsearlo. Con un mapa, los datos quedan en variables:
declare -A incidencias_por_ciudad envios_por_ciudad
while IFS=, read -r _ _ ciudad _ estado _; do
(( envios_por_ciudad["$ciudad"]++ ))
[[ "$estado" == "incidencia" ]] && (( incidencias_por_ciudad["$ciudad"]++ ))
done < <(tail -n +2 "$RUTA_CSV")
for ciudad in "${!envios_por_ciudad[@]}"; do
printf '%-10s %4d envíos, %3d incidencias\n' \
"$ciudad" "${envios_por_ciudad[$ciudad]}" "${incidencias_por_ciudad[$ciudad]:-0}"
doneSalida: Valencia 128 envíos, 11 incidencias y una línea igual por cada ciudad. Tres detalles que hacen que esto funcione:
(( mapa["$clave"]++ ))funciona aunque la clave no exista todavía: en contexto aritmético una variable sin valor cuenta como 0, así que el primer incremento la crea con valor 1. No hace falta inicializar nada.${incidencias_por_ciudad[$ciudad]:-0}usa el valor por defecto de 03-06 para las ciudades sin incidencias, que no tienen entrada en ese mapa.- Todo el conteo se hace en una sola pasada y sin procesos externos: sustituye a ocho invocaciones de
grep. Para ficheros de millones de líneas, en cambio, el bucle de Bash es lento y conviene delegar enawk(06-01).
- Poblar arrays:
mapfile y IFS
mapfile y IFSPara llenar un array con la salida de un comando, una línea por elemento, la herramienta correcta es mapfile (también llamada readarray, son sinónimos):
mapfile -t logs < <(find /var/log/veloz -name 'app.log.*' -type f)
echo "${#logs[@]} ficheros rotados"; printf ' %s\n' "${logs[@]}"La opción -t elimina el salto de línea final de cada elemento; sin ella cada entrada arrastraría un \n y las comparaciones fallarían. Otras opciones útiles: -n N (leer como mucho N líneas) y -s N (saltar las N primeras, práctico para la cabecera de un CSV). Nunca uses arr=( $(comando) ) sin comillas para esto: divide por espacios y aplica globbing, así que un nombre con espacios o un * en la salida rompen el array.
Para trocear una cadena en array, se cambia IFS temporalmente y se usa read -a:
linea="E1001,2026-08-03,Valencia,alopez,entregado,24.50"
IFS=',' read -r -a campos <<< "$linea"
echo "${campos[2]}" # Valencia
echo "${#campos[@]}" # 6<<< es un here-string: alimenta la entrada estándar del comando con esa cadena (lo verás a fondo en 05-05). -a campos le dice a read que reparta los campos en el array en vez de en variables sueltas. Como el IFS= va delante del comando, solo afecta a esa línea.
- Pasar arrays a funciones
Aquí hay una limitación real de Bash: no se puede pasar un array como un solo argumento. Todo lo que una función recibe son cadenas. Hay dos soluciones:
# a) Expandir el array en argumentos y recogerlos con "$@"
mostrar() { local -a items=( "$@" ); printf ' - %s\n' "${items[@]}"; }
mostrar "${ciudades[@]}"Simple y suficiente cuando el array es lo único que pasas. Si necesitas pasar además otros argumentos, ponlos primero y usa shift.
# b) Nameref: pasar el NOMBRE del array (única opción para asociativos)
resumir_mapa() {
local -n _mapa="$1" # _mapa pasa a ser un alias del array real
local clave
for clave in "${!_mapa[@]}"; do printf '%-10s %s\n' "$clave" "${_mapa[$clave]}"; done
}
resumir_mapa incidencias_por_ciudad # sin $, se pasa el NOMBREEl nameref (local -n, introducido en 04-02) es imprescindible con arrays asociativos, porque "${mapa[@]}" perdería las claves por el camino. También es la forma de devolver un array: la función escribe en local -n salida="$2" y el llamante recibe el array completo.
- Tabla de operaciones
| Operación | Array indexado | Array asociativo |
|---|---|---|
| Declarar | declare -a a (opcional) |
declare -A m (obligatorio) |
| Crear | a=(x y z) |
m=([k1]=v1 [k2]=v2) |
| Asignar | a[3]="x" |
m[clave]="v" |
| Leer | ${a[3]} |
${m[clave]} |
| Todos los valores | "${a[@]}" |
"${m[@]}" |
| Todas las claves | "${!a[@]}" (números) |
"${!m[@]}" (texto, sin orden) |
| Número de elementos | ${#a[@]} |
${#m[@]} |
| Añadir | a+=(x) |
m[nueva]="v" |
| ¿Existe la clave? | [[ -v a[3] ]] |
[[ -v m[clave] ]] |
| Borrar un elemento | unset 'a[3]' (deja hueco) |
unset 'm[clave]' |
| Rebanada / orden | "${a[@]:1:2}", ya ordenado |
no aplica; sort sobre "${!m[@]}" |
- Aplicación en
informe-diario.sh
informe-diario.shSustituimos el bucle que releía el CSV por ciudad por una sola pasada que acumula tres mapas:
readonly CIUDADES=(Valencia Sevilla Bilbao Madrid)
readonly REPARTIDORES=(alopez mgarcia jruiz)
# acumular_dia — Rellena los mapas globales a partir del CSV de una fecha.
# Uso: acumular_dia <fecha> Devuelve: 0 | 6 si no hay ninguna línea
acumular_dia() {
local fecha="${1:?}" f ciudad estado importe leidas=0
declare -gA ENVIOS INCIDENCIAS IMPORTE # -g: globales pese a estar en función
while IFS=, read -r _ f ciudad _ estado importe; do
[[ "$f" == "$fecha" ]] || continue
(( leidas++ ))
(( ENVIOS["$ciudad"]++ ))
(( IMPORTE["$ciudad"] += ${importe%.*} )) # parte entera, de momento
[[ "$estado" == "incidencia" ]] && (( INCIDENCIAS["$ciudad"]++ ))
done < <(tail -n +2 "$RUTA_CSV")
(( leidas > 0 )) || return 6
}
tabla_ciudades() {
local c
printf '%-10s %8s %13s %10s\n' CIUDAD ENVÍOS INCIDENCIAS IMPORTE
for c in "${CIUDADES[@]}"; do
[[ -v ENVIOS[$c] ]] || continue
printf '%-10s %8d %13d %9d€\n' "$c" "${ENVIOS[$c]}" "${INCIDENCIAS[$c]:-0}" "${IMPORTE[$c]}"
done
}Novedades: declare -gA, porque dentro de una función declare crea variables locales por defecto y -g las hace globales para que tabla_ciudades las vea (es la alternativa al nameref cuando los mapas son compartidos por todo el script); el bucle recorre "${CIUDADES[@]}" y no las claves del mapa, así la tabla sale siempre en el mismo orden y no en el arbitrario de la tabla hash; y una sola pasada sin ningún grep, con lo que el script pasa de lanzar 8 procesos a lanzar 1.
Queda pendiente lo evidente: ${importe%.*} tira los céntimos porque Bash no suma decimales. Esa expansión la explicaremos formalmente en la próxima lección, y los decimales llegarán en 04-06.
Errores Comunes y Consejos
- Olvidar
declare -Aen un asociativo. Bash no avisa: convierte las claves en aritmética y todo acaba machacando el índice 0. Es el error número uno. - Escribir
${arr[@]}sin comillas. Rompe con cualquier elemento que tenga espacios. Siempre"${arr[@]}". - Usar
$arrcreyendo que es todo el array. Es solo el elemento 0. - Esperar orden en las claves de un asociativo. No lo hay. Ordena explícitamente o recorre una lista fija.
arr=( $(comando) ). Usamapfile -t arr < <(comando).- Contar con
for (( i=0; i<${#a[@]}; i++ ))sobre un array con huecos. Recorre"${!a[@]}". - Consejo: usa MAYÚSCULAS para los mapas globales compartidos del script y minúsculas para los arrays locales; distinguirlos de un vistazo evita colisiones.
Ejercicios
Ejercicio 1. Crea un array con los tres repartidores, añade pcastro al final, muestra cuántos hay, recórrelo numerado (1) alopez) y muestra los dos últimos.
Ejercicio 2. Con un array asociativo, cuenta cuántos envíos tiene cada repartidor en envios.csv en una sola pasada, e imprime el resultado ordenado alfabéticamente por repartidor.
Ejercicio 3. Escribe campo_n() que reciba una línea CSV y un número de campo, y escriba ese campo en stdout usando un array. Debe funcionar así: campo_n "E1001,2026-08-03,Valencia,alopez,entregado,24.50" 3 → Valencia.
Soluciones
Solución 1.
repartidores=(alopez mgarcia jruiz)
repartidores+=(pcastro)
echo "Total: ${#repartidores[@]}" # Total: 4
for i in "${!repartidores[@]}"; do printf '%d) %s\n' "$(( i+1 ))" "${repartidores[$i]}"; done
echo "Últimos dos: ${repartidores[@]: -2}" # jruiz pcastro${!repartidores[@]} da los índices; sumamos 1 solo para presentarlos empezando en 1.
Solución 2.
declare -A por_repartidor
while IFS=, read -r _ _ _ rep _ _; do (( por_repartidor["$rep"]++ )); done \
< <(tail -n +2 /srv/veloz/datos/envios.csv)
for r in $(printf '%s\n' "${!por_repartidor[@]}" | sort); do
printf '%-10s %4d\n' "$r" "${por_repartidor[$r]}"
doneSalida: alopez 137, jruiz 98, mgarcia 121. El printf | sort es necesario porque el orden de las claves es impredecible.
Solución 3.
# campo_n — Extrae un campo de una línea CSV (1-indexado).
# Uso: campo_n <linea> <n> Salida: el campo Devuelve: 0 | 2 si n fuera de rango
campo_n() {
local linea="${1:?}" n="${2:?}"; local -a campos
IFS=',' read -r -a campos <<< "$linea"
(( n >= 1 && n <= ${#campos[@]} )) || return 2
printf '%s\n' "${campos[n-1]}"
}Fíjate en ${campos[n-1]}: dentro de los corchetes hay contexto aritmético, así que ni $ ni $(( )) hacen falta. El -1 traduce del índice humano (1) al de Bash (0).
Conclusión
Ya tienes memoria estructurada. Los arrays indexados guardan listas ordenadas —ciudades, repartidores, ficheros— y se recorren con for x in "${arr[@]}" o por índice con "${!arr[@]}"; los asociativos, declarados obligatoriamente con declare -A, guardan mapas de clave a valor y hacen posible el patrón contador que resume un CSV entero en una sola pasada, con los resultados en variables en lugar de en texto. mapfile -t los llena desde un comando, IFS=',' read -r -a los llena desde una cadena, y los namerefs los llevan y traen de las funciones.
En el camino han aparecido dos expansiones que hemos usado sin justificar: ${importe%.*} para quedarnos con la parte entera y ${var:-0} para el valor por defecto. Pertenecen a la misma familia de manipulación de cadenas que empezaste en 03-06 y que la próxima lección completa: cortar subcadenas, quitar prefijos y sufijos, sustituir texto, cambiar mayúsculas y formatear tablas con printf. Todo ello sin lanzar un solo proceso externo, que es la diferencia entre un script que tarda dos segundos y uno que tarda dos minutos.
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
