Terminábamos la lección anterior con un diagnóstico incómodo: los bucles nos habían quitado la repetición entre ejecuciones del script, pero dentro del fichero seguíamos escribiendo tres veces el mismo bloque de validación y cuatro veces el mismo printf de formato. En Bash no hay clases, ni módulos, ni objetos: la función es la única unidad de reutilización que existe. Todo lo que quieras escribir una vez y usar muchas —validar, formatear, registrar, calcular— tiene que ser una función. En esta lección aprenderás a definirlas, a pasarles datos, a recuperar resultados (que no es lo que parece) y a documentarlas; y al final refactorizarás informe-diario.sh para que deje de ser una pared de código y pase a ser un conjunto de piezas con nombre.

Contenido

  1. Las dos sintaxis y cuál preferir
  2. Dónde se definen: definiciones arriba, main abajo
  3. Parámetros: las funciones son mini-scripts
  4. local, y el bug que te espera si lo olvidas
  5. return devuelve un código de salida, no un dato
  6. Cómo devolver datos de verdad
  7. Funciones que fallan, y recursividad
  8. Funciones frente a scripts y alias
  9. Documentar una función
  10. Refactor de informe-diario.sh

  1. Las dos sintaxis y cuál preferir

Bash admite dos formas de declarar una función, y una tercera que las mezcla:

saludar() { echo "Hola desde Veloz Envíos"; }      # forma POSIX: la recomendada
function saludar { echo "Hola"; }                   # palabra clave: Bash/ksh
function saludar() { echo "Hola"; }                 # mezcla: válida pero redundante
Aspecto nombre() { } function nombre { }
Portabilidad POSIX: funciona en sh, dash, zsh Solo Bash, ksh, zsh
Legibilidad Los paréntesis marcan que es invocable La palabra clave destaca más
Recomendación Úsala siempre Evítala salvo estilo de equipo

Usaremos nombre() { ... } en todo el curso. Es la que espera ShellCheck (08-05) y la que sigue funcionando si algún día el script tiene que correr bajo sh (08-07).

Reglas de nombre: letras, dígitos y guiones bajos; por convención, minúsculas con _. No pongas paréntesis al llamarla: se invoca como un comando cualquiera, saludar, no saludar().

  1. Dónde se definen: definiciones arriba, main abajo

Bash lee el script de arriba abajo. Una función solo existe después de que el intérprete haya pasado por su definición:

saludar          # ERROR: saludar: orden no encontrada
saludar() { echo "hola"; }

De aquí nace el patrón estructural que usarás en todos tus scripts a partir de ahora:

#!/usr/bin/env bash
readonly RUTA_CSV="/srv/veloz/datos/envios.csv"   # 1) cabecera y constantes
uso() { ... }                                      # 2) TODAS las definiciones
validar_entorno() { ... }
resumen_ciudad() { ... }
main() { validar_entorno; resumen_ciudad "$1"; }   # 3) el flujo principal
main "$@"                                          # 4) única llamada, última línea

Ventajas concretas de este esqueleto: el lector encuentra el flujo del programa en main y no hurgando entre 300 líneas; nada se ejecuta hasta la última línea, así que el orden de definición entre funciones deja de importar (cuando main corre, todas están cargadas); y main "$@" reenvía los argumentos del script a la función, con las comillas alrededor de $@ que 03-05 declaró obligatorias.

  1. Parámetros: las funciones son mini-scripts

Aquí está la sorpresa para quien viene de otros lenguajes: los parámetros no se declaran en la firma. Una función recibe argumentos exactamente igual que un script: $1, $2, $#, "$@", shift.

contar_estado() {
    (( $# >= 2 )) || { echo "contar_estado: faltan argumentos" >&2; return 2; }
    local ciudad="$1" estado="$2"
    grep -c ",${ciudad},[^,]*,${estado}," "$RUTA_CSV"
}

contar_estado Valencia incidencia      # se llama sin comas ni paréntesis

Dos matices que sorprenden: $0 no cambia dentro de una función (sigue siendo el nombre del script; para el nombre de la función está ${FUNCNAME[0]}), y $1 son los argumentos de la función, no los del script, así que si los necesitas dentro hay que pasárselos con mi_funcion "$@".

  1. local, y el bug que te espera si lo olvidas

Por defecto, todas las variables de Bash son globales, incluidas las que asignas dentro de una función. Esto produce uno de los bugs más difíciles de rastrear del lenguaje:

contar() { total=0; for x in 1 2 3; do total=$(( total + x )); done; echo "$total"; }
total=999
contar            # imprime 6
echo "$total"     # imprime 6, no 999: la función pisó tu variable

La función ha destruido una variable del programa principal sin avisar. La solución es declarar local toda variable de trabajo interno:

contar() { local total=0 x; for x in 1 2 3; do total=$(( total+x )); done; echo "$total"; }
total=999; contar; echo "$total"     # imprime 6 y luego 999 ✓

Detalles de local: solo es válido dentro de una función; admite varias a la vez (local a b c=0); su ámbito es dinámico, de modo que una función llamada desde otra ve las local de quien la llamó (no lo aproveches). Y cuidado con local var=$(comando): el $? que queda es el de local, siempre 0, no el del comando; si necesitas comprobarlo, sepáralo en local var; var=$(comando) || return 1.

Regla de oro: si una variable no está declarada local, es porque quieres deliberadamente que sobreviva a la función.

  1. return devuelve un código de salida, no un dato

return N termina la función y fija $? con N. Es el mismo mecanismo de los códigos de salida de 03-01, con las mismas limitaciones: un entero entre 0 y 255, donde 0 significa éxito.

csv_utilizable() {
    [[ -f "$RUTA_CSV" ]] || return 3      # no existe
    [[ -r "$RUTA_CSV" ]] || return 4      # sin permiso de lectura
    [[ -s "$RUTA_CSV" ]] || return 5      # vacío
}
if csv_utilizable; then echo "CSV listo"; else echo "CSV no utilizable ($?)" >&2; fi

Como la función devuelve un código, puede usarse directamente en un if, en un &&, en un while… igual que cualquier comando. Esa es la razón por la que las funciones de comprobación se llaman con nombres tipo pregunta (csv_utilizable, es_ciudad_valida) y devuelven 0/1.

Lo que no puedes hacer es return 3.5, return "Valencia" ni return 1000 (se trunca a 1000 % 256 = 232). Si omites return, la función devuelve el código del último comando ejecutado.

  1. Cómo devolver datos de verdad

Hay tres técnicas, en orden de preferencia:

a) Escribir a stdout y capturar con $( ). Es la forma idiomática:

media_importe() { awk -F, -v c="$1" '$3==c {s+=$6;n++} END{printf "%.2f",s/n}' "$RUTA_CSV"; }
m=$(media_importe Valencia); echo "Media en Valencia: $m €"

Su coste: $( ) crea un subshell, y la función no puede escribir nada más en stdout —mensajes de progreso incluidos— sin contaminar el resultado. Por eso los mensajes informativos siempre van a stderr, como verás en log_info más abajo.

b) Asignar a una variable global acordada (resultado=...). Rápido y sin subshell, pero acopla la función a un nombre concreto.

c) Nameref con local -n (Bash 4.3+). El llamante decide el nombre de la variable de salida y la función escribe en ella indirectamente:

media_importe() {
    local ciudad="$1"
    local -n _salida="$2"       # _salida es un ALIAS de la variable nombrada en $2
    _salida=$(awk -F, -v c="$ciudad" '$3==c {s+=$6;n++} END{printf "%.2f",s/n}' "$RUTA_CSV")
}
media_importe Valencia media_val
echo "$media_val"               # 26.34

local -n es la versión con ámbito de declare -n. Es la técnica que usarás para "devolver" arrays (04-03), donde $( ) perdería la separación entre elementos. Trampa: el nombre local no puede coincidir con el que pasa el llamante o Bash da error de referencia circular; por eso se prefija con _.

  1. Funciones que fallan, y recursividad

Encadenar funciones con &&, || y $? funciona igual que con comandos externos: validar_entorno || exit $? propaga el código de la función al sistema operativo, y csv_utilizable && procesar_csv encadena. Ojo: exit dentro de una función mata el script entero, no solo la función. Es correcto en funciones de aborto como morir(), pero en una función reutilizable prefiere return y deja que el llamante decida.

Bash admite recursividad —factorial() { local n="$1"; (( n <= 1 )) && { echo 1; return; }; echo $(( n * $(factorial $(( n - 1 )) ) )); }— pero cada nivel crea un subshell por el $( ), así que es lento; el límite lo fija FUNCNEST. En scripts de operaciones, un bucle es casi siempre más claro y más rápido.

  1. Funciones frente a scripts y alias

Criterio Alias Función Script separado
Acepta argumentos No (solo los pega al final) Sí ($1, "$@")
Lógica con if/bucles No
Dónde vive ~/.bashrc En el script o en lib/ Fichero propio en bin/
Invocable desde otro programa No No (salvo export -f)
Coste de ejecución Nulo Nulo (mismo proceso) Un proceso nuevo
Puede modificar el shell actual No

