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

Tema 22

Bibliotecas de funciones

Después de unos cuantos scripts, las mismas funciones aparecen copiadas en todos: error, morir, requerir, a_legible. Cuando se mejora una, hay que mejorarla en cada copia, y alguna siempre queda atrás. La solución es la misma que en cualquier lenguaje: reunir las funciones comunes en un archivo aparte, una biblioteca, y cargarla desde cada script. Este tema muestra cómo hacerlo en Bash de forma robusta, y cómo organizar un proyecto de varios scripts.

1. Cargar un archivo con source

Como vimos en el tema 2, source archivo, o su forma abreviada . archivo, lee un archivo y ejecuta sus líneas en la shell actual, sin crear un proceso. Si el archivo solo contiene definiciones de funciones y variables, el efecto es que esas funciones y variables quedan disponibles en el script que lo cargó, como si estuvieran escritas en él.

una biblioteca mínima y un script que la usa
# comun.sh
error() {
  printf '%s: error: %s\n' "${0##*/}" "$*" >&2
}

# mi-script
#!/usr/bin/env bash
source ./comun.sh
error "algo salió mal"            # la función viene de comun.sh

Dentro de la biblioteca, $0 sigue siendo el script que la cargó, porque no hay un proceso nuevo: por eso error muestra el nombre de mi-script, que es lo correcto. Y todo lo que la biblioteca define queda en el mismo espacio que el script: sus funciones, sus variables globales y cualquier opción que active, como set -e.

El ejemplo tiene un problema, sin embargo: ./comun.sh es una ruta relativa al directorio actual, no al del script. Funciona si se ejecuta el script desde su propio directorio, y falla desde cualquier otro.

2. Encontrar la biblioteca

La forma robusta es construir la ruta de la biblioteca a partir de la ubicación del propio script. Bash la ofrece en el array BASH_SOURCE: ${BASH_SOURCE[0]} es la ruta del archivo que contiene la línea que se está ejecutando.

VariableEn el scriptDentro de una biblioteca cargada
$0La ruta con que se invocó el script.La misma: la del script.
${BASH_SOURCE[0]}La ruta del script.La ruta de la biblioteca.

Para cargar una biblioteca que está junto al script, o en un directorio relativo a él, se toma el directorio de esa ruta:

cargar una biblioteca relativa al script
readonly DIR_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR_SCRIPT/../lib/comun.sh"

El cd seguido de pwd convierte la ruta en absoluta: si el script se invocó como ../herramientas/bin/script, dirname daría una ruta relativa, válida solo desde el directorio de ese momento. Con la ruta absoluta, el script puede cambiar de directorio después y seguir encontrando sus archivos. Se usa BASH_SOURCE y no $0 porque funciona también cuando el propio script es cargado con source por otro, como en las pruebas.

3. Scripts instalados mediante un enlace

En el tema 2 propusimos instalar un script en ~/bin mediante un enlace simbólico a su ubicación en el proyecto. Con bibliotecas, eso tiene una consecuencia: BASH_SOURCE contiene la ruta del enlace, ~/bin/script, y la biblioteca no está en ~/bin/../lib sino en el proyecto.

readlink -f resuelve los enlaces y devuelve la ruta real del archivo. Agregándolo, el script encuentra sus bibliotecas lo invoquen como lo invoquen:

la forma completa
readonly DIR_BASE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
source "$DIR_BASE/lib/comun.sh"
PasoCon ~/bin/limpiar → ~/proyectos/herramientas/bin/limpiar
${BASH_SOURCE[0]}/home/ana/bin/limpiar
readlink -f/home/ana/proyectos/herramientas/bin/limpiar
dirname/home/ana/proyectos/herramientas/bin
/.., cd y pwd/home/ana/proyectos/herramientas

readlink -f es de GNU; en macOS existe desde la versión 12.3, y antes había que usar realpath o un equivalente. Es una línea larga, pero se escribe una vez al comienzo de cada script y no hay que volver a pensar en ella.

4. Qué va en una biblioteca, y qué no

Una biblioteca se ejecuta dentro del script que la carga, de modo que todo lo que haga, lo hace en él. Por eso debe limitarse a definir, sin actuar:

SíNo
Definiciones de funciones.Código suelto que hace algo al cargarse: crear archivos, mostrar mensajes, cambiar de directorio.
Constantes propias, de solo lectura y con prefijo.set -euo pipefail u otras opciones: esa decisión es del script.
Valores por defecto que el script pueda cambiar antes de cargarla: : "${LOG_NIVEL:=INFO}".exit: terminaría el script que la cargó.
Un comentario al comienzo que diga qué contiene y cómo se usa.Una línea shebang ni permiso de ejecución: no es un programa.

