← Automatización con Scripts en Bash Bloque 5 · Funciones y argumentos · Tema 18 de 42

Tema 18

Funciones

Desde el tema 4 los scripts del curso usan pequeñas funciones como error, confirmar o uso, sin que las hayamos estudiado. Una función es un bloque de comandos con nombre, que se puede llamar como si fuera un comando más. Este tema explica cómo se definen, cómo reciben datos, por qué en Bash sus variables son globales salvo que se indique lo contrario, y cómo organizar un script entero en funciones para que se lea como una descripción de lo que hace.

1. Por qué funciones

Un script de cincuenta líneas sin funciones se puede leer de arriba abajo. Uno de doscientas ya no: las mismas tres líneas para mostrar un error aparecen en diez lugares, y para entender qué hace hay que leerlo entero. Las funciones resuelven tres problemas a la vez:

ProblemaCómo lo resuelve una función
RepeticiónEl código se escribe una vez y se llama desde donde haga falta. Si hay que cambiarlo, se cambia en un lugar.
LegibilidadUn nombre como comprobar_espacio explica qué hace un bloque sin tener que leerlo. El programa principal se lee como una lista de pasos.
AislamientoCada función recibe sus datos como argumentos y usa sus propias variables locales, sin interferir con el resto.

2. Definir y llamar

Una función se define con su nombre, un par de paréntesis vacíos y un bloque entre llaves:

definir una función
saludar() {
  echo "Hola, $USER"
  echo "Hoy es $(date +%A)"
}

Se llama escribiendo su nombre, como cualquier comando: saludar. Los paréntesis de la definición no llevan nada dentro ni se escriben al llamarla; solo indican que lo que sigue es el cuerpo de una función. Bash admite también la forma function saludar { … }, heredada de otra shell, pero la de paréntesis es la estándar y la que usa este curso, como establecimos en el tema 4.

Bash lee y ejecuta un script de arriba abajo, y una función debe estar definida antes de que se ejecute la línea que la llama:

terminal
$ bash -c 'saludar; saludar() { echo hola; }'
bash: line 1: saludar: command not found

Por eso las funciones se escriben al comienzo del script, después de la configuración y antes del programa principal, como en la estructura del tema 2. Una función sí puede llamar a otra definida más abajo, siempre que la llamada ocurra después de que ambas existan.

3. Los argumentos de una función

Una función recibe argumentos igual que un script: se escriben después del nombre al llamarla, y dentro de ella se leen con $1, $2 y los demás. $# es la cantidad, y "$@", todos juntos. Mientras se ejecuta la función, esas variables son las suyas; al volver, se recuperan las del script.

terminal
$ mostrar() { echo "Recibí $# argumentos; el primero es «$1»"; }
$ mostrar "informe final.pdf" otro
Recibí 2 argumentos; el primero es «informe final.pdf»

Hay una excepción: $0 sigue siendo el nombre del script, no el de la función. El nombre de la función que se está ejecutando está en ${FUNCNAME[0]}, útil para mensajes de error.

Una buena costumbre es copiar los argumentos a variables locales con nombre en la primera línea de la función. $1 y $2 no dicen qué contienen; origen y destino, sí:

argumentos con nombre
copiar_seguro() {
  local origen="$1" destino="$2"

  if [[ -e "$destino" ]]; then
    echo "No sobrescribo $destino" >&2
    return 1
  fi
  cp -- "$origen" "$destino"
}

copiar_seguro informe.pdf /respaldos/informe.pdf

return 1 termina la función con código 1, de la misma forma que exit 1 terminaría el script. Lo veremos en detalle en el tema 19.

4. Todas las variables son globales

En la mayoría de los lenguajes, las variables creadas dentro de una función existen solo dentro de ella. En Bash no: toda variable es global, se cree donde se cree, salvo que se declare como local. Una función que usa una variable con el mismo nombre que el script la pisa sin aviso:

terminal
$ contar() { for i in 1 2 3; do :; done; }
$ for i in a b; do contar; echo "i vale $i"; done
i vale 3
i vale 3

El bucle exterior esperaba a y b, pero la función usó también una variable llamada i, que es la misma. Errores así son difíciles de encontrar, porque cada parte, mirada por separado, es correcta.

5. local

La palabra local, dentro de una función, declara variables que existen solo mientras se ejecuta la función. Si fuera de ella había una variable con el mismo nombre, queda intacta, y se recupera al volver:

terminal
$ contar() { local i; for i in 1 2 3; do :; done; }
$ for i in a b; do contar; echo "i vale $i"; done
i vale a
i vale b

La regla práctica es sencilla: toda variable que se usa dentro de una función se declara con local, incluidas las de los bucles. Solo quedan globales las que la función debe compartir a propósito con el resto del script, y conviene que eso sea la excepción y esté comentado.

FormaEfecto
local xVariable local, sin valor.
local x="valor" y=2Varias variables locales con valor.
local -r MAX=10Local y de solo lectura.
local -a lista=()Un array local.
local -A tabla=()Un array asociativo local.
declare x dentro de una funciónTambién crea una variable local.
No combines local con una sustitución de comandos

Como vimos en el tema 10, en local salida=$(comando) el código de salida es el de local, que siempre tiene éxito: si comando falla, nadie se entera, ni siquiera set -e. Se declara en una línea y se asigna en la siguiente: local salida y luego salida=$(comando). ShellCheck lo señala con SC2155.

6. El ámbito dinámico

Las variables locales de Bash tienen una particularidad: una función que llama a otra le deja ver sus variables locales. Esto se llama ámbito dinámico, y es distinto de lo que hacen casi todos los lenguajes modernos:

terminal
$ x=global
$ mostrar() { echo "mostrar ve x=$x"; }
$ externa() { local x=interna; mostrar; }
$ externa
mostrar ve x=interna
$ mostrar
mostrar ve x=global

mostrar ve una x u otra según quién la llamó. Se puede aprovechar, pero hace que el comportamiento de una función dependa de su contexto, y eso la vuelve difícil de entender. La recomendación es no depender de él: una función debe recibir lo que necesita como argumentos, y no leer variables locales de quien la llama.

7. Las funciones son comandos

Para Bash, una función es un comando más. Eso significa que todo lo que se puede hacer con un comando se puede hacer con una función: usarla como condición, redirigir su salida, conectarla a una tubería o ejecutarla en segundo plano.

funciones usadas como comandos
if hay_espacio "$destino" 500; then …        # como condición, por su código
generar_informe > informe.txt                # redirigir toda su salida
listar_respaldos | sort -r | head -5         # en una tubería
comprimir_registros &                        # en segundo plano
copiar_seguro a b || echo "No se copió" >&2  # con || y &&

Como en todo comando, el código de salida de una función es el de su último comando, salvo que use return. Y su salida es todo lo que escriben los comandos de su interior. Estas dos propiedades son las que se usan para devolver resultados, el tema siguiente.

Cuando una función tiene el mismo nombre que un comando, la función gana, como vimos en el tema 3. Si dentro de ella hay que llamar al comando original, se usa command: una función rm() { command rm -i "$@"; } agrega una confirmación y luego llama al rm verdadero, sin llamarse a sí misma infinitamente.

8. La función main

Una estructura muy extendida pone todo el programa principal también dentro de una función, llamada por convención main, y la última línea del script la llama pasándole los argumentos:

la estructura con main
#!/usr/bin/env bash
set -euo pipefail

readonly CONFIGURACION=…

funcion_auxiliar() {
  …
}

main() {
  local argumento="${1:?falta el argumento}"
  funcion_auxiliar "$argumento"
}

main "$@"

Tiene tres ventajas sobre dejar el programa principal suelto al final:

Para scripts breves no hace falta. A partir de unas cincuenta líneas, o cuando el script tiene varias funciones, conviene adoptarla.

9. Funciones de utilidad

Hay funciones que aparecen en casi todos los scripts, porque resuelven necesidades comunes. Estas son las que usará el curso de aquí en adelante:

un juego de funciones de utilidad
# Mensaje de error, con el nombre del script, en la salida de errores.
error() {
  printf '%s: error: %s\n' "${0##*/}" "$*" >&2
}

# Error y fin del script.
morir() {
  error "$@"
  exit 1
}

# Comprueba que estén instalados los programas indicados.
requerir() {
  local programa
  for programa in "$@"; do
    command -v "$programa" > /dev/null || morir "falta el programa $programa"
  done
}

# Pregunta sí o no; éxito solo si la respuesta empieza con «s».
confirmar() {
  local respuesta
  read -rp "$1 [s/N] " respuesta
  [[ "${respuesta,,}" == s* ]]
}