Traducción práctica: los alias son atajos de teclado personales (alias ll='ls -l'), las funciones son la reutilización dentro de un programa, y los scripts son la unidad que se invoca desde fuera —cron, systemd, otro script—. informe-diario.sh es un script; validar_entorno es una función suya.

  1. Documentar una función

Una función sin cabecera obliga a leer su cuerpo para saber cómo se usa. Adopta este formato desde hoy:

# resumen_ciudad — Imprime el resumen de envíos de una ciudad en una fecha.
# Uso:       resumen_ciudad <ciudad> <fecha>
# Argumentos: $1 ciudad (Valencia|Sevilla|Bilbao|Madrid)   $2 fecha (yyyy-MM-dd)
# Salida:    Una línea formateada en stdout
# Devuelve:  0 si hay datos; 6 si la ciudad no tiene envíos ese día
resumen_ciudad() { ... }

Cuatro apartados fijos —uso, argumentos, salida, código de retorno— y una línea de descripción. Es barato de escribir y elimina la mitad de las preguntas sobre el script.

  1. Refactor de informe-diario.sh

Aplicamos todo lo anterior. Este es el esqueleto del script tras el refactor (omitimos el parseo de opciones, que ya tienes de 03-05):

#!/usr/bin/env bash
# informe-diario.sh — Informe diario de envíos de Veloz Envíos.
readonly RUTA_CSV="${VELOZ_CSV:-/srv/veloz/datos/envios.csv}"
readonly RUTA_LOG="${VELOZ_LOG:-/var/log/veloz/app.log}"
readonly CIUDADES=(Valencia Sevilla Bilbao Madrid)

# log_info/log_error — Registran un mensaje con marca de tiempo en STDERR.
log_info()  { printf '%s [INFO]  %s\n'  "$(date '+%F %T')" "$*" >&2; }
log_error() { printf '%s [ERROR] %s\n'  "$(date '+%F %T')" "$*" >&2; }

# validar_entorno — Comprueba que existen y son legibles CSV y log.
# Devuelve: 0 correcto | 3 falta fichero | 4 sin permiso de lectura
validar_entorno() {
    local f
    for f in "$RUTA_CSV" "$RUTA_LOG"; do
        [[ -f "$f" ]] || { log_error "No existe: $f";   return 3; }
        [[ -r "$f" ]] || { log_error "Sin lectura: $f"; return 4; }
    done
    log_info "Entorno validado"
}

# contar_errores — Nº de líneas [ERROR] del log en una fecha. Salida: entero.
contar_errores() { grep -c "^${1:?} .*\[ERROR\]" "$RUTA_LOG" || true; }

# resumen_ciudad — Línea formateada con totales de una ciudad.
# Uso: resumen_ciudad <ciudad> <fecha>   Devuelve: 0 | 6 si no hay envíos
resumen_ciudad() {
    local ciudad="${1:?}" fecha="${2:?}" total incid
    total=$(grep -c "^[^,]*,${fecha},${ciudad}," "$RUTA_CSV")
    (( total > 0 )) || { log_info "Sin envíos en $ciudad"; return 6; }
    incid=$(grep "^[^,]*,${fecha},${ciudad}," "$RUTA_CSV" | grep -c ',incidencia,')
    printf '%-10s %5d envíos %5d incidencias\n' "$ciudad" "$total" "$incid"
}

main() {
    local fecha="${1:-$(date +%F)}" ciudad
    validar_entorno || exit $?
    log_info "Informe del $fecha"
    printf '%-10s %12s %16s\n' CIUDAD ENVÍOS INCIDENCIAS
    for ciudad in "${CIUDADES[@]}"; do resumen_ciudad "$ciudad" "$fecha"; done
    log_info "Errores en el log: $(contar_errores "$fecha")"
}
main "$@"

Qué hemos ganado: un solo sitio que valida (si mañana hay que comprobar el fichero de configuración, se toca validar_entorno y nada más); mensajes separados de datos, porque log_info escribe en stderr y así informe-diario.sh > informe.txt guarda la tabla limpia mientras los avisos siguen en pantalla —la separación de flujos de 02-04 aplicada con criterio—; un bucle de cuatro líneas en lugar de cuatro bloques idénticos, de modo que añadir Zaragoza es añadir una palabra a CIUDADES; y un main que cabe en una pantalla y se lee como el enunciado del problema.