Las funciones de una biblioteca, en cambio, sí pueden terminar el script, como morir, porque se las llama a propósito. Lo que no debe hacer es actuar por el solo hecho de ser cargada.

5. Cargar una sola vez

En un proyecto con varias bibliotecas, unas dependen de otras: archivos.sh puede necesitar morir de comun.sh, y el script carga las dos. comun.sh terminaría cargándose dos veces. En la mayoría de los casos no pasa nada grave, pero si la biblioteca declara constantes con readonly, la segunda carga falla. La costumbre es poner una guarda al comienzo de cada biblioteca:

la guarda contra la carga doble
# comun.sh — funciones generales para los scripts del proyecto.
# Se carga con «source»; no ejecuta nada por sí misma.

[[ -n "${_COMUN_SH:-}" ]] && return 0
_COMUN_SH=1

…

La primera vez, la variable no existe, la condición es falsa y la biblioteca se carga, dejando la variable definida. La segunda vez, la variable ya existe y return termina la carga en ese punto. return es válido en un archivo cargado con source, y termina solo la carga del archivo, no el script. El valor por defecto :- evita el error de set -u en la primera carga.

6. Nombres que no choquen

Como vimos en el tema 18, las funciones y las variables globales comparten un único espacio de nombres. Una biblioteca que define limpiar pisa, sin aviso, una función del mismo nombre en el script, o en otra biblioteca. En un proyecto propio y pequeño, con funciones bien nombradas, el riesgo es bajo. Cuando la biblioteca crece o se comparte, conviene usar un prefijo común:

funciones y variables con prefijo
# registro.sh — funciones de registro; todas empiezan con «log_».
: "${LOG_NIVEL:=INFO}"              # el script puede fijarlo antes de cargarla

log_info()  { _log_escribir INFO "$@"; }
log_aviso() { _log_escribir AVISO "$@"; }
log_error() { _log_escribir ERROR "$@"; }

_log_escribir() {                   # interna: el guion bajo inicial lo indica
  …
}

Las funciones que la biblioteca usa internamente, y que los scripts no deberían llamar, llevan además un guion bajo inicial. Bash no impide llamarlas, pero el guion bajo comunica la intención. Lo mismo vale para sus variables globales: LOG_NIVEL es configuración pública; _COMUN_SH, un detalle interno.

7. ¿Se ejecuta o se carga?

A veces conviene que un mismo archivo pueda usarse de las dos formas: ejecutado, como un comando, y cargado, como biblioteca, por ejemplo para probar sus funciones por separado. Comparando BASH_SOURCE con $0 se distingue el caso: si son iguales, el archivo se está ejecutando; si no, alguien lo cargó con source.

ejecutar main solo si el archivo se ejecuta
main() {
  …
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  main "$@"
fi

Ejecutado, el script funciona normalmente. Cargado con source desde una prueba, define sus funciones sin ejecutar main, y la prueba puede llamar a cada función con distintos datos y comprobar el resultado. Es la base de las herramientas de pruebas para Bash, como bats, que se usan en proyectos de scripts grandes.

8. Organizar un proyecto

Cuando los scripts de una misma área se multiplican, conviene reunirlos en un proyecto con una estructura reconocible. Esta es una organización habitual:

la estructura de un proyecto de scripts
herramientas/
├── bin/                      # los scripts ejecutables, sin extensión
│   ├── archivar-descargas
│   ├── respaldar
│   └── verificar-sitios
├── lib/                      # las bibliotecas, con extensión .sh
│   ├── comun.sh
│   ├── archivos.sh
│   └── registro.sh
├── conf/                     # archivos de configuración de ejemplo
│   └── servicios.conf.ejemplo
├── pruebas/                  # pruebas de las funciones
└── LEEME.md                  # qué es, cómo se instala, qué hace cada script

Los nombres siguen las convenciones del tema 2: los ejecutables sin extensión, porque son comandos; las bibliotecas con .sh, porque se cargan con source. Con el proyecto en un repositorio Git, cada cambio queda registrado, se puede volver atrás y se puede instalar el mismo conjunto de herramientas en varias máquinas con un git clone.

9. Instalar el proyecto

Hay dos formas de dejar los scripts disponibles como comandos, y las dos funcionan con la línea de readlink -f de la sección 3:

FormaCómoCuándo
Agregar bin al PATHexport PATH="$HOME/proyectos/herramientas/bin:$PATH" en ~/.bashrcPara uso personal: todos los scripts del proyecto quedan disponibles.
Enlaces en ~/binln -s ~/proyectos/herramientas/bin/respaldar ~/bin/Para elegir qué scripts instalar.

Para instalar en el sistema, para todos los usuarios, la convención es copiar las bibliotecas a /usr/local/lib/herramientas/ y los scripts a /usr/local/bin/. En ese caso los scripts ya no están junto a sus bibliotecas, y deben buscarlas en varias rutas posibles: primero junto al script, para el desarrollo, y después en /usr/local/lib/herramientas.

10. Bibliotecas y ShellCheck

Cuando ShellCheck encuentra un source con una ruta que se calcula al ejecutar, no puede saber qué archivo es, y avisa con SC1091. Además, sin leer la biblioteca, no sabe qué funciones y variables define. Como vimos en el tema 4, se le indica la ruta con una directiva, relativa al script:

indicar a ShellCheck dónde está la biblioteca
# shellcheck source=../lib/comun.sh
source "$DIR_BASE/lib/comun.sh"

Y se ejecuta con shellcheck -x bin/*, que le permite seguir los source. Las bibliotecas, que no tienen línea shebang, se revisan con shellcheck -s bash lib/*.sh, para indicarle el dialecto.

11. Un proyecto completo

El directorio de descargas acumula archivos que nadie vuelve a mirar. Este proyecto contiene dos bibliotecas y un script que las usa para mover las descargas de más de un mes a subdirectorios por mes, como antiguos/2026-09/, informando cuánto espacio ordenó.

herramientas/lib/comun.sh
# comun.sh — funciones generales para los scripts del proyecto.
# Se carga con «source»; no ejecuta nada por sí misma.

[[ -n "${_COMUN_SH:-}" ]] && return 0
_COMUN_SH=1

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

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

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

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

confirmar() {
  local respuesta
  read -rp "$1 [s/N] " respuesta
  [[ "${respuesta,,}" == s* ]]
}
herramientas/lib/archivos.sh
# archivos.sh — funciones para trabajar con archivos.
# Se carga con «source»; no ejecuta nada por sí misma.

[[ -n "${_ARCHIVOS_SH:-}" ]] && return 0
_ARCHIVOS_SH=1

# a_legible BYTES — escribe el tamaño en unidades legibles: «1.5 KiB».
a_legible() {
  local bytes="$1"
  local -a unidades=(B KiB MiB GiB TiB)
  local i=0 decimas=$(( bytes * 10 ))
  while (( decimas >= 10240 && i < ${#unidades[@]} - 1 )); do
    decimas=$(( decimas / 1024 ))
    i=$(( i + 1 ))
  done
  if (( i == 0 )); then
    printf '%d B\n' "$bytes"
  else
    printf '%d.%d %s\n' $(( decimas / 10 )) $(( decimas % 10 )) "${unidades[i]}"
  fi
}

# mes_de ARCHIVO — escribe el mes de modificación del archivo: «2026-09».
mes_de() {
  date -r "$1" +%Y-%m
}
herramientas/bin/archivar-descargas
#!/usr/bin/env bash
#
# archivar-descargas — mueve las descargas antiguas a subdirectorios por mes.
#
# Uso: archivar-descargas [-n] [-d DÍAS] [DIRECTORIO]   (30 días; ~/Descargas)
#
set -euo pipefail

readonly DIR_BASE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
# shellcheck source=../lib/comun.sh
source "$DIR_BASE/lib/comun.sh"
# shellcheck source=../lib/archivos.sh
source "$DIR_BASE/lib/archivos.sh"

uso() {
  echo "Uso: ${0##*/} [-n] [-d DÍAS] [DIRECTORIO]" >&2
  exit 2
}

main() {
  local simular=0 dias=30 opcion
  while getopts ":nd:" opcion; do
    case "$opcion" in
      n) simular=1 ;;
      d) dias="$OPTARG" ;;
      *) uso ;;
    esac
  done
  shift $(( OPTIND - 1 ))
  [[ "$dias" =~ ^[0-9]+$ ]] || { error "-d debe ser un número: $dias"; uso; }

  local dir="${1:-$HOME/Descargas}"
  [[ -d "$dir" ]] || morir "no existe el directorio $dir"
  requerir find date stat

  local archivo destino tam movidos=0 bytes=0
  while IFS= read -r -d '' archivo; do
    destino="$dir/antiguos/$(mes_de "$archivo")"
    tam=$(stat -c %s -- "$archivo")
    if (( simular )); then
      echo "movería ${archivo##*/} → antiguos/${destino##*/}/"
    else
      mkdir -p -- "$destino"
      mv -n -- "$archivo" "$destino/"
    fi
    movidos=$(( movidos + 1 ))
    bytes=$(( bytes + tam ))
  done < <(find "$dir" -maxdepth 1 -type f -mtime +"$dias" -print0)

  echo "Archivos archivados: $movidos ($(a_legible "$bytes"))"
}

main "$@"
terminal
$ ln -s ~/proyectos/herramientas/bin/archivar-descargas ~/bin/
$ archivar-descargas -n
movería factura-agosto.pdf → antiguos/2026-08/
movería ubuntu-24.04.iso → antiguos/2026-09/
Archivos archivados: 2 (1.4 GiB)
$ archivar-descargas
Archivos archivados: 2 (1.4 GiB)
$ archivar-descargas                       # una segunda vez: ya no queda nada viejo
Archivos archivados: 0 (0 B)

12. Errores frecuentes

EscribisteQué pasaCorrección
source ./lib/comun.shFunciona solo desde el directorio del proyecto.Una ruta construida desde BASH_SOURCE.
dirname "$0" para ubicar la bibliotecaFalla si el script se carga con source o se ejecuta mediante un enlace.readlink -f "${BASH_SOURCE[0]}"
set -e o exit en el cuerpo de una bibliotecaCambian el comportamiento del script que la carga, o lo terminan.Solo definiciones en la biblioteca.
Una biblioteca sin guarda, con readonlyLa segunda carga falla con readonly variable.La guarda con return 0.
exit 0 como guardaTermina el script entero.return 0.
Funciones con nombres genéricos en una biblioteca compartidaPisan funciones del script, sin aviso.Un prefijo: log_info.
shellcheck bin/script con bibliotecasSC1091, y avisos de funciones «no definidas».Directiva source= y shellcheck -x.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Dentro de una biblioteca cargada con source, ¿qué contiene $0?

El nombre del script que la cargó, porque source no crea un proceso nuevo. Por eso las funciones de error de una biblioteca muestran el nombre del script.

2. ¿Por qué source ./lib/comun.sh no es una buena forma de cargar una biblioteca?

Porque la ruta es relativa al directorio actual, no al del script. Si el script se ejecuta desde otro directorio, no encuentra la biblioteca.

3. ¿Qué diferencia hay entre $0 y ${BASH_SOURCE[0]}?

$0 es siempre la ruta con que se invocó el script. ${BASH_SOURCE[0]} es la ruta del archivo que contiene la línea en ejecución: en una biblioteca, la de la biblioteca.

4. ¿Para qué sirve readlink -f al ubicar las bibliotecas?

Para resolver los enlaces simbólicos. Si el script se instaló como un enlace en ~/bin, BASH_SOURCE contiene la ruta del enlace; readlink -f da la del archivo real, junto al cual están las bibliotecas.

5. ¿Por qué una biblioteca no debe contener set -euo pipefail?

Porque se ejecuta dentro del script que la carga, y cambiaría su comportamiento. Las opciones de la shell son una decisión del script.

6. ¿Cómo funciona la guarda [[ -n "${_COMUN_SH:-}" ]] && return 0?

La primera vez la variable no existe, la biblioteca se carga y la define. La segunda, la variable existe y return termina la carga del archivo sin volver a definir nada.

7. ¿Por qué la guarda usa return y no exit?

Porque en un archivo cargado con source, return termina solo la carga del archivo, mientras que exit terminaría el script que lo cargó.

8. ¿Para qué sirve if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi?

Para que el archivo ejecute main solo cuando se lo ejecuta directamente. Si se lo carga con source, por ejemplo desde una prueba, define sus funciones sin ejecutar nada.

9. ¿Por qué los scripts de bin/ no llevan extensión y las bibliotecas sí?

Porque los scripts son comandos, y a quien los usa no le importa en qué lenguaje están escritos. Las bibliotecas no son comandos: se cargan con source y la extensión indica que son código de shell.

10. ¿Cómo se le indica a ShellCheck dónde está una biblioteca cargada con una ruta calculada?

Con una directiva # shellcheck source=ruta/relativa.sh antes del source, y ejecutándolo con shellcheck -x.

Para practicar

  1. Crea la estructura de directorios de la sección 8 en ~/proyectos/herramientas y copia allí las dos bibliotecas y archivar-descargas.
  2. Ejecuta archivar-descargas -n desde el directorio del proyecto, desde tu directorio personal y mediante un enlace en ~/bin.
  3. Reemplaza en el script la línea de DIR_BASE por una que use dirname "$0", y comprueba en qué caso deja de funcionar.
  4. Carga comun.sh dos veces en una misma sesión y comprueba con declare -F que las funciones están definidas una sola vez. Quita la guarda, agrega una constante con readonly y repite.
  5. Convierte alguno de los scripts de temas anteriores, como respaldar del tema 18, para que use comun.sh en lugar de definir sus propias funciones.
  6. Agrega a archivos.sh una función y escribe un pequeño script de prueba que la cargue y compruebe su resultado con varios valores.
  7. Pasa shellcheck -x por el script y shellcheck -s bash por las bibliotecas.