"$*", a diferencia de "$@", une todos los argumentos en un solo texto, separados por espacios: es lo que conviene para armar un mensaje, de modo que error "no existe" "$archivo" muestre una sola línea. Copiar estas funciones en cada script funciona, pero no es ideal: si se mejora una, hay que mejorarla en todos. En el tema 22 veremos cómo reunirlas en una biblioteca que los scripts cargan con source.

10. Nombres y documentación

Como dijimos en el tema 4, los nombres de función van en minúsculas, con guion bajo, y empiezan con un verbo que diga qué hace: crear_respaldo, comprobar_espacio, enviar_aviso. Las funciones que responden sí o no se nombran como una pregunta: hay_espacio, es_numero, existe_usuario, de modo que if hay_espacio … se lea como una frase.

Una función que no es evidente lleva un comentario breve encima, que diga qué hace, qué argumentos recibe y qué devuelve:

una función documentada
# hay_espacio DIRECTORIO MEGABYTES
# Éxito si el sistema de archivos de DIRECTORIO tiene al menos MEGABYTES libres.
hay_espacio() {
  local dir="$1" necesarios="$2"
  local libres
  libres=$(df -BM --output=avail -- "$dir" | tail -1 | tr -dc '0-9')
  (( libres >= necesarios ))
}

Una función debería hacer una sola cosa. Si su nombre necesita un «y», como comprobar_y_copiar, probablemente sean dos funciones. Y si es tan larga que no se ve entera en la pantalla, conviene partirla.

11. Inspeccionar y exportar funciones

Algunos comandos permiten ver y manejar las funciones definidas:

ComandoEfecto
type nombreDice si nombre es una función, y la muestra.
declare -f nombreMuestra la definición de la función.
declare -FLista los nombres de todas las funciones definidas.
unset -f nombreElimina la función.
export -f nombreHace que la función esté disponible en los procesos Bash hijos.

Como las variables, las funciones no pasan a los procesos hijos salvo que se exporten. export -f es útil en un caso concreto: cuando un comando externo debe ejecutar una función del script, como xargs o find -exec, que lanzan un Bash nuevo con bash -c:

usar una función desde find
procesar() { echo "Procesando $1"; }
export -f procesar
find . -name '*.log' -exec bash -c 'procesar "$1"' _ {} \;

12. Un script completo

Este script crea un respaldo comprimido de un directorio, con la fecha y la hora en el nombre, y borra los respaldos de ese mismo directorio con más de un número de días. Está organizado en funciones: cada una hace una cosa, y main se lee como una descripción del procedimiento.

respaldar
#!/usr/bin/env bash
#
# respaldar — crea un respaldo comprimido de un directorio y borra los antiguos.
#
# Uso:     respaldar ORIGEN [DESTINO]    (DESTINO por defecto: ~/respaldos)
# Entorno: DIAS  días que se conservan los respaldos (14)
# Salida:  0 si se creó el respaldo; 1 ante un error; 2 si el uso es incorrecto.
#
set -euo pipefail

readonly DIAS="${DIAS:-14}"

error() {
  printf '%s: error: %s\n' "${0##*/}" "$*" >&2
}

morir() {
  error "$@"
  exit 1
}

uso() {
  echo "Uso: ${0##*/} ORIGEN [DESTINO]" >&2
  exit 2
}

requerir() {
  local programa
  for programa in "$@"; do
    command -v "$programa" > /dev/null || morir "falta el programa $programa"
  done
}

# crear_respaldo ORIGEN DESTINO — empaqueta ORIGEN en DESTINO, con fecha y hora.
crear_respaldo() {
  local origen="$1" destino="$2"
  local nombre
  nombre="$(basename "$origen")-$(date +%Y%m%d-%H%M%S).tar.gz"
  tar -czf "$destino/$nombre" -C "$(dirname "$origen")" "$(basename "$origen")"
  echo "Creado $destino/$nombre"
}

# borrar_antiguos DESTINO PREFIJO — borra los respaldos de PREFIJO con más de DIAS días.
borrar_antiguos() {
  local destino="$1" prefijo="$2"
  local borrados
  borrados=$(find "$destino" -maxdepth 1 -name "$prefijo-*.tar.gz" -mtime +"$DIAS" -print -delete | wc -l)
  echo "Respaldos de más de $DIAS días borrados: $borrados"
}

