Llevamos todo el módulo aplazando los números. informe-diario.sh ya sabe recorrer ciudades, acumular contadores en mapas y despachar subcomandos, pero cuando llega el momento de decir qué porcentaje de envíos acabó en incidencia, o cuál es el importe medio, se rinde: hemos ido truncando céntimos con ${importe%.*} porque, y esta es la frase que resume la lección entera, Bash solo sabe hacer aritmética entera. No es una limitación menor ni un detalle: condiciona cómo se escribe cada cálculo en un script y obliga a conocer las salidas de emergencia. Aquí verás la aritmética nativa a fondo, sus trampas —incluida una que rompe scripts de fechas cada mes de agosto— y las herramientas externas para cuando hacen falta decimales de verdad.
Contenido
- Bash solo hace enteros
$(( ))y(( )): valor frente a comando- Operadores disponibles
- Bases numéricas y la trampa del
08 declare -i,letyexpr- Decimales con
bc - Decimales con
awk, y comparativa - Porcentajes y redondeo
RANDOM,SECONDSy aritmética de fechas- Límites de 64 bits
informe-diario.shcompleto
- Bash solo hace enteros
echo $(( 10 / 4 )) # 2 ← no 2.5; trunca hacia cero, NO redondea
echo $(( 1 / 3 )) # 0
echo $(( 24.50 + 10 )) # error de sintaxis: "24.50" no es un número válidoBash trabaja con enteros con signo de 64 bits, y punto. La división trunca: 7/2 es 3, y -7/2 es -3 (hacia cero, no hacia abajo). Los literales con punto decimal ni siquiera se aceptan como entrada. Las tres consecuencias prácticas que gobiernan todo lo demás: para porcentajes y medias hay que multiplicar antes de dividir o el resultado será 0; los importes en euros se guardan mejor en céntimos (enteros) y se formatean al final; y cuando de verdad hacen falta decimales en el cálculo, se delega en bc o awk.
$(( )) y (( )): valor frente a comando
$(( )) y (( )): valor frente a comandoSon dos construcciones distintas que comparten sintaxis interna: n=$(( 3 + 4 )) es una expansión que se sustituye por el valor (n vale 7), mientras que (( n > 5 )) es un comando que no produce salida y solo fija $? (aquí, 0 porque la comparación es cierta).
Dentro de ambas, las variables no llevan $, porque el contexto aritmético ya sabe que son variables:
total=128; incidencias=11
echo $(( incidencias * 100 / total )) # 8 ← sin $ dentro
echo $(( $incidencias * 100 / $total )) # 8 ← con $ funciona, pero sobra
(( total++ )); (( total += 10 )) # 129, luego 139El $ solo es imprescindible para los parámetros posicionales ($1) y los especiales ($#), y para elementos de arrays asociativos con clave variable: (( ENVIOS[$c]++ )).
Ahora la parte contraintuitiva. Como comando, (( )) invierte el criterio de C:
| Expresión | Valor aritmético | $? (código de salida) |
En un if |
|---|---|---|---|
(( 1 )) / (( 5 )) |
1 / 5 | 0 | cierto |
(( 0 )) / (( 3 > 5 )) |
0 | 1 | falso |
Es coherente: en aritmética, "distinto de cero" es verdadero; en el shell, éxito es 0. (( )) traduce entre ambos mundos. Pero eso crea una trampa real con set -e (lo verás en 05-03), que aborta el script ante cualquier comando que falle: con contador=0, el post-incremento de (( contador++ )) evalúa al valor anterior, es decir 0, así que $? vale 1 y el script muere. Las soluciones, en orden de preferencia:
(( ++contador )) (pre-incremento: evalúa a 1, código 0), (( contador++ )) || true, o contador=$(( contador + 1 )), que al ser una expansión nunca falla. Regla de bolsillo: usa $(( )) para calcular y (( )) para condiciones; si usas (( )) para incrementar bajo set -e, pon ++ delante o añade || true.
- Operadores disponibles
| Categoría | Operadores | Ejemplo |
|---|---|---|
| Aritméticos | + - * / % ** |
$(( 2 ** 10 )) → 1024 |
| Incremento y asignación | ++ --, = += -= *= /= %= |
(( ++i )), (( suma += imp )) |
| Comparación | == != < <= > >= |
(( total > 100 )) |
| Lógicos y ternario | && || !, c ? a : b |
$(( n > 0 ? n : -n )) |
| Bits | & | ^ ~ << >> |
$(( 1 << 3 )) → 8 |
Ejemplos: echo $(( 17 % 5 )) da el resto 2; max=$(( a > b ? a : b )) guarda el mayor de dos; y (( errores > 0 && verboso )) && mostrar_detalle combina dos condiciones numéricas. Dentro de (( )) puedes usar < y > con seguridad, algo imposible en [ ] donde serían redirecciones. Por eso para comparar números siempre (( )), y [[ ]] queda para cadenas y ficheros. Es la misma recomendación de 03-03, ahora justificada.
- Bases numéricas y la trampa del
08
08En contexto aritmético, Bash interpreta el prefijo de un número:
| Escritura | Base | Valor |
|---|---|---|
42 / 0x2A |
Decimal / hexadecimal | 42 |
052 |
Octal (cero inicial) | 42 |
2#101010 |
Binaria (base#numero) |
42 |
10#08 |
Decimal forzada | 8 |
El tercero es la bomba. Un cero a la izquierda significa octal, y en octal no existen los dígitos 8 y 9, así que mes="08"; echo $(( mes + 1 )) no imprime 9: aborta con bash: 08: valor demasiado grande para la base.
Este error aparece exactamente donde más duele: procesando fechas y horas, que vienen con ceros a la izquierda de forma natural (08 de agosto, 09 de septiembre, la hora 08:00). Un script que funcionó once meses revienta en agosto. La solución es forzar la base con 10#:
mes="08"; echo $(( 10#$mes + 1 )) # 9 ✓
hora="09"
(( 10#$hora >= 8 && 10#$hora < 20 )) && echo "Horario de reparto"Fíjate en que aquí el $ es obligatorio: 10#$mes construye textualmente 10#08 antes de evaluarlo. La alternativa es quitar el cero con una expansión de 04-04, ${mes#0}, aunque falla si el valor es 00. Aplica 10# a todo número que venga de fuera —de date, de un fichero, de un argumento— porque no controlas si trae ceros delante.
declare -i, let y expr
declare -i, let y exprdeclare -i marca una variable como entera: cualquier asignación se evalúa como aritmética.
declare -i contador=0
contador+=5; contador="3 * 4" # 5 (suma, no concatena), luego 12 (la cadena se evalúa)
contador="hola" # 0: lo no numérico vale cero, SIN avisarEse último comportamiento silencioso es la razón por la que muchas guías de estilo lo desaconsejan: prefieren variables normales y $(( )) explícito, que deja visible dónde hay cálculo. Úsalo para acumuladores evidentes (declare -i total=0) y no para datos de entrada.
let "n = 3 + 4" y n=$(expr 3 + 4) son formas heredadas de escribir n=$(( 3 + 4 )). expr es especialmente malo: crea un proceso por cada operación, exige espacios alrededor de cada operador y obliga a escapar el * como \*; existe solo por compatibilidad con shells antiguos (08-07). let es interno y correcto, pero (( )) hace lo mismo con mejor sintaxis. Escribe (( )) y $(( )); reconoce let y expr cuando los veas en scripts viejos.
- Decimales con
bc
bcbc es una calculadora de precisión arbitraria. Lee expresiones por la entrada estándar y escribe el resultado:
bc <<< "24.50 * 3" # 73.50 ← here-string, más corto que echo | bc
echo "scale=2; 10 / 4" | bc # 2.50
total=1487; entregados=1362
printf 'Entregas: %.2f%%\n' "$(bc -l <<< "scale=2; $entregados*100/$total")" # 91.59%Las claves:
- Sin
scale, la división es entera:echo "10/4" | bcda2. Hay que fijarscale=Nal principio de la expresión, o usar-l(que carga la librería matemática y ponescale=20); si con-lsolo quieres dos decimales, combinabc -l <<< "scale=2; 10/4"o formatea después conprintf '%.2f'(donde%%imprime un%literal). - Las variables de Bash se expanden antes, así que van con
$y entrecomilladas:bc <<< "scale=2; $a / $b". - Ojo con el separador decimal:
bcusa punto, siempre. Si tus datos vienen con coma, conviértelos con${v/,/.}(04-04). bcno es un builtin: cada llamada es un proceso. Dentro de un bucle de 10.000 líneas, eso se nota.
- Decimales con
awk, y comparativa
awk, y comparativaawk también calcula en coma flotante, y tiene una ventaja: puede leer el fichero y calcular a la vez, en una sola pasada y un solo proceso. awk -F, 'NR>1 {s += $6; n++} END {printf "%.2f\n", s/n}' envios.csv devuelve 26.34, el importe medio, sin bucle de Bash por medio.
| Criterio | $(( )) nativo |
bc |
awk |
|---|---|---|---|
| Decimales | No | Sí | Sí |
| Procesos lanzados | 0 | 1 por llamada | 1 por llamada |
| Velocidad en bucles | Máxima | Baja | Baja (pero 1 sola pasada) |
| Precisión | 64 bits enteros | Arbitraria | Coma flotante (doble) |
| Puede leer ficheros | No | No | Sí |
Criterio de elección: enteros → $(( )); un cálculo decimal suelto → bc; agregar un fichero entero → awk. Este último es el protagonista de 06-01, donde verás que muchos bucles de este módulo se reducen a una línea.
- Porcentajes y redondeo
Con enteros, la técnica es multiplicar por 100 antes de dividir:
incidencias=11; total=128
echo $(( incidencias * 100 / total )) # 8 ✓ correcto
echo $(( incidencias / total * 100 )) # 0 ✗ la división trunca a 0 antes de multiplicarPara conseguir un decimal sin salir de Bash, multiplica por 1000 y monta la cadena a mano; y para redondear al entero más próximo en lugar de truncar, suma la mitad del divisor antes de dividir:
p=$(( incidencias * 1000 / total )); printf '%d.%d%%\n' $(( p/10 )) $(( p%10 )) # 8.5%
echo $(( (incidencias * 100 + total / 2) / total )) # 9 en vez de 8: redondeo enteroY la versión limpia con dos decimales, que es la que usaremos: printf 'Incidencias: %.2f%%\n' "$(bc -l <<< "scale=4; $incidencias * 100 / $total")" imprime Incidencias: 8.59%. Nota el scale=4 seguido de %.2f: se calcula con más precisión de la necesaria y se redondea al formatear, que es la forma correcta de evitar errores de redondeo acumulados.
RANDOM, SECONDS y aritmética de fechas
RANDOM, SECONDS y aritmética de fechasRANDOM (visto en 03-02) devuelve un entero pseudoaleatorio entre 0 y 32767 en cada lectura. Con el módulo se acota a un rango, útil para muestrear:
indice=$(( RANDOM % ${#CIUDADES[@]} )) # una ciudad al azar del array
echo "Auditando ${CIUDADES[indice]}"Con el operador % los valores bajos quedan ligerísimamente favorecidos; para una auditoría de muestreo es irrelevante, para criptografía RANDOM no sirve en absoluto (usa /dev/urandom).
SECONDS cuenta los segundos desde que arrancó el shell o desde la última vez que se le asignó un valor, así que SECONDS=0; trabajo; printf 'Procesado en %d s\n' "$SECONDS" cronometra un bloque sin más. Para medidas más finas, date +%s da la marca de tiempo Unix en segundos (y %s%3N en milisegundos), de modo que inicio=$(date +%s) seguido de $(( $(date +%s) - inicio )) mide la duración exacta.
Aritmética de fechas. No la hagas a mano: los meses tienen distinta duración y existen los años bisiestos. date -d (GNU) entiende expresiones relativas:
ayer=$(date -d 'yesterday' +%F) # 2026-08-02
hace_7=$(date -d '7 days ago' +%F) # 2026-07-27
manana=$(date -d "$fecha + 1 day" +%F) # sobre una fecha concreta
dias=$(( ( $(date -d "$f2" +%s) - $(date -d "$f1" +%s) ) / 86400 )) # días entre fechasConvertir ambas fechas a segundos Unix y restar es el idioma universal para "cuántos días han pasado". Lo usarás en 07-01 para programar tareas y en 07-03 para borrar respaldos con más de N días.
- Límites de 64 bits
Bash usa enteros con signo de 64 bits: de -9.223.372.036.854.775.808 a 9.223.372.036.854.775.807. Al pasarse, desborda en silencio: echo $(( 9223372036854775807 + 1 )) imprime -9223372036854775808. Ningún error, ningún aviso: el número da la vuelta. En la práctica no lo tocarás sumando importes, pero sí puede aparecer con marcas de tiempo en nanosegundos o tamaños en bytes multiplicados varias veces. Si sospechas que un cálculo puede acercarse a ese orden de magnitud, usa bc, que tiene precisión arbitraria y no desborda nunca.
informe-diario.sh completo
informe-diario.sh completoAñadimos los cálculos que faltaban. Los importes se acumulan en céntimos —enteros— y solo se convierten a euros al imprimir:
# acumular_dia guarda IMPORTE[ciudad] en CÉNTIMOS (entero)
acumular_dia() {
local fecha="${1:?}" f ciudad estado importe euros cent
declare -gA ENVIOS INCIDENCIAS IMPORTE
while IFS=, read -r _ f ciudad _ estado importe; do
[[ "$f" == "$fecha" ]] || continue
euros="${importe%.*}"; cent="${importe#*.}" # 04-04
(( ENVIOS["$ciudad"]++ ))
(( IMPORTE["$ciudad"] += 10#$euros * 100 + 10#$cent )) # 10#: por si hay "05"
[[ "$estado" == "incidencia" ]] && (( INCIDENCIAS["$ciudad"]++ ))
done < <(tail -n +2 "$RUTA_CSV")
}
# porcentaje — Calcula a/b*100. Uso: porcentaje <parte> <total>
porcentaje() { # el guard evita la división por cero
(( ${2:?} == 0 )) && { echo "0.00"; return 0; }; bc -l <<< "scale=4; $1 * 100 / $2"
}
tabla_ciudades() {
local c env inc imp total_env=0 total_inc=0
printf '%-10s %7s %7s %8s %10s %9s\n' CIUDAD ENVÍOS INCID. '%INCID' IMPORTE MEDIA
for c in "${CIUDADES[@]}"; do
[[ -v ENVIOS[$c] ]] || continue
env="${ENVIOS[$c]}"; inc="${INCIDENCIAS[$c]:-0}"; imp="${IMPORTE[$c]}"
printf '%-10s %7d %7d %7.2f%% %9.2f€ %8.2f€\n' \
"$c" "$env" "$inc" "$(porcentaje "$inc" "$env")" \
"$(bc -l <<< "scale=4; $imp/100")" \
"$(bc -l <<< "scale=4; $imp/100/$env")"
(( total_env += env, total_inc += inc ))
done
printf '%-10s %7d %7d %7.2f%%\n' TOTAL "$total_env" "$total_inc" \
"$(porcentaje "$total_inc" "$total_env")"
printf 'Entregas correctas: %.2f%%\n' \
"$(porcentaje "$(( total_env-total_inc ))" "$total_env")"
}Salida:
CIUDAD ENVÍOS INCID. %INCID IMPORTE MEDIA Valencia 128 11 8.59% 3204.55€ 25.04€ Madrid 152 14 9.21% 4013.75€ 26.41€ TOTAL 450 41 9.11% Entregas correctas: 90.89%
Y la variación respecto al día anterior, que compara dos ejecuciones:
variacion() { # devuelve "n/d" si ayer no hubo envíos
(( ${2:?} == 0 )) && { echo "n/d"; return 0; }; bc -l <<< "scale=2; ($1-$2)*100/$2"
}
ayer_env=$(grep -c ",$(date -d yesterday +%F)," "$RUTA_CSV")
printf 'Variación de volumen: %+.2f%%\n' "$(variacion "$total_env" "$ayer_env")" # +3.45%Tres decisiones de diseño que conviene subrayar: los importes viven como enteros en céntimos durante todo el cálculo, bc se invoca una vez por celda y no dentro del bucle de lectura (que procesa miles de líneas), y porcentaje() comprueba el divisor antes de dividir, porque una división por cero en bc no aborta pero contamina la salida.
Errores Comunes y Consejos
- Dividir antes de multiplicar.
a / b * 100da 0 casi siempre. Siemprea * 100 / b. - El
08octal. Todo número que venga dedateo de un fichero:10#$n. (( contador++ ))conset -e. El post-incremento devuelve código 1 la primera vez y mata el script. Usa++contador.- Olvidar
scaleenbc. Sin él la división es entera, igual que en Bash, y te preguntarás por qué10/4da 2. - No comprobar el divisor. Un día sin envíos hace que
totalvalga 0; en$(( ))eso aborta con "division by 0". - Llamar a
bcdentro de un bucle de miles de iteraciones. Acumula en enteros y convierte al final, o pasa el trabajo entero aawk. - Consejo: guarda dinero, tiempos y magnitudes en la unidad mínima entera (céntimos, segundos, bytes). Toda la aritmética se vuelve exacta y solo el
printffinal necesita decimales.
Ejercicios
Ejercicio 1. Escribe pct_entero(), que calcule el porcentaje redondeado (no truncado) de $1 sobre $2 usando solo aritmética nativa y devuelva 0 si el total es cero.
Ejercicio 2. Un script recibe una hora en formato HH (por ejemplo 08 o 19) y debe imprimir Horario de reparto si está entre 8 y 19 inclusive, y Fuera de horario en caso contrario. Escríbelo evitando la trampa del octal.
Ejercicio 3. Calcula el importe medio por envío de envios.csv con dos decimales de dos formas: acumulando en céntimos con Bash + bc, y con una sola línea de awk. Mide cuánto tarda cada una con SECONDS.
Soluciones
Solución 1.
pct_entero() {
local parte="${1:?}" total="${2:?}"
(( total == 0 )) && { echo 0; return 0; }
echo $(( (parte * 100 + total / 2) / total ))
}
pct_entero 11 128 # 9 (8.59 redondeado)El + total / 2 antes de dividir es el truco estándar de redondeo con enteros: suma media unidad del resultado final.
Solución 2.
hora="${1:?falta la hora}"
if (( 10#$hora >= 8 && 10#$hora <= 19 )); then echo "Horario de reparto"
else echo "Fuera de horario"; fiSin el 10#, la entrada 08 provocaría valor demasiado grande para la base y el script moriría cada mañana a las ocho. Con él, funciona con 08, 8 y 19 igual de bien.
Solución 3.
readonly CSV=/srv/veloz/datos/envios.csv
SECONDS=0; suma=0; n=0
while IFS=, read -r _ _ _ _ _ importe; do
(( suma += 10#${importe%.*} * 100 + 10#${importe#*.} )); (( ++n ))
done < <(tail -n +2 "$CSV")
printf 'Bash: %.2f€ en %d s\n' "$(bc -l <<< "scale=4; $suma/100/$n")" "$SECONDS"
SECONDS=0
printf 'awk: %s€ en %d s\n' \
"$(awk -F, 'NR>1 {s+=$6; n++} END {printf "%.2f", s/n}' "$CSV")" "$SECONDS"Ambas dan 26.34€. Con unos pocos miles de líneas los dos tardan menos de un segundo, pero al crecer el fichero la diferencia se dispara a favor de awk: una sola pasada, un solo proceso, coma flotante nativa. Es la herramienta que estrenarás en 06-01.
Conclusión
Bash calcula con enteros de 64 bits y nada más: la división trunca, los decimales no se aceptan y por eso todo cálculo monetario se hace en céntimos y todo porcentaje multiplica antes de dividir. $(( )) produce el valor y (( )) actúa como comando con el código de salida invertido —cero aritmético significa fallo—, dentro de ambos las variables van sin $, y 10#$n te salva de que un 08 procedente de una fecha reviente el script. Cuando hacen falta decimales de verdad, bc -l con su scale resuelve el cálculo suelto y awk resuelve el fichero entero; printf %.2f pone el formato final. RANDOM, SECONDS y date -d completan el instrumental para muestrear, medir y comparar fechas.
Con esto se cierra el Módulo 4 y, sobre todo, se cierra la transformación de informe-diario.sh: lo que en el Módulo 3 era un guion lineal que había que invocar una vez por ciudad es hoy un programa con bucles, funciones documentadas, mapas de contadores, formato con printf, subcomandos despachados por case y estadísticas con decimales. Tiene estructura.
Lo que aún no tiene es robustez. Si el CSV está a medio escribir, si el disco se llena, si alguien lo lanza dos veces a la vez, el script hará cosas raras sin decir por qué. En el Módulo 5 damos ese salto: find, xargs, tar y ficheros temporales seguros con mktemp (05-01); gestión de procesos y señales (05-02); set -euo pipefail, trap y depuración con set -x (05-03); expresiones regulares de verdad con =~ (05-04); descriptores de fichero y here-documents (05-05); y por fin la librería lib/comun.sh que llevamos prometiendo desde 04-02, donde tus funciones dejarán de vivir dentro de un solo script para convertirse en el toolkit compartido de Veloz Envíos (05-06).
Curso de Programación en Bash
Módulo 1: Introducción a Bash
- ¿Qué es Bash?
- Configurando tu Entorno
- Navegación Básica en la Línea de Comandos
- Entendiendo el Shell
- Encontrar Ayuda: man, help y --help
Módulo 2: Comandos Básicos de Bash
- Operaciones con Archivos y Directorios
- Comandos de Procesamiento de Texto
- Permisos y Propiedad de Archivos
- Redirección y Tuberías
- Comodines y Expansión de Rutas
- Historial y Atajos de Teclado
Módulo 3: Fundamentos de Scripting
- Creando y Ejecutando un Script
- Variables y Constantes
- Operadores Básicos
- Sentencias Condicionales
- Argumentos y Entrada del Usuario
- Comillas, Expansión y Sustitución
Módulo 4: Scripting Intermedio
- Bucles en Bash
- Funciones en Bash
- Arrays y Arrays Asociativos
- Manipulación de Cadenas
- La Sentencia case y los Menús Interactivos
- Aritmética y Cálculos Numéricos
Módulo 5: Técnicas Avanzadas de Scripting
- Operaciones Avanzadas con Archivos
- Gestión de Procesos
- Manejo de Errores y Depuración
- Expresiones Regulares
- Entrada/Salida Avanzada: Descriptores y Here-Documents
- Scripts Modulares y Librerías Reutilizables
Módulo 6: Trabajando con Herramientas Externas
Módulo 7: Automatización y Programación
- Trabajos Cron
- Automatizando Tareas
- Scripts de Respaldo y Restauración
- Monitoreo y Registro
- Servicios y Temporizadores con systemd
- Automatización Remota con SSH
Módulo 8: Mejores Prácticas y Optimización
- Escribiendo Código Legible
- Optimizando Scripts en Bash
- Consideraciones de Seguridad
- Control de Versiones con Git
- Análisis Estático con ShellCheck y shfmt
- Pruebas Automatizadas con Bats
- Portabilidad: POSIX sh frente a Bashismos