El || true de contar_errores merece explicación: grep -c devuelve código 1 cuando cuenta 0 coincidencias, lo que en un script con set -e (05-03) abortaría el programa. || true fuerza un código 0 sin alterar la salida.

Estas funciones son genéricas y las querrás también en otros scripts del toolkit. En 05-06 las moverás a ~/veloz-ops/lib/comun.sh y las cargarás con source, convirtiendo el toolkit en una librería de verdad.

Errores Comunes y Consejos

  • Llamar a la función con paréntesis: resumen_ciudad("Valencia") no es un error de sintaxis, es otra cosa y fallará de forma rara. Se llama resumen_ciudad Valencia.
  • Olvidar local. El bug más caro de la lección. Declara local todo lo interno, siempre.
  • Mezclar mensajes y resultado en stdout. Si la función se captura con $( ), cualquier echo de progreso acaba dentro del resultado. Mensajes → stderr.
  • Creer que return devuelve datos. Devuelve un código 0-255. return $(wc -l < fichero) con 300 líneas devolvería 44.
  • Definir una función después de usarla. Definiciones arriba, main "$@" al final.
  • Consejo: nombra las funciones con verbo (validar_, contar_, generar_) y las de comprobación como predicados (es_valida, existe_). El nombre debe hacer innecesario leer el cuerpo.

Ejercicios

Ejercicio 1. Escribe es_ciudad_valida() que reciba una ciudad y devuelva 0 si está entre las cuatro operativas y 1 en caso contrario, sin imprimir nada. Úsala en un if.

Ejercicio 2. Escribe envios_de() que reciba un repartidor y escriba en stdout su número de envíos, y demuestra que se captura con $( ). Añade la cabecera de documentación completa.

Ejercicio 3. Corrige esta función, que tiene dos defectos:

total_importe() {
    suma=0
    while IFS=, read -r _ _ _ _ _ importe; do suma=$(( suma + ${importe%.*} )); done < "$1"
    return $suma
}

Soluciones

Solución 1.

es_ciudad_valida() {
    local ciudad="${1:?falta ciudad}" c
    for c in Valencia Sevilla Bilbao Madrid; do [[ "$ciudad" == "$c" ]] && return 0; done
    return 1
}
if es_ciudad_valida "$1"; then echo "OK"; else echo "Ciudad desconocida" >&2; fi

En 04-05 verás que un case hace esto en tres líneas, y en 04-03 que un array asociativo lo hace en una.

Solución 2.

# envios_de — Cuenta los envíos de un repartidor.
# Uso: envios_de <repartidor>   Argumentos: $1 (alopez|mgarcia|jruiz)
# Salida: número entero en stdout   Devuelve: 0 siempre
envios_de() { local r="${1:?falta repartidor}"; grep -c ",${r}," "$RUTA_CSV" || true; }

n=$(envios_de alopez); echo "alopez ha hecho $n envíos"

Solución 3. Los dos defectos son que suma es global y que return no puede devolver un total (se trunca a módulo 256):

total_importe() {
    local fichero="${1:?}" suma=0 importe
    while IFS=, read -r _ _ _ _ _ importe; do suma=$(( suma + ${importe%.*} )); done < "$fichero"
    echo "$suma"                  # el resultado sale por stdout, no por return
}
total=$(total_importe "$RUTA_CSV")

De paso: ${importe%.*} recorta la parte decimal —una expansión que dominarás en 04-04— porque Bash no suma decimales, algo que resolverás del todo en 04-06.

Conclusión

Las funciones convierten un guion en un programa. Se definen con nombre() { }, se colocan todas arriba con un main "$@" como última línea, reciben argumentos igual que un script mediante $1 y "$@", protegen su estado interno con local, señalan éxito o fracaso con return (un código, nunca un dato) y entregan resultados escribiendo a stdout para que el llamante los capture con $( ) —o mediante un nameref cuando eso no basta—. Con validar_entorno, contar_errores, resumen_ciudad y las dos funciones de registro, informe-diario.sh ya tiene esqueleto.

Le falta memoria. Nuestro main ya usa "${CIUDADES[@]}" sin haber explicado qué es esa sintaxis, y el bucle vuelve a leer el CSV entero una vez por ciudad porque no tiene dónde acumular un contador por cada una. Eso son los arrays (04-03): listas indexadas y, mejor aún, mapas de clave→valor que permiten contar incidencias por ciudad y por repartidor en una sola pasada, sustituyendo al viejo sort | uniq -c que arrastramos desde el Módulo 2.

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