main() {
  (( $# >= 1 && $# <= 2 )) || uso
  local origen="${1%/}"                     # sin la barra final, si la tiene
  local destino="${2:-$HOME/respaldos}"

  [[ -d "$origen" ]] || morir "no existe el directorio $origen"
  requerir tar find
  mkdir -p -- "$destino"

  crear_respaldo "$origen" "$destino"
  borrar_antiguos "$destino" "$(basename "$origen")"
}

main "$@"
terminal
$ respaldar ~/proyecto/
Creado /home/ana/respaldos/proyecto-20261014-121811.tar.gz
Respaldos de más de 14 días borrados: 1
$ respaldar; echo "código $?"
Uso: respaldar ORIGEN [DESTINO]
código 2
$ respaldar ~/no-existe
respaldar: error: no existe el directorio /home/ana/no-existe

13. Errores frecuentes

EscribisteQué pasaCorrección
Llamar a la función antes de definirlacommand not foundDefinir antes, o usar la estructura con main.
saludar() al llamarlaBash lo toma como el comienzo de una nueva definición.saludar, sin paréntesis.
Variables sin local dentro de una funciónPisan variables del script con el mismo nombre.local para todas las variables de la función.
local x=$(comando)El fallo de comando pasa inadvertido.local x y luego x=$(comando).
exit dentro de una función para terminarlaTermina el script entero.return.
Usar $0 para el nombre de la funciónDa el nombre del script.${FUNCNAME[0]}.
ls() { ls -l "$@"; }La función se llama a sí misma sin fin.command ls -l "$@".
Una función que lee variables locales de quien la llamaSu comportamiento depende del contexto.Pasarle los datos como argumentos.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué tres problemas resuelven las funciones?

La repetición, porque el código se escribe una vez; la legibilidad, porque un nombre explica un bloque; y el aislamiento, porque cada función recibe sus datos y usa sus propias variables.

2. ¿Por qué falla saludar; saludar() { echo hola; }?

Porque Bash ejecuta de arriba abajo, y cuando llega a la llamada la función todavía no está definida.

3. Dentro de una función, ¿qué contienen $1 y $0?

$1 es el primer argumento que recibió la función. $0 sigue siendo el nombre del script; el de la función está en ${FUNCNAME[0]}.

4. ¿Qué ocurre si una función usa una variable i sin declararla con local, y el script también usa i?

Es la misma variable: la función modifica la del script. Si el script la usaba como variable de un bucle, su valor queda alterado.

5. ¿Por qué se recomienda escribir local x y x=$(cmd) en dos líneas?

Porque en local x=$(cmd) el código de salida es el de local, que siempre tiene éxito, y el fallo de cmd queda oculto.

6. ¿Qué es el ámbito dinámico?

Que una función ve las variables locales de la función que la llamó. Así, el valor que ve una función depende de quién la llama, no solo de dónde está escrita.

7. ¿Cuál es el código de salida de una función sin return?

El de su último comando ejecutado.

8. Menciona dos ventajas de terminar un script con main "$@".

Las variables del programa principal pueden ser locales; el orden de definición de las funciones deja de importar; y un script truncado no se ejecuta a medias, porque la llamada a main está al final.

9. ¿Por qué error usa "$*" y no "$@"?

Porque "$*" une todos los argumentos en un solo texto, que es lo que necesita printf para el %s del mensaje. Con "$@", cada argumento sería un dato separado y printf repetiría el formato.

10. ¿Cómo se escribe una función ls que llame al ls real con una opción agregada?

ls() { command ls -l "$@"; }. command hace que se ejecute el comando y no la función, que de otro modo se llamaría a sí misma indefinidamente.

11. ¿Para qué sirve export -f?

Para que una función esté disponible en los procesos Bash hijos, por ejemplo los que lanzan find -exec bash -c o xargs.

Para practicar

  1. Escribe una función linea que reciba un carácter y una cantidad y dibuje una línea con ese carácter repetido, por ejemplo linea = 40.
  2. Reproduce el problema de la variable i compartida y corrígelo con local.
  3. Escribe la función hay_espacio de la sección 10 y úsala en un if para comprobar si tu directorio personal tiene al menos 1000 MB libres.
  4. Toma uno de los scripts de temas anteriores, por ejemplo comprobar-respaldo del tema 11, y reorganízalo con funciones y una función main.
  5. Crea un archivo con las funciones de utilidad de la sección 9 y cárgalo con source desde un script de prueba (adelanto del tema 22).
  6. Prueba respaldar con un directorio propio, y comprueba con tar -tzf que las rutas dentro del archivo empiezan con el nombre del directorio.