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

  1. Crear arrays indexados
  2. Acceder a elementos y a todo el array
  3. "${arr[@]}" frente a "${arr[*]}"
  4. Longitud, índices y recorrido
  5. Añadir, rebanar y borrar
  6. Arrays asociativos
  7. El patrón contador
  8. Poblar arrays: mapfile y IFS
  9. Pasar arrays a funciones
  10. Tabla de operaciones
  11. Aplicación en informe-diario.sh

  1. 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 saltar

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

  1. 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 $.

  1. "${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, desastre

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

  1. 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 existentes

Cuidado 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 → jruiz

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

  1. 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 entero

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

  1. 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 GARANTIZADO

Tambié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ú:

for c in $(printf '%s\n' "${!zona[@]}" | sort); do printf '%-12s %s\n' "$c" "${zona[$c]}"; done

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]'.

  1. 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}"
done

Salida: 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 en awk (06-01).

  1. Poblar arrays: mapfile y IFS

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

  1. 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 NOMBRE

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

  1. 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[@]}"

  1. Aplicación en informe-diario.sh

Sustituimos 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 -A en 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 $arr creyendo 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) ). Usa mapfile -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" 3Valencia.

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]}"
done

Salida: 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

Módulo 2: Comandos Básicos de Bash

Módulo 3: Fundamentos de Scripting

Módulo 4: Scripting Intermedio

Módulo 5: Técnicas Avanzadas de Scripting

Módulo 6: Trabajando con Herramientas Externas

Módulo 7: Automatización y Programación

Módulo 8: Mejores Prácticas y Optimización

Módulo 9: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados