← Automatización con Scripts en Bash Bloque 3 · Códigos de salida y condicionales · Tema 12 de 42

Tema 12

Condiciones compuestas

Las decisiones reales rara vez dependen de una sola pregunta. Hay que respaldar si algo cambió y hay espacio; avisar si el disco está lleno o el servicio no responde; seguir solo si el archivo no existe. Este tema explica cómo combinar condiciones, cómo encadenar varias alternativas con elif, cómo evitar los if anidados que vuelven ilegible un script, y una trampa de las condiciones de una sola línea que conviene conocer antes de caer en ella.

1. Y, o, no

Tres operadores lógicos permiten construir condiciones a partir de otras:

OperadorNombreEs cierto si…
A && BYA y B son ciertas.
A || BOAl menos una de las dos es cierta.
! ANoA es falsa.

En Bash, los mismos símbolos aparecen en dos lugares distintos, con reglas algo diferentes: dentro de [[ ]], donde combinan pruebas, y entre comandos, donde combinan códigos de salida. Las dos secciones siguientes explican cada caso.

2. Dentro de [[ ]]

Dentro de [[ ]], && y || combinan las pruebas del tema 11 en una sola expresión:

combinar pruebas
if [[ -f "$archivo" && -r "$archivo" ]]; then
  echo "Existe y se puede leer"
fi

if [[ "$respuesta" == s* || "$respuesta" == y* ]]; then
  echo "Respuesta afirmativa, en español o en inglés"
fi

Para números se usa (( )), que tiene sus propios && y ||, como vimos en el tema 7. Es la forma más legible de expresar un rango:

un rango numérico
if (( dias >= 1 && dias <= 365 )); then
  echo "Plazo válido"
fi
-a y -o: no los uses

Con [ ] existían -a (y) y -o (o) para combinar dentro de los corchetes: [ -f "$a" -a -r "$a" ]. El estándar POSIX los declara obsoletos, porque con ciertos valores la expresión se vuelve ambigua: si una variable contiene ! o -a, test no puede saber si es un operador o un dato. Si necesitas [ ] por portabilidad, combina varios corchetes con los operadores de la shell: [ -f "$a" ] && [ -r "$a" ].

3. Entre comandos

Fuera de los corchetes, && y || unen comandos, y deciden según sus códigos de salida, como vimos en el tema 25 del curso de Terminal. Una condición de if puede ser una combinación de comandos y de pruebas:

combinar comandos y pruebas
if [[ -f "$registro" ]] && grep -q "ERROR" "$registro"; then
  echo "El registro tiene errores"
fi

if ping -c1 -W2 servidor > /dev/null || ping -c1 -W2 servidor-respaldo > /dev/null; then
  echo "Al menos uno de los dos servidores responde"
fi

Mezclar comandos con pruebas no es posible dentro de [[ ]], que solo admite las pruebas de test y comparaciones. Por eso, cuando una de las condiciones es un comando, los operadores van afuera, uniendo un [[ ]] con el comando.

4. Precedencia y agrupación

Cuando se mezclan && y ||, importa cuál se evalúa primero, y aquí los dos contextos difieren:

ContextoReglaA || B && C significa
Dentro de [[ ]] y (( ))&& tiene más precedencia, como en matemática y en otros lenguajes.A || (B && C)
Entre comandosLos dos tienen la misma precedencia y se evalúan de izquierda a derecha.(A || B) && C
terminal
$ true || false && false; echo $?          # (true || false) && false
1
$ [[ -n x || -z x && -z x ]]; echo $?       # -n x || (-z x && -z x)
0

La forma de no depender de estas reglas es agrupar explícitamente. Dentro de [[ ]] se usan paréntesis; entre comandos, llaves, que agrupan sin crear una subshell:

agrupar
if [[ ( "$dia" == sáb || "$dia" == dom ) && "$FORZAR" != 1 ]]; then
  echo "Hoy no se respalda"
fi

if { [[ -f a.log ]] || [[ -f b.log ]]; } && [[ -d /respaldos ]]; then
  echo "Hay registros y hay dónde guardarlos"
fi

Aunque la precedencia diera el resultado correcto sin paréntesis, agruparlos ahorra a quien lee el tener que recordarla. Y si una condición necesita más de dos niveles de agrupación, es señal de que conviene partirla en pasos con nombre, como veremos en la sección 8.

5. La evaluación en cortocircuito

Bash evalúa las condiciones compuestas de izquierda a derecha, y se detiene en cuanto conoce el resultado. En A && B, si A es falsa, el conjunto ya es falso y B no se evalúa. En A || B, si A es cierta, B no se evalúa. Esto se llama evaluación en cortocircuito, y tiene dos consecuencias prácticas.

La primera es que el orden sirve de protección: la condición de la izquierda puede garantizar que la de la derecha sea segura.

la primera condición protege a la segunda
if [[ -f "$config" ]] && grep -q "^modo=produccion" "$config"; then
  …                                     # grep solo se ejecuta si el archivo existe
fi

if [[ -v LIMITE ]] && (( LIMITE > 0 )); then
  …                                     # con set -u, LIMITE solo se lee si existe
fi

if (( total > 0 )) && (( errores * 100 / total > 5 )); then
  …                                     # no se divide por cero
fi

La segunda es que el orden afecta al rendimiento: si una condición es barata, como una prueba de [[ ]], y otra es cara, como un grep sobre un archivo grande o una consulta por red, conviene poner primero la barata, para que la cara se evalúe solo cuando haga falta.

6. Negar condiciones

El operador ! invierte el resultado, y puede ir en tres lugares, con el mismo efecto:

tres formas de negar
if [[ ! -d "$dir" ]]; then …            # dentro de [[ ]], sobre una prueba
if ! [[ -d "$dir" ]]; then …            # fuera, sobre todo el comando [[ ]]
if ! grep -q "$usuario" lista.txt; then …   # sobre cualquier comando

Con condiciones compuestas, negar requiere cuidado. Las leyes de De Morgan dicen cómo se transforma una negación: «no (A y B)» equivale a «no A o no B», y «no (A o B)» equivale a «no A y no B». En la práctica, conviene escribir la forma que se lea más natural:

dos formas de la misma condición
if ! [[ -f "$a" && -r "$a" ]]; then     # no (existe y se lee)
if [[ ! -f "$a" || ! -r "$a" ]]; then   # no existe, o no se lee

Las dobles negaciones, como if ! [[ ! -e … ]], siempre tienen una forma más directa. Y cuando una condición negada lleva un else, invertirla suele dejar el código más claro: es más fácil leer «si existe… si no…» que «si no existe… si no…».

7. Varias alternativas: elif

Cuando hay más de dos caminos posibles, elif (contracción de else if) encadena condiciones. Se evalúan en orden, y se ejecuta el bloque de la primera que sea cierta; si ninguna lo es, el else final, si existe.

clasificar el uso del disco
uso=$(df --output=pcent / | tail -1 | tr -dc '0-9')

if (( uso >= 95 )); then
  nivel="crítico"
elif (( uso >= 85 )); then
  nivel="alto"
elif (( uso >= 70 )); then
  nivel="moderado"
else
  nivel="normal"
fi
echo "Uso del disco: $uso % ($nivel)"

El orden de las condiciones es parte de la lógica. Como solo se ejecuta la primera que coincide, las más restrictivas van antes: si uso >= 70 estuviera primero, un disco al 98 % se clasificaría como «moderado». Cada elif puede asumir que todas las condiciones anteriores fueron falsas, y por eso la segunda no necesita decir uso >= 85 && uso < 95.

Cuando todas las condiciones comparan una misma variable con distintos valores o patrones, case lo expresa mejor que una cadena de elif. Es el tema siguiente.

8. Salir pronto en lugar de anidar

Un script que debe comprobar varias cosas antes de hacer su trabajo tiende a crecer hacia la derecha, con cada condición dentro de la anterior:

anidado: difícil de seguir
if [[ -n "$origen" ]]; then
  if [[ -d "$origen" ]]; then
    if [[ -w "$destino" ]]; then
      tar -czf "$destino/respaldo.tgz" "$origen"
    else
      echo "No se puede escribir en $destino" >&2
      exit 1
    fi
  else
    echo "No existe $origen" >&2
    exit 1
  fi
else
  echo "Falta el origen" >&2
  exit 2
fi

Cada mensaje de error queda lejos de la condición que lo provoca, y el trabajo real, la línea de tar, queda enterrado en el centro. La alternativa es invertir cada condición y salir en cuanto algo no se cumple. A estas comprobaciones iniciales se las llama cláusulas de guarda:

con cláusulas de guarda: se lee de arriba abajo
[[ -n "$origen" ]]  || { echo "Falta el origen" >&2; exit 2; }
[[ -d "$origen" ]]  || { echo "No existe $origen" >&2; exit 1; }
[[ -w "$destino" ]] || { echo "No se puede escribir en $destino" >&2; exit 1; }

tar -czf "$destino/respaldo.tgz" "$origen"

Cada línea es una precondición con su mensaje al lado, y después de ellas el script puede asumir que todo está en orden. Es el patrón que usamos en el tema 11, con la función falla, y el que seguirán los scripts del curso: primero se descarta lo que impide trabajar, después se trabaja.

9. Condiciones de una línea

Para decisiones simples, un if de tres líneas puede ser excesivo. Los operadores && y || permiten escribir una condición y su acción en una sola línea:

Forma cortaEquivale aSe lee
condición && acciónif condición; then acción; fi«si se cumple, haz esto»
condición || acciónif ! condición; then acción; fi«si no se cumple, haz esto»
usos típicos
[[ -d "$destino" ]] || mkdir -p "$destino"
[[ "${DEPURAR:-}" == 1 ]] && set -x
(( errores > 0 )) && echo "Hubo $errores errores" >&2
command -v rsync > /dev/null || { echo "Falta rsync" >&2; exit 1; }

Funcionan bien cuando la acción es breve y no hay alternativa. Si la acción tiene varios pasos, o hace falta un «si no», el if completo es más claro. La última línea muestra un modismo útil: command -v comprueba si un programa está instalado, y es la forma de verificar los requisitos de un script al comenzar.

10. La trampa de a && b || c

Es tentador combinar las dos formas cortas para escribir un if/else en una línea: condición && acción_si || acción_no. Parece equivalente, y casi siempre se comporta igual. Pero no lo es:

terminal
$ copiar() { echo "copiando..."; return 1; }    # una acción que falla
$ true && copiar || echo "no se cumplió la condición"
copiando...
no se cumplió la condición

La condición se cumplió, y aun así se ejecutó la rama del «no». Por la regla de la sección 4, la línea se agrupa como (true && copiar) || echo: el || no mira solo la condición, sino el resultado de true && copiar. Si la acción del «sí» falla, se ejecuta también la del «no». En el mejor caso aparece un mensaje engañoso; en el peor, se ejecutan las dos ramas de una decisión que debía elegir una.

a && b || c no es un if/else

Úsalo solo si b no puede fallar, como un echo o una asignación, y aun así con cuidado. Si b es un comando que hace algo real, escribe el if completo. ShellCheck señala este patrón con el aviso SC2015.

11. Las condiciones y set -e

El modo set -e, que estudiaremos en el tema 23, detiene el script cuando un comando falla. Pero no todo código distinto de cero es un fallo: un comando usado como condición puede dar falso, y eso es una respuesta, no un error. Por eso set -e no se activa con:

Esto tiene una consecuencia que conviene recordar: en [[ -f "$a" ]] && procesar "$a", si procesar falla, el script se detiene, porque es el último de la lista. Pero si el archivo no existe, la prueba da falso, el script sigue, y esa línea deja un código 1. Si fuera la última de una función o del script, el código de salida sería 1 sin que nada hubiera fallado, el caso que vimos en el tema 10. Las condiciones cortas son cómodas en medio de un script; al final, mejor un if.

12. Un script completo

Respaldar un directorio que no cambió desde ayer gasta espacio y tiempo. Este script decide si corresponde hacer un respaldo, y lo explica. Responde con su código de salida, de modo que otro script, o cron, puede usarlo como condición: debe-respaldar ~/proyecto && respaldo ~/proyecto.

debe-respaldar
#!/usr/bin/env bash
#
# debe-respaldar — decide si corresponde respaldar un directorio.
#
# Uso:     debe-respaldar DIRECTORIO
# Entorno: FORZAR=1           respaldar siempre
#          FINES_DE_SEMANA=1  respaldar también sábados y domingos
#          MARCA=ARCHIVO      archivo cuya fecha indica el último respaldo
#                             (por defecto, DIRECTORIO/.ultimo-respaldo)
# Salida:  0 si corresponde respaldar; 1 si no; 2 si el uso es incorrecto.
#
set -euo pipefail

dir="${1:-}"
if [[ -z "$dir" || ! -d "$dir" ]]; then
  echo "Uso: ${0##*/} DIRECTORIO (debe existir)" >&2
  exit 2
fi
marca="${MARCA:-$dir/.ultimo-respaldo}"
dia=$(date +%u)                         # 1 = lunes … 7 = domingo

respaldar() { echo "Respaldar: $*"; exit 0; }
omitir()    { echo "No respaldar: $*"; exit 1; }

if [[ "${FORZAR:-0}" == 1 ]]; then
  respaldar "se pidió forzarlo"
elif [[ ! -e "$marca" ]]; then
  respaldar "nunca se respaldó"
elif (( dia >= 6 )) && [[ "${FINES_DE_SEMANA:-0}" != 1 ]]; then
  omitir "es fin de semana"
elif find "$dir" -type f -newer "$marca" -print -quit | grep -q .; then
  respaldar "hay archivos modificados desde el último respaldo"
else
  omitir "nada cambió desde el último respaldo"
fi
terminal
$ debe-respaldar ~/proyecto
Respaldar: nunca se respaldó
$ touch ~/proyecto/.ultimo-respaldo               # lo que haría el script de respaldo al terminar
$ debe-respaldar ~/proyecto; echo "código $?"
No respaldar: nada cambió desde el último respaldo
código 1
$ echo "nueva línea" >> ~/proyecto/notas.txt
$ debe-respaldar ~/proyecto && echo "→ ejecutar el respaldo"
Respaldar: hay archivos modificados desde el último respaldo
→ ejecutar el respaldo

13. Errores frecuentes

EscribisteQué pasaCorrección
[[ -f a ]] && [[ -f b ]] || [[ -f c ]] esperando «a y (b o c)»Entre comandos se agrupa como «(a y b) o c».[[ -f a && ( -f b || -f c ) ]]
[ -f "$a" -a -r "$a" ]Obsoleto; ambiguo con ciertos valores.[[ -f $a && -r $a ]]
[[ -f $a ]] && grep x $a || echo noSi grep no encuentra nada, también se ejecuta el echo.Un if/else completo.
[[ -f $a && grep -q x $a ]]Error de sintaxis: grep no es una prueba.[[ -f $a ]] && grep -q x "$a"
elif en el orden equivocadoUna condición más general atrapa los casos de una más específica.De la más restrictiva a la más general.
if [[ … ]]; then if …; then if …Anidamiento profundo, errores lejos de su causa.Cláusulas de guarda.
(( a / b > 2 )) && (( b > 0 ))Divide antes de comprobar el divisor.Primero (( b > 0 )).
Última línea [[ … ]] && accionEl script o la función terminan con 1 si la prueba es falsa.Un if, o exit 0 después.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cómo se pregunta si un archivo existe y contiene la palabra «ERROR»?

Combinando una prueba y un comando fuera de los corchetes: [[ -f "$a" ]] && grep -q ERROR "$a". grep no puede ir dentro de [[ ]].

2. ¿Por qué se desaconsejan -a y -o dentro de [ ]?

Porque son obsoletos en POSIX y producen expresiones ambiguas cuando los datos contienen caracteres como ! o textos que parecen operadores. Se reemplazan por varios [ ] unidos con && y ||, o por [[ ]].

3. ¿Cómo se evalúa A || B && C entre comandos, y cómo dentro de [[ ]]?

Entre comandos, de izquierda a derecha: (A || B) && C. Dentro de [[ ]], && tiene precedencia: A || (B && C).

4. ¿Qué es la evaluación en cortocircuito?

Que Bash deja de evaluar en cuanto conoce el resultado: en A && B, si A es falsa, B no se evalúa; en A || B, si A es cierta, tampoco.

5. ¿Por qué (( total > 0 )) && (( errores * 100 / total > 5 )) no puede dividir por cero?

Porque si total es 0, la primera condición es falsa y, por el cortocircuito, la segunda no llega a evaluarse.

6. ¿Qué condición equivale a ! [[ -f $a || -d $a ]]?

[[ ! -f $a && ! -d $a ]]: «no es archivo ni directorio». Es una de las leyes de De Morgan.

7. En una cadena de elif, ¿qué bloque se ejecuta si varias condiciones son ciertas?

Solo el de la primera que sea cierta, en el orden en que están escritas.

8. ¿Por qué clasificar un número de mayor a menor (≥ 95, ≥ 85, ≥ 70) funciona con elif, y al revés no?

Porque se ejecuta la primera condición cierta. Si la primera fuera ≥ 70, un 98 la cumpliría y nunca llegaría a la de ≥ 95.

9. ¿Qué es una cláusula de guarda, y qué ventaja tiene sobre los if anidados?

Una comprobación al comienzo que termina el script, o la función, si algo no se cumple. Deja cada condición junto a su mensaje de error, evita la sangría creciente y permite que el resto del código asuma que todo está en orden.

10. ¿Qué hace command -v rsync > /dev/null || exit 1?

Comprueba si rsync está disponible; si no lo está, termina el script con código 1.

11. ¿Por qué prueba && accion || alternativa no equivale a un if/else?

Porque || mira el resultado de prueba && accion. Si la prueba es cierta pero la acción falla, también se ejecuta la alternativa.

12. Con set -e, ¿se detiene el script si en [[ -f x ]] && procesar x el archivo no existe? ¿Y si procesar falla?

Si el archivo no existe, no: la prueba no es el último comando de la lista. Si procesar falla, sí: es el último comando.

13. ¿Por qué en debe-respaldar la comprobación con find va en último lugar?

Porque es la más costosa: recorre el directorio. Las condiciones anteriores son pruebas baratas, y si alguna decide el resultado, find no llega a ejecutarse.

Para practicar

  1. Escribe una condición que sea cierta si una variable n está entre 1 y 10, o es exactamente 100. Pruébala con 0, 5, 50 y 100.
  2. Comprueba con echo $? cómo se evalúa true || false && false entre comandos, y lo mismo dentro de [[ ]] con pruebas equivalentes.
  3. Reproduce la trampa de a && b || c con una función que falla, y reescríbela con un if.
  4. Escribe un script que clasifique la carga del sistema (el primer número de /proc/loadavg, multiplicado por 100 para usar enteros) en «baja», «media» y «alta» con elif.
  5. Reescribe con cláusulas de guarda el ejemplo anidado de la sección 8 y comprueba que da los mismos mensajes.
  6. Agrega al comienzo de un script tuyo una comprobación de requisitos con command -v para cada programa externo que use.
  7. Prueba debe-respaldar en un directorio propio: sin marca, con la marca recién creada y después de modificar un archivo. Combínalo con comprobar-respaldo del tema 